diff --git a/.gitignore b/.gitignore index f01378b61a..496e1c7c55 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,12 @@ Release/ /.build/ /.venv/ /build/ +/build-base/ +/doc-coverage.info +/base-doc-coverage.info +/doc-coverage-report.md +/doc-review-report.md +/doc-review-comments.json /db/ /out.txt /Testing/ diff --git a/include/xrpl/ledger/detail/ApplyViewBase.h b/include/xrpl/ledger/detail/ApplyViewBase.h index 451ec4b5d8..1b8520bedf 100644 --- a/include/xrpl/ledger/detail/ApplyViewBase.h +++ b/include/xrpl/ledger/detail/ApplyViewBase.h @@ -1,3 +1,14 @@ +/** @file + * Declares `ApplyViewBase`, the abstract concrete base class shared by all + * buffered mutable ledger views used during transaction application. + * + * `ApplyViewBase` lives in `xrpl::detail` to signal that it is internal + * infrastructure; transaction processing code works with `ApplyView` or + * `ApplyViewImpl` references. The three concrete subclasses — + * `ApplyViewImpl`, `Sandbox`, and `PaymentSandbox` — are the only types + * that need to reach into this layer directly. + */ + #pragma once #include @@ -119,6 +130,12 @@ public: [[nodiscard]] std::unique_ptr slesEnd() const override; + /** Return an iterator to the first SLE whose key is not less than `key`, + * drawn from the base snapshot only. + * + * @param key The lower-bound key for the search. + * @return An iterator into the base SLE map at or after `key`. + */ [[nodiscard]] std::unique_ptr slesUpperBound(uint256 const& key) const override; /** @} */ @@ -131,9 +148,21 @@ public: [[nodiscard]] std::unique_ptr txsEnd() const override; + /** Test whether a transaction exists in the base snapshot's tx-map. + * + * @param key The transaction ID to look up. + * @return `true` if the transaction is present in the base ledger's + * transaction map. + */ [[nodiscard]] bool txExists(key_type const& key) const override; + /** Read a transaction and its metadata from the base snapshot's tx-map. + * + * @param key The transaction ID to retrieve. + * @return A pair of `(STTx, STObject metadata)` for the transaction, + * or `{nullptr, nullptr}` if not found. + */ [[nodiscard]] tx_type txRead(key_type const& key) const override; /** @} */ diff --git a/include/xrpl/ledger/detail/RawStateTable.h b/include/xrpl/ledger/detail/RawStateTable.h index 169a7c505e..c87e35e9e4 100644 --- a/include/xrpl/ledger/detail/RawStateTable.h +++ b/include/xrpl/ledger/detail/RawStateTable.h @@ -11,27 +11,73 @@ namespace xrpl::detail { -// Helper class that buffers raw modifications +/** In-memory write buffer that accumulates SLE mutations before flushing them + * to a backing `RawView`. + * + * Every mutable ledger view (`OpenView`, and indirectly `ApplyStateTable`) + * embeds a `RawStateTable` as its delta accumulator. The three mutation + * methods — `erase`, `insert`, and `replace` — apply a state-machine + * collapse so the map stays minimal: insert-then-erase cancels out entirely; + * erase-then-insert upgrades to replace; and illegal sequences (double-erase, + * double-insert) throw `std::logic_error`. `read`, `exists`, and `succ` + * overlay the pending delta transparently onto the supplied base `ReadView`, + * so callers always see a coherent merged state. Once a transaction succeeds, + * `apply()` flushes the buffer to the target `RawView` in a single pass. + * + * The `items_` map uses a `boost::container::pmr::monotonic_buffer_resource` + * with a 256 KB initial arena for O(1) amortised allocation during the burst + * of mutations that constitute a single transaction round. Because the + * resource cannot be shared or assigned, copy construction allocates a fresh + * resource and deep-copies the map; move construction transfers the + * `unique_ptr` directly. Both assignment operators are deleted. + * + * XRP fee destruction is tracked separately in `dropsDestroyed_` and + * replayed as a single `rawDestroyXRP` call during `apply()`. + * + * @note This class is an internal implementation detail of `OpenView`. + * Transaction logic should not interact with it directly; use the + * `RawView` interface instead. + * @see OpenView, RawView + */ class RawStateTable { public: using key_type = ReadView::key_type; - // Initial size for the monotonic_buffer_resource used for allocations - // The size was chosen from the old `qalloc` code (which this replaces). - // It is unclear how the size initially chosen in qalloc. + + /** Initial arena size for the PMR monotonic buffer resource. + * + * Inherited from the legacy `qalloc` scheme this replaced. The 256 KB + * budget covers the typical per-transaction working set without triggering + * heap growth for the common case. + */ static constexpr size_t kINITIAL_BUFFER_SIZE = kilobytes(256); + /** Construct an empty table with a fresh 256 KB monotonic arena. */ RawStateTable() : monotonic_resource_{std::make_unique( kINITIAL_BUFFER_SIZE)} , items_{monotonic_resource_.get()} {}; + /** Copy-construct by allocating a fresh monotonic arena and copying items. + * + * The SLE `shared_ptr` values in `items_` are shared with the source — + * not deep-copied — which is safe because SLEs are immutable once + * published. `dropsDestroyed_` is copied verbatim. + * + * @param rhs The source table to copy. + */ RawStateTable(RawStateTable const& rhs) : monotonic_resource_{std::make_unique( kINITIAL_BUFFER_SIZE)} , items_{rhs.items_, monotonic_resource_.get()} , dropsDestroyed_{rhs.dropsDestroyed_} {}; + /** Move-construct by transferring the monotonic resource and items map. + * + * After the move, the source table is left in a valid but empty state. + * The `unique_ptr` transfer preserves the stable address that `items_`' + * `polymorphic_allocator` holds. + */ RawStateTable(RawStateTable&&) = default; RawStateTable& @@ -39,48 +85,166 @@ public: RawStateTable& operator=(RawStateTable const&) = delete; + /** Flush all buffered mutations to a backing `RawView`. + * + * First calls `to.rawDestroyXRP(dropsDestroyed_)` to replay accumulated + * fee burns, then iterates `items_` and dispatches each pending action + * to the corresponding `rawErase`, `rawInsert`, or `rawReplace` method. + * The table is not cleared after apply; this object should be discarded + * or destroyed once flushed. + * + * @param to The target `RawView` that receives all buffered mutations. + */ void apply(RawView& to) const; + /** Test whether an SLE exists, overlaying the pending delta onto `base`. + * + * Checks the pending buffer first: a pending erase returns `false`; a + * pending insert or replace returns `true` only if `k.check()` passes + * (type-tag validation). 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 specifying key and expected SLE type. + * @return `true` if the entry exists and its type satisfies `k.check()`. + */ [[nodiscard]] bool exists(ReadView const& base, Keylet const& k) const; + /** Find the smallest key strictly greater than `key` in the merged state. + * + * Runs two parallel searches: (1) walks `base.succ()` repeatedly, + * skipping any base key that has a pending `Action::Erase`; (2) scans + * `items_` forward from `key` for the first non-erase entry. Returns + * the lower of the two candidates. If `last` is given and the result is + * `>= last`, returns `std::nullopt` (half-open range semantics). + * + * @param base The underlying read-only ledger state. + * @param key Exclusive lower bound; the search begins strictly after this. + * @param last Optional exclusive upper bound; `std::nullopt` means unbounded. + * @return The next existing key, or `std::nullopt` if none is in range. + */ [[nodiscard]] std::optional succ(ReadView const& base, key_type const& key, std::optional const& last) const; + /** Stage an SLE deletion, applying state-machine transition rules. + * + * Transitions on the key's existing pending action: + * - None → records `Action::Erase`. + * - `Insert` → removes the entry entirely (net-zero; base is unaffected). + * - `Replace` → downgrades to `Action::Erase`. + * - `Erase` → `LogicError` (double-delete). + * + * @param sle The ledger entry to stage for deletion; key is taken from the SLE. + * @throws std::logic_error if the key already has a pending erase. + */ void erase(std::shared_ptr const& sle); + /** Stage an SLE creation, applying state-machine transition rules. + * + * Transitions on the key's existing pending action: + * - None → records `Action::Insert`. + * - `Erase` → upgrades to `Action::Replace` (delete-then-recreate in + * the same transaction batch). + * - `Insert` → `LogicError` (duplicate insert). + * - `Replace` → `LogicError` (key already present in the delta). + * + * @param sle The new ledger entry to stage; key is taken from the SLE. + * @throws std::logic_error if the key is already pending insert or replace. + */ void insert(std::shared_ptr const& sle); + /** Stage an SLE field update, applying state-machine transition rules. + * + * Transitions on the key's existing pending action: + * - None → records `Action::Replace`. + * - `Insert` → updates the stored SLE pointer; preserves `Insert` + * because from the base's perspective the key is still being created. + * - `Replace` → updates the stored SLE pointer. + * - `Erase` → `LogicError` (cannot replace a deleted key). + * + * @param sle The updated ledger entry to stage; key is taken from the SLE. + * @throws std::logic_error if the key has a pending erase. + */ void replace(std::shared_ptr const& sle); + /** Read an SLE, overlaying the pending delta onto `base`. + * + * Checks the buffer first: a pending erase returns `nullptr`; a pending + * insert or replace returns the buffered SLE if `k.check()` passes + * (guards 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 key and expected SLE type. + * @return The SLE if it exists and the type matches, otherwise `nullptr`. + */ [[nodiscard]] std::shared_ptr read(ReadView const& base, Keylet const& k) const; + /** Accumulate XRP drops to destroy at `apply()` time. + * + * Drops are not forwarded individually; they accumulate in + * `dropsDestroyed_` and are 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 destroyXRP(XRPAmount const& fee); + /** Return a begin iterator for the merged SLE range over `base` and the delta. + * + * The returned iterator implements the two-pointer merge defined by + * `SlesIterImpl`: pending inserts appear in sorted position, pending + * erases are hidden, and pending replaces shadow the base entry. + * + * @param base The underlying read-only ledger state to merge with. + * @return A heap-allocated `iter_base` positioned at the first merged SLE. + */ [[nodiscard]] std::unique_ptr slesBegin(ReadView const& base) const; + /** Return an end sentinel for the merged SLE range over `base` and the delta. + * + * @param base The underlying read-only ledger state to merge with. + * @return A heap-allocated `iter_base` positioned past the last merged SLE. + */ [[nodiscard]] std::unique_ptr slesEnd(ReadView const& base) const; + /** 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 Exclusive lower bound for the search. + * @return A heap-allocated `iter_base` positioned at the first qualifying SLE. + */ [[nodiscard]] std::unique_ptr slesUpperBound(ReadView const& base, uint256 const& key) const; private: + /** Pending mutation kind for an entry in `items_`. */ enum class Action { - Erase, - Insert, - Replace, + Erase, /**< Entry is scheduled for deletion. */ + Insert, /**< Entry is being created; does not yet exist in the base. */ + Replace, /**< Entry exists in the base and has been modified. */ }; + /** Private iterator class that merges base-view SLEs with the pending + * delta; defined in the `.cpp`. */ class SlesIterImpl; + /** Pairs a pending `Action` with the SLE it acts on. + * + * Stored as the mapped value in `items_`. The SLE pointer is always + * non-null; for `Erase` it is the last version written before the + * deletion was staged (used by `RawView::rawErase`). + */ struct SleAction { Action action; @@ -99,11 +263,17 @@ private: SleAction, std::less, boost::container::pmr::polymorphic_allocator>>; + // monotonic_resource_ must outlive `items_`. Make a pointer so it may be // easily moved. std::unique_ptr monotonic_resource_; + + /** Ordered map from ledger key to pending mutation; backed by the + * monotonic arena for O(1) amortised node allocation. */ items_t items_; + /** Accumulated XRP drops burned by fees; replayed as one `rawDestroyXRP` + * call during `apply()`. */ XRPAmount dropsDestroyed_{0}; }; diff --git a/include/xrpl/ledger/detail/ReadViewFwdRange.h b/include/xrpl/ledger/detail/ReadViewFwdRange.h index c548ccb101..381f46c844 100644 --- a/include/xrpl/ledger/detail/ReadViewFwdRange.h +++ b/include/xrpl/ledger/detail/ReadViewFwdRange.h @@ -1,3 +1,13 @@ +/** @file + * Type-erased forward-iterator infrastructure for `ReadView` traversal. + * + * Defines `ReadViewFwdIter` (the abstract iterator interface) and + * `ReadViewFwdRange` (the STL-compatible range wrapper) that together let + * any `ReadView` subclass expose its state and transaction maps through a + * single, stable iterator type. Callers interact indirectly via + * `ReadView::sles` and `ReadView::txs`; this header is internal plumbing. + */ + #pragma once #include @@ -10,8 +20,18 @@ class ReadView; namespace detail { -// A type-erased ForwardIterator -// +/** Abstract base defining the four primitive operations of a type-erased forward iterator. + * + * Each concrete `ReadView` implementation provides a private subclass of + * this template and hands heap-allocated instances to `ReadViewFwdRange::Iterator` + * via the factory methods `slesBegin()`, `slesEnd()`, `slesUpperBound()`, + * `txsBegin()`, and `txsEnd()` on `ReadView`. Callers never interact with + * this class directly. + * + * @tparam ValueType The element type yielded by the iterator — + * `std::shared_ptr` for state-map iteration or + * `ReadView::tx_type` for transaction-map iteration. + */ template class ReadViewFwdIter { @@ -27,21 +47,57 @@ public: virtual ~ReadViewFwdIter() = default; + /** Returns a heap-allocated deep copy of this iterator. + * + * Provides value-semantics copy for the owning `unique_ptr` wrapper. + * Each concrete subclass must return a new instance of itself in the + * same position. + * + * @return A `unique_ptr` to a fresh copy of this iterator instance. + */ [[nodiscard]] virtual std::unique_ptr copy() const = 0; + /** Returns `true` if this iterator denotes the same position as @p impl. + * + * Both iterators must be over the same underlying view; mixing iterators + * from different views produces undefined behavior. + * + * @param impl The other iterator to compare against. + * @return `true` when both iterators point to the same element (or both + * are end sentinels). + */ [[nodiscard]] virtual bool equal(ReadViewFwdIter const& impl) const = 0; + /** Advances this iterator to the next element in the sequence. */ virtual void increment() = 0; + /** Returns the element at the current iterator position. + * + * @return The current `ValueType` value. The result is cached by the + * wrapping `Iterator` so repeated dereferences are inexpensive. + * @throw May throw if the underlying view operation fails. + */ [[nodiscard]] virtual value_type dereference() const = 0; }; -// A range using type-erased ForwardIterator -// +/** STL-compatible forward range backed by a type-erased iterator. + * + * Wraps a `ReadViewFwdIter` behind a regular value-type iterator + * so that callers can write range-for loops over any `ReadView` subclass + * without knowing the concrete iterator type. Virtual dispatch is hidden + * inside the `impl_` pointer; the public `Iterator` API is fully inlined. + * + * `ReadView::SlesType` and `ReadView::TxsType` inherit from this template; + * application code should use those types rather than instantiating + * `ReadViewFwdRange` directly. + * + * @tparam ValueType The element type — must be noexcept-move-constructible + * so that `Iterator` move operations are noexcept. + */ template class ReadViewFwdRange { @@ -53,6 +109,18 @@ public: "ReadViewFwdRange move and move assign constructors should be " "noexcept"); + /** STL forward iterator over a `ReadViewFwdRange`. + * + * Value-type wrapper around a heap-allocated `iter_base`. Copy uses + * `iter_base::copy()` for a polymorphic deep clone; move transfers + * ownership of the `unique_ptr` without allocation and is `noexcept`. + * Dereference results are cached in `cache_` and cleared on advance, + * amortizing the cost of repeated `*it` or `it->` calls in tight loops. + * + * @note Comparing iterators from different views triggers an + * `XRPL_ASSERT` in debug builds. The `view_` pointer is carried + * solely for this cross-view sanity check. + */ class Iterator { public: @@ -66,43 +134,127 @@ public: using iterator_category = std::forward_iterator_tag; + /** Constructs a singular (default) iterator. + * + * A default-constructed iterator is not dereferenceable and must + * not be incremented. It compares equal only to other + * default-constructed iterators. + */ Iterator() = default; + /** Copy-constructs an independent iterator at the same position. + * + * Calls `iter_base::copy()` to deep-clone the polymorphic + * implementation, producing a new iterator that advances + * independently of @p other. + * + * @param other The iterator to clone. + */ Iterator(Iterator const& other); + + /** Move-constructs an iterator, transferring ownership of the impl. + * + * @param other The iterator to move from; left in a valid but + * singular state. + */ Iterator(Iterator&& other) noexcept; - // Used by the implementation + /** Constructs an iterator from a raw view pointer and a polymorphic impl. + * + * Used exclusively by `ReadView`'s factory methods (`slesBegin()`, + * `slesEnd()`, etc.). Not intended for direct use by callers. + * + * @param view The owning view; stored only for cross-view assertion. + * @param impl The heap-allocated concrete iterator; ownership is + * transferred to this object. + */ explicit Iterator(ReadView const* view, std::unique_ptr impl); + /** Copy-assigns from another iterator at the same position. + * + * Deep-clones via `iter_base::copy()`. + * + * @param other The iterator to copy. + * @return `*this`. + */ Iterator& operator=(Iterator const& other); + /** Move-assigns from another iterator. + * + * @param other The iterator to move from; left in a valid but + * singular state. + * @return `*this`. + */ Iterator& operator=(Iterator&& other) noexcept; + /** Returns `true` if both iterators denote the same position. + * + * Delegates to `iter_base::equal()`. Two null `impl_` pointers also + * compare equal (both are end sentinels / default-constructed). + * + * @param other The iterator to compare against. + * @return `true` when both iterators are at the same element. + * @note Asserts in debug builds that both iterators belong to the + * same view. Comparing iterators from different views is + * undefined behaviour. + */ bool operator==(Iterator const& other) const; + /** Returns `true` if the iterators denote different positions. + * + * @param other The iterator to compare against. + * @return `true` when the iterators are not at the same element. + */ bool operator!=(Iterator const& other) const; + /** Returns a reference to the current element. + * + * The result is cached after the first call; subsequent calls before + * the next `operator++` return the cached value at no extra cost. + * + * @return A `const` reference to the current `ValueType`. + * @throw May throw if the underlying `iter_base::dereference()` call fails. + */ // Can throw reference operator*() const; + /** Returns a pointer to the current element. + * + * Delegates to `operator*()` so caching and exception behaviour are + * identical to that of the dereference operator. + * + * @return A `const` pointer to the current `ValueType`. + * @throw May throw if the underlying `iter_base::dereference()` call fails. + */ // Can throw pointer operator->() const; + /** Advances the iterator and clears the dereference cache. + * + * @return `*this` after advancing to the next element. + */ Iterator& operator++(); + /** Returns a copy of the current iterator, then advances. + * + * @return An iterator to the element before the advance. + */ Iterator operator++(int); private: + /** Owning view; compared in `operator==` to catch cross-view misuse. */ ReadView const* view_ = nullptr; + /** Heap-allocated polymorphic iterator; null for the end sentinel. */ std::unique_ptr impl_{}; + /** One-slot dereference cache; cleared on each advance. */ std::optional mutable cache_; }; @@ -118,11 +270,19 @@ public: ReadViewFwdRange& operator=(ReadViewFwdRange const&) = default; + /** Constructs a range bound to @p view. + * + * The range stores a raw pointer to the view. The view must outlive + * the range and any iterators derived from it. + * + * @param view The `ReadView` whose factory methods supply iterators. + */ explicit ReadViewFwdRange(ReadView const& view) : view_(&view) { } protected: + /** The view whose factory methods supply concrete `iter_base` instances. */ ReadView const* view_; }; diff --git a/include/xrpl/ledger/helpers/AMMHelpers.h b/include/xrpl/ledger/helpers/AMMHelpers.h index c62437bf75..d71bdd2a5d 100644 --- a/include/xrpl/ledger/helpers/AMMHelpers.h +++ b/include/xrpl/ledger/helpers/AMMHelpers.h @@ -1,3 +1,22 @@ +/** @file + * Mathematical and operational backbone of the XRPL Automated Market Maker. + * + * Provides every computation needed to run a constant-product AMM pool: + * LP token minting and burning (XLS-30d Equations 3, 4, 7, 8), spot-price + * quality alignment against the central limit order book, swap execution with + * rigorous directional rounding, and ledger-state helpers for pool balance + * queries and AMM account lifecycle management. + * + * All arithmetic observes the pool invariant: + * @code + * sqrt(poolAsset1 × poolAsset2) >= LPTokenBalance + * @endcode + * Rounding is always directed to keep the pool at least as large as required. + * The `fixAMMv1_1` amendment introduced per-step directional rounding for + * swaps; `fixAMMv1_3` extended this discipline to LP token and + * deposit/withdrawal formulas. Pre-amendment paths are preserved for + * historic ledger replay. + */ #pragma once #include @@ -22,6 +41,17 @@ namespace xrpl { namespace detail { +/** Scale @p amount down by 99.99% as a last-resort quality rescue. + * + * When the rounded offer from `getAMMOfferStartWithTakerGets` or + * `getAMMOfferStartWithTakerPays` still falls below the target quality due + * to XRP integer-drop discretization, this function shrinks it by 0.01% + * (rounding toward zero) so the resulting offer quality meets or exceeds + * the target without generating an implausibly small trade. + * + * @param amount The offer side (takerGets or takerPays) to reduce. + * @return The reduced amount, or zero if already at zero. + */ Number reduceOffer(auto const& amount) { @@ -34,22 +64,41 @@ reduceOffer(auto const& amount) } // namespace detail +/** Direction tag used throughout deposit/withdrawal and rounding helpers. + * + * Passed to functions that behave asymmetrically between deposit (LP tokens + * rounded down, assets rounded up) and withdrawal (LP tokens rounded up, + * assets rounded down) to preserve the pool invariant. + */ enum class IsDeposit : bool { No = false, Yes = true }; -/** Calculate LP Tokens given AMM pool reserves. - * @param asset1 AMM one side of the pool reserve - * @param asset2 AMM another side of the pool reserve - * @return LP Tokens as IOU +/** Compute the initial LP token supply for a newly seeded AMM pool. + * + * Uses the geometric mean `sqrt(asset1 × asset2)`, which sets the + * pool invariant to equality at creation: `sqrt(asset1 × asset2) == LPTokens`. + * Under `fixAMMv1_3` the result is rounded downward so the pool starts + * with a slight surplus, preserving the invariant. + * + * @param asset1 Balance of the first pool asset. + * @param asset2 Balance of the second pool asset. + * @param lptIssue Asset descriptor identifying the LP token currency/issuer. + * @return Initial LP token amount as an IOU `STAmount`. */ STAmount ammLPTokens(STAmount const& asset1, STAmount const& asset2, Asset const& lptIssue); -/** Calculate LP Tokens given asset's deposit amount. - * @param asset1Balance current AMM asset1 balance - * @param asset1Deposit requested asset1 deposit amount - * @param lptAMMBalance AMM LPT balance - * @param tfee trading fee in basis points - * @return tokens +/** LP tokens minted for a single-asset deposit (XLS-30d Equation 3). + * + * A single-sided deposit is economically equivalent to a proportional + * deposit plus a fee-bearing swap; the fee is embedded via `feeMult` and + * `feeMultHalf`. Under `fixAMMv1_3` the final multiplication is rounded + * downward so fewer tokens are issued, preserving the pool invariant. + * + * @param asset1Balance Current pool balance of the asset being deposited. + * @param asset1Deposit Amount being deposited. + * @param lptAMMBalance Current total LP token supply. + * @param tfee Trading fee in basis points (e.g. 1000 = 1%). + * @return LP tokens to mint for the depositor. */ STAmount lpTokensOut( @@ -58,12 +107,19 @@ lpTokensOut( STAmount const& lptAMMBalance, std::uint16_t tfee); -/** Calculate asset deposit given LP Tokens. - * @param asset1Balance current AMM asset1 balance - * @param lpTokens LP Tokens - * @param lptAMMBalance AMM LPT balance - * @param tfee trading fee in basis points - * @return +/** Asset deposit required to receive a given number of LP tokens (XLS-30d Equation 4). + * + * Inverse of `lpTokensOut`: solves Equation 3 for the deposit amount given a + * desired token output. The solution is a quadratic whose positive root is + * found via `solveQuadraticEq`. Under `fixAMMv1_3` the result is rounded + * upward so the depositor contributes slightly more, preserving the pool + * invariant. + * + * @param asset1Balance Current pool balance of the asset to deposit. + * @param lptAMMBalance Current total LP token supply. + * @param lpTokens Desired LP token amount. + * @param tfee Trading fee in basis points. + * @return Asset amount the depositor must contribute. */ STAmount ammAssetIn( @@ -72,13 +128,18 @@ ammAssetIn( STAmount const& lpTokens, std::uint16_t tfee); -/** Calculate LP Tokens given asset's withdraw amount. Return 0 - * if can't calculate. - * @param asset1Balance current AMM asset1 balance - * @param asset1Withdraw requested asset1 withdraw amount - * @param lptAMMBalance AMM LPT balance - * @param tfee trading fee in basis points - * @return tokens out amount +/** LP tokens to burn for a single-asset withdrawal (XLS-30d Equation 7). + * + * Computes how many LP tokens must be redeemed to withdraw a specified asset + * amount. Returns zero if the inputs make calculation impossible. Under + * `fixAMMv1_3` the final multiplication is rounded upward so more tokens must + * be burned, preserving the pool invariant. + * + * @param asset1Balance Current pool balance of the asset being withdrawn. + * @param asset1Withdraw Requested withdrawal amount. + * @param lptAMMBalance Current total LP token supply. + * @param tfee Trading fee in basis points. + * @return LP tokens the withdrawer must burn, or zero if the calculation fails. */ STAmount lpTokensIn( @@ -87,12 +148,18 @@ lpTokensIn( STAmount const& lptAMMBalance, std::uint16_t tfee); -/** Calculate asset withdrawal by tokens - * @param assetBalance balance of the asset being withdrawn - * @param lptAMMBalance total AMM Tokens balance - * @param lpTokens LP Tokens balance - * @param tfee trading fee in basis points - * @return calculated asset amount +/** Asset returned when burning a given number of LP tokens (XLS-30d Equation 8). + * + * Inverse of `lpTokensIn`: solves Equation 7 for the withdrawal amount given + * the token burn. Under `fixAMMv1_3` the final multiplication is rounded + * downward so the withdrawer receives slightly less, preserving the pool + * invariant. + * + * @param assetBalance Current pool balance of the asset to withdraw. + * @param lptAMMBalance Current total LP token supply. + * @param lpTokens LP tokens being burned. + * @param tfee Trading fee in basis points. + * @return Asset amount returned to the withdrawer. */ STAmount ammAssetOut( @@ -101,12 +168,19 @@ ammAssetOut( STAmount const& lpTokens, std::uint16_t tfee); -/** Check if the relative distance between the qualities - * is within the requested distance. - * @param calcQuality calculated quality - * @param reqQuality requested quality - * @param dist requested relative distance - * @return true if within dist, false otherwise +/** Check whether two `Quality` values are within a relative tolerance. + * + * `Quality` has no subtraction operator, so the comparison is performed via + * `Quality::rate()`, which returns the *inverse* of quality (output/input). + * The formula `(min.rate - max.rate) / min.rate < dist` is equivalent to + * the standard `(max - min) / max < dist` after accounting for the inversion. + * Used in `changeSpotPriceQuality` to suppress trace-level errors when the + * quality mismatch is within one part in ten million (1e-7). + * + * @param calcQuality Computed quality. + * @param reqQuality Target quality. + * @param dist Maximum acceptable relative distance (e.g. `Number(1, -7)`). + * @return `true` if the two qualities are within @p dist of each other. */ inline bool withinRelativeDistance(Quality const& calcQuality, Quality const& reqQuality, Number const& dist) @@ -120,12 +194,18 @@ withinRelativeDistance(Quality const& calcQuality, Quality const& reqQuality, Nu return ((min.rate() - max.rate()) / min.rate()) < dist; } -/** Check if the relative distance between the amounts - * is within the requested distance. - * @param calc calculated amount - * @param req requested amount - * @param dist requested relative distance - * @return true if within dist, false otherwise +/** Check whether two numeric amounts are within a relative tolerance. + * + * Computes `(max - min) / max` and tests that it is less than @p dist. + * Accepted for `STAmount`, `IOUAmount`, `XRPAmount`, `MPTAmount`, and + * `Number`. Used alongside the `Quality` overload to emit quality-mismatch + * errors only when the discrepancy is truly significant. + * + * @tparam Amt Amount type; constrained to the five types listed above. + * @param calc Computed amount. + * @param req Target amount. + * @param dist Maximum acceptable relative distance. + * @return `true` if the two amounts are within @p dist of each other. */ template requires( @@ -141,34 +221,49 @@ withinRelativeDistance(Amt const& calc, Amt const& req, Number const& dist) return ((max - min) / max) < dist; } -/** Solve quadratic equation to find takerGets or takerPays. Round - * to minimize the amount in order to maximize the quality. +/** Smallest positive root of `a·x² + b·x + c = 0`, used to minimize offer size. + * + * Uses the numerically stable "citardauq" formula (Blinn 2006): when `b > 0` + * it computes `2c / (-b - sqrt(d))` instead of the standard + * `(-b + sqrt(d)) / 2a`, avoiding catastrophic cancellation when the two + * terms in the numerator are nearly equal. Minimizing the root maximizes + * offer quality in `getAMMOfferStartWithTakerGets` / `getAMMOfferStartWithTakerPays`. + * + * @param a Quadratic coefficient. + * @param b Linear coefficient. + * @param c Constant term. + * @return The smallest positive root, or `std::nullopt` if the discriminant + * is negative (no real solution) or the root is non-positive. */ std::optional solveQuadraticEqSmallest(Number const& a, Number const& b, Number const& c); -/** Generate AMM offer starting with takerGets when AMM pool - * from the payment perspective is IOU(in)/XRP(out) - * Equations: - * Spot Price Quality after the offer is consumed: - * Qsp = (O - o) / (I + i) -- equation (1) - * where O is poolPays, I is poolGets, o is takerGets, i is takerPays - * Swap out: - * i = (I * o) / (O - o) * f -- equation (2) - * where f is (1 - tfee/100000), tfee is in basis points - * Effective price targetQuality: - * Qep = o / i -- equation (3) - * There are two scenarios to consider - * A) Qsp = Qep. Substitute i in (1) with (2) and solve for o - * and Qsp = targetQuality(Qt): - * o**2 + o * (I * Qt * (1 - 1 / f) - 2 * O) + O**2 - Qt * I * O = 0 - * B) Qep = Qsp. Substitute i in (3) with (2) and solve for o - * and Qep = targetQuality(Qt): - * o = O - I * Qt / f - * Since the scenario is not known a priori, both A and B are solved and - * the lowest value of o is takerGets. takerPays is calculated with - * swap out eq (2). If o is less or equal to 0 then the offer can't - * be generated. +/** Generate a synthetic AMM offer whose quality matches @p targetQuality, + * starting from takerGets (XRP out, IOU in). + * + * Used when the pool pays XRP (IOU-in / XRP-out). Starting from the XRP + * side ensures that rounding XRP down to integer drops improves rather than + * degrades offer quality (post-`fixAMMv1_1` behavior). + * + * Two binding constraints are solved and the smaller takerGets is chosen: + * - Scenario A — post-swap spot price equals @p targetQuality: + * `o² + o·(I·Qt·(1 - 1/f) - 2·O) + O² - Qt·I·O = 0` + * - Scenario B — effective offer price equals @p targetQuality: + * `o = O - I·Qt / f` + * + * where `O = poolPays`, `I = poolGets`, `f = feeMult(tfee)`. + * takerPays is then derived from the swap-out equation. If the resulting + * offer quality is still below @p targetQuality after rounding, a 99.99% + * rescale via `detail::reduceOffer` is attempted. + * + * @tparam TIn Asset type flowing into the pool (IOU side). + * @tparam TOut Asset type flowing out of the pool (XRP side). + * @param pool Current AMM pool balances (`in` = poolGets, `out` = poolPays). + * @param targetQuality Desired offer quality (CLOB best quality). + * @param tfee Trading fee in basis points. + * @return Seated `{takerPays, takerGets}` amounts, or `std::nullopt` if a + * valid offer cannot be generated (e.g. target quality unreachable at + * current fee). */ template std::optional> @@ -214,28 +309,30 @@ getAMMOfferStartWithTakerGets( return amounts; } -/** Generate AMM offer starting with takerPays when AMM pool - * from the payment perspective is XRP(in)/IOU(out) or IOU(in)/IOU(out). - * Equations: - * Spot Price Quality after the offer is consumed: - * Qsp = (O - o) / (I + i) -- equation (1) - * where O is poolPays, I is poolGets, o is takerGets, i is takerPays - * Swap in: - * o = (O * i * f) / (I + i * f) -- equation (2) - * where f is (1 - tfee/100000), tfee is in basis points - * Effective price quality: - * Qep = o / i -- equation (3) - * There are two scenarios to consider - * A) Qsp = Qep. Substitute o in (1) with (2) and solve for i - * and Qsp = targetQuality(Qt): - * i**2 * f + i * I * (1 + f) + I**2 - I * O / Qt = 0 - * B) Qep = Qsp. Substitute i in (3) with (2) and solve for i - * and Qep = targetQuality(Qt): - * i = O / Qt - I / f - * Since the scenario is not known a priori, both A and B are solved and - * the lowest value of i is takerPays. takerGets is calculated with - * swap in eq (2). If i is less or equal to 0 then the offer can't - * be generated. +/** Generate a synthetic AMM offer whose quality matches @p targetQuality, + * starting from takerPays (XRP in, or IOU/IOU). + * + * Used for XRP-in/IOU-out and IOU/IOU pools. Starting from the XRP + * side (takerPays) under `fixAMMv1_1` keeps rounding effects favorable. + * + * Two binding constraints are solved and the smaller takerPays is chosen: + * - Scenario A — post-swap spot price equals @p targetQuality: + * `i²·f + i·I·(1+f) + I² - I·O/Qt = 0` + * - Scenario B — effective offer price equals @p targetQuality: + * `i = O/Qt - I/f` + * + * where `O = poolPays`, `I = poolGets`, `f = feeMult(tfee)`. + * takerGets is then derived from the swap-in equation. If the resulting + * offer quality is still below @p targetQuality after rounding, a 99.99% + * rescale via `detail::reduceOffer` is attempted. + * + * @tparam TIn Asset type flowing into the pool. + * @tparam TOut Asset type flowing out of the pool. + * @param pool Current AMM pool balances (`in` = poolGets, `out` = poolPays). + * @param targetQuality Desired offer quality (CLOB best quality). + * @param tfee Trading fee in basis points. + * @return Seated `{takerPays, takerGets}` amounts, or `std::nullopt` if a + * valid offer cannot be generated. */ template std::optional> @@ -281,21 +378,34 @@ getAMMOfferStartWithTakerPays( return amounts; } -/** Generate AMM offer so that either updated Spot Price Quality (SPQ) - * is equal to LOB quality (in this case AMM offer quality is - * better than LOB quality) or AMM offer is equal to LOB quality - * (in this case SPQ is better than LOB quality). - * Pre-amendment code calculates takerPays first. If takerGets is XRP, - * it is rounded down, which results in worse offer quality than - * LOB quality, and the offer might fail to generate. - * Post-amendment code calculates the XRP offer side first. The result - * is rounded down, which makes the offer quality better. - * It might not be possible to match either SPQ or AMM offer to LOB - * quality. This generally happens at higher fees. - * @param pool AMM pool balances - * @param quality requested quality - * @param tfee trading fee in basis points - * @return seated in/out amounts if the quality can be changed +/** Generate a synthetic AMM offer that aligns the pool's spot price with a CLOB quality. + * + * The payment engine calls this when it encounters both AMM pools and order + * book offers for the same currency pair. The resulting offer has a quality + * such that either the post-swap spot price equals @p quality (AMM offer + * quality is better) or the offer's effective price equals @p quality (the + * post-swap spot price is better) — whichever produces the smaller offer. + * + * Amendment behavior: + * - Pre-`fixAMMv1_1`: always solves for takerPays first; rounding down XRP + * takerGets can push quality below target, causing the offer to be rejected. + * - Post-`fixAMMv1_1`: solves for the XRP side first (takerGets when pool pays + * XRP, takerPays otherwise) so XRP rounding improves rather than degrades + * quality. Falls back to `detail::reduceOffer` if quality is still below + * target after rounding. + * + * A quality mismatch larger than 1e-7 is logged at `j.error()` level; smaller + * mismatches are trace-only. + * + * @tparam TIn Asset type flowing into the pool. + * @tparam TOut Asset type flowing out of the pool. + * @param pool Current AMM pool balances. + * @param quality Target quality (best CLOB offer quality for this pair). + * @param tfee Trading fee in basis points. + * @param rules Current ledger rules (for amendment checks). + * @param j Journal for diagnostic logging. + * @return Seated `{takerPays, takerGets}` amounts, or `std::nullopt` if the + * quality cannot be achieved (generally at high fees). */ template std::optional> @@ -398,26 +508,26 @@ changeSpotPriceQuality( return amounts; } -/** AMM pool invariant - the product (A * B) after swap in/out has to remain - * at least the same: (A + in) * (B - out) >= A * B - * XRP round-off may result in a smaller product after swap in/out. - * To address this: - * - if on swapIn the out is XRP then the amount is round-off - * downward, making the product slightly larger since out - * value is reduced. - * - if on swapOut the in is XRP then the amount is round-off - * upward, making the product slightly larger since in - * value is increased. - */ +// --- Swap-in / Swap-out --- -/** Swap assetIn into the pool and swap out a proportional amount - * of the other asset. Implements AMM Swap in. - * @see [XLS30d:AMM - * Swap](https://github.com/XRPLF/XRPL-Standards/discussions/78) - * @param pool current AMM pool balances - * @param assetIn amount to swap in - * @param tfee trading fee in basis points - * @return +/** Deposit @p assetIn into the pool and receive a proportional amount of the + * other asset (AMM Swap in, XLS-30d). + * + * Formula: `out = pool.out - (pool.in × pool.out) / (pool.in + assetIn × feeMult(tfee))` + * + * Pool invariant: `(pool.in + assetIn) × (pool.out - out) >= pool.in × pool.out`. + * XRP integer rounding can violate this; post-`fixAMMv1_1` each sub-expression + * has an explicitly directed rounding mode so the pool retains a tiny surplus. + * The output is always rounded downward so the trader receives less, not more. + * + * @tparam TIn Asset type deposited (poolGets side). + * @tparam TOut Asset type received (poolPays side). + * @param pool Current AMM pool balances. + * @param assetIn Amount being deposited into the pool. + * @param tfee Trading fee in basis points. + * @return Amount of the output asset the trader receives; zero if the pool + * denominator is non-positive. + * @see [XLS-30d AMM Swap](https://github.com/XRPLF/XRPL-Standards/discussions/78) */ template TOut @@ -476,14 +586,23 @@ swapAssetIn(TAmounts const& pool, TIn const& assetIn, std::uint16_t t Number::RoundingMode::Downward); } -/** Swap assetOut out of the pool and swap in a proportional amount - * of the other asset. Implements AMM Swap out. - * @see [XLS30d:AMM - * Swap](https://github.com/XRPLF/XRPL-Standards/discussions/78) - * @param pool current AMM pool balances - * @param assetOut amount to swap out - * @param tfee trading fee in basis points - * @return +/** Withdraw @p assetOut from the pool and compute the required input asset (AMM Swap out, XLS-30d). + * + * Formula: `in = ((pool.in × pool.out) / (pool.out - assetOut) - pool.in) / feeMult(tfee)` + * + * The input is always rounded upward so the trader pays at least what the + * pool needs to maintain its invariant. Post-`fixAMMv1_1` each intermediate + * step is individually directed; if the pool denominator is non-positive (i.e. + * @p assetOut >= the entire pool), the maximum representable `TIn` is returned. + * + * @tparam TIn Asset type deposited (poolGets side). + * @tparam TOut Asset type withdrawn (poolPays side). + * @param pool Current AMM pool balances. + * @param assetOut Amount being withdrawn from the pool. + * @param tfee Trading fee in basis points. + * @return Amount of the input asset the trader must pay; `toMaxAmount` + * if the requested output would exhaust the pool. + * @see [XLS-30d AMM Swap](https://github.com/XRPLF/XRPL-Standards/discussions/78) */ template TIn @@ -542,35 +661,46 @@ swapAssetOut(TAmounts const& pool, TOut const& assetOut, std::uint16_ Number::RoundingMode::Upward); } -/** Return square of n. - */ +/** Return `n²`. */ Number square(Number const& n); -/** Adjust LP tokens to deposit/withdraw. - * Amount type keeps 16 digits. Maintaining the LP balance by adding - * deposited tokens or subtracting withdrawn LP tokens from LP balance - * results in losing precision in LP balance. I.e. the resulting LP balance - * is less than the actual sum of LP tokens. To adjust for this, subtract - * old tokens balance from the new one for deposit or vice versa for - * withdraw to cancel out the precision loss. - * @param lptAMMBalance LPT AMM Balance - * @param lpTokens LP tokens to deposit or withdraw - * @param isDeposit Yes if deposit, No if withdraw +/** Adjust LP tokens to account for 16-digit precision loss in the running balance. + * + * Adding newly-minted tokens to an already-large `lptAMMBalance` can lose + * significance in the least-significant digit: the stored balance advances + * by less than `lpTokens`. This function round-trips through the 16-digit + * representation by computing `(balance + tokens) - balance` (deposit) or + * `(tokens - balance) + balance` (withdraw), returning the value that will + * actually be committed to the ledger. Result is forced downward to ensure + * the adjusted tokens do not exceed the requested tokens. + * + * @param lptAMMBalance Current total LP token supply stored on the AMM SLE. + * @param lpTokens Tokens being minted or burned. + * @param isDeposit `IsDeposit::Yes` for deposit, `IsDeposit::No` for withdrawal. + * @return Adjusted token amount that exactly matches the representable delta + * in the 16-digit balance. */ STAmount adjustLPTokens(STAmount const& lptAMMBalance, STAmount const& lpTokens, IsDeposit isDeposit); -/** Calls adjustLPTokens() and adjusts deposit or withdraw amounts if - * the adjusted LP tokens are less than the provided LP tokens. - * @param amountBalance asset1 pool balance - * @param amount asset1 to deposit or withdraw - * @param amount2 asset2 to deposit or withdraw - * @param lptAMMBalance LPT AMM Balance - * @param lpTokens LP tokens to deposit or withdraw - * @param tfee trading fee in basis points - * @param isDeposit Yes if deposit, No if withdraw - * @return +/** Adjust deposit/withdrawal asset amounts to match the precision-corrected LP token count. + * + * Calls `adjustLPTokens()` to compute the representable token delta. If the + * adjusted count is less than @p lpTokens, the corresponding asset amounts are + * scaled down so the ledger does not grant assets that exceed what the LP token + * math supports. A no-op when `fixAMMv1_3` is active because `getRoundedLPTokens` + * already incorporates the precision adjustment. + * + * @param amountBalance Current pool balance of the primary asset. + * @param amount Primary asset amount to deposit or withdraw. + * @param amount2 Secondary asset amount for two-sided operations; `std::nullopt` + * for single-asset operations. + * @param lptAMMBalance Current total LP token supply. + * @param lpTokens Calculated LP tokens before precision adjustment. + * @param tfee Trading fee in basis points. + * @param isDeposit `IsDeposit::Yes` for deposit, `IsDeposit::No` for withdrawal. + * @return Tuple of `(adjustedAmount, adjustedAmount2, adjustedLPTokens)`. */ std::tuple, STAmount> adjustAmountsByLPTokens( @@ -582,17 +712,46 @@ adjustAmountsByLPTokens( std::uint16_t tfee, IsDeposit isDeposit); -/** Positive solution for quadratic equation: - * x = (-b + sqrt(b**2 + 4*a*c))/(2*a) +/** Positive root of `a·x² + b·x + c = 0` using the standard formula. + * + * Computes `x = (-b + sqrt(b² - 4·a·c)) / (2·a)`. Used by `ammAssetIn` + * to invert Equation 4; the discriminant is guaranteed non-negative by the + * deposit formula's domain. + * + * @param a Quadratic coefficient. + * @param b Linear coefficient. + * @param c Constant term. + * @return The positive root. */ Number solveQuadraticEq(Number const& a, Number const& b, Number const& c); +/** Multiply @p amount by @p frac with an explicitly directed rounding mode. + * + * Installs @p rm for both the `Number` multiplication and the subsequent + * `toSTAmount` conversion so that rounding is applied once at the final step, + * not accumulated through intermediates. This is the building block for all + * `fixAMMv1_3` directional-rounding paths. + * + * @param amount Base `STAmount` to scale. + * @param frac Scaling factor. + * @param rm Rounding mode to apply at the final conversion step. + * @return `amount × frac` rounded according to @p rm, expressed in the same + * asset as @p amount. + */ STAmount multiply(STAmount const& amount, Number const& frac, Number::RoundingMode rm); namespace detail { +/** Select the LP token rounding direction that preserves the pool invariant. + * + * Deposit: round downward (fewer tokens minted → pool worth more per token). + * Withdraw: round upward (more tokens burned → pool retains slightly more). + * + * @param isDeposit Direction of the operation. + * @return `Downward` for deposit, `Upward` for withdrawal. + */ inline Number::RoundingMode getLPTokenRounding(IsDeposit isDeposit) { @@ -602,6 +761,14 @@ getLPTokenRounding(IsDeposit isDeposit) : Number::RoundingMode::Upward; } +/** Select the asset rounding direction that preserves the pool invariant. + * + * Deposit: round upward (depositor pays slightly more → pool is larger). + * Withdraw: round downward (withdrawer receives slightly less → pool retains). + * + * @param isDeposit Direction of the operation. + * @return `Upward` for deposit, `Downward` for withdrawal. + */ inline Number::RoundingMode getAssetRounding(IsDeposit isDeposit) { @@ -613,10 +780,19 @@ getAssetRounding(IsDeposit isDeposit) } // namespace detail -/** Round AMM equal deposit/withdrawal amount. Deposit/withdrawal formulas - * calculate the amount as a fractional value of the pool balance. The rounding - * takes place on the last step of multiplying the balance by the fraction if - * AMMv1_3 is enabled. +/** Compute a proportional asset amount with amendment-gated directional rounding. + * + * Used for two-sided (equal) deposit/withdrawal where the asset amount is + * `balance × frac`. Under `fixAMMv1_3` the final multiplication is rounded + * via `detail::getAssetRounding` (upward on deposit, downward on withdraw). + * Without the amendment the result uses the current ambient rounding mode. + * + * @tparam A Type of @p frac; either `STAmount` or `Number`. + * @param rules Current ledger rules. + * @param balance Pool balance of the asset. + * @param frac Fraction of the pool balance to apply. + * @param isDeposit Direction; controls rounding when `fixAMMv1_3` is active. + * @return `balance × frac` rounded to preserve the pool invariant. */ template STAmount @@ -637,14 +813,20 @@ getRoundedAsset(Rules const& rules, STAmount const& balance, A const& frac, IsDe return multiply(balance, frac, rm); } -/** Round AMM single deposit/withdrawal amount. - * The lambda's are used to delay evaluation until the function - * is executed so that the calculation is not done twice. noRoundCb() is - * called if AMMv1_3 is disabled. Otherwise, the rounding is set and - * the amount is: - * isDeposit is Yes - the balance multiplied by productCb() - * isDeposit is No - the result of productCb(). The rounding is - * the same for all calculations in productCb() +/** Compute a single-asset deposit/withdrawal amount with amendment-gated rounding. + * + * The callback form defers evaluation to avoid computing the formula twice: + * - Without `fixAMMv1_3`: calls `noRoundCb()` and converts without directed rounding. + * - With `fixAMMv1_3`, deposit: calls `multiply(balance, productCb(), rm)`. + * - With `fixAMMv1_3`, withdrawal: installs @p rm globally and calls `productCb()` + * so every arithmetic step inside the callback shares the same rounding direction. + * + * @param rules Current ledger rules. + * @param noRoundCb Produces the unrounded result (pre-amendment path). + * @param balance Pool balance of the asset. + * @param productCb Produces the rounding fraction (post-amendment path). + * @param isDeposit Direction; controls which rounding mode is selected. + * @return Rounded asset amount preserving the pool invariant. */ STAmount getRoundedAsset( @@ -654,12 +836,18 @@ getRoundedAsset( std::function const& productCb, IsDeposit isDeposit); -/** Round AMM deposit/withdrawal LPToken amount. Deposit/withdrawal formulas - * calculate the lptokens as a fractional value of the AMM total lptokens. - * The rounding takes place on the last step of multiplying the balance by - * the fraction if AMMv1_3 is enabled. The tokens are then - * adjusted to factor in the loss in precision (we only keep 16 significant - * digits) when adding the lptokens to the balance. +/** Compute a proportional LP token amount with amendment-gated rounding and precision adjustment. + * + * Used for two-sided (equal) deposit/withdrawal. Under `fixAMMv1_3` the + * multiplication `balance × frac` is rounded via `detail::getLPTokenRounding`, + * then `adjustLPTokens` corrects for the 16-digit precision loss introduced + * when adding the result to the running LP token balance. + * + * @param rules Current ledger rules. + * @param balance Current total LP token supply. + * @param frac Fraction of the pool's LP supply to mint or burn. + * @param isDeposit Direction; controls rounding and sign of the adjustment. + * @return LP token amount after rounding and precision correction. */ STAmount getRoundedLPTokens( @@ -668,16 +856,22 @@ getRoundedLPTokens( Number const& frac, IsDeposit isDeposit); -/** Round AMM single deposit/withdrawal LPToken amount. - * The lambda's are used to delay evaluation until the function is executed - * so that the calculations are not done twice. - * noRoundCb() is called if AMMv1_3 is disabled. Otherwise, the rounding is set - * and the lptokens are: - * if isDeposit is Yes - the result of productCb(). The rounding is - * the same for all calculations in productCb() - * if isDeposit is No - the balance multiplied by productCb() - * The lptokens are then adjusted to factor in the loss in precision - * (we only keep 16 significant digits) when adding the lptokens to the balance. +/** Compute a single-asset LP token amount with amendment-gated rounding and precision adjustment. + * + * The callback form avoids evaluating the formula twice: + * - Without `fixAMMv1_3`: calls `noRoundCb()` with no directed rounding. + * - With `fixAMMv1_3`, deposit: installs the LP rounding mode globally and + * calls `productCb()` (all arithmetic inside shares the direction). + * - With `fixAMMv1_3`, withdrawal: calls `multiply(lptAMMBalance, productCb(), rm)`. + * In all post-amendment cases, `adjustLPTokens` then corrects for 16-digit + * precision loss in the running LP balance. + * + * @param rules Current ledger rules. + * @param noRoundCb Produces the unrounded result (pre-amendment path). + * @param lptAMMBalance Current total LP token supply. + * @param productCb Produces the rounding fraction (post-amendment path). + * @param isDeposit Direction; controls rounding mode selection. + * @return LP token amount after rounding and precision correction. */ STAmount getRoundedLPTokens( @@ -687,16 +881,21 @@ getRoundedLPTokens( std::function const& productCb, IsDeposit isDeposit); -/* Next two functions adjust asset in/out amount to factor in the adjusted - * lptokens. The lptokens are calculated from the asset in/out. The lptokens are - * then adjusted to factor in the loss in precision. The adjusted lptokens might - * be less than the initially calculated tokens. Therefore, the asset in/out - * must be adjusted. The rounding might result in the adjusted amount being - * greater than the original asset in/out amount. If this happens, - * then the original amount is reduced by the difference in the adjusted amount - * and the original amount. The actual tokens and the actual adjusted amount - * are then recalculated. The minimum of the original and the actual - * adjusted amount is returned. +/** Adjust a single-asset deposit amount to match the precision-corrected LP token count. + * + * Under `fixAMMv1_3`: computes `ammAssetIn(balance, lptAMMBalance, tokens, tfee)`. + * If rounding causes the derived asset amount to exceed @p amount, the deposit is + * reduced by the overshoot and both tokens and asset are recomputed, then the minimum + * of original and adjusted amounts is returned. Before the amendment, returns the + * inputs unchanged. + * + * @param rules Current ledger rules. + * @param balance Pool balance of the asset being deposited. + * @param amount Requested deposit amount. + * @param lptAMMBalance Current total LP token supply. + * @param tokens LP token count before precision adjustment. + * @param tfee Trading fee in basis points. + * @return `{adjustedTokens, adjustedAmount}` pair. */ std::pair adjustAssetInByTokens( @@ -706,6 +905,23 @@ adjustAssetInByTokens( STAmount const& lptAMMBalance, STAmount const& tokens, std::uint16_t tfee); + +/** Adjust a single-asset withdrawal amount to match the precision-corrected LP token count. + * + * Under `fixAMMv1_3`: computes `ammAssetOut(balance, lptAMMBalance, tokens, tfee)`. + * If rounding causes the derived asset amount to exceed @p amount, the withdrawal is + * reduced by the overshoot and both tokens and asset are recomputed, then the minimum + * of original and adjusted amounts is returned. Before the amendment, returns the + * inputs unchanged. + * + * @param rules Current ledger rules. + * @param balance Pool balance of the asset being withdrawn. + * @param amount Requested withdrawal amount. + * @param lptAMMBalance Current total LP token supply. + * @param tokens LP token count before precision adjustment. + * @param tfee Trading fee in basis points. + * @return `{adjustedTokens, adjustedAmount}` pair. + */ std::pair adjustAssetOutByTokens( Rules const& rules, @@ -715,8 +931,20 @@ adjustAssetOutByTokens( STAmount const& tokens, std::uint16_t tfee); -/** Find a fraction of tokens after the tokens are adjusted. The fraction - * is used to adjust equal deposit/withdraw amount. +/** Recompute the LP token fraction after precision adjustment. + * + * Under `fixAMMv1_3` the precision-adjusted token count may differ from the + * originally requested count, so the fraction `tokens / lptAMMBalance` must + * be recomputed from the adjusted value before it is used to scale equal + * deposit/withdrawal amounts. Returns @p frac unchanged when `fixAMMv1_3` + * is inactive (the precision adjustment has not yet been applied). + * + * @param rules Current ledger rules. + * @param lptAMMBalance Current total LP token supply. + * @param tokens Precision-adjusted LP token count. + * @param frac Original fraction before adjustment. + * @return Adjusted fraction `tokens / lptAMMBalance`, or @p frac if + * `fixAMMv1_3` is not active. */ Number adjustFracByTokens( @@ -725,7 +953,19 @@ adjustFracByTokens( STAmount const& tokens, Number const& frac); -/** Get AMM pool balances. +/** Read the AMM's current pool asset balances from the ledger. + * + * Delegates to `accountHolds` for each asset, respecting freeze and + * authorization policy. Does not read the LP token balance. + * + * @param view Ledger state to query. + * @param ammAccountID AccountID of the AMM's pseudo-account. + * @param asset1 First pool asset. + * @param asset2 Second pool asset. + * @param freezeHandling Whether to enforce freeze restrictions. + * @param authHandling Whether to enforce authorization restrictions. + * @param j Journal for diagnostic logging. + * @return `{balance1, balance2}` pair in the same asset order as the inputs. */ std::pair ammPoolHolds( @@ -737,9 +977,23 @@ ammPoolHolds( AuthHandling authHandling, beast::Journal const j); -/** Get AMM pool and LP token balances. If both optIssue are - * provided then they are used as the AMM token pair issues. - * Otherwise the missing issues are fetched from ammSle. +/** Read the AMM's pool balances and total LP token supply from the ledger. + * + * When both optional assets are provided they are validated against the AMM + * SLE's stored pair and used as the query order; providing only one resolves + * the counterpart from `ammSle`. If neither is provided, the canonical order + * from `ammSle` is used. An invalid asset pair (mismatched with the AMM SLE) + * indicates a corrupted AMM object and returns `tecAMM_INVALID_TOKENS`. + * + * @param view Ledger state to query. + * @param ammSle The AMM's `ltAMM` SLE. + * @param optAsset1 Optional first asset override. + * @param optAsset2 Optional second asset override. + * @param freezeHandling Whether to enforce freeze restrictions. + * @param authHandling Whether to enforce authorization restrictions. + * @param j Journal for diagnostic logging. + * @return `{balance1, balance2, lpTokenBalance}` on success, or + * `Unexpected(tecAMM_INVALID_TOKENS)` if the asset pair is invalid. */ Expected, TER> ammHolds( @@ -751,7 +1005,21 @@ ammHolds( AuthHandling authHandling, beast::Journal const j); -/** Get the balance of LP tokens. +/** Read an LP's token balance from its direct trustline with the AMM account. + * + * Intentionally bypasses `accountHolds` — that function would also check + * whether the AMM's underlying pool assets are frozen (under + * `fixFrozenLPTokenTransfer`), which is incorrect policy for LP token balance + * queries. Only the LP token trustline's own freeze flag is checked. + * Trust-line orientation: raw `sfBalance` is negated when `lpAccount > ammAccount`. + * + * @param view Ledger state to query. + * @param asset1 First pool asset (used to derive the LP token currency). + * @param asset2 Second pool asset. + * @param ammAccount AccountID of the AMM's pseudo-account (LP token issuer). + * @param lpAccount AccountID of the liquidity provider. + * @param j Journal for diagnostic logging. + * @return The LP's token balance, or zero if the trustline is absent or frozen. */ STAmount ammLPHolds( @@ -762,6 +1030,17 @@ ammLPHolds( AccountID const& lpAccount, beast::Journal const j); +/** Read an LP's token balance using the asset pair stored in @p ammSle. + * + * Convenience overload; extracts `sfAsset`, `sfAsset2`, and `sfAccount` from + * @p ammSle and delegates to the five-parameter `ammLPHolds`. + * + * @param view Ledger state to query. + * @param ammSle The AMM's `ltAMM` SLE. + * @param lpAccount AccountID of the liquidity provider. + * @param j Journal for diagnostic logging. + * @return The LP's token balance, or zero if the trustline is absent or frozen. + */ STAmount ammLPHolds( ReadView const& view, @@ -769,25 +1048,72 @@ ammLPHolds( AccountID const& lpAccount, beast::Journal const j); -/** Get AMM trading fee for the given account. The fee is discounted - * if the account is the auction slot owner or one of the slot's authorized - * accounts. +/** Get the effective AMM trading fee for @p account. + * + * Returns the auction slot's `sfDiscountedFee` if the slot is unexpired and + * @p account is either the slot owner or one of up to four authorized accounts; + * otherwise returns the AMM's global `sfTradingFee`. Expiration is compared + * against the ledger's `parentCloseTime` (the slot stores + * `parentCloseTime + TOTAL_TIME_SLOT_SECS` at creation, i.e. 24 hours). + * + * @param view Ledger state providing the current close time. + * @param ammSle The AMM's `ltAMM` SLE. + * @param account The account whose fee rate is needed. + * @return Fee rate in basis points (0–1000). */ std::uint16_t getTradingFee(ReadView const& view, SLE const& ammSle, AccountID const& account); -/** Returns total amount held by AMM for the given token. +/** Read the AMM account's raw pool-asset balance, bypassing balance hooks. + * + * Unlike `accountHolds`, this function does not invoke `balanceHookIOU` or + * `balanceHookMPT`, so the result is unaffected by `PaymentSandbox` + * deferred-credit accounting. Used when the AMM needs its own unmodified + * balance for math, not for payment routing. Returns zero if the trustline + * or MPToken object is absent or frozen. + * + * @param view Ledger state to query. + * @param ammAccountID AccountID of the AMM's pseudo-account. + * @param asset The pool asset to query (IOU, XRP, or MPT). + * @return The raw balance, or zero if unavailable. */ STAmount ammAccountHolds(ReadView const& view, AccountID const& ammAccountID, Asset const& asset); -/** Delete trustlines to AMM. If all trustlines are deleted then - * AMM object and account are deleted. Otherwise tecINCOMPLETE is returned. +/** Remove all ledger objects owned by the AMM and, if successful, delete the AMM itself. + * + * Deletion is ordered: IOU trustlines first, then MPToken objects, then the + * AMM SLE and its `AccountRoot`. Because each ledger transaction has a bounded + * work budget, not all trustlines may be removable in one call; in that case + * `tecINCOMPLETE` is returned and the caller must submit additional transactions + * to finish. The AMM can be re-deposited while deletion is incomplete. + * + * @param view Sandbox for applying state changes. + * @param asset First pool asset (used to locate the AMM keylet). + * @param asset2 Second pool asset. + * @param j Journal for diagnostic logging. + * @return `tesSUCCESS` on full deletion, `tecINCOMPLETE` if trustlines remain, + * or `tecINTERNAL` for unexpected ledger inconsistencies. */ TER deleteAMMAccount(Sandbox& view, Asset const& asset, Asset const& asset2, beast::Journal j); -/** Initialize Auction and Voting slots and set the trading/discounted fee. +/** Initialize the vote slot and auction slot on a new or re-created AMM. + * + * Called on both `AMMCreate` and on `AMMDeposit` when the pool was previously + * drained to zero. Sets up: + * - One vote entry for @p account with full weight (`kVOTE_WEIGHT_SCALE_FACTOR`). + * - An auction slot owned by @p account, expiring in 24 hours, at zero price. + * - `sfDiscountedFee` = `tfee / kAUCTION_SLOT_DISCOUNTED_FEE_FRACTION`. + * - Absent-field canonicalization: fee fields are removed if their value is zero. + * - Under `fixCleanup3_2_0`, stale `sfAuthAccounts` from any previous slot owner + * are cleared. + * + * @param view Apply-view for the current transaction. + * @param ammSle The AMM's `ltAMM` SLE (modified in place). + * @param account The creator/re-depositor receiving the slot. + * @param lptAsset The LP token asset descriptor (used as the `sfPrice` currency). + * @param tfee Trading fee in basis points to set. */ void initializeFeeAuctionVote( @@ -797,16 +1123,41 @@ initializeFeeAuctionVote( Asset const& lptAsset, std::uint16_t tfee); -/** Return true if the Liquidity Provider is the only AMM provider, false - * otherwise. Return tecINTERNAL if encountered an unexpected condition, - * for instance Liquidity Provider has more than one LPToken trustline. +/** Determine whether @p lpAccount is the sole remaining liquidity provider. + * + * Walks the AMM account's owner directory (up to 10 pages, covering at most + * 4 objects) counting LPToken trustlines, pool-asset trustlines, MPToken + * objects, and the AMM SLE itself. Any second LPToken trustline belonging to + * a different account returns `false` immediately. + * + * @param view Ledger state to query. + * @param ammIssue The LP token issue (currency + AMM account as issuer). + * @param lpAccount AccountID of the candidate sole LP. + * @return `true` if @p lpAccount is the only LP, `false` if other LPs exist, + * or `Unexpected(tecINTERNAL)` for any unexpected directory state + * (e.g. more than one LPToken trustline for @p lpAccount). */ Expected isOnlyLiquidityProvider(ReadView const& view, Issue const& ammIssue, AccountID const& lpAccount); -/** Due to rounding, the LPTokenBalance of the last LP might - * not match the LP's trustline balance. If it's within the tolerance, - * update LPTokenBalance to match the LP's trustline balance. +/** Reconcile the AMM's `sfLPTokenBalance` with the last LP's trustline balance. + * + * Accumulated rounding over the life of the pool can cause the AMM's running + * `sfLPTokenBalance` to differ slightly from the sole LP's trustline balance. + * This function: + * 1. Confirms @p account is the only remaining LP via `isOnlyLiquidityProvider`. + * 2. If so, verifies the discrepancy is within 0.1% (tolerance `1e-3`). + * 3. If within tolerance, updates `sfLPTokenBalance` to @p lpTokens so the + * final withdrawal leaves the AMM in a fully consistent state. + * + * @param sb Sandbox for applying the balance correction. + * @param lpTokens The last LP's actual trustline balance. + * @param ammSle The AMM's `ltAMM` SLE (updated in place if correction applied). + * @param account AccountID of the candidate sole LP. + * @return `true` if the balance was reconciled or no adjustment was needed + * (other LPs exist), `Unexpected(tecAMM_INVALID_TOKENS)` if the + * discrepancy exceeds tolerance, or `Unexpected(tecINTERNAL)` on an + * unexpected directory error. */ Expected verifyAndAdjustLPTokenBalance( diff --git a/include/xrpl/ledger/helpers/AccountRootHelpers.h b/include/xrpl/ledger/helpers/AccountRootHelpers.h index 353c27fe41..3d225c8c1c 100644 --- a/include/xrpl/ledger/helpers/AccountRootHelpers.h +++ b/include/xrpl/ledger/helpers/AccountRootHelpers.h @@ -1,3 +1,12 @@ +/** @file + * Free functions for querying and mutating `ltACCOUNT_ROOT` ledger entries. + * + * Provides the canonical helpers for freeze-state queries, spendable XRP + * balance, owner-count bookkeeping, transfer fees, destination-tag + * enforcement, and the creation and detection of pseudo-accounts (AMM, + * Vault, LoanBroker). Almost every transaction processor depends on at + * least one function here. + */ #pragma once #include @@ -15,26 +24,60 @@ namespace xrpl { -/** Check if the issuer has the global freeze flag set. - @param issuer The account to check - @return true if the account has global freeze set -*/ +/** Check whether an IOU issuer has the global freeze flag active. + * + * XRP is never frozen; this function returns `false` immediately for the XRP + * account. For any other issuer it reads `lsfGlobalFreeze` from the + * account root. Missing accounts are treated as non-frozen. + * + * @param view The read-only ledger view to query. + * @param issuer The account whose freeze state is to be checked. + * @return `true` if `issuer` is a non-XRP account with `lsfGlobalFreeze` set; + * `false` otherwise. + */ [[nodiscard]] bool isGlobalFrozen(ReadView const& view, AccountID const& issuer); -// Calculate liquid XRP balance for an account. -// This function may be used to calculate the amount of XRP that -// the holder is able to freely spend. It subtracts reserve requirements. -// -// ownerCountAdj adjusts the owner count in case the caller calculates -// before ledger entries are added or removed. Positive to add, negative -// to subtract. -// -// @param ownerCountAdj positive to add to count, negative to reduce count. +/** Compute the spendable XRP balance for an account after reserve deduction. + * + * Queries the account's current balance and owner count through the view's + * virtual hook methods (`balanceHookIOU`, `ownerCountHook`) so that + * `PaymentSandbox` can overlay uncommitted in-flight changes without any + * branching here. The reserve is then subtracted; if the balance is below + * the reserve, the function returns zero rather than a negative amount. + * + * Pseudo-accounts (AMM, Vault, LoanBroker) bypass the reserve calculation + * entirely and receive the full balance as spendable XRP, because they + * cannot submit transactions and must never be blocked by reserve checks. + * + * @param view The ledger view to query. + * @param id The account whose liquid XRP balance is computed. + * @param ownerCountAdj Signed delta applied to `sfOwnerCount` before the + * reserve is calculated. Pass a positive value when the caller is about + * to add ledger entries; pass a negative value when entries are about to + * be removed. This lets callers reason about post-mutation availability + * before the state is committed to the view. + * @param j Journal for trace-level diagnostics. + * @return The spendable XRP amount, clamped to zero from below. + */ [[nodiscard]] XRPAmount xrpLiquid(ReadView const& view, AccountID const& id, std::int32_t ownerCountAdj, beast::Journal j); -/** Adjust the owner count up or down. */ +/** Increment or decrement `sfOwnerCount` on an account SLE and notify the view. + * + * Delegates to a file-static helper that clamps the result to + * `[0, UINT32_MAX]`, logging at `fatal` severity if either bound would be + * exceeded — silent wrapping of the `uint32_t` field would corrupt ledger + * state. After clamping, `view.adjustOwnerCountHook()` is called before the + * new value is written; `PaymentSandbox` overrides that hook to track the + * high-water-mark count, ensuring subsequent `ownerCountHook` reads use the + * most conservative value seen during the payment. + * + * @param view The mutable view on which the SLE update is recorded. + * @param sle The account SLE to adjust; a null pointer is silently ignored. + * @param amount Signed delta to apply to `sfOwnerCount`; must be non-zero. + * @param j Journal for fatal-level diagnostics on overflow or underflow. + */ void adjustOwnerCount( ApplyView& view, @@ -42,45 +85,89 @@ adjustOwnerCount( std::int32_t amount, beast::Journal j); -/** Returns IOU issuer transfer fee as Rate. Rate specifies - * the fee as fractions of 1 billion. For example, 1% transfer rate - * is represented as 1,010,000,000. - * @param issuer The IOU issuer +/** Return the IOU transfer fee for an issuer as a `Rate` value. + * + * `Rate` expresses the fee as a fraction of one billion, so a 1% fee is + * represented as 1,010,000,000. If the issuer account does not exist or + * has not set `sfTransferRate`, `parityRate` (no fee, i.e., 1,000,000,000) + * is returned — callers never need to handle a null case. + * + * @param view The ledger view to query. + * @param issuer The IOU issuer whose transfer fee is requested. + * @return The issuer's `Rate`, or `parityRate` if none is configured. */ [[nodiscard]] Rate transferRate(ReadView const& view, AccountID const& issuer); -/** Generate a pseudo-account address from a pseudo owner key. - @param pseudoOwnerKey The key to generate the address from - @return The generated account ID -*/ +/** Derive a collision-free pseudo-account `AccountID` from an owner key. + * + * Iterates up to 256 attempts. Each attempt hashes a counter, the parent + * ledger's hash, and `pseudoOwnerKey` through `sha512Half` then + * `ripesha_hasher` (RIPEMD-160(SHA-256(...))). The parent-hash component + * prevents precomputation of collisions. The first candidate address that + * has no existing `AccountRoot` in `view` is returned. + * + * @param view The ledger view used to check for address collisions. + * @param pseudoOwnerKey The 256-bit key identifying the pseudo-account owner + * (e.g., the AMM or Vault object ID). + * @return A collision-free `AccountID`, or `beast::kZERO` if all 256 + * attempts collided. `createPseudoAccount` propagates exhaustion as + * `tecDUPLICATE`. + * @note The 256-attempt cap is consensus-critical and must not be changed + * without an amendment, as it determines the pseudo-account address space. + */ AccountID pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey); -/** Returns the list of fields that define an ACCOUNT_ROOT as a pseudo-account - if set. - - The list is constructed during initialization and is const after that. - Pseudo-account designator fields MUST be maintained by including the - SField::sMD_PseudoAccount flag in the SField definition. -*/ +/** Return the singleton list of `SField`s that designate a pseudo-account. + * + * Built once at first call by scanning the `ltACCOUNT_ROOT` `SOTemplate` + * from `LedgerFormats` and selecting every field whose `SField::sMD_PseudoAccount` + * metadata bit is set. Currently includes `sfAMMID`, `sfVaultID`, and + * `sfLoanBrokerID`. The discovery is fully data-driven: adding a new + * pseudo-account type requires only tagging its key field with + * `SField::sMD_PseudoAccount` in `sfields.macro` — no manual registration + * here is needed. + * + * @return A const reference to the cached vector of pseudo-account fields. + * @note Non-active amendments are harmless: the corresponding field will + * never be set in practice, so the list remains correct regardless of + * which amendments are enabled. + */ [[nodiscard]] std::vector const& getPseudoAccountFields(); -/** Returns true if and only if sleAcct is a pseudo-account or specific - pseudo-accounts in pseudoFieldFilter. - - Returns false if sleAcct is: - - NOT a pseudo-account OR - - NOT a ltACCOUNT_ROOT OR - - null pointer -*/ +/** Determine whether an SLE is a pseudo-account (optionally of a specific type). + * + * Returns `true` only when all three conditions hold: `sleAcct` is non-null, + * its ledger-entry type is `ltACCOUNT_ROOT`, and at least one pseudo-account + * designator field (from `getPseudoAccountFields()`) is present. When + * `pseudoFieldFilter` is non-empty, only fields in the filter are considered, + * allowing callers to distinguish AMM pseudo-accounts from Vault + * pseudo-accounts. + * + * @param sleAcct The SLE to inspect; may be null. + * @param pseudoFieldFilter Optional subset of pseudo-account fields to match + * against. An empty set (the default) matches any pseudo-account field. + * @return `true` if `sleAcct` is a pseudo-account (of a type in the filter + * when one is provided); `false` otherwise. + */ [[nodiscard]] bool isPseudoAccount( std::shared_ptr sleAcct, std::set const& pseudoFieldFilter = {}); -/** Convenience overload that reads the account from the view. */ +/** Convenience overload that looks up the account from a `ReadView`. + * + * Reads the `AccountRoot` for `accountId` via `keylet::account()` and + * delegates to the SLE overload. + * + * @param view The ledger view to query. + * @param accountId The account address to look up. + * @param pseudoFieldFilter Optional field filter forwarded to the SLE overload. + * @return `true` if the account exists and is a pseudo-account matching the + * filter; `false` otherwise. + */ [[nodiscard]] inline bool isPseudoAccount( ReadView const& view, @@ -90,22 +177,48 @@ isPseudoAccount( return isPseudoAccount(view.read(keylet::account(accountId)), pseudoFieldFilter); } -/** - * Create pseudo-account, storing pseudoOwnerKey into ownerField. +/** Create a protocol-owned pseudo-account `AccountRoot` SLE. * - * The list of valid ownerField is maintained in AccountRootHelpers.cpp and - * the caller to this function must perform necessary amendment check(s) - * before using a field. The amendment check is **not** performed in - * createPseudoAccount. + * Derives a collision-free address via `pseudoAccountAddress()`, constructs + * an `AccountRoot` with zero balance, `lsfDisableMaster | lsfDefaultRipple | + * lsfDepositAuth`, and stores `pseudoOwnerKey` in `ownerField`. When + * `featureSingleAssetVault` or `featureLendingProtocol` is enabled, + * `sfSequence` is set to `0`; otherwise it is set to the current ledger + * sequence. The zero sequence makes pseudo-accounts visually distinguishable + * and provides an extra barrier against accidental transaction submission. + * + * In debug builds, an `XRPL_ASSERT` fires if `ownerField` does not carry the + * `SField::sMD_PseudoAccount` flag, catching misuse at development time. + * + * @param view The mutable ledger view into which the new SLE is + * inserted. + * @param pseudoOwnerKey The 256-bit key of the owning object (e.g., the AMM + * or Vault ledger entry key); stored in `ownerField` on the new SLE. + * @param ownerField The back-link field written on the new SLE; must be + * one of the fields returned by `getPseudoAccountFields()`. + * @return The newly created SLE on success, or `tecDUPLICATE` if all 256 + * address derivation attempts collided. + * @note Amendment checks are the **caller's** responsibility. This function + * is amendment-neutral by design; callers such as `VaultCreate` and + * `LoanBrokerSet` must gate on the relevant feature flag before invoking. */ [[nodiscard]] Expected, TER> createPseudoAccount(ApplyView& view, uint256 const& pseudoOwnerKey, SField const& ownerField); -/** Checks the destination and tag. - - - Checks that the SLE is not null. - - If the SLE requires a destination tag, checks that there is a tag. -*/ +/** Validate a payment destination SLE and its destination-tag requirement. + * + * Returns `tecNO_DST` if `toSle` is null (the destination account does not + * exist), and `tecDST_TAG_NEEDED` if the destination has set + * `lsfRequireDestTag` but the transaction supplies no tag. Returns + * `tesSUCCESS` otherwise. + * + * @param toSle The destination account SLE; may be null. + * @param hasDestinationTag `true` if the transaction includes a destination + * tag field. + * @return `tecNO_DST`, `tecDST_TAG_NEEDED`, or `tesSUCCESS`. + * @note The ledger enforces the *presence* of a tag but never interprets its + * value; semantics (e.g., exchange user IDs) are opaque to the protocol. + */ [[nodiscard]] TER checkDestinationAndTag(SLE::const_ref toSle, bool hasDestinationTag); diff --git a/include/xrpl/ledger/helpers/CredentialHelpers.h b/include/xrpl/ledger/helpers/CredentialHelpers.h index e06d225934..d1c5f8ff7b 100644 --- a/include/xrpl/ledger/helpers/CredentialHelpers.h +++ b/include/xrpl/ledger/helpers/CredentialHelpers.h @@ -1,3 +1,17 @@ +/** @file + * Central contract for credential and deposit pre-authorization logic. + * + * Included by every fund-transfer transactor (Payment, EscrowFinish, + * PaymentChannelClaim, VaultDeposit) that must honor destination-account + * access controls. + * + * Functions divide along the preclaim / doApply boundary: + * - `xrpl::credentials::*` — read-only checks safe to call from preclaim. + * - `xrpl::verifyDepositPreauth` / `xrpl::verifyValidDomain` — mutating + * counterparts that must be called from doApply when the corresponding + * preclaim function succeeds, so that expired credential objects are + * physically deleted from the ledger as a side effect. + */ #pragma once #include @@ -13,57 +27,225 @@ namespace xrpl { namespace credentials { -// These function will be used by the code that use DepositPreauth / Credentials -// (and any future pre-authorization modes) as part of authorization (all the -// transfer funds transactions) - -// Check if credential sfExpiration field has passed ledger's parentCloseTime +/** Test whether a credential SLE has passed its expiration time. + * + * Reads `sfExpiration` from @p sleCredential, defaulting to + * `std::numeric_limits::max()` when the field is absent, so + * credentials with no expiration field never expire. + * + * @param sleCredential The credential SLE to inspect. + * @param closed The parent ledger's close time. Must be a + * NetClock epoch value — do not pass wall-clock time. + * @return `true` if the credential has expired, `false` otherwise. + */ bool checkExpired(SLE const& sleCredential, NetClock::time_point const& closed); -// Actually remove a credentials object from the ledger +/** Remove a credential SLE and its entries from both owner directories. + * + * A credential is indexed in two owner directories — the issuer's and the + * subject's. Reserve-count accounting depends on acceptance state: + * - Before acceptance (`lsfAccepted` unset): only the issuer holds the + * reserve; only the issuer's count is decremented. + * - After acceptance with distinct accounts: the subject holds the reserve + * and its count is decremented. + * - When issuer and subject are the same account, only one directory + * removal is performed. + * + * @note Paths indicating ledger corruption (missing account SLE, failed + * `dirRemove`) are marked `LCOV_EXCL` and are unreachable under normal + * operation. + * + * @param view Mutable ledger view through which the SLE is erased. + * @param sleCredential The credential SLE to delete; must not be null. + * @param j Journal for fatal-level error logging. + * @return `tesSUCCESS` on success; `tecNO_ENTRY` if @p sleCredential is + * null; `tecINTERNAL` or `tefBAD_LEDGER` on internal directory + * inconsistency. + */ [[nodiscard]] TER deleteSLE(ApplyView& view, std::shared_ptr const& sleCredential, beast::Journal j); -// Amendment and parameters checks for sfCredentialIDs field +/** Validate the `sfCredentialIDs` field of a transaction at preflight time. + * + * Enforces non-empty, at most `kMAX_CREDENTIALS_ARRAY_SIZE` entries, and no + * duplicate hashes. Returns `tesSUCCESS` immediately when `sfCredentialIDs` + * is absent, as credentials are optional for most transaction types. + * + * @param tx The transaction under preflight validation. + * @param j Journal for trace-level malformed-transaction logging. + * @return `tesSUCCESS` if the field is absent or valid; `temMALFORMED` if + * the array is empty, too large, or contains duplicates. + */ NotTEC checkFields(STTx const& tx, beast::Journal j); -// Accessing the ledger to check if provided credentials are valid. Do not use -// in doApply (only in preclaim) since it does not remove expired credentials. -// If you call it in preclaim, you also must call verifyDepositPreauth in -// doApply +/** Verify that all credentials in a transaction exist, are owned by the + * sender, and have been accepted — for use in preclaim only. + * + * Checks each ID in `sfCredentialIDs`: the SLE must exist, its `sfSubject` + * must equal @p src, and `lsfAccepted` must be set. Expiration is + * deliberately not checked here; expired credentials are deleted in doApply + * by `verifyDepositPreauth` or `verifyValidDomain`. + * + * @note If this returns `tesSUCCESS` in preclaim, the caller must invoke + * `verifyDepositPreauth` in doApply to garbage-collect any credentials + * that expire before the enclosing transaction applies. + * + * @param tx The transaction whose `sfCredentialIDs` field is inspected. + * @param view Read-only ledger view for SLE lookups. + * @param src The account that must own every listed credential. + * @param j Journal for trace-level logging. + * @return `tesSUCCESS` if `sfCredentialIDs` is absent or all credentials are + * valid; `tecBAD_CREDENTIALS` if any credential is missing, belongs to a + * different account, or has not been accepted. + */ TER valid(STTx const& tx, ReadView const& view, AccountID const& src, beast::Journal j); -// Check if subject has any credential maching the given domain. If you call it -// in preclaim and it returns tecEXPIRED, you should call verifyValidDomain in -// doApply. This will ensure that expired credentials are deleted. +/** Check whether @p subject holds a live, accepted credential for a + * permissioned domain — for use in preclaim only. + * + * Reads the `PermissionedDomain` SLE, iterates its `sfAcceptedCredentials` + * array, and looks up the corresponding credential SLE for @p subject. + * A credential qualifies when it exists, has not expired, and carries + * `lsfAccepted`. + * + * Because a `ReadView` is immutable, expired credentials cannot be deleted + * here. The function returns `tecEXPIRED` when all matching credentials + * are expired — signaling the caller that the condition may resolve in + * doApply where `verifyValidDomain` will physically remove them. + * + * @note If this returns `tecEXPIRED` in preclaim, the caller must invoke + * `verifyValidDomain` in doApply so that expired objects are + * garbage-collected even if the transaction ultimately fails. + * + * @param view Read-only ledger view. + * @param domainID Key of the `PermissionedDomain` SLE to check against. + * @param subject Account that must hold a qualifying credential. + * @return `tesSUCCESS` if a live accepted credential exists; `tecEXPIRED` + * if only expired credentials were found; `tecNO_AUTH` if no matching + * credential exists; `tecOBJECT_NOT_FOUND` if the domain does not exist. + */ TER validDomain(ReadView const& view, uint256 domainID, AccountID const& subject); -// This function is only called when we about to return tecNO_PERMISSION -// because all the checks for the DepositPreauth authorization failed. +/** Check whether a set of credential IDs matches a credential-set + * `DepositPreauth` entry for the destination account. + * + * Builds a sorted `std::set>` of + * `(issuer, credentialType)` pairs from @p credIDs and tests for the + * existence of the corresponding `keylet::depositPreauth(dst, sorted)`. + * The sorted representation matches the canonical key used at + * `DepositPreauth` creation time. + * + * @note Credential existence is assumed to have been confirmed in preclaim. + * A missing SLE here indicates an internal consistency error. + * @note `Slice` members in the internal sorted set are non-owning views + * into SLE storage. A `lifeExtender` vector keeps the SLEs alive for + * the duration of the lookup. + * + * @param view Read-only ledger view for SLE and keylet lookups. + * @param credIDs The `sfCredentialIDs` vector from the transaction. + * @param dst The destination account whose `DepositPreauth` is checked. + * @return `tesSUCCESS` if a matching `DepositPreauth` object exists; + * `tecNO_PERMISSION` if none exists; `tefINTERNAL` if a credential SLE + * is unexpectedly missing or a duplicate pair is encountered. + */ TER authorizedDepositPreauth(ReadView const& view, STVector256 const& ctx, AccountID const& dst); -// Sort credentials array, return empty set if there are duplicates +/** Build a sorted `(issuer, credentialType)` set from a credentials array. + * + * Produces the canonical representation used to key `DepositPreauth` + * objects. Each element of @p credentials must carry `sfIssuer` and + * `sfCredentialType`. + * + * @param credentials An `STArray` of credential pairs, as stored in a + * `DepositPreauth` or `PermissionedDomainSet` transaction. + * @return A sorted set of `(AccountID, Slice)` pairs; an empty set if any + * duplicate `(issuer, credentialType)` pair is detected. + */ std::set> makeSorted(STArray const& credentials); -// Check credentials array passed to DepositPreauth/PermissionedDomainSet -// transactions +/** Validate a credential array in `DepositPreauth` or + * `PermissionedDomainSet` transactions at preflight time. + * + * Credentials in these transactions are `(issuer, credentialType)` pairs + * rather than object hashes. Enforces: non-empty; at most @p maxSize + * entries; valid issuer `AccountID`; `sfCredentialType` length in + * `[1, kMAX_CREDENTIAL_TYPE_LENGTH]` bytes; and no logical duplicates + * (detected via `sha512Half(issuer, credentialType)`). + * + * @param credentials The `STArray` of credential pairs to validate. + * @param maxSize Maximum permitted array length (caller-supplied per + * transaction type). + * @param j Journal for trace-level malformed-transaction logging. + * @return `tesSUCCESS` if all entries are valid; `temARRAY_EMPTY`, + * `temARRAY_TOO_LARGE`, `temINVALID_ACCOUNT_ID`, or `temMALFORMED` + * on the first constraint violation found. + */ NotTEC checkArray(STArray const& credentials, unsigned maxSize, beast::Journal j); } // namespace credentials -// Check expired credentials and for credentials maching DomainID of the ledger -// object +/** Enforce domain-credential authorization in doApply, deleting expired + * credentials as a side effect. + * + * The doApply counterpart to `credentials::validDomain`. Collects all + * credential SLEs for @p account that match the `sfAcceptedCredentials` + * list of the `PermissionedDomain` at @p domainID, calls + * `credentials::removeExpired` to physically delete any that have expired, + * then re-checks whether at least one live, accepted credential remains. + * + * The two-pass design (collect → expire → re-validate) ensures expired + * objects are garbage-collected even when the surrounding transaction + * ultimately fails. + * + * @param view Mutable ledger view; expired credential SLEs are erased. + * @param account Account whose credentials are being verified. + * @param domainID Key of the `PermissionedDomain` SLE. + * @param j Journal for trace/error logging. + * @return `tesSUCCESS` if a live accepted credential for the domain exists; + * `tecEXPIRED` if only expired credentials were found; `tecNO_PERMISSION` + * if no matching credential exists; `tecOBJECT_NOT_FOUND` if the domain + * SLE is missing; or a propagated `TER` error from `removeExpired` under + * `fixSecurity3_1_3`. + */ TER verifyValidDomain(ApplyView& view, AccountID const& account, uint256 domainID, beast::Journal j); -// Check expired credentials and for existing DepositPreauth ledger object +/** Enforce deposit pre-authorization in doApply, deleting expired credentials + * as a side effect. + * + * Called by Payment, EscrowFinish, and PaymentChannelClaim when the + * destination account has `lsfDepositAuth` set. Authorization succeeds + * when any of the following hold: + * - `src == dst` (self-payments are always allowed). + * - `keylet::depositPreauth(dst, src)` exists (account-level pre-auth). + * - A credential-set `DepositPreauth` object exists for the credentials + * submitted via `sfCredentialIDs` (via `credentials::authorizedDepositPreauth`). + * + * If `sfCredentialIDs` is present, `credentials::removeExpired` is called + * unconditionally before the authorization tests. If any credential was + * expired, `tecEXPIRED` is returned immediately without attempting + * authorization. + * + * @param tx The transaction under doApply; may carry `sfCredentialIDs`. + * @param view Mutable ledger view; expired credential SLEs may be erased. + * @param src The sending account. + * @param dst The destination account. + * @param sleDst The destination account's SLE, used to test `lsfDepositAuth`. + * If null, `lsfDepositAuth` is treated as unset and the function returns + * `tesSUCCESS`. + * @param j Journal for trace/error logging. + * @return `tesSUCCESS` if authorized or `lsfDepositAuth` is not set; + * `tecEXPIRED` if submitted credentials have expired; + * `tecNO_PERMISSION` if no matching pre-authorization exists; or a + * propagated error from `removeExpired` or `authorizedDepositPreauth`. + */ TER verifyDepositPreauth( STTx const& tx, diff --git a/include/xrpl/ledger/helpers/DelegateHelpers.h b/include/xrpl/ledger/helpers/DelegateHelpers.h index 78ccc46d0b..99f56bd00a 100644 --- a/include/xrpl/ledger/helpers/DelegateHelpers.h +++ b/include/xrpl/ledger/helpers/DelegateHelpers.h @@ -1,3 +1,12 @@ +/** @file + * Runtime enforcement helpers for the XRPL delegate account system. + * + * Transactors call these two functions in sequence during permission + * validation: `checkTxPermission` for the broad transaction-type gate, + * then `loadGranularPermission` when a more restrictive, field-level + * check is needed. The permission schema and encoding convention live + * in `xrpl/protocol/Permissions.h`. + */ #pragma once #include @@ -7,24 +16,64 @@ namespace xrpl { -/** - * Check if the delegate account has permission to execute the transaction. - * @param delegate The delegate account. - * @param tx The transaction that the delegate account intends to execute. - * @return tesSUCCESS if the transaction is allowed, terNO_DELEGATE_PERMISSION - * if not. +/** Determine whether a delegate relationship grants blanket permission for + * a transaction type. + * + * Scans the `sfPermissions` array of the `ltDELEGATE` ledger entry for an + * element whose `sfPermissionValue` equals `tx.getTxnType() + 1` — the + * transaction-level encoding used on-ledger. Returns `tesSUCCESS` on the + * first match, or `terNO_DELEGATE_PERMISSION` if no match is found. + * + * A null `delegate` pointer is treated as a missing ledger entry and + * returns `terNO_DELEGATE_PERMISSION` immediately. + * + * The result is `NotTEC` (no `tec` fee-claim codes) because the two + * meaningful outcomes are `tesSUCCESS` and `terNO_DELEGATE_PERMISSION`. + * The `ter` (retry) code is intentional: the `ltDELEGATE` object could be + * updated in a subsequent ledger, so an identical transaction may succeed + * in the future without modification. + * + * @param delegate Immutable `ltDELEGATE` SLE obtained via `view.read()`; + * may be null, in which case `terNO_DELEGATE_PERMISSION` is returned. + * @param tx The transaction whose type is being checked. + * @return `tesSUCCESS` if the delegate holds a transaction-level permission + * for `tx`'s type; `terNO_DELEGATE_PERMISSION` otherwise. + * @note Callers should resolve the SLE via `keylet::delegate(account, + * delegate)` and pass it directly. If the SLE is absent from the + * ledger, `view.read()` returns null and the guard here handles it. + * @see loadGranularPermission — for fine-grained per-flag enforcement when + * this function returns `terNO_DELEGATE_PERMISSION`. */ NotTEC checkTxPermission(std::shared_ptr const& delegate, STTx const& tx); -/** - * Load the granular permissions granted to the delegate account for the - * specified transaction type - * @param delegate The delegate account. - * @param type Used to determine which granted granular permissions to load, - * based on the transaction type. - * @param granularPermissions Granted granular permissions tied to the - * transaction type. +/** Populate a set with all granular sub-operation permissions the delegate + * holds for a given transaction type. + * + * Walks the `sfPermissions` array of the `ltDELEGATE` ledger entry. For + * each element, it casts the `sfPermissionValue` to `GranularPermissionType` + * and asks `Permission::getInstance().getGranularTxType()` whether that + * granular type belongs to `type`. Matching values are inserted into + * `granularPermissions`. + * + * A null `delegate` pointer is a silent no-op; the output set is left + * unchanged. + * + * The set is caller-owned and passed by reference so transactors can declare + * it on the stack, avoiding heap allocation. Callers may also accumulate + * results from multiple calls if needed. + * + * @param delegate Immutable `ltDELEGATE` SLE; may be null (no-op). + * @param type The transaction type whose granular permissions should be + * collected (e.g., `ttTRUST_SET`, `ttPAYMENT`). + * @param granularPermissions Output set populated with every + * `GranularPermissionType` the delegate holds that maps to `type`. + * @note This function is the second stage of a two-step check. Call + * `checkTxPermission` first; only invoke this when that returns + * `terNO_DELEGATE_PERMISSION` and the transaction type supports + * granular flags. Calling it unconditionally wastes a full scan of + * the permissions array on the common case. + * @see checkTxPermission — for the broad transaction-type gate. */ void loadGranularPermission( diff --git a/include/xrpl/ledger/helpers/DirectoryHelpers.h b/include/xrpl/ledger/helpers/DirectoryHelpers.h index 2ae188182d..96f4d88e88 100644 --- a/include/xrpl/ledger/helpers/DirectoryHelpers.h +++ b/include/xrpl/ledger/helpers/DirectoryHelpers.h @@ -1,3 +1,22 @@ +/** @file + * Traversal utilities for ledger directory nodes (`ltDIR_NODE`). + * + * A directory is a linked list of pages (`SLE` of type `ltDIR_NODE`), + * where each page holds an `sfIndexes` field (`STVector256`) of child + * ledger-entry keys and an `sfIndexNext` field that chains to the next + * page. Owner directories track every object an account holds; order- + * book directories track standing offers at a given quality. + * + * This header provides: + * - A const-aware template core (`detail::internalDirFirst` / + * `detail::internalDirNext`) that unifies the read and write traversal + * paths at compile time. + * - A deprecated step-iterator API (`cdirFirst`, `cdirNext`, `dirFirst`, + * `dirNext`) used only where cursor patching during deletion is required. + * - Higher-level callback iterators (`forEachItem`, `forEachItemAfter`) + * for exhaustive and paginated walks. + * - `dirIsEmpty` and `describeOwnerDir` utility helpers. + */ #pragma once #include @@ -15,6 +34,32 @@ namespace xrpl { namespace detail { +/** Advance a directory cursor to the next entry, crossing page boundaries. + * + * When the cursor has consumed all entries in the current page, the function + * follows `sfIndexNext` to load the next page and tail-calls itself to yield + * the first entry of that page in a single logical step. If `sfIndexNext` is + * zero the directory is exhausted: `entry` is zeroed and `false` is returned. + * + * The `if constexpr` branch selects `view.read()` when `N` is `SLE const` + * (read-only traversal via `ReadView`) and `view.peek()` when `N` is `SLE` + * (mutable traversal via `ApplyView`), keeping both paths in one template. + * + * @tparam V A view type derived from `ReadView`. + * @tparam N Either `SLE` (mutable) or `SLE const` (read-only). + * @param view The ledger view to query pages from. + * @param root The 256-bit key of the directory's root (anchor) page. + * @param page In/out: the current page SLE; updated when a page boundary + * is crossed. + * @param index In/out: the zero-based cursor within `page->sfIndexes`; + * incremented to point past the entry that was just returned. + * @param entry Out: the key of the current entry on success; zeroed on + * end-of-directory. + * @return `true` if an entry was produced; `false` if the directory is + * exhausted. + * @note An `XRPL_ASSERT` fires in instrumented builds if `index` exceeds + * the page's entry count, indicating a corrupted cursor. + */ template < class V, class N, @@ -64,6 +109,23 @@ internalDirNext( return true; } +/** Initialise a directory cursor at the first entry of the root page. + * + * Loads the root page via `view.read()` (when `N` is `SLE const`) or + * `view.peek()` (when `N` is `SLE`), resets the index to zero, then + * delegates to `internalDirNext` to yield the first entry. + * + * @tparam V A view type derived from `ReadView`. + * @tparam N Either `SLE` (mutable) or `SLE const` (read-only). + * @param view The ledger view to query pages from. + * @param root The 256-bit key of the directory's root (anchor) page. + * @param page Out: set to the root page SLE on success; unchanged if the + * root page is absent. + * @param index Out: set to zero before delegating to `internalDirNext`. + * @param entry Out: the key of the first entry on success. + * @return `true` if the directory has at least one entry; `false` if the + * root page is absent or the directory is empty. + */ template < class V, class N, @@ -119,6 +181,24 @@ cdirFirst( unsigned int& index, uint256& entry); +/** Returns the first entry in the directory, advancing the index. + * + * Mutable overload of `cdirFirst` for use with `ApplyView`. Yields a + * `shared_ptr` obtained via `view.peek()`, allowing the caller to + * modify the page SLE if required. + * + * @deprecated Prefer the `Dir` range adaptor or `forEachItem` for new + * code. Use this overload only when cursor patching during deletion + * is required (see `cleanupOnAccountDelete` in `View.cpp`). + * + * @param view The mutable view against which to operate. + * @param root The 256-bit key of the directory's root page. + * @param page Out: set to the root page SLE obtained via `peek()`. + * @param index Out: set to the cursor position within `page->sfIndexes`. + * @param entry Out: the key of the first directory entry. + * @return `true` if the directory has at least one entry; `false` + * otherwise. + */ bool dirFirst( ApplyView& view, @@ -151,6 +231,31 @@ cdirNext( unsigned int& index, uint256& entry); +/** Advances the mutable directory cursor to the next entry. + * + * Mutable overload of `cdirNext` for use with `ApplyView`. Page + * transitions are handled transparently: when `index` reaches the end + * of the current page, `sfIndexNext` is followed and the cursor is reset + * to the first entry of the new page. + * + * @deprecated Prefer the `Dir` range adaptor or `forEachItem` for new + * code. The primary use case for this function is cursor patching + * during deletion: `cleanupOnAccountDelete` (in `View.cpp`) decrements + * `index` after each deletion so the cursor stays aligned as entries + * shift — a technique that relies on the cursor being externally + * accessible. + * + * @param view The mutable view against which to operate. + * @param root The 256-bit key of the directory's root page. + * @param page In/out: the current page SLE; updated on page boundary + * crossing. + * @param index In/out: the cursor position within `page->sfIndexes`; + * incremented past the returned entry. + * @param entry Out: the key of the current entry on success; zeroed when + * the directory is exhausted. + * @return `true` if an entry was produced; `false` if the directory is + * exhausted. + */ bool dirNext( ApplyView& view, @@ -160,19 +265,61 @@ dirNext( uint256& entry); /** @} */ -/** Iterate all items in the given directory. */ +/** Exhaustively walk every entry in a directory, invoking a callback for each. + * + * Iterates all pages of the directory in `sfIndexNext` chain order, calling + * `f` with the materialised child SLE for every key in `sfIndexes`. The + * child SLE is obtained via `view.read(keylet::child(key))` and may be + * `nullptr` if the referenced entry is absent from the view; the callback + * must handle that case. Iteration terminates when `sfIndexNext` is zero or + * a page SLE is missing; there is no early-exit mechanism. + * + * @param view The read-only ledger view to query. + * @param root Keylet of the directory's root page; must have type + * `ltDIR_NODE`. + * @param f Callback invoked with each child SLE (possibly `nullptr`). + * @note An `XRPL_ASSERT` fires in instrumented builds if `root.type` is + * not `ltDIR_NODE`; in release builds the function returns silently. + */ void forEachItem( ReadView const& view, Keylet const& root, std::function const&)> const& f); -/** Iterate all items after an item in the given directory. - @param after The key of the item to start after - @param hint The directory page containing `after` - @param limit The maximum number of items to return - @return `false` if the iteration failed -*/ +/** Paginated directory walk, delivering items that follow a cursor key. + * + * Supports cursor-based pagination as used by RPC handlers such as + * `account_offers`, `account_lines`, and `account_channels`. When + * `after` is non-zero the function first attempts to jump to the `hint` + * page (the page the client last saw) to avoid re-scanning all prior + * pages; if the hint does not contain `after`, it falls back to a linear + * scan from the root. Once the cursor is located, subsequent entries are + * delivered to `f` until `limit` is reached or the directory is exhausted. + * + * The callback `f` returns `bool`: `true` to continue (and decrement the + * limit counter), `false` to stop immediately regardless of the remaining + * limit. Callers conventionally request `limit + 1` items and infer a + * non-empty next page when exactly `limit + 1` items are delivered. + * + * @param view The read-only ledger view to query. + * @param root Keylet of the directory's root page; must have type + * `ltDIR_NODE`. + * @param after Cursor key: only entries that follow this key in directory + * order are delivered. Pass `uint256()` (zero) to start from the + * beginning, in which case the function always returns `true`. + * @param hint Page number expected to contain `after`; used as a fast- + * path optimisation. Ignored when `after` is zero or when the hint + * page does not actually contain `after`. + * @param limit Maximum number of `true`-returning callback invocations + * before the walk stops. + * @param f Callback invoked for each qualifying child SLE (possibly + * `nullptr` if the key is absent). Return `true` to continue + * iteration; `false` to stop early. + * @return `true` if `after` was found (or `after` is zero); `false` if + * the cursor key was never located, indicating a stale or invalid + * marker that callers should surface as a pagination error. + */ bool forEachItemAfter( ReadView const& view, @@ -182,7 +329,15 @@ forEachItemAfter( unsigned int limit, std::function const&)> const& f); -/** Iterate all items in an account's owner directory. */ +/** Exhaustively walk every entry in an account's owner directory. + * + * Convenience overload that resolves `id` to `keylet::ownerDir(id)` and + * forwards to `forEachItem(view, Keylet, f)`. + * + * @param view The read-only ledger view to query. + * @param id The account whose owner directory should be iterated. + * @param f Callback invoked with each child SLE (possibly `nullptr`). + */ inline void forEachItem( ReadView const& view, @@ -192,12 +347,22 @@ forEachItem( forEachItem(view, keylet::ownerDir(id), f); } -/** Iterate all items after an item in an owner directory. - @param after The key of the item to start after - @param hint The directory page containing `after` - @param limit The maximum number of items to return - @return `false` if the iteration failed -*/ +/** Paginated walk of an account's owner directory after a cursor key. + * + * Convenience overload that resolves `id` to `keylet::ownerDir(id)` and + * forwards to `forEachItemAfter(view, Keylet, after, hint, limit, f)`. + * + * @param view The read-only ledger view to query. + * @param id The account whose owner directory should be iterated. + * @param after Cursor key; pass `uint256()` (zero) to start from the + * beginning. + * @param hint Page number expected to contain `after`. + * @param limit Maximum number of `true`-returning callback invocations. + * @param f Callback invoked for each qualifying child SLE. Return `true` + * to continue; `false` to stop early. + * @return `true` if `after` was found (or is zero); `false` if the cursor + * was never located. + */ inline bool forEachItemAfter( ReadView const& view, @@ -210,13 +375,36 @@ forEachItemAfter( return forEachItemAfter(view, keylet::ownerDir(id), after, hint, limit, f); } -/** Returns `true` if the directory is empty - @param key The key of the directory -*/ +/** Returns `true` if the directory contains no entries. + * + * An empty `sfIndexes` array on the root page is necessary but not + * sufficient: the root is an anchor page and may have an empty index + * while `sfIndexNext` still points to a populated subsequent page. Both + * conditions — empty `sfIndexes` *and* `sfIndexNext == 0` — must hold + * before declaring the directory empty. A missing root SLE is also + * treated as empty. + * + * @param view The read-only ledger view to query. + * @param k Keylet of the directory's root page. + * @return `true` if the directory has no entries or does not exist; + * `false` otherwise. + */ [[nodiscard]] bool dirIsEmpty(ReadView const& view, Keylet const& k); -/** Returns a function that sets the owner on a directory SLE */ +/** Returns a callback that stamps a new directory page with its owner account. + * + * The returned `std::function` sets `sfOwner = account` on + * the newly allocated `ltDIR_NODE` SLE. It is passed as the `describe` + * argument to `ApplyView::dirInsert` throughout the codebase (e.g., + * `RippleStateHelpers.cpp`, `PaymentChannelCreate.cpp`) and is invoked only + * when `dirInsert` actually allocates a fresh overflow page, keeping the + * owning account ID out of the generic insertion logic. + * + * @param account The `AccountID` to record as `sfOwner` on each new page. + * @return A callable suitable for the `describe` parameter of + * `ApplyView::dirInsert`. + */ [[nodiscard]] std::function describeOwnerDir(AccountID const& account); diff --git a/include/xrpl/ledger/helpers/EscrowHelpers.h b/include/xrpl/ledger/helpers/EscrowHelpers.h index 5aa5214b1f..3d929a7aed 100644 --- a/include/xrpl/ledger/helpers/EscrowHelpers.h +++ b/include/xrpl/ledger/helpers/EscrowHelpers.h @@ -1,3 +1,13 @@ +/** @file + * Token-delivery helper for IOU and MPT escrow resolution. + * + * Implements `escrowUnlockApplyHelper`, the single function responsible for + * crediting the appropriate account when an IOU or MPT escrow is finished + * (`EscrowFinish`) or cancelled (`EscrowCancel`) under `featureTokenEscrow`. + * The function is specialised once for `Issue` (IOU trust-line path) and once + * for `MPTIssue` (MPToken path); callers reach the correct specialisation via + * `std::visit` on the `Asset` variant, with zero runtime dispatch overhead. + */ #pragma once #include @@ -13,6 +23,33 @@ namespace xrpl { +/** Credit an account with tokens held in escrow, applying transfer-fee logic. + * + * Primary template — no body is provided. Only the `Issue` and `MPTIssue` + * full specialisations are defined. Callers should invoke via `std::visit` + * on an `Asset` variant so the compiler selects the correct specialisation + * at compile time. + * + * @tparam T Asset type; must satisfy `ValidIssueType` (`Issue` or `MPTIssue`). + * @param view Mutable ledger view on which state changes are applied. + * @param lockedRate Transfer rate snapshotted at escrow creation time. + * Pass `kPARITY_RATE` for cancellations (return to sender, no fee). + * @param sleDest SLE for the destination account (`receiver`); used for + * owner-count and reserve checks when auto-creating a trust line or + * MPToken holding object. + * @param xrpBalance Pre-fee XRP balance of the destination account; compared + * against the incremental reserve required to create a new holding object. + * @param amount Escrowed token amount (face value locked at escrow creation). + * @param issuer Token issuer. + * @param sender Escrow creator / original token sender. + * @param receiver Account that will receive the unlocked tokens. + * @param createAsset When `true`, auto-creates a trust line or MPToken object + * for `receiver` if one does not already exist. Callers set this only + * when the transaction submitter is also the beneficiary, preserving + * account sovereignty over directory entries. + * @param journal Logging sink. + * @return `tesSUCCESS` on success, or a `tec` error code on failure. + */ template TER escrowUnlockApplyHelper( @@ -27,6 +64,40 @@ escrowUnlockApplyHelper( bool createAsset, beast::Journal journal); +/** IOU trust-line specialisation of `escrowUnlockApplyHelper`. + * + * Delivers IOU tokens from a finished or cancelled escrow to `receiver`, + * optionally creating the trust line and applying the snapshotted transfer + * fee. + * + * **Issuer short-circuits.** `sender == issuer` returns `tecINTERNAL` (an + * issuer cannot be an escrow originator for their own obligation). + * `receiver == issuer` returns `tesSUCCESS` immediately — delivery to the + * issuer is a redemption handled by the calling transactor at the balance + * level. + * + * **Trust line creation.** When `createAsset` is `true` and no trust line + * exists, one is created with a zero balance and zero limit via `trustCreate`. + * The `sfDefaultRipple` flag is inherited from `sleDest`. Reserve is checked + * first; insufficient reserve returns `tecNO_LINE_INSUF_RESERVE`. When + * `createAsset` is `false` and no line exists, returns `tecNO_LINE`. + * + * **Transfer fee.** The effective rate is `min(lockedRate, currentRate)`, + * protecting the receiver from a rate increase during the escrow lifetime. + * The fee is deducted *from* `amount` (not added on top), so `receiver` gets + * `amount - fee`. When neither party is the issuer and the rate differs from + * `kPARITY_RATE`, the check against the trust-line limit uses `finalAmt`. + * + * **Limit check.** When `createAsset` is `false`, the post-transfer balance + * is compared to `receiver`'s trust-line limit; `tecLIMIT_EXCEEDED` is + * returned if the delivery would exceed it. This check is skipped when + * `createAsset` is `true` because a freshly created line has a zero limit + * and would always fail it spuriously. + * + * @note This function is reached via `std::visit` on an `Asset` variant in + * `EscrowFinish` and `EscrowCancel`. `EscrowCancel` always passes + * `kPARITY_RATE` so no fee is charged on the return-to-sender path. + */ template <> inline TER escrowUnlockApplyHelper( @@ -70,21 +141,21 @@ escrowUnlockApplyHelper( initialBalance.get().account = noAccount(); if (TER const ter = trustCreate( - view, // payment sandbox - recvLow, // is dest low? - issuer, // source - receiver, // destination - trustLineKey.key, // ledger index - sleDest, // Account to add to - false, // authorize account - (sleDest->getFlags() & lsfDefaultRipple) == 0, // - false, // freeze trust line - false, // deep freeze trust line - initialBalance, // zero initial balance - Issue(currency, receiver), // limit of zero - 0, // quality in - 0, // quality out - journal); // journal + view, + recvLow, + issuer, + receiver, + trustLineKey.key, + sleDest, + false, + (sleDest->getFlags() & lsfDefaultRipple) == 0, + false, + false, + initialBalance, + Issue(currency, receiver), + 0, + 0, + journal); !isTesSuccess(ter)) { return ter; // LCOV_EXCL_LINE @@ -97,57 +168,43 @@ escrowUnlockApplyHelper( return tecNO_LINE; auto const xferRate = transferRate(view, amount); - // update if issuer rate is less than locked rate + // Cap to the lower of the snapshotted and current rate to protect the receiver. if (xferRate < lockedRate) lockedRate = xferRate; - // Transfer Rate only applies when: - // 1. Issuer is not involved in the transfer (senderIssuer or - // receiverIssuer) - // 2. The locked rate is different from the parity rate - - // NOTE: Transfer fee in escrow works a bit differently from a normal - // payment. In escrow, the fee is deducted from the locked/sending amount, - // whereas in a normal payment, the transfer fee is taken on top of the - // sending amount. + // Fee is deducted from `amount` (not added on top): finalAmt = amount - fee. + // No fee when either party is the issuer, or when lockedRate == kPARITY_RATE. auto finalAmt = amount; if ((!senderIssuer && !receiverIssuer) && lockedRate != kPARITY_RATE) { - // compute transfer fee, if any auto const xferFee = amount.value() - divideRound(amount, lockedRate, amount.get(), true); - // compute balance to transfer finalAmt = amount.value() - xferFee; } - // validate the line limit if the account submitting txn is not the receiver - // of the funds + // Limit check skipped when createAsset is true (freshly created line has + // zero limit and would always fail spuriously). if (!createAsset) { auto const sleRippleState = view.peek(trustLineKey); if (!sleRippleState) return tecINTERNAL; // LCOV_EXCL_LINE - // if the issuer is the high, then we use the low limit - // otherwise we use the high limit + // recvLow true → receiver is low side → use sfLowLimit; else sfHighLimit. STAmount const lineLimit = sleRippleState->getFieldAmount(recvLow ? sfLowLimit : sfHighLimit); STAmount lineBalance = sleRippleState->getFieldAmount(sfBalance); - // flip the sign of the line balance if the issuer is not high if (!recvLow) lineBalance.negate(); - // add the final amount to the line balance lineBalance += finalAmt; - // if the transfer would exceed the line limit return tecLIMIT_EXCEEDED if (lineLimit < lineBalance) return tecLIMIT_EXCEEDED; } - // if destination is not the issuer then transfer funds if (!receiverIssuer) { auto const ter = directSendNoFee(view, issuer, receiver, finalAmt, true, journal); @@ -157,6 +214,32 @@ escrowUnlockApplyHelper( return tesSUCCESS; } +/** MPT specialisation of `escrowUnlockApplyHelper`. + * + * Delivers MPT tokens from a finished or cancelled escrow to `receiver`, + * optionally creating an MPToken holding object and applying the snapshotted + * transfer fee. + * + * **MPToken creation.** When `createAsset` is `true`, `receiver` is not the + * issuer, and no MPToken SLE exists for this issuance, one is created via + * `createMPToken` and the owner count is incremented. Insufficient reserve + * returns `tecINSUFFICIENT_RESERVE`. If no MPToken exists after the creation + * attempt (and `receiver` is not the issuer), returns `tecNO_PERMISSION`. + * + * **Transfer fee.** Identical to the `Issue` path: effective rate is + * `min(lockedRate, currentRate)`, fee is deducted *from* `amount`, and no + * fee is applied when either party is the issuer or the rate is parity. + * + * **`fixTokenEscrowV1` bug fix.** The gross amount passed to `unlockEscrowMPT` + * (used to reduce `sfOutstandingAmount`) is `amount` when the amendment is + * enabled, and `finalAmt` otherwise. Without the fix, the outstanding supply + * is only reduced by the net delivered amount, silently retaining the fee + * portion; with the fix, the full face value is removed from circulation and + * the fee is burned from the outstanding supply. + * + * @note `EscrowCancel` passes `kPARITY_RATE` so no fee is charged when + * tokens are returned to the original sender. + */ template <> inline TER escrowUnlockApplyHelper( @@ -189,7 +272,6 @@ escrowUnlockApplyHelper( return ter; // LCOV_EXCL_LINE } - // update owner count. adjustOwnerCount(view, sleDest, 1, journal); } @@ -197,25 +279,16 @@ escrowUnlockApplyHelper( return tecNO_PERMISSION; auto const xferRate = transferRate(view, amount); - // update if issuer rate is less than locked rate + // Cap to the lower of the snapshotted and current rate to protect the receiver. if (xferRate < lockedRate) lockedRate = xferRate; - // Transfer Rate only applies when: - // 1. Issuer is not involved in the transfer (senderIssuer or - // receiverIssuer) - // 2. The locked rate is different from the parity rate - - // NOTE: Transfer fee in escrow works a bit differently from a normal - // payment. In escrow, the fee is deducted from the locked/sending amount, - // whereas in a normal payment, the transfer fee is taken on top of the - // sending amount. + // Fee is deducted from `amount` (not added on top): finalAmt = amount - fee. + // No fee when either party is the issuer, or when lockedRate == kPARITY_RATE. auto finalAmt = amount; if ((!senderIssuer && !receiverIssuer) && lockedRate != kPARITY_RATE) { - // compute transfer fee, if any auto const xferFee = amount.value() - divideRound(amount, lockedRate, amount.asset(), true); - // compute balance to transfer finalAmt = amount.value() - xferFee; } return unlockEscrowMPT( diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index b9711c4053..ea997a2fd4 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -1,109 +1,167 @@ #pragma once +/** @file + * Computational core of the XLS-66 lending protocol. + * + * Defines the data structures and mathematical primitives that model every + * stage of an amortized loan's life cycle: computing periodic payments, + * splitting each payment into principal / interest / management-fee + * components, and handling regular, late, full (early-closure), and + * overpayment scenarios. The top-level entry point `loanMakePayment()` + * implements `make_payment` from XLS-66 §3.2.4.4. Every lending transactor + * (`LoanSet`, `LoanPay`, `LoanDelete`, `LoanBroker*`) either calls these + * functions or operates on the structures defined here. + */ + #include #include #include namespace xrpl { -// Lending protocol has dependencies, so capture them here. +/** Verify that all amendment prerequisites for the lending protocol are active. + * + * Every lending transactor calls this in `checkExtraFeatures()`. Adding a new + * prerequisite here gates all lending transactions atomically, so callers do + * not need to replicate the dependency list. + * + * @param rules Active amendment rules for the current ledger. + * @param tx The transaction being validated; inspected for optional fields + * (e.g., `sfDomainID`) that require additional amendments. + * @return `true` if all required amendments are enabled and the transaction is + * consistent with them; `false` if the caller must reject the transaction. + */ bool checkLendingProtocolDependencies(Rules const& rules, STTx const& tx); +/** Number of seconds in a 365-day year; used to prorate annual rates. */ static constexpr std::uint32_t kSECONDS_IN_YEAR = 365 * 24 * 60 * 60; +/** Convert an annualized interest rate to a per-payment-period rate. + * + * Prorates the annual rate by the fraction `paymentInterval / kSECONDS_IN_YEAR`. + * Implements Equation (1) from XLS-66, Section A-2 Equation Glossary. All + * amortization math in this module flows from this single conversion. + * + * @param interestRate Annual interest rate in tenth-of-a-basis-point units. + * @param paymentInterval Length of one payment period in seconds. + * @return The per-period rate as a `Number` at full floating-point precision. + */ Number loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval); -/// Ensure the periodic payment is always rounded consistently +/** Round a periodic payment amount upward to the asset's representable precision. + * + * Borrowers must never benefit from rounding, so periodic payments are always + * rounded upward. Delegates to `roundToAsset(..., Number::RoundingMode::Upward)`. + * + * @param asset Asset whose representable precision constrains rounding. + * @param periodicPayment Unrounded installment amount. + * @param scale Exponent that defines the target precision. + * @return The smallest representable value >= `periodicPayment` at `scale`. + */ inline Number roundPeriodicPayment(Asset const& asset, Number const& periodicPayment, std::int32_t scale) { return roundToAsset(asset, periodicPayment, scale, Number::RoundingMode::Upward); } -/* Represents the breakdown of amounts to be paid and changes applied to the - * Loan object while processing a loan payment. +/** Breakdown of amounts paid and loan-object changes produced by one payment. * - * This structure is returned after processing a loan payment transaction and - * captures the amounts that need to be paid. The actual ledger entry changes - * are made in LoanPay based on this structure values. + * Returned by `loanMakePayment()` after a payment is processed. The three + * non-negative fields (`principalPaid`, `interestPaid`, `feePaid`) are the + * amounts disbursed to the loan principal, the vault, and the broker + * respectively. `valueChange` records unexpected movement in the total + * outstanding balance: * - * The sum of principalPaid, interestPaid, and feePaid represents the total - * amount to be deducted from the borrower's account. The valueChange field - * tracks whether the loan's total value increased or decreased beyond normal - * amortization. + * - **0** for a well-timed regular payment. + * - **negative** for an overpayment (extra principal reduces future interest). + * - **positive** for a late payment (penalty interest increased the debt). * - * This structure is explained in the XLS-66 spec, section 3.2.4.2 (Payment - * Processing). + * `operator+=` accumulates results across multiple payment rounds within a + * single transaction. + * + * @note Defined in XLS-66 §3.2.4.2 (Payment Processing). */ struct LoanPaymentParts { - // The amount of principal paid that reduces the loan balance. - // This amount is subtracted from sfPrincipalOutstanding in the Loan object - // and paid to the Vault + /** Amount that reduces `sfPrincipalOutstanding`; paid to the vault. */ Number principalPaid = kNUM_ZERO; - // The total amount of interest paid to the Vault. - // This includes: - // - Tracked interest from the amortization schedule - // - Untracked interest (e.g., late payment penalty interest) - // This value is always non-negative. + /** Total interest paid to the vault, including both tracked amortization + * interest and any untracked late-payment penalty interest. Always >= 0. + */ Number interestPaid = kNUM_ZERO; - // The change in the loan's total value outstanding. - // - If valueChange < 0: Loan value decreased - // - If valueChange > 0: Loan value increased - // - If valueChange = 0: No value adjustment - // - // For regular on-time payments, this is always 0. Non-zero values occur - // when: - // - Overpayments reduce the loan balance beyond the scheduled amount - // - Late payments add penalty interest to the loan value - // - Early full payment may increase or decrease the loan value based on - // terms + /** Net change in the loan's total outstanding value. + * + * Zero for on-time regular payments. Negative when an overpayment + * reduces the principal beyond the scheduled amount. Positive when a + * late-payment penalty interest increases the debt. + */ Number valueChange = kNUM_ZERO; - /* The total amount of fees paid to the Broker. - * This includes: - * - Tracked management fees from the amortization schedule - * - Untracked fees (e.g., late payment fees, service fees, origination - * fees) This value is always non-negative. + /** Total fees paid to the broker, including tracked management fees and any + * untracked late or service fees. Always >= 0. */ Number feePaid = kNUM_ZERO; + /** Accumulate payment parts from a subsequent payment round. + * + * Used by `loanMakePayment()` when a single transaction covers more than + * one installment. Asserts that all fields of `other` are non-negative. + * + * @param other Parts from the next completed round. + * @return Reference to `*this` with all fields incremented. + */ LoanPaymentParts& operator+=(LoanPaymentParts const& other); + /** Compare two `LoanPaymentParts` for exact equality across all four fields. + * + * @param other The instance to compare against. + * @return `true` if every field compares equal. + */ bool operator==(LoanPaymentParts const& other) const; }; -/** This structure captures the parts of a loan state. +/** Snapshot of the financial state of a loan at a point in time. * - * Whether the values are theoretical (unrounded) or rounded will depend on how - * it was computed. + * Holds the four numbers that describe where a loan stands. Values may be + * theoretical (unrounded, from `computeTheoreticalLoanState()`) or rounded to + * ledger precision (from `constructRoundedLoanState()`), depending on context. * - * Many of the fields can be derived from each other, but they're all provided - * here to reduce code duplication and possible mistakes. - * e.g. - * * interestOutstanding = valueOutstanding - principalOutstanding - * * interestDue = interestOutstanding - managementFeeDue + * @invariant `interestDue + managementFeeDue == valueOutstanding - principalOutstanding`. + * This is enforced at runtime by `XRPL_ASSERT_PARTS` inside `interestOutstanding()`. + * + * Many fields are derivable from each other; they are all stored explicitly to + * reduce duplication and the risk of sign/orientation mistakes. */ struct LoanState { - // Total value still due to be paid by the borrower. + /** Total value still owed by the borrower (`sfTotalValueOutstanding`). */ Number valueOutstanding; - // Principal still due to be paid by the borrower. + + /** Principal component still outstanding (`sfPrincipalOutstanding`). */ Number principalOutstanding; - // Interest still due to be paid to the Vault. - // This is a portion of interestOutstanding + + /** Net interest due to the vault; equals + * `valueOutstanding - principalOutstanding - managementFeeDue`. + */ Number interestDue; - // Management fee still due to be paid to the broker. - // This is a portion of interestOutstanding + + /** Management fee due to the broker (`sfManagementFeeOutstanding`); + * a sub-portion of the gross interest outstanding. + */ Number managementFeeDue; - // Interest still due to be paid by the borrower. + /** Sum of `interestDue` and `managementFeeDue`. + * + * Asserts the `LoanState` invariant before returning. + * + * @return `interestDue + managementFeeDue`, i.e., the gross interest still owed. + */ [[nodiscard]] Number interestOutstanding() const { @@ -115,42 +173,60 @@ struct LoanState } }; -/* Describes the initial computed properties of a loan. +/** Fully-initialized description of a loan's payment structure. * - * This structure contains the fundamental calculated values that define a - * loan's payment structure and amortization schedule. These properties are - * computed: - * - At loan creation (LoanSet transaction) - * - When loan terms change (e.g., after an overpayment that reduces the loan - * balance) + * Computed by `computeLoanProperties()` at loan creation (`LoanSet`) and + * after each overpayment re-amortization. Passed to `checkLoanGuards()` for + * validation before being written to the Loan ledger entry. + * + * The `loanScale` is derived dynamically from the `STAmount` exponent of the + * rounded total value outstanding and clamped to `minimumScale`. Using a + * consistent scale for all subsequent rounding prevents dust-accumulation bugs + * where tiny remainders can never be fully cleared. */ struct LoanProperties { - // The unrounded amount to be paid at each regular payment period. - // Calculated using the standard amortization formula based on principal, - // interest rate, and number of payments. - // The actual amount paid in the LoanPay transaction must be rounded up to - // the precision of the asset and loan. + /** Unrounded fixed installment amount computed from the amortization formula. + * + * The amount actually required from the borrower each period is this value + * rounded upward via `roundPeriodicPayment()`. + */ Number periodicPayment; - // The loan's current state, with all values rounded to the loan's scale. + /** Current loan state with all fields rounded to `loanScale`. */ LoanState loanState; - // The scale (decimal places) used for rounding all loan amounts. - // This is the maximum of: - // - The asset's native scale - // - A minimum scale required to represent the periodic payment accurately - // All loan state values (principal, interest, fees) are rounded to this - // scale. + /** Decimal exponent used for rounding all loan amounts. + * + * Set to the maximum of the asset's native scale and a minimum scale + * sufficient to represent the periodic payment accurately. All principal, + * interest, and fee values are rounded to this exponent. + */ std::int32_t loanScale{}; - // The principal portion of the first payment. + /** Unrounded principal portion of the very first periodic payment. + * + * Checked by `checkLoanGuards()` to ensure it is > 0: the first payment + * pays the least principal in an amortized schedule, so if it is positive + * then all subsequent payments will also reduce principal. + */ Number firstPaymentPrincipal; }; -// Some values get re-rounded to the vault scale any time they are adjusted. In -// addition, they are prevented from ever going below zero. This helps avoid -// accumulated rounding errors and leftover dust amounts. +/** Apply an adjustment to a loan value, re-round to vault scale, and clamp to zero. + * + * Certain loan values are re-rounded to the vault scale every time they are + * adjusted, preventing the accumulation of rounding dust across many payment + * cycles. If the result would be negative (possible due to sub-scale rounding + * errors), it is clamped to zero. + * + * @tparam NumberProxy A proxy type that supports assignment and dereferencing + * to `Number` (e.g., `STObject::ValueProxy`). + * @param value Proxy to the value being adjusted; updated in place. + * @param adjustment The signed delta to apply before re-rounding. + * @param asset Asset that constrains representable precision. + * @param vaultScale Exponent for re-rounding the result. + */ template void adjustImpreciseNumber( @@ -165,6 +241,16 @@ adjustImpreciseNumber( value = 0; } +/** Extract the decimal scale of a vault's `sfAssetsTotal` field. + * + * Returns `Number::kMIN_EXPONENT - 1` (an unusably small sentinel) when + * `vaultSle` is null, so callers can detect the invalid case without a + * separate null check. + * + * @param vaultSle Const reference to the Vault SLE; may be null. + * @return The scale of `sfAssetsTotal` relative to `sfAsset`, or a sentinel + * value if `vaultSle` is null. + */ inline int getAssetsTotalScale(SLE::const_ref vaultSle) { @@ -173,6 +259,30 @@ getAssetsTotalScale(SLE::const_ref vaultSle) return scale(vaultSle->at(sfAssetsTotal), vaultSle->at(sfAsset)); } +/** Validate that a set of computed loan properties is self-consistent and payable. + * + * Enforces four guards derived from XLS-66 to reject loans that would fail + * to amortize correctly under the spec's rounding rules: + * + * 1. If `expectInterest` is `true`, total lifetime interest must be > 0. + * 2. `firstPaymentPrincipal` must be > 0 (ensures every payment reduces principal). + * 3. The rounded periodic payment must not round to zero. + * 4. `floor(valueOutstanding / roundedPayment)` must equal `paymentTotal` + * (ensures the loan closes in exactly the specified number of installments). + * + * Called from loan creation (`LoanSet::doApply()`) and after each overpayment + * re-amortization inside `detail::tryOverpayment()`. + * + * @param vaultAsset Asset used for rounding the periodic payment. + * @param principalRequested Loan principal; used to derive total interest outstanding. + * @param expectInterest `true` when the loan's interest rate is non-zero. + * @param paymentTotal Total number of scheduled payments. + * @param properties Computed loan properties to validate. + * @param j Journal for diagnostic log messages. + * @return `tesSUCCESS` if all guards pass; `tecPRECISION_LOSS` when the loan + * cannot be amortized accurately at the given principal / rate / scale + * combination; `tecINTERNAL` for unexpected internal inconsistencies. + */ TER checkLoanGuards( Asset const& vaultAsset, @@ -182,6 +292,23 @@ checkLoanGuards( LoanProperties const& properties, beast::Journal j); +/** Compute the theoretically correct loan state at full arithmetic precision. + * + * Derives what each outstanding balance *should* be purely from the payment + * schedule, with no ledger-rounding effects. Used as a target in + * `computePaymentComponents()` and `detail::tryOverpayment()` to measure and + * correct accumulated rounding drift. Implements `calculate_true_loan_state` + * from XLS-66 §3.2.4.4 (Equations 30-33 from Section A-2). + * + * @param rules Active amendment rules (passed to the internal + * `loanPrincipalFromPeriodicPayment()` call). + * @param periodicPayment Fixed installment amount. + * @param periodicRate Pre-computed per-period interest rate. + * @param paymentRemaining Number of payments still remaining after this point. + * @param managementFeeRate Broker fee rate in tenth-bips. + * @return Unrounded `LoanState` derived from the schedule, or a fully-zeroed + * state if `paymentRemaining == 0`. + */ LoanState computeTheoreticalLoanState( Rules const& rules, @@ -190,18 +317,48 @@ computeTheoreticalLoanState( std::uint32_t const paymentRemaining, TenthBips32 const managementFeeRate); -// Constructs a valid LoanState object from arbitrary inputs +/** Build a `LoanState` from the three directly-tracked loan balances. + * + * Derives `interestDue = totalValueOutstanding - principalOutstanding - + * managementFeeOutstanding` rather than accepting it as a parameter, ensuring + * the `LoanState` invariant always holds. Prefer this over constructing + * `LoanState` directly to avoid sign/ordering mistakes. + * + * @param totalValueOutstanding Total value still owed by the borrower. + * @param principalOutstanding Principal component still outstanding. + * @param managementFeeOutstanding Management fee component still outstanding. + * @return `LoanState` with `interestDue` derived from the other three fields. + */ LoanState constructLoanState( Number const& totalValueOutstanding, Number const& principalOutstanding, Number const& managementFeeOutstanding); -// Constructs a valid LoanState object from a Loan object, which always has -// rounded values +/** Build a `LoanState` from a Loan ledger entry's current rounded values. + * + * Convenience wrapper that reads `sfTotalValueOutstanding`, + * `sfPrincipalOutstanding`, and `sfManagementFeeOutstanding` from the SLE + * and delegates to `constructLoanState()`. + * + * @param loan Const reference to the Loan SLE. + * @return `LoanState` reflecting the current rounded ledger values. + */ LoanState constructRoundedLoanState(SLE::const_ref loan); +/** Compute the broker's management fee on a given interest amount. + * + * Calculates `roundDown(tenthBipsOfValue(value, managementFeeRate), scale)`. + * Downward rounding ensures the vault always receives its full share. + * Implements Equation (32) from XLS-66, Section A-2 Equation Glossary. + * + * @param asset Asset used to constrain rounding. + * @param value Gross interest amount from which the fee is taken. + * @param managementFeeRate Broker's rate in tenth-of-a-basis-point units. + * @param scale Exponent for rounding the result downward. + * @return Broker fee rounded down to `scale`. + */ Number computeManagementFee( Asset const& asset, @@ -209,6 +366,25 @@ computeManagementFee( TenthBips32 managementFeeRate, std::int32_t scale); +/** Compute the total interest charge for an early full payment. + * + * Sums two components (Equations 27-28 from XLS-66, Section A-2): + * - Accrued interest since the last payment (`detail::loanAccruedInterest()`). + * - Prepayment penalty (`closeInterestRate` applied to the theoretical + * principal outstanding); zero when `closeInterestRate == 0`. + * + * @param theoreticalPrincipalOutstanding Unrounded principal derived from the + * payment schedule (not the rounded ledger value). + * @param periodicRate Pre-computed per-period interest rate. + * @param parentCloseTime Close time of the parent ledger (the "now" for the + * accrued-interest calculation). + * @param paymentInterval Payment period length in seconds. + * @param prevPaymentDate Due date of the most recently completed payment. + * @param startDate Loan start date. + * @param closeInterestRate Prepayment penalty rate in tenth-of-a-basis-point + * units; 0 means no prepayment penalty. + * @return `accruedInterest + prepaymentPenalty`, both non-negative. + */ Number computeFullPaymentInterest( Number const& theoreticalPrincipalOutstanding, @@ -223,94 +399,91 @@ namespace detail { // These classes and functions should only be accessed by LendingHelper // functions and unit tests -enum class PaymentSpecialCase { None, Final, Extra }; +/** Classification of a single payment's scheduling role. */ +enum class PaymentSpecialCase { + None, /**< Regular scheduled installment. */ + Final, /**< Last payment that closes out the loan. */ + Extra /**< Overpayment beyond the regular schedule. */ +}; -/* Represents a single loan payment component parts. - -* This structure captures the "delta" (change) values that will be applied to -* the tracked fields in the Loan ledger object when a payment is processed. -* -* These are called "deltas" because they represent the amount by which each -* corresponding field in the Loan object will be reduced. -* They are "tracked" as they change tracked loan values. -*/ +/** Tracked delta values that will be applied to the Loan ledger entry on payment. + * + * Each field represents the amount by which the corresponding `sf*` field in + * the Loan object will be reduced. These are "tracked" because they alter the + * loan's amortization schedule; contrast with the untracked amounts in + * `ExtendedPaymentComponents`. + * + * The relationship `trackedValueDelta == trackedPrincipalDelta + + * trackedInterestPart() + trackedManagementFeeDelta` must hold at all times. + */ struct PaymentComponents { - // The change in total value outstanding for this payment. - // This amount will be subtracted from sfTotalValueOutstanding in the Loan - // object. Equal to the sum of trackedPrincipalDelta, - // trackedInterestPart(), and trackedManagementFeeDelta. + /** Amount subtracted from `sfTotalValueOutstanding`. + * + * Equals `trackedPrincipalDelta + trackedInterestPart() + + * trackedManagementFeeDelta`. + */ Number trackedValueDelta; - // The change in principal outstanding for this payment. - // This amount will be subtracted from sfPrincipalOutstanding in the Loan - // object, representing the portion of the payment that reduces the - // original loan amount. + /** Amount subtracted from `sfPrincipalOutstanding`. */ Number trackedPrincipalDelta; - // The change in management fee outstanding for this payment. - // This amount will be subtracted from sfManagementFeeOutstanding in the - // Loan object. This represents only the tracked management fees from the - // amortization schedule and does not include additional untracked fees - // (such as late payment fees) that go directly to the broker. + /** Amount subtracted from `sfManagementFeeOutstanding`. + * + * Covers only scheduled management fees from the amortization table; + * unscheduled fees (e.g., late fees) live in `ExtendedPaymentComponents`. + */ Number trackedManagementFeeDelta; - // Indicates if this payment has special handling requirements. - // - none: Regular scheduled payment - // - final: The last payment that closes out the loan - // - extra: An additional payment beyond the regular schedule (overpayment) + /** Scheduling classification of this payment. */ PaymentSpecialCase specialCase = PaymentSpecialCase::None; - // Calculates the tracked interest portion of this payment. - // This is derived from the other components as: - // trackedValueDelta - trackedPrincipalDelta - trackedManagementFeeDelta - // - // @return The amount of tracked interest included in this payment that - // will be paid to the vault. + /** Net interest portion of this payment, paid to the vault. + * + * Derived as `trackedValueDelta - trackedPrincipalDelta - + * trackedManagementFeeDelta`. + * + * @return Scheduled interest component; always >= 0 for well-formed input. + */ [[nodiscard]] Number trackedInterestPart() const; }; -/* Extends PaymentComponents with untracked payment amounts. +/** `PaymentComponents` extended with untracked fees and interest. * - * This structure adds untracked fees and interest to the base - * PaymentComponents, representing amounts that don't affect the Loan object's - * tracked state but are still part of the total payment due from the borrower. + * Untracked amounts are paid out to the broker (`untrackedManagementFee`) and + * vault (`untrackedInterest`) without altering the Loan object's amortization + * state. They arise from charges outside the regular schedule — late payment + * penalties, service fees, origination fees. * - * Untracked amounts include: - * - Late payment fees that go directly to the Broker - * - Late payment penalty interest that goes directly to the Vault - * - Service fees + * `totalDue` is computed eagerly in the constructor as + * `trackedValueDelta + untrackedInterest + untrackedManagementFee`. * - * The key distinction is that tracked amounts reduce the Loan object's state - * (sfTotalValueOutstanding, sfPrincipalOutstanding, - * sfManagementFeeOutstanding), while untracked amounts are paid directly to the - * recipient without affecting the loan's amortization schedule. + * @note `untrackedManagementFee` and `untrackedInterest` may individually be + * negative (e.g., adjustments), but the corresponding fields in the + * returned `LoanPaymentParts` are always clamped to >= 0. */ struct ExtendedPaymentComponents : public PaymentComponents { - // Additional management fees that go directly to the Broker. - // This includes fees not part of the standard amortization schedule - // (e.g., late fees, service fees, origination fees). - // This value may be negative, though the final value returned in - // LoanPaymentParts.feePaid will never be negative. + /** Unscheduled fee paid directly to the broker (e.g., late fee, service fee). */ Number untrackedManagementFee; - // Additional interest that goes directly to the Vault. - // This includes interest not part of the standard amortization schedule - // (e.g., late payment penalty interest). - // This value may be negative, though the final value returned in - // LoanPaymentParts.interestPaid will never be negative. + /** Unscheduled interest paid directly to the vault (e.g., late penalty). */ Number untrackedInterest; - // The complete amount due from the borrower for this payment. - // Calculated as: trackedValueDelta + untrackedInterest + - // untrackedManagementFee - // - // This value is used to validate that the payment amount provided by the - // borrower is sufficient to cover all components of the payment. + /** Total amount due from the borrower for this payment. + * + * Equals `trackedValueDelta + untrackedInterest + untrackedManagementFee`. + * Used to verify the borrower's supplied amount is sufficient. + */ Number totalDue; + /** Construct from a base `PaymentComponents` plus the two untracked amounts. + * + * @param p Base tracked components. + * @param fee Untracked management fee for the broker. + * @param interest Untracked interest for the vault; defaults to zero. + */ ExtendedPaymentComponents(PaymentComponents const& p, Number fee, Number interest = kNUM_ZERO) : PaymentComponents(p) , untrackedManagementFee(fee) @@ -320,26 +493,27 @@ struct ExtendedPaymentComponents : public PaymentComponents } }; -/* Represents the differences between two loan states. +/** Component-wise difference between two `LoanState` objects. * - * This structure is used to capture the change in each component of a loan's - * state, typically when computing the difference between two LoanState objects - * (e.g., before and after a payment). It is a convenient way to capture changes - * in each component. How that difference is used depends on the context. + * Produced by `operator-(LoanState, LoanState)`. Used in `tryOverpayment()` + * to measure the gap between the theoretical (unrounded) loan state and the + * rounded ledger state, allowing accumulated rounding error to be preserved + * across re-amortization. */ struct LoanStateDeltas { - // The difference in principal outstanding between two loan states. + /** Change in principal outstanding. */ Number principal; - // The difference in interest due between two loan states. + /** Change in interest due. */ Number interest; - // The difference in management fee outstanding between two loan states. + /** Change in management fee outstanding. */ Number managementFee; - /* Calculates the total change across all components. - * @return The sum of principal, interest, and management fee deltas. + /** Sum of all three delta components. + * + * @return `principal + interest + managementFee`. */ [[nodiscard]] Number total() const @@ -347,11 +521,46 @@ struct LoanStateDeltas return principal + interest + managementFee; } - // Ensures all delta values are non-negative. + /** Clamp all fields to zero from below. + * + * Rounding can occasionally produce tiny negative deltas when the + * theoretical target exceeds the current rounded state by a sub-scale + * amount. This method eliminates those artifacts before the deltas are + * used as payment amounts. + */ void nonNegative(); }; +/** Simulate a principal overpayment and re-amortize the loan in a local sandbox. + * + * Cannot simply recalculate from scratch because accumulated rounding errors + * from the loan's history must be preserved. The algorithm: + * 1. Computes the theoretical (unrounded) current state from the schedule. + * 2. Measures the rounding error gap against the current ledger state. + * 3. Reduces the theoretical principal by the overpayment amount. + * 4. Calls `computeLoanProperties()` for the new schedule. + * 5. Re-applies the preserved rounding errors before final rounding. + * 6. Validates the result with `checkLoanGuards()`. + * + * Returns `Unexpected(tesSUCCESS)` (no error, but no commit) when the + * overpayment would leave the loan in an invalid state — the caller treats + * this as a silent no-op rather than a transaction failure. + * + * @param rules Active amendment rules. + * @param asset Loan asset. + * @param loanScale Current loan rounding exponent. + * @param overpaymentComponents Breakdown of the overpayment (tracked + untracked). + * @param roundedLoanState Current rounded loan state from the ledger. + * @param periodicPayment Current fixed installment amount. + * @param periodicRate Per-period interest rate. + * @param paymentRemaining Payments remaining before the overpayment. + * @param managementFeeRate Broker fee rate in tenth-bips. + * @param j Journal for diagnostic logging. + * @return Pair of (`LoanPaymentParts`, re-amortized `LoanProperties`) on success; + * `Unexpected(TER)` on a hard failure, or `Unexpected(tesSUCCESS)` when the + * overpayment is silently suppressed. + */ Expected, TER> tryOverpayment( Rules const& rules, @@ -365,18 +574,79 @@ tryOverpayment( TenthBips16 const managementFeeRate, beast::Journal j); +/** Compute `(1 + r)^n - 1` accurately for near-zero `r` via binomial expansion. + * + * Direct subtraction `power(1 + r, n) - 1` suffers catastrophic cancellation + * when `r` is small: the result `~r*n` sits far below the leading `1` in + * `(1+r)^n`, consuming most of `Number`'s 19-digit mantissa. The binomial + * expansion avoids this: + * + * `(1 + r)^n - 1 = nr + C(n,2) r^2 + ... + r^n` + * + * Each term is derived from the previous as + * `term_{k+1} = term_k * r * (n - k) / (k + 1)`. The loop terminates early + * when adding the next term leaves the running sum unchanged (below `Number`'s + * precision floor). + * + * @param periodicRate Per-period rate `r`; must be >= 0. + * @param paymentsRemaining Number of periods `n`. + * @return `(1 + r)^n - 1`, or 0 if `r == 0` or `n == 0`. + * @note For `r * n >= 1e-9` the closed-form path in `computePowerMinusOneHybrid()` + * is ~30-500x faster and equally accurate; prefer the hybrid for production use. + */ [[nodiscard]] Number computePowerMinusOne(Number const& periodicRate, std::uint32_t paymentsRemaining); +/** Compute `(1 + r)^n - 1`, selecting the numerically stable path automatically. + * + * When `r * n >= 1e-9` the closed-form `power(1 + r, n) - 1` retains enough + * precision and is ~30-500x faster than the binomial expansion. Below that + * threshold, catastrophic cancellation degrades the result to fewer than ~10 + * significant digits, so the call is forwarded to `computePowerMinusOne()`. + * + * @param periodicRate Per-period rate `r`; must be >= 0. + * @param paymentsRemaining Number of periods `n`. + * @return `(1 + r)^n - 1`, or 0 if `r == 0` or `n == 0`. + * @note The 1e-9 threshold is verified by `testComputePowerMinusOneHybrid` to + * yield agreement between both paths to within `Number`'s post-subtraction + * precision (~10 significant digits) at the crossover. + */ [[nodiscard]] Number computePowerMinusOneHybrid(Number const& periodicRate, std::uint32_t paymentsRemaining); +/** Compute the standard amortization payment factor `r(1+r)^n / ((1+r)^n - 1)`. + * + * Multiplying this factor by the outstanding principal yields the fixed periodic + * payment. Implements Equation (6) from XLS-66, Section A-2. + * + * When `fixCleanup3_2_0` is active the denominator is evaluated via + * `computePowerMinusOneHybrid()` to avoid catastrophic cancellation at near-zero + * rates. The pre-amendment path uses `power(1+r, n) - 1` directly and is + * preserved for historic replay. + * + * @param rules Active amendment rules (gates the hybrid path). + * @param periodicRate Per-period rate `r`; must be >= 0. + * @param paymentsRemaining Number of remaining payments `n`. + * @return The payment factor; `1/n` when `r == 0`; 0 when `n == 0`. + */ [[nodiscard]] Number computePaymentFactor( Rules const& rules, Number const& periodicRate, std::uint32_t paymentsRemaining); +/** Split a gross interest amount into net vault interest and broker management fee. + * + * Computes `fee = computeManagementFee(interest, managementFeeRate)` and + * returns `(interest - fee, fee)`. Implements Equation (33) from XLS-66, + * Section A-2 Equation Glossary. + * + * @param asset Asset used for rounding the fee. + * @param interest Gross interest amount to split. + * @param managementFeeRate Broker's share of gross interest in tenth-bips. + * @param loanScale Exponent for rounding the fee downward. + * @return Pair `(netInterest, fee)` where `netInterest + fee == interest`. + */ std::pair computeInterestAndFeeParts( Asset const& asset, @@ -384,6 +654,19 @@ computeInterestAndFeeParts( TenthBips16 managementFeeRate, std::int32_t loanScale); +/** Compute the fixed installment amount for a standard amortized loan. + * + * Implements `principal * paymentFactor(r, n)`. For zero-interest loans + * the formula degenerates to equal principal slices (`principal / n`). + * Implements Equation (7) from XLS-66, Section A-2 Equation Glossary. + * + * @param rules Active amendment rules (passed to `computePaymentFactor`). + * @param principalOutstanding Current outstanding principal. + * @param periodicRate Per-period interest rate. + * @param paymentsRemaining Number of payments remaining. + * @return Unrounded periodic payment; 0 if `principalOutstanding == 0` + * or `paymentsRemaining == 0`. + */ Number loanPeriodicPayment( Rules const& rules, @@ -391,6 +674,20 @@ loanPeriodicPayment( Number const& periodicRate, std::uint32_t paymentsRemaining); +/** Reverse-calculate the outstanding principal implied by a given periodic payment. + * + * The inverse of `loanPeriodicPayment()`: recovers what the principal should be + * at a given point in the amortization schedule. Used by + * `computeTheoreticalLoanState()` and the early-closure path. Implements + * Equation (10) from XLS-66, Section A-2 Equation Glossary. + * + * @param rules Active amendment rules (passed to `computePaymentFactor`). + * @param periodicPayment Fixed installment amount. + * @param periodicRate Per-period interest rate. + * @param paymentsRemaining Number of payments remaining. + * @return Theoretical outstanding principal; 0 if `paymentsRemaining == 0`; + * `periodicPayment * paymentsRemaining` when `periodicRate == 0`. + */ Number loanPrincipalFromPeriodicPayment( Rules const& rules, @@ -398,6 +695,19 @@ loanPrincipalFromPeriodicPayment( Number const& periodicRate, std::uint32_t paymentsRemaining); +/** Compute the penalty interest accrued on an overdue payment. + * + * Calculates `principal * loanPeriodicRate(lateInterestRate, secondsOverdue)`. + * Returns 0 if the payment is on time or early, if `principalOutstanding == 0`, + * or if `lateInterestRate == 0`. Implements Equation (16) from XLS-66, + * Section A-2 Equation Glossary. + * + * @param principalOutstanding Current outstanding principal. + * @param lateInterestRate Annualized penalty rate in tenth-of-a-basis-point units. + * @param parentCloseTime Close time of the parent ledger (the "now"). + * @param nextPaymentDueDate Timestamp when the payment was originally due. + * @return Unrounded late penalty interest; 0 if the payment is not overdue. + */ Number loanLatePaymentInterest( Number const& principalOutstanding, @@ -405,6 +715,22 @@ loanLatePaymentInterest( NetClock::time_point parentCloseTime, std::uint32_t nextPaymentDueDate); +/** Compute the interest that has accrued since the last payment. + * + * Prorates the periodic interest by the fraction of the payment interval that + * has elapsed since `prevPaymentDate` (or `startDate` for the first payment). + * Returns 0 if `principalOutstanding == 0`, `periodicRate == 0`, or the current + * time is before `startDate`. Implements Equation (27) from XLS-66, + * Section A-2 Equation Glossary. + * + * @param principalOutstanding Current outstanding principal. + * @param periodicRate Pre-computed per-period interest rate. + * @param parentCloseTime Close time of the parent ledger (the "now"). + * @param startDate Loan start date (epoch seconds). + * @param prevPaymentDate Due date of the most recently completed payment. + * @param paymentInterval Payment period length in seconds. + * @return Unrounded accrued interest; always >= 0. + */ Number loanAccruedInterest( Number const& principalOutstanding, @@ -414,6 +740,22 @@ loanAccruedInterest( std::uint32_t prevPaymentDate, std::uint32_t paymentInterval); +/** Compute payment components for a principal overpayment. + * + * Splits the overpayment into a tracked value delta (principal reduction), + * an untracked management fee for the broker, and an optional untracked + * interest charge for the vault. Implements Equations (20-22) from XLS-66, + * Section A-2 Equation Glossary. + * + * @param asset Loan asset for rounding. + * @param loanScale Rounding exponent. + * @param overpayment Extra principal amount paid above schedule. + * @param overpaymentInterestRate Rate applied to the overpayment as an interest + * charge; 0 means no interest on the extra principal. + * @param overpaymentFeeRate Fee rate applied to the overpayment for the broker. + * @param managementFeeRate Standard broker fee rate used to split interest. + * @return `ExtendedPaymentComponents` with `specialCase == Extra`. + */ ExtendedPaymentComponents computeOverpaymentComponents( Asset const& asset, @@ -423,6 +765,25 @@ computeOverpaymentComponents( TenthBips32 const overpaymentFeeRate, TenthBips16 const managementFeeRate); +/** Compute how a single scheduled installment splits into tracked components. + * + * Uses the theoretical loan state (from `computeTheoreticalLoanState()`) as a + * target and caps the deltas at the available balances and the periodic payment + * amount to avoid over-reducing the ledger fields. Implements + * `compute_payment_due()` from XLS-66 §3.2.4.4. + * + * @param rules Active amendment rules. + * @param asset Loan asset for rounding. + * @param scale Rounding exponent. + * @param totalValueOutstanding Current total value outstanding. + * @param principalOutstanding Current principal outstanding. + * @param managementFeeOutstanding Current management fee outstanding. + * @param periodicPayment Fixed unrounded installment amount. + * @param periodicRate Pre-computed per-period interest rate. + * @param paymentRemaining Number of payments still remaining. + * @param managementFeeRate Broker fee rate in tenth-bips. + * @return `PaymentComponents` with all tracked deltas set. + */ PaymentComponents computePaymentComponents( Rules const& rules, @@ -438,15 +799,44 @@ computePaymentComponents( } // namespace detail +/** Compute the component-wise difference between two loan states. + * + * @return `LoanStateDeltas` with each field equal to the corresponding + * `lhs` field minus the `rhs` field. + */ detail::LoanStateDeltas operator-(LoanState const& lhs, LoanState const& rhs); +/** Subtract a set of deltas from a loan state. + * + * @return New `LoanState` with each field reduced by the corresponding delta. + */ LoanState operator-(LoanState const& lhs, detail::LoanStateDeltas const& rhs); +/** Add a set of deltas to a loan state. + * + * @return New `LoanState` with each field increased by the corresponding delta. + */ LoanState operator+(LoanState const& lhs, detail::LoanStateDeltas const& rhs); +/** Compute all derived loan properties from raw interest-rate parameters. + * + * Convenience overload that converts `interestRate` and `paymentInterval` to a + * per-period rate via `loanPeriodicRate()` and delegates to the `periodicRate` + * overload. See that overload's documentation for the full algorithm. + * + * @param rules Active amendment rules. + * @param asset Loan asset. + * @param principalOutstanding Requested or remaining principal. + * @param interestRate Annual interest rate in tenth-of-a-basis-point units. + * @param paymentInterval Payment period length in seconds. + * @param paymentsRemaining Total number of scheduled payments. + * @param managementFeeRate Broker fee rate in tenth-bips. + * @param minimumScale Floor on the derived `loanScale`. + * @return `LoanProperties` suitable for `checkLoanGuards()`. + */ LoanProperties computeLoanProperties( Rules const& rules, @@ -458,6 +848,30 @@ computeLoanProperties( TenthBips32 managementFeeRate, std::int32_t minimumScale); +/** Compute all derived loan properties from a pre-converted periodic rate. + * + * Calculates `periodicPayment`, the rounded total value outstanding, the + * `loanScale` (derived from the `STAmount` exponent of the total value, + * clamped to `minimumScale`), and `firstPaymentPrincipal`. Implements + * concepts from XLS-66 §3.2.4.3 and Equations 30-33 from Section A-2. + * + * The `loanScale` is not fixed at loan creation — it is derived dynamically so + * that all subsequent rounding of principal, interest, and fees uses a + * consistent number of decimal places, preventing dust-accumulation bugs. + * + * Called at loan creation (`LoanSet::doApply()`) and after each overpayment + * re-amortization inside `detail::tryOverpayment()`. + * + * @param rules Active amendment rules. + * @param asset Loan asset. + * @param principalOutstanding Requested or remaining principal. + * @param periodicRate Pre-computed per-period interest rate. + * @param paymentsRemaining Total number of scheduled payments. + * @param managementFeeRate Broker fee rate in tenth-bips. + * @param minimumScale Floor on the derived `loanScale`. + * @return `LoanProperties` with all fields computed and ready for + * `checkLoanGuards()`. + */ LoanProperties computeLoanProperties( Rules const& rules, @@ -468,15 +882,63 @@ computeLoanProperties( TenthBips32 managementFeeRate, std::int32_t minimumScale); +/** Check whether a value is already rounded to the given scale. + * + * Compares the downward- and upward-rounded forms; equality means no + * sub-scale precision remains. Used as a precondition guard and + * post-condition assertion throughout the payment pipeline. + * + * @param asset Asset whose representable precision constrains rounding. + * @param value The value to test. + * @param scale Exponent that defines the target precision. + * @return `true` if `roundDown(value) == roundUp(value)` at `scale`. + */ bool isRounded(Asset const& asset, Number const& value, std::int32_t scale); -// Indicates what type of payment is being made. -// regular, late, and full are mutually exclusive. -// overpayment is an "add on" to a regular payment, and follows that path with -// potential extra work at the end. -enum class LoanPaymentType { Regular = 0, Late, Full, Overpayment }; +/** Classification of the payment being made in a `LoanPay` transaction. + * + * The values are mutually exclusive for `Regular`, `Late`, and `Full`. + * `Overpayment` is an add-on to a `Regular` payment: it follows the regular + * path and may trigger a re-amortization step at the end if excess funds remain. + */ +enum class LoanPaymentType { + Regular = 0, /**< Scheduled installment, paid on time. */ + Late, /**< Overdue installment, carries penalty interest. */ + Full, /**< Early full closure, may carry a prepayment penalty. */ + Overpayment /**< Regular payment with additional principal reduction. */ +}; +/** Execute a loan payment and return the breakdown of amounts disbursed. + * + * Top-level entry point called by `LoanPay::doApply()`. Dispatches to the + * appropriate internal calculation path based on `paymentType`: + * + * - **Regular / Overpayment**: loops up to `kLOAN_MAXIMUM_PAYMENTS_PER_TRANSACTION` + * times applying `computePaymentComponents()`. When `paymentType == Overpayment` + * and funds remain after all regular installments, + * `computeOverpaymentComponents()` + `detail::tryOverpayment()` handle the + * re-amortization step. + * - **Late**: calls `detail::computeLatePayment()` then commits. + * - **Full**: calls `detail::computeFullPayment()` then commits. + * + * Any overdue payment not flagged `Late` is rejected with `tecEXPIRED`. Loan + * completion (all balances zeroed) and schedule advancement are handled as part + * of committing each payment round. Implements `make_payment` from XLS-66 + * §3.2.4.4. + * + * @param asset Loan asset (for rounding and balance operations). + * @param view Apply view providing rules, parent close time, and SLE mutation. + * @param loan Mutable reference to the Loan SLE; updated in place. + * @param brokerSle Const reference to the LoanBroker SLE; supplies + * `sfManagementFeeRate`. + * @param amount Amount the borrower is supplying for this payment. + * @param paymentType One of `Regular`, `Late`, `Full`, or `Overpayment`. + * @param j Journal for diagnostic log messages. + * @return `Expected` with the payment breakdown on + * success; an error TER (`tecEXPIRED`, `tecINSUFFICIENT_PAYMENT`, + * `tecKILLED`, etc.) on failure. + */ Expected loanMakePayment( Asset const& asset, diff --git a/include/xrpl/ledger/helpers/MPTokenHelpers.h b/include/xrpl/ledger/helpers/MPTokenHelpers.h index 6544b18dd1..ed893e2806 100644 --- a/include/xrpl/ledger/helpers/MPTokenHelpers.h +++ b/include/xrpl/ledger/helpers/MPTokenHelpers.h @@ -1,3 +1,16 @@ +/** @file + * MPT-specific ledger helper declarations. + * + * Declares the MPT counterpart to `RippleStateHelpers.h`. The asset-agnostic + * `TokenHelpers.h` dispatchers route `MPTIssue`-typed calls here via + * `std::visit` on the `Asset` variant. In addition to the functions that + * mirror IOU trust-line semantics (freeze, transfer rate, holding lifecycle, + * authorization), this header exposes operations with no IOU equivalent: + * escrow accounting, DEX permission gating, supply-overflow arithmetic, and + * the two-phase authorization protocol specific to MPT. + * + * @see RippleStateHelpers.h, TokenHelpers.h + */ #pragma once #include @@ -20,15 +33,65 @@ namespace xrpl { // //------------------------------------------------------------------------------ +/** Check whether an entire MPT issuance is globally frozen. + * + * Reads the `MPTokenIssuance` SLE and tests `lsfMPTLocked`. A missing + * issuance SLE is treated as unfrozen. + * + * @param view The ledger state to query. + * @param mptIssue The MPT issuance to check. + * @return `true` if `lsfMPTLocked` is set on the issuance; `false` otherwise. + */ [[nodiscard]] bool isGlobalFrozen(ReadView const& view, MPTIssue const& mptIssue); +/** Check whether a specific account's MPToken holding is individually frozen. + * + * Reads the per-holder `MPToken` SLE and tests `lsfMPTLocked`. Returns + * `false` if no `MPToken` SLE exists for the account (i.e., the account + * holds no balance for this issuance). + * + * @param view The ledger state to query. + * @param account The account whose holding is checked. + * @param mptIssue The MPT issuance to check against. + * @return `true` if the account's `MPToken` carries `lsfMPTLocked`; + * `false` otherwise. + */ [[nodiscard]] bool isIndividualFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue); +/** Check whether an account's access to an MPT issuance is frozen by any tier. + * + * Applies three checks in order: global issuance lock (`isGlobalFrozen`), + * per-account holding lock (`isIndividualFrozen`), and vault pseudo-account + * freeze (`isVaultPseudoAccountFrozen`). Short-circuits on the first match. + * + * @param view The ledger state to query. + * @param account The account to check. + * @param mptIssue The MPT issuance to check against. + * @param depth Recursion depth guard forwarded to `isVaultPseudoAccountFrozen`; + * bounds pathological nested-vault configurations (currently unreachable + * in practice, but defended against up to `maxAssetCheckDepth`). + * @return `true` if any freeze tier applies; `false` otherwise. + */ [[nodiscard]] bool isFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue, int depth = 0); +/** Check whether any account in a set is frozen for an MPT issuance. + * + * Sequences checks across separate passes to minimize cost: the global freeze + * is tested once and short-circuits immediately; individual per-account locks + * are checked for every account before the more expensive vault + * pseudo-account recursion begins. + * + * @param view The ledger state to query. + * @param accounts The set of accounts to check. + * @param mptIssue The MPT issuance to check against. + * @param depth Recursion depth guard forwarded to `isVaultPseudoAccountFrozen`. + * @return `true` if the global freeze is set, or if any account carries an + * individual freeze, or if any account is a frozen vault pseudo-account; + * `false` otherwise. + */ [[nodiscard]] bool isAnyFrozen( ReadView const& view, @@ -42,10 +105,18 @@ isAnyFrozen( // //------------------------------------------------------------------------------ -/** Returns MPT transfer fee as Rate. Rate specifies - * the fee as fractions of 1 billion. For example, 1% transfer rate - * is represented as 1,010,000,000. - * @param issuanceID MPTokenIssuanceID of MPTTokenIssuance object +/** Convert the `sfTransferFee` field of an MPT issuance to the XRPL `Rate` type. + * + * `sfTransferFee` is a `uint16` in the range 0–50,000 representing 0–50% + * (units of 0.001%). The encoding maps to `Rate` via + * `1,000,000,000 + (10,000 × fee)`, so a 50,000 field value becomes + * `1,500,000,000` (50% surcharge over the gross). When `sfTransferFee` is + * absent, `parityRate` (1,000,000,000 — no fee) is returned. + * + * @param view The ledger state to query. + * @param issuanceID The `MPTokenIssuanceID` of the issuance. + * @return The transfer rate as a `Rate` value; `parityRate` when no fee is + * configured or the issuance SLE is absent. */ [[nodiscard]] Rate transferRate(ReadView const& view, MPTID const& issuanceID); @@ -56,6 +127,18 @@ transferRate(ReadView const& view, MPTID const& issuanceID); // //------------------------------------------------------------------------------ +/** Read-only pre-check: verify that an independent holding can be created. + * + * Validates two preconditions before `addEmptyHolding` mutates the ledger: + * the `MPTokenIssuance` must exist, and it must carry `lsfMPTCanTransfer`. + * Tokens without `lsfMPTCanTransfer` can only move directly between the + * issuer and counterparties, making independent holdings meaningless. + * + * @param view The ledger state to query. + * @param mptIssue The MPT issuance the caller wants to hold. + * @return `tesSUCCESS`, `tecOBJECT_NOT_FOUND` if the issuance SLE is absent, + * or `tecNO_AUTH` if `lsfMPTCanTransfer` is not set. + */ [[nodiscard]] TER canAddHolding(ReadView const& view, MPTIssue const& mptIssue); @@ -65,6 +148,33 @@ canAddHolding(ReadView const& view, MPTIssue const& mptIssue); // //------------------------------------------------------------------------------ +/** Core MPToken SLE lifecycle function — create, delete, or toggle authorization. + * + * Behavior depends on `holderID`: + * - `holderID` absent (`nullopt`): `account` is the holder. Without + * `tfMPTUnauthorize`, a new zero-balance `MPToken` SLE is created and + * inserted into the owner directory; the XRP reserve is enforced when + * `ownerCount >= 2` (same policy as trust lines). With `tfMPTUnauthorize`, + * the existing SLE is erased and the owner count decremented. + * - `holderID` set: `account` must be the issuance's issuer. The function + * toggles `lsfMPTAuthorized` on the holder's existing `MPToken` SLE. + * + * @param view The mutable ledger state. + * @param priorBalance XRP balance before this transaction; used only for the + * reserve check when creating a new holding (`holderID` absent and + * `tfMPTUnauthorize` not set). + * @param mptIssuanceID The issuance being authorized or deauthorized. + * @param account Submitting account: the holder (when `holderID` is absent) + * or the issuer (when `holderID` is set). + * @param journal Logging sink. + * @param flags Transaction flags; `tfMPTUnauthorize` selects the + * delete/deauthorize path. + * @param holderID When set, `account` is the issuer and this is the holder + * whose `lsfMPTAuthorized` flag is toggled. + * @return `tesSUCCESS`, `tecINSUFFICIENT_RESERVE` if reserves are too low, + * `tecDUPLICATE` if the holding already exists, or a `tef` code on + * invariant violations. + */ [[nodiscard]] TER authorizeMPToken( ApplyView& view, @@ -75,12 +185,31 @@ authorizeMPToken( std::uint32_t flags = 0, std::optional holderID = std::nullopt); -/** Check if the account lacks required authorization for MPT. +/** Preclaim (read-only) authorization check for an MPT holding. * - * requireAuth check is recursive for MPT shares in a vault, descending to - * assets in the vault, up to maxAssetCheckDepth recursion depth. This is - * purely defensive, as we currently do not allow such vaults to be created. - * WeakAuth intentionally allows missing MPTokens under MPToken V2. + * Issuers are always authorized. When `featureSingleAssetVault` is active, + * vault and `LoanBroker` pseudo-accounts are implicitly authorized, and the + * check recurses into the vault's underlying asset (bounded by `depth` + * vs. `kMAX_ASSET_CHECK_DEPTH`). Domain-based authorization via + * `credentials::validDomain` takes precedence over `lsfMPTAuthorized` when + * `sfDomainID` is present on the issuance — a passing domain check succeeds + * even if no `MPToken` SLE exists. + * + * `WeakAuth` intentionally permits a missing `MPToken` SLE; used in MPToken + * V2 flows where the SLE is created on demand during apply. + * + * @note The recursion through vault assets is purely defensive; the ledger + * does not currently permit nested-vault MPT configurations. + * @param view The ledger state to query (read-only; called in preclaim). + * @param mptIssue The MPT issuance being accessed. + * @param account The account requesting access. + * @param authType Controls leniency toward missing `MPToken` SLEs; + * `WeakAuth` allows a missing SLE, `StrongAuth`/`Legacy` require it. + * @param depth Current recursion depth; guards against theoretical infinite + * recursion through nested vault configurations. + * @return `tesSUCCESS` if authorized, `tecOBJECT_NOT_FOUND` if the issuance + * is absent, `tecNO_AUTH` if authorization fails, or `tecEXPIRED` if + * domain credentials have expired. */ [[nodiscard]] TER requireAuth( @@ -90,11 +219,25 @@ requireAuth( AuthType authType = AuthType::Legacy, int depth = 0); -/** Enforce account has MPToken to match its authorization. +/** Enforce account has MPToken to match its authorization (doApply phase). * - * Called from doApply - it will check for expired (and delete if found any) - * credentials matching DomainID set in MPTokenIssuance. Must be called if - * requireAuth(...MPTIssue...) returned tesSUCCESS or tecEXPIRED in preclaim. + * Must be called when `requireAuth` returned `tesSUCCESS` or `tecEXPIRED` + * during preclaim. Re-checks authorization and, if a `sfDomainID` is set on + * the issuance, runs `verifyValidDomain` (which deletes expired credentials + * as a side effect). When domain authorization succeeds but the account has + * no `MPToken` SLE, one is created on the fly using `priorBalance` for the + * XRP reserve check. + * + * @note Must not be called for the issuer account. + * @param view The mutable ledger state (called in doApply). + * @param mptIssuanceID The issuance being accessed. + * @param account The holder account; must not be the issuer. + * @param priorBalance XRP balance before this transaction; used when lazily + * allocating a new `MPToken` SLE for domain-authorized holders. + * @param j Logging sink. + * @return `tesSUCCESS`, `tecNO_AUTH` if not authorized, `tecEXPIRED` if + * credentials have expired, or `tecINSUFFICIENT_RESERVE` if the reserve + * check fails during on-demand SLE creation. */ [[nodiscard]] TER enforceMPTokenAuthorization( @@ -104,9 +247,20 @@ enforceMPTokenAuthorization( XRPAmount const& priorBalance, beast::Journal j); -/** Check if the destination account is allowed - * to receive MPT. Return tecNO_AUTH if it doesn't - * and tesSUCCESS otherwise. +/** Check whether a transfer between two accounts is permitted by the issuance. + * + * When `lsfMPTCanTransfer` is absent, third-party transfers are blocked. + * Transfers where either `from` or `to` is the issuer are always allowed, + * mirroring the IOU trust-line policy that lets issuers send and receive + * their own tokens unconditionally. + * + * @param view The ledger state to query. + * @param mptIssue The MPT issuance involved in the transfer. + * @param from The sending account. + * @param to The receiving account. + * @return `tesSUCCESS` if the transfer is permitted, `tecOBJECT_NOT_FOUND` + * if the issuance SLE is absent, or `tecNO_AUTH` if `lsfMPTCanTransfer` + * is unset and neither endpoint is the issuer. */ [[nodiscard]] TER canTransfer( @@ -115,8 +269,16 @@ canTransfer( AccountID const& from, AccountID const& to); -/** Check if Asset can be traded on DEX. return tecNO_PERMISSION - * if it doesn't and tesSUCCESS otherwise. +/** Check whether an asset may be traded on the DEX. + * + * Dispatches via `asset.visit`: XRP and IOU assets always succeed; for MPT, + * reads the issuance SLE and checks `lsfMPTCanTrade`. + * + * @param view The ledger state to query. + * @param asset The asset to check; non-MPT assets always pass. + * @return `tesSUCCESS` if trading is permitted, `tecOBJECT_NOT_FOUND` if + * the MPT issuance SLE is absent, or `tecNO_PERMISSION` if + * `lsfMPTCanTrade` is not set. */ [[nodiscard]] TER canTrade(ReadView const& view, Asset const& asset); @@ -127,6 +289,24 @@ canTrade(ReadView const& view, Asset const& asset); // //------------------------------------------------------------------------------ +/** Create a zero-balance `MPToken` holding for `accountID`. + * + * Short-circuits to `tesSUCCESS` when the caller is the issuer — issuers + * never hold a `MPToken` SLE for their own issuance. For all other accounts, + * delegates to `authorizeMPToken`, which enforces the XRP reserve requirement + * and inserts the SLE into the owner directory. Returns `tefINTERNAL` if the + * issuance SLE is missing or globally locked (invariant violations). + * + * @param view The mutable ledger state. + * @param accountID The account requesting the holding. + * @param priorBalance XRP balance before this transaction; forwarded to + * `authorizeMPToken` for the reserve check. + * @param mptIssue The MPT issuance to hold. + * @param journal Logging sink. + * @return `tesSUCCESS`, `tecDUPLICATE` if a holding already exists, + * `tecINSUFFICIENT_RESERVE` if reserves are too low, or `tefINTERNAL` + * on issuance-state invariant violations. + */ [[nodiscard]] TER addEmptyHolding( ApplyView& view, @@ -135,6 +315,23 @@ addEmptyHolding( MPTIssue const& mptIssue, beast::Journal journal); +/** Delete a zero-balance `MPToken` holding. + * + * Requires `sfMPTAmount` to be zero and, when `fixSecurity3_1_3` is enabled, + * `sfLockedAmount` to be zero as well; returns `tecHAS_OBLIGATIONS` otherwise. + * When `accountID` is the issuer and no `MPToken` SLE exists, returns + * `tesSUCCESS` immediately — the normal issuer state. Otherwise delegates to + * `authorizeMPToken` with `tfMPTUnauthorize` to erase the SLE and decrement + * the owner count. + * + * @param view The mutable ledger state. + * @param accountID The account whose holding is being removed. + * @param mptIssue The MPT issuance. + * @param journal Logging sink. + * @return `tesSUCCESS`, `tecOBJECT_NOT_FOUND` if no holding exists (and + * caller is not the issuer), or `tecHAS_OBLIGATIONS` if the holding + * carries a non-zero balance or locked amount. + */ [[nodiscard]] TER removeEmptyHolding( ApplyView& view, @@ -148,6 +345,22 @@ removeEmptyHolding( // //------------------------------------------------------------------------------ +/** Move MPT funds from a holder's spendable balance into escrow. + * + * Decrements `sfMPTAmount` and increments `sfLockedAmount` on the sender's + * `MPToken` SLE, then increments `sfLockedAmount` on the `MPTokenIssuance` + * SLE. `sfOutstandingAmount` on the issuance is deliberately left unchanged — + * escrowed tokens remain outstanding until the escrow completes and the + * recipient actually receives them. All arithmetic is guarded by + * `canSubtract`/`canAdd`. + * + * @param view The mutable ledger state. + * @param uGrantorID The account placing tokens in escrow; must not be the issuer. + * @param saAmount The MPT amount to lock; must be a valid `MPTIssue` amount. + * @param j Logging sink. + * @return `tesSUCCESS`, or a `tec`/`tef` error if the issuance or `MPToken` + * SLE is missing, the sender is the issuer, or an arithmetic guard fires. + */ TER lockEscrowMPT( ApplyView& view, @@ -155,6 +368,28 @@ lockEscrowMPT( STAmount const& saAmount, beast::Journal j); +/** Release MPT funds from escrow and credit the recipient. + * + * Decrements `sfLockedAmount` on both the sender's `MPToken` SLE and the + * `MPTokenIssuance` SLE by `grossAmount`. Then, depending on the receiver: + * - Receiver is a third party: `sfMPTAmount` on the receiver's `MPToken` is + * incremented by `netAmount`. + * - Receiver is the issuer: `sfOutstandingAmount` on the issuance is + * decremented by `netAmount` — tokens return to the issuer and retire. + * When `fixTokenEscrowV1` is enabled and `grossAmount > netAmount`, the fee + * difference is additionally subtracted from `sfOutstandingAmount` because + * the fee tokens are effectively burned. All arithmetic is guarded by + * `canSubtract`/`canAdd`. + * + * @param view The mutable ledger state. + * @param uGrantorID The escrow grantor; must not be the issuer. + * @param uGranteeID The escrow grantee (may be the issuer). + * @param netAmount The MPT amount credited to the receiver after fees. + * @param grossAmount The MPT amount unlocked from escrow (>= `netAmount`). + * @param j Logging sink. + * @return `tesSUCCESS`, or a `tec`/`tef` error on missing SLEs or + * arithmetic guard failure. + */ TER unlockEscrowMPT( ApplyView& view, @@ -164,6 +399,18 @@ unlockEscrowMPT( STAmount const& grossAmount, beast::Journal j); +/** Low-level primitive: insert a new `MPToken` SLE and link it into the owner directory. + * + * Inserts the SLE unconditionally without checking for duplicates, enforcing + * reserves, or verifying issuance validity. Callers must perform those checks + * before invoking this function. + * + * @param view The mutable ledger state. + * @param mptIssuanceID The issuance the token belongs to. + * @param account The account that will own the `MPToken`. + * @param flags Initial `sfFlags` value for the new SLE. + * @return `tesSUCCESS`, or `tecDIR_FULL` if the owner directory is full. + */ TER createMPToken( ApplyView& view, @@ -171,6 +418,21 @@ createMPToken( AccountID const& account, std::uint32_t const flags); +/** Idempotently ensure a `MPToken` holding exists for `holder`. + * + * Succeeds immediately if `holder` is the issuer or if the `MPToken` SLE + * already exists. Otherwise calls `createMPToken` and increments the owner + * count. Suitable for apply-phase callers that need to auto-create a holding + * without the full reserve and issuance validity checks performed by + * `addEmptyHolding`. + * + * @param view The mutable ledger state. + * @param mptIssue The MPT issuance the holder will hold. + * @param holder The account to receive the holding. + * @param j Logging sink. + * @return `tesSUCCESS`, `tecDIR_FULL` if the owner directory is full, or + * `tecINTERNAL` if the holder's account SLE is missing. + */ TER checkCreateMPT( xrpl::ApplyView& view, @@ -184,25 +446,62 @@ checkCreateMPT( // //------------------------------------------------------------------------------ -// MaximumAmount doesn't exceed 2**63-1 +/** Return the configured supply cap for an MPT issuance. + * + * Returns `sfMaximumAmount` when present, or `kMAX_MP_TOKEN_AMOUNT` (2^63−1) + * when the field is absent, representing an uncapped issuance. The result is + * always non-negative and fits in a `std::int64_t`. + * + * @param sleIssuance The `MPTokenIssuance` SLE to query. + * @return The maximum allowed outstanding amount. + */ std::int64_t maxMPTAmount(SLE const& sleIssuance); -// OutstandingAmount may overflow and available amount might be negative. -// But available amount is always <= |MaximumAmount - OutstandingAmount|. +/** Compute remaining issuance headroom from a pre-read SLE. + * + * Returns `maxMPTAmount(sleIssuance) - sfOutstandingAmount`. May transiently + * be negative when the payment engine is processing a path step that + * temporarily exceeds `MaximumAmount` under `AllowMPTOverflow::Yes`. + * + * @param sleIssuance The `MPTokenIssuance` SLE to query. + * @return Headroom as a signed 64-bit integer; may be negative. + */ std::int64_t availableMPTAmount(SLE const& sleIssuance); +/** Compute remaining issuance headroom by reading the SLE from the view. + * + * Convenience overload that performs the SLE lookup. Throws + * `std::runtime_error` if the issuance SLE is absent — a missing issuance at + * this call site indicates a ledger consistency failure rather than a user + * error. + * + * @param view The ledger state to query. + * @param mptID The `MPTID` of the issuance. + * @return Headroom as a signed 64-bit integer; may be negative. + * @throws std::runtime_error if the `MPTokenIssuance` SLE is absent. + */ std::int64_t availableMPTAmount(ReadView const& view, MPTID const& mptID); -/** Checks for two types of OutstandingAmount overflow during a send operation. - * 1. **Direct directSendNoFee (Overflow: No):** A true overflow check when - * `OutstandingAmount > MaximumAmount`. This threshold is used for direct - * directSendNoFee transactions that bypass the payment engine. - * 2. **accountSend & Payment Engine (Overflow: Yes):** A temporary overflow - * check when `OutstandingAmount > UINT64_MAX`. This higher threshold is used - * for `accountSend` and payments processed via the payment engine. +/** Check whether crediting `sendAmount` would overflow the outstanding supply. + * + * Two distinct overflow thresholds are applied based on `allowOverflow`: + * 1. **`AllowMPTOverflow::No` (direct send):** Enforces the strict cap + * `OutstandingAmount + sendAmount ≤ MaximumAmount`. Used by + * `directSendNoFee` transactions that bypass the payment engine. + * 2. **`AllowMPTOverflow::Yes` (payment engine):** Raises the effective + * ceiling to `UINT64_MAX` to allow transient in-flight values that exceed + * `MaximumAmount` during path routing. A matching redemption step in the + * same transaction collapses the overshoot before settlement. + * + * @param sendAmount The proposed additional issuance; must be non-negative. + * @param outstandingAmount Current `sfOutstandingAmount` from the issuance SLE. + * @param maximumAmount The configured cap (`sfMaximumAmount` or + * `kMAX_MP_TOKEN_AMOUNT`). + * @param allowOverflow Selects which ceiling to apply. + * @return `true` if adding `sendAmount` would exceed the applicable limit. */ bool isMPTOverflow( @@ -211,18 +510,33 @@ isMPTOverflow( std::int64_t maximumAmount, AllowMPTOverflow allowOverflow); -/** - * Determine funds available for an issuer to sell in an issuer owned offer. - * Issuing step, which could be either MPTEndPointStep last step or BookStep's - * TakerPays may overflow OutstandingAmount. Redeeming step, in BookStep's - * TakerGets redeems the offer's owner funds, essentially balancing out - * the overflow, unless the offer's owner is the issuer. +/** Determine funds available for an issuer to sell in an issuer-owned DEX offer. + * + * During an issuing step (outbound from the issuer), the issuer's + * "available" balance is the remaining issuance headroom (`availableMPTAmount`) + * adjusted by `balanceHookSelfIssueMPT` to account for any amount already + * sold within the same payment. Without this hook, offer-crossing could + * allow the issuer to exceed `sfMaximumAmount` across parallel paths in the + * same transaction. + * + * @param view The ledger state to query. + * @param issue The MPT issuance for which to compute issuer funds. + * @return The effective amount the issuer can sell; zero if the issuance SLE + * is absent. */ [[nodiscard]] STAmount issuerFundsToSelfIssue(ReadView const& view, MPTIssue const& issue); -/** Facilitate tracking of MPT sold by an issuer owning MPT sell offer. - * See ApplyView::issuerSelfDebitHookMPT(). +/** Track MPT sold by an issuer that owns an MPT sell offer. + * + * Records the cumulative amount sold during the current payment step so that + * subsequent calls to `issuerFundsToSelfIssue` return a correctly reduced + * available balance. Delegates to `ApplyView::issuerSelfDebitHookMPT` after + * computing the current issuance headroom. + * + * @param view The mutable ledger state. + * @param issue The MPT issuance being sold. + * @param amount The additional amount sold in this step. */ void issuerSelfDebitHookMPT(ApplyView& view, MPTIssue const& issue, std::uint64_t amount); @@ -233,9 +547,26 @@ issuerSelfDebitHookMPT(ApplyView& view, MPTIssue const& issue, std::uint64_t amo // //------------------------------------------------------------------------------ -/* Return true if a transaction is allowed for the specified MPT/account. The - * function checks MPTokenIssuance and MPToken objects flags to determine if the - * transaction is allowed. +/** Comprehensive MPT transaction permission check for DEX and payment types. + * + * Verifies in order: the issuer account exists, the `MPTokenIssuance` SLE + * exists, the issuance is not globally locked (`lsfMPTLocked`), the + * `lsfMPTCanTrade` flag is set, and — for non-issuer accounts — that + * `lsfMPTCanTransfer` is set and the account's own `MPToken` is not + * individually locked. A missing `MPToken` SLE for a non-issuer is treated + * as passing: some transaction types create the `MPToken` on demand and + * perform their own missing-token checks. + * + * @note Must not be called with `txType == ttPAYMENT`; use the payment-engine + * path's own checks for payments. + * @param v The ledger state to query. + * @param tx The transaction type being gated. + * @param asset The asset involved; non-MPT assets always succeed. + * @param accountID The account initiating the transaction. + * @return `tesSUCCESS`, `tecOBJECT_NOT_FOUND` if the issuance is absent, + * `tecNO_ISSUER` if the issuer account is gone, `tecLOCKED` if the + * issuance or account is frozen, or `tecNO_PERMISSION` if trading or + * transfer is not permitted. */ TER checkMPTTxAllowed(ReadView const& v, TxType tx, Asset const& asset, AccountID const& accountID); diff --git a/include/xrpl/ledger/helpers/NFTokenHelpers.h b/include/xrpl/ledger/helpers/NFTokenHelpers.h index 4294e1ca13..c18b702b7d 100644 --- a/include/xrpl/ledger/helpers/NFTokenHelpers.h +++ b/include/xrpl/ledger/helpers/NFTokenHelpers.h @@ -1,3 +1,20 @@ +/** + * @file NFTokenHelpers.h + * @brief Core helpers for NFT paged-directory and offer management. + * + * Declares all mutable and read-only operations on the NFToken paged-directory + * structure and offer queues. Every transaction that touches an NFToken — + * minting, burning, transferring, or creating/cancelling offers — calls these + * helpers rather than manipulating ledger state directly. + * + * @note NFTs are packed into doubly-linked `ltNFTOKEN_PAGE` SLEs, each + * holding up to `kDIR_MAX_TOKENS_PER_PAGE` (32) tokens sorted by + * `compareTokens()`. Tokens sharing the same low-96-bit masked value + * (issuer + taxon) are *equivalent* and must be collocated on the same + * page. Page key invariant: every token's low 96 bits are strictly less + * than the low 96 bits of its enclosing page key. + */ + #pragma once #include @@ -13,18 +30,48 @@ namespace xrpl::nft { /** Delete up to a specified number of offers from the specified token offer - * directory. */ + * directory. + * + * Iterates the directory page-by-page, deleting offers in reverse index order + * within each page. Reverse iteration is required because `sfIndexes` is + * vector-backed and forward deletion would corrupt the remaining indices. + * Stops as soon as `maxDeletableOffers` offers have been removed. + * + * @param view The apply view to mutate. + * @param directory Keylet of the NFT buy or sell offer directory to drain. + * @param maxDeletableOffers Maximum number of offers to remove in this call. + * @return The number of offers actually deleted. + * @note Returns 0 immediately if `maxDeletableOffers` is 0. Used by + * `NFTokenBurn` to drain open offers within the per-transaction + * deletion cap (`maxDeletableTokenOfferEntries`). + */ std::size_t removeTokenOffersWithLimit( ApplyView& view, Keylet const& directory, std::size_t maxDeletableOffers); -/** Finds the specified token in the owner's token directory. */ +/** Finds the specified token in the owner's token directory. + * + * Read-only traversal: locates the `ltNFTOKEN_PAGE` candidate via `succ()` + * and searches the page's `sfNFTokens` array for a matching `sfNFTokenID`. + * + * @param view The read-only view to query. + * @param owner The account whose NFT directory is searched. + * @param nftokenID The 256-bit NFT identifier to look up. + * @return The matching token `STObject`, or `std::nullopt` if not found. + * @see findTokenAndPage for the mutable overload that also returns the page. + */ std::optional findToken(ReadView const& view, AccountID const& owner, uint256 const& nftokenID); -/** Finds the token in the owner's token directory. Returns token and page. */ +/** Token and its containing page, returned by `findTokenAndPage()`. + * + * Bundles the located token `STObject` with the mutable `shared_ptr` + * page so callers can modify the token in place without a second ledger + * traversal. The page pointer must be used exclusively on the same + * `ApplyView` that produced it. + */ struct TokenAndPage { STObject token; @@ -35,17 +82,81 @@ struct TokenAndPage { } }; + +/** Finds the token in the owner's token directory and returns it with its page. + * + * Mutable traversal via `ApplyView::peek()`. Returns both the token + * `STObject` and the `shared_ptr` page so that callers such as + * `NFTokenAcceptOffer` can pass the page directly to `removeToken()`, + * avoiding a redundant page lookup. + * + * @param view The apply view to query (mutable; uses `peek()`). + * @param owner The account whose NFT directory is searched. + * @param nftokenID The 256-bit NFT identifier to look up. + * @return A `TokenAndPage` containing the token and its page, or + * `std::nullopt` if the token is not found. + * @see findToken for the read-only alternative that returns only the token. + */ std::optional findTokenAndPage(ApplyView& view, AccountID const& owner, uint256 const& nftokenID); -/** Insert the token in the owner's token directory. */ +/** Insert the token in the owner's token directory. + * + * Locates or creates the appropriate `ltNFTOKEN_PAGE` via `getPageForToken()`. + * If the target page is full, it is split to make room; each split increments + * the owner's reserve count. Tokens are kept sorted within a page by + * `compareTokens()` (low 96-bit key first, full ID as tiebreaker). + * + * @param view The apply view to mutate. + * @param owner The account that will own the token. + * @param nft The token `STObject` to insert; must contain `sfNFTokenID`. + * @return `tesSUCCESS` on success, or `tecNO_SUITABLE_NFTOKEN_PAGE` if the + * target page is entirely filled with equivalent tokens (same low 96-bit + * key) and no split is possible. + */ TER insertToken(ApplyView& view, AccountID owner, STObject&& nft); -/** Remove the token from the owner's token directory. */ +/** Remove the token from the owner's token directory. + * + * Page-discovery overload: locates the containing `ltNFTOKEN_PAGE` via + * `succ()` and then delegates to the two-argument form. Use this when + * the caller does not already hold a page reference. + * + * After erasure, attempts to merge the affected page with its neighbours; + * each successful merge credits one reserve. If the page becomes empty it + * is unlinked and erased. + * + * @param view The apply view to mutate. + * @param owner The account that currently holds the token. + * @param nftokenID The 256-bit NFT identifier to remove. + * @return `tesSUCCESS`, or `tecNO_ENTRY` if the page or token cannot be + * found. + * @see removeToken(ApplyView&, AccountID const&, uint256 const&, shared_ptr const&) + * for the overload that skips the page lookup. + */ TER removeToken(ApplyView& view, AccountID const& owner, uint256 const& nftokenID); +/** Remove the token from the owner's token directory using a pre-located page. + * + * Caller-supplied page overload: skips the `succ()`-based page lookup when + * the caller already holds the page (e.g., from `findTokenAndPage()`). + * The `page` pointer must have been obtained from the same `ApplyView` + * instance. + * + * Under the `fixNFTokenPageLinks` amendment, if the emptied page is the final + * anchor page (`nftpage_max`), its contents are replaced with those of the + * previous page and the now-empty previous page is erased, preserving the + * invariant that the last page always has the stable sentinel key. + * + * @param view The apply view to mutate. + * @param owner The account that currently holds the token. + * @param nftokenID The 256-bit NFT identifier to remove. + * @param page The mutable SLE page known to contain the token. + * @return `tesSUCCESS`, or `tecNO_ENTRY` if the token is not found on the + * supplied page. + */ TER removeToken( ApplyView& view, @@ -53,28 +164,74 @@ removeToken( uint256 const& nftokenID, std::shared_ptr const& page); -/** Deletes the given token offer. - - An offer is tracked in two separate places: - - The token's 'buy' directory, if it's a buy offer; or - - The token's 'sell' directory, if it's a sell offer; and - - The owner directory of the account that placed the offer. - - The offer also consumes one incremental reserve. +/** Deletes the given token offer and removes it from both tracking directories. + * + * An offer is tracked in two separate places: + * - The token's `nft_buys` directory, if it is a buy offer; or + * - The token's `nft_sells` directory, if it is a sell offer; and + * - The owner's owner directory. + * + * Both directory entries are removed, the owner's reserve count is + * decremented by one, and the offer SLE is erased. + * + * @param view The apply view to mutate. + * @param offer The SLE for the offer to delete; must be of type + * `ltNFTOKEN_OFFER`. + * @return `true` if the offer was successfully deleted; `false` if the SLE + * is not of type `ltNFTOKEN_OFFER` or if a directory removal fails, + * acting as a type-safety guard. */ bool deleteTokenOffer(ApplyView& view, std::shared_ptr const& offer); -/** Repairs the links in an NFTokenPage directory. - - Returns true if a repair took place, otherwise false. -*/ +/** Repairs the links in an NFToken page directory. + * + * Walks the entire `ltNFTOKEN_PAGE` chain for the owner and corrects any + * broken `sfNextPageMin` / `sfPreviousPageMin` links. If the final page does + * not have the expected `nftpage_max` sentinel key, its contents are migrated + * to a newly created SLE with the correct key, the old SLE is erased, and the + * chain is relinked. Owner count is unchanged by this operation because the + * page count is preserved. + * + * Intended to be called by the `LedgerStateFix` transaction on accounts with + * known directory corruption. + * + * @param view The apply view to mutate. + * @param owner The account whose NFToken page directory is to be repaired. + * @return `true` if any correction was applied; `false` if the directory was + * already consistent. + */ bool repairNFTokenDirectoryLinks(ApplyView& view, AccountID const& owner); +/** Ordering predicate for NFToken IDs within and across pages. + * + * Sorts first by the low 96 bits of each ID (the `pageMask` region that + * determines page placement), then by the full 256-bit value as a + * tiebreaker. This ensures deterministic ordering for tokens that share + * the same low 96-bit prefix (equivalent tokens) and must co-reside on + * a single page. + * + * @param a First NFToken ID. + * @param b Second NFToken ID. + * @return `true` if `a` sorts before `b`. + */ bool compareTokens(uint256 const& a, uint256 const& b); +/** Modify the URI of an existing NFToken in the owner's directory. + * + * Locates the token's page and updates the `sfURI` field in the token's + * `STObject` within the page's `sfNFTokens` array. If `uri` is + * `std::nullopt`, the `sfURI` field is removed from the token. + * + * @param view The apply view to mutate. + * @param owner The account that owns the token. + * @param nftokenID The 256-bit NFT identifier whose URI is to be changed. + * @param uri The new URI value, or `std::nullopt` to clear the URI. + * @return `tesSUCCESS` on success, or `tecINTERNAL` if the page or token + * cannot be located (indicates ledger inconsistency). + */ TER changeTokenURI( ApplyView& view, @@ -82,7 +239,33 @@ changeTokenURI( uint256 const& nftokenID, std::optional const& uri); -/** Preflight checks shared by NFTokenCreateOffer and NFTokenMint */ +/** Preflight checks shared by NFTokenCreateOffer and NFTokenMint. + * + * Validates offer parameters that require no ledger access: negative or + * zero amounts (buy offers must carry a non-zero amount), zero IOU amounts, + * zero expiration, and malformed `owner`/`destination` combinations. + * A buy offer must supply `owner` (the targeted token holder); a sell offer + * must not (the seller is implicit). Neither party may designate itself as + * the destination. + * + * Defaults (`owner = nullopt`, `txFlags = tfSellNFToken`) allow + * `NFTokenMint` to reuse this path with minimal adaptation. + * + * @param acctID Account executing the transaction. + * @param amount The offer amount; must be non-negative and, for buy offers, + * non-zero and non-zero for IOUs. + * @param dest Optional destination account that may exclusively accept the + * offer; must not equal `acctID`. + * @param expiration Optional offer expiration; must not be zero. + * @param nftFlags The flags field of the NFToken being offered. + * @param rules Current ledger rule set used for amendment checks. + * @param owner For buy offers, the account that currently holds the token; + * must be absent for sell offers. + * @param txFlags Transaction flags; `tfSellNFToken` distinguishes sell from + * buy. + * @return `tesSUCCESS` if all static checks pass, or a `temXXX` error code + * indicating which parameter is invalid. + */ NotTEC tokenOfferCreatePreflight( AccountID const& acctID, @@ -94,7 +277,37 @@ tokenOfferCreatePreflight( std::optional const& owner = std::nullopt, std::uint32_t txFlags = tfSellNFToken); -/** Preclaim checks shared by NFTokenCreateOffer and NFTokenMint */ +/** Preclaim checks shared by NFTokenCreateOffer and NFTokenMint. + * + * Accesses the ledger to validate conditions that cannot be checked + * statically: + * - For non-XRP offers on tokens without `flagCreateTrustLines`, verifies + * that the NFT issuer's trust line for the IOU exists and is not frozen. + * Under `featureNFTokenMintOffer`, an issuer selling their own currency is + * exempt from this check. + * - Enforces `flagTransferable`: if absent and the transacting account is + * neither the issuer nor the current `sfNFTokenMinter`, returns + * `tefNFTOKEN_IS_NOT_TRANSFERABLE`. + * - For buy offers, verifies the account currently has sufficient funds. + * - Verifies `dest` and `owner` accounts exist and have not set + * `lsfDisallowIncomingNFTokenOffer`. + * - Under `fixEnforceNFTokenTrustlineV2`, calls `checkTrustlineAuthorized()` + * to reject offers backed by unauthorized trust lines that carry a balance. + * + * @param view The read-only ledger view. + * @param acctID Account executing the transaction. + * @param nftIssuer Issuer encoded in the NFToken ID. + * @param amount The offer amount. + * @param dest Optional restricted destination account. + * @param nftFlags The flags field of the NFToken being offered. + * @param xferFee Transfer fee encoded in the NFToken ID (basis points). + * @param j Journal for diagnostic logging. + * @param owner For buy offers, the account that currently holds the token. + * @param txFlags Transaction flags; `tfSellNFToken` distinguishes sell from + * buy. + * @return `tesSUCCESS` if all ledger-state checks pass, or a `tecXXX` / + * `tefXXX` error code. + */ TER tokenOfferCreatePreclaim( ReadView const& view, @@ -108,7 +321,28 @@ tokenOfferCreatePreclaim( std::optional const& owner = std::nullopt, std::uint32_t txFlags = tfSellNFToken); -/** doApply implementation shared by NFTokenCreateOffer and NFTokenMint */ +/** doApply implementation shared by NFTokenCreateOffer and NFTokenMint. + * + * Reserves XRP for the new `ltNFTOKEN_OFFER` object, inserts the offer into + * the account's owner directory and into the token's buy or sell directory + * (determined by `tfSellNFToken` in `txFlags`), constructs the SLE with the + * supplied fields, and increments the owner count. + * + * @param view The apply view to mutate. + * @param acctID Account executing the transaction. + * @param amount The offer amount. + * @param dest Optional restricted destination account. + * @param expiration Optional expiration time for the offer. + * @param seqProxy Sequence or ticket proxy used to derive the offer keylet. + * @param nftokenID The 256-bit ID of the NFToken being offered. + * @param priorBalance The account's XRP balance before the transaction fee + * was deducted; used to verify the reserve requirement. + * @param j Journal for diagnostic logging. + * @param txFlags Transaction flags; `tfSellNFToken` controls offer direction. + * @return `tesSUCCESS` on success, `tecINSUFFICIENT_RESERVE` if the account + * cannot cover the new object reserve, or `tecDIR_FULL` if either + * directory is at capacity. + */ TER tokenOfferCreateApply( ApplyView& view, @@ -122,6 +356,25 @@ tokenOfferCreateApply( beast::Journal j, std::uint32_t txFlags = tfSellNFToken); +/** Verify that an account is authorized to hold a given IOU trust line. + * + * Only active under the `fixEnforceNFTokenTrustlineV2` amendment; returns + * `tesSUCCESS` unconditionally when the amendment is not enabled. + * + * When active, checks that if the IOU issuer requires authorization + * (`lsfRequireAuth`), the trust line between `id` and the issuer exists and + * carries the appropriate `lsfLowAuth` / `lsfHighAuth` flag. The issuer + * account is always considered authorized to hold its own issuance. + * + * @param view The read-only ledger view. + * @param id The account whose authorization is being verified. + * @param j Journal for diagnostic logging. + * @param issue The IOU issue (currency + issuer) to check; must not be XRP. + * @return `tesSUCCESS` if authorized, `tecNO_ISSUER` if the issuer account + * does not exist, `tecNO_LINE` if the required trust line is absent, or + * `tecNO_AUTH` if the trust line exists but is not authorized. + * @note Only valid for custom (non-XRP) currencies; asserts otherwise. + */ TER checkTrustlineAuthorized( ReadView const& view, @@ -129,6 +382,26 @@ checkTrustlineAuthorized( beast::Journal const j, Issue const& issue); +/** Verify that an IOU trust line is not deep-frozen for a given account. + * + * Only active under the `featureDeepFreeze` amendment; returns + * `tesSUCCESS` unconditionally when the amendment is not enabled. + * + * When active, checks whether the trust line between `id` and the IOU issuer + * carries either `lsfLowDeepFreeze` or `lsfHighDeepFreeze`. Either side + * enacting deep freeze blocks token receipt, regardless of which party set it. + * The issuer account is always permitted to accept its own issuance; accounts + * with no trust line are treated as not frozen. + * + * @param view The read-only ledger view. + * @param id The account whose deep-freeze status is being checked. + * @param j Journal for diagnostic logging. + * @param issue The IOU issue (currency + issuer) to check; must not be XRP. + * @return `tesSUCCESS` if not deep-frozen or if no trust line exists, + * `tecNO_ISSUER` if the issuer account does not exist, or `tecFROZEN` + * if the trust line is deep-frozen. + * @note Only valid for custom (non-XRP) currencies; asserts otherwise. + */ TER checkTrustlineDeepFrozen( ReadView const& view, diff --git a/include/xrpl/ledger/helpers/OfferHelpers.h b/include/xrpl/ledger/helpers/OfferHelpers.h index 9096071811..c3f0015435 100644 --- a/include/xrpl/ledger/helpers/OfferHelpers.h +++ b/include/xrpl/ledger/helpers/OfferHelpers.h @@ -9,18 +9,38 @@ namespace xrpl { -/** Delete an offer. - - Requirements: - The offer must exist. - The caller must have already checked permissions. - - @param view The ApplyView to modify. - @param sle The offer to delete. - @param j Journal for logging. - - @return tesSUCCESS on success, otherwise an error code. -*/ +/** Remove an offer and its directory back-references from the ledger. + * + * Performs the full teardown sequence atomically within the transaction + * buffer: removes the offer from the owner's directory, removes it from + * the order-book quality directory, decrements the owner's reserve count, + * and erases the SLE. For hybrid offers (flagged `lsfHybrid`) that + * participate in one or more Permissioned DEX domains, each entry in + * `sfAdditionalBooks` is also removed from its domain-specific book + * directory before the owner-count adjustment and erasure. + * + * If `sle` is null the function returns `tesSUCCESS` immediately, + * allowing callers to pass the result of a failed `peek()` without + * a pre-check (defensive against double-delete within one batch). + * + * @pre The offer SLE must exist in the ledger and both its + * `sfOwnerNode` and `sfBookNode` back-references must be valid. + * @pre The caller must have already verified that the submitting + * account is authorized to delete this offer; this function + * performs no ownership or permission check. + * + * @param view The `ApplyView` transaction buffer to modify. + * @param sle The offer SLE to delete. May be null (treated as no-op). + * @param j Journal for diagnostic logging. + * + * @return `tesSUCCESS` on success, or `tefBAD_LEDGER` if a directory + * back-reference is missing (invariant violation; should not occur + * in a well-formed ledger). + * + * @note `[[nodiscard]]` is intentionally absent: `BookTip` and payment + * path callers do not always inspect the return value, and enforcing + * the attribute would have broken compilation across the engine. + */ // [[nodiscard]] // nodiscard commented out so Flow, BookTip and others compile. TER offerDelete(ApplyView& view, std::shared_ptr const& sle, beast::Journal j); diff --git a/include/xrpl/ledger/helpers/PaymentChannelHelpers.h b/include/xrpl/ledger/helpers/PaymentChannelHelpers.h index 24838f1331..021aae5a32 100644 --- a/include/xrpl/ledger/helpers/PaymentChannelHelpers.h +++ b/include/xrpl/ledger/helpers/PaymentChannelHelpers.h @@ -7,6 +7,35 @@ namespace xrpl { +/** Tear down a payment channel and return unspent XRP to its source account. + * + * Performs four ledger mutations in order: + * 1. Removes the channel from the source's owner directory (`sfOwnerNode`). + * 2. Conditionally removes the channel from the destination's owner directory + * (`sfDestinationNode`) — the field is absent on older channel objects that + * predate destination-directory tracking, so its presence is tested before + * the removal attempt. + * 3. Credits the unspent balance (`sfAmount - sfBalance`) back to the source + * account. `sfAmount` is the total XRP escrowed; `sfBalance` is the + * cumulative amount already paid to the destination. + * 4. Decrements the source's owner count and erases the `ltPAYCHAN` SLE. + * + * Called by both `PaymentChannelClaim` and `PaymentChannelFund` whenever a + * channel must be closed — on expiry (`cancelAfter`/`expiration` elapsed), on + * an explicit `tfClose` flag, or when the channel is fully drained. + * + * @param slep The `ltPAYCHAN` SLE to close; must satisfy + * `sfAmount >= sfBalance` (asserted). + * @param view The apply view through which all ledger mutations are made. + * @param key The ledger key of the channel SLE (used for directory removal). + * @param j Journal for fatal-level diagnostic messages on internal errors. + * @return `tesSUCCESS` on the normal path; `tefBAD_LEDGER` if an owner + * directory removal fails (indicates corrupted ledger state); + * `tefINTERNAL` if the source account SLE cannot be found. + * @note The `tefBAD_LEDGER` and `tefINTERNAL` branches are annotated + * `LCOV_EXCL` — they guard against ledger corruption that cannot occur + * during correct operation. + */ TER closeChannel( std::shared_ptr const& slep, diff --git a/include/xrpl/ledger/helpers/PermissionedDEXHelpers.h b/include/xrpl/ledger/helpers/PermissionedDEXHelpers.h index 04b12f2fc5..60fc5b1f56 100644 --- a/include/xrpl/ledger/helpers/PermissionedDEXHelpers.h +++ b/include/xrpl/ledger/helpers/PermissionedDEXHelpers.h @@ -1,13 +1,90 @@ +/** + * @file PermissionedDEXHelpers.h + * @brief Domain membership predicates for the Permissioned DEX. + * + * Declares the two authorization gatekeepers used by `xrpl::permissioned_dex` + * to enforce credential-based access control on restricted order books. + * Both functions are called from transaction preclaim logic and from live + * order-book traversal in `OfferStream`. + */ #pragma once #include namespace xrpl::permissioned_dex { -// Check if an account is in a permissioned domain +/** + * @brief Test whether an account currently qualifies as a member of a + * permissioned domain. + * + * Resolves the `PermissionedDomain` ledger object identified by @p domainID + * and applies a two-tier membership test: + * + * 1. **Owner shortcut** — the domain's `sfOwner` is always considered a member, + * avoiding a bootstrap problem where the owner couldn't trade in their own + * domain. + * 2. **Credential scan** — for all other accounts, the function iterates + * `sfAcceptedCredentials` and returns `true` as soon as it finds a + * credential issued to @p account that (a) carries the `lsfAccepted` flag + * and (b) has not expired according to `credentials::checkExpired` evaluated + * against the ledger's `parentCloseTime`. + * + * Expiry is evaluated against `parentCloseTime` (not wall time) so that all + * validators reach the same deterministic result regardless of local clock skew. + * + * @param view The read-only ledger view to query. + * @param account The account whose domain membership is being tested. + * @param domainID The identifier of the `PermissionedDomain` ledger object. + * @return `true` if @p account is the domain owner or holds at least one + * accepted, non-expired credential listed in the domain; `false` if the + * domain object does not exist, or if no qualifying credential is found. + * + * @note Called from `OfferCreate` preclaim (rejects with `tecNO_PERMISSION` if + * `false`) and twice from `Payment` preclaim — once for the sender, once for + * the destination — since a domain payment requires both parties to be + * members. Also called internally by `offerInDomain`. + */ [[nodiscard]] bool accountInDomain(ReadView const& view, AccountID const& account, Domain const& domainID); -// Check if an offer is in the permissioned domain +/** + * @brief Test whether a specific offer is still legitimately part of a + * permissioned domain at the time it is being consumed. + * + * Called by `OfferStream` during order-book traversal to handle the race + * between offer creation and subsequent credential expiry. An offer that was + * valid when placed may become invalid if the owner's credentials expire before + * the offer is matched. When this function returns `false`, `OfferStream` + * removes the offer from the book immediately (`permRmOffer`) instead of + * matching it. + * + * The function performs the following checks in order: + * - Offer SLE must exist (defensive; should not occur in a well-formed book). + * - Offer must carry `sfDomainID` (defensive; should not occur). + * - `sfDomainID` must match @p domainID (defensive; should not occur). + * - **Post-`fixSecurity3_1_3`**: a hybrid offer (`lsfHybrid`) must have + * `sfAdditionalBooks` present with exactly one entry; a violation is logged + * as an error and `false` is returned. + * - **Pre-`fixSecurity3_1_3`**: a hybrid offer must have `sfAdditionalBooks` + * present (size is not validated). + * - Delegates the final membership check to `accountInDomain` for the offer's + * owner (`sfAccount`). + * + * The three defensive checks are marked `LCOV_EXCL_LINE`; they guard against + * invariant violations that cannot occur under normal operation but are retained + * as safety nets. + * + * @param view The read-only ledger view to query. + * @param offerID The hash identifier of the offer SLE to validate. + * @param domainID The permissioned domain the offer is expected to belong to. + * @param j Journal used to log an error if a hybrid offer has a missing + * or malformed `sfAdditionalBooks` field. + * @return `true` if the offer passes all structural checks and its owner is + * currently a member of @p domainID; `false` otherwise. + * + * @note The `fixSecurity3_1_3` amendment tightens hybrid-offer validation from + * a presence-only check on `sfAdditionalBooks` to a presence-plus-size-one + * check. Both code paths must be preserved for deterministic historic replay. + */ [[nodiscard]] bool offerInDomain( ReadView const& view, diff --git a/include/xrpl/ledger/helpers/RippleStateHelpers.h b/include/xrpl/ledger/helpers/RippleStateHelpers.h index 17b0f7673e..827bb50535 100644 --- a/include/xrpl/ledger/helpers/RippleStateHelpers.h +++ b/include/xrpl/ledger/helpers/RippleStateHelpers.h @@ -1,3 +1,23 @@ +/** @file + * IOU trustline (RippleState) operations for the XRP Ledger. + * + * Declares every ledger operation that reads from or writes to a + * `RippleState` (trustline) SLE: credit-limit and balance queries, + * freeze checks, trustline lifecycle, IOU issuance/redemption, + * authorization and rippling enforcement, zero-balance holding + * management, and AMM-specific cleanup. + * + * This file is the IOU-specific leaf of the token helper layer. + * Asset-agnostic callers should go through the dispatchers in + * `TokenHelpers.h`, which branch on `Issue` vs `MPTIssue` and + * delegate here for the IOU path. + * + * @note The trustline orientation invariant is pervasive here: + * `sfLowLimit` always belongs to the account whose `AccountID` + * compares less; `sfHighLimit` to the other. Every function + * applies this flip internally — callers supply `(account, issuer)` + * and receive results in account-centric terms. + */ #pragma once #include @@ -10,27 +30,29 @@ #include #include -//------------------------------------------------------------------------------ -// -// RippleState (Trustline) helpers -// -//------------------------------------------------------------------------------ +// --- RippleState (Trustline) helpers --- namespace xrpl { -//------------------------------------------------------------------------------ -// -// Credit functions (from Credit.h) -// -//------------------------------------------------------------------------------ +// --- Credit queries --- -/** Calculate the maximum amount of IOUs that an account can hold - @param view the ledger to check against. - @param account the account of interest. - @param issuer the issuer of the IOU. - @param currency the IOU to check. - @return The maximum amount that can be held. -*/ +/** Read the maximum IOU balance that @p account has authorised @p issuer to + * carry on their behalf. + * + * Reads `sfLowLimit` or `sfHighLimit` from the trustline depending on + * which side `account` occupies (low if `account < issuer`). The issuer + * field of the returned amount is rewritten to `account` so the result is + * safe to consume without knowing the binary-ordering of the two accounts. + * Returns a zero-valued `STAmount` (with the correct issue) if no trustline + * exists. + * + * @param view Read-only ledger view to query. + * @param account The account whose credit limit is requested. + * @param issuer The IOU issuer. + * @param currency The currency of the trustline. + * @return The credit limit expressed from @p account's perspective, or zero + * if no trustline exists. + */ /** @{ */ STAmount creditLimit( @@ -39,16 +61,35 @@ creditLimit( AccountID const& issuer, Currency const& currency); +/** Convenience wrapper returning the credit limit as `IOUAmount`. + * + * @param v Read-only ledger view to query. + * @param acc The account whose credit limit is requested. + * @param iss The IOU issuer. + * @param cur The currency of the trustline. + * @return The credit limit as `IOUAmount`, or zero if no trustline exists. + * @see creditLimit + */ IOUAmount creditLimit2(ReadView const& v, AccountID const& acc, AccountID const& iss, Currency const& cur); /** @} */ -/** Returns the amount of IOUs issued by issuer that are held by an account - @param view the ledger to check against. - @param account the account of interest. - @param issuer the issuer of the IOU. - @param currency the IOU to check. -*/ +/** Read the IOU balance that @p account currently holds. + * + * `sfBalance` is stored in "low-account-sends-to-high-account" orientation. + * When `account` is the high side the stored value is negated before being + * returned, so callers always receive a balance expressed as "how much of + * this currency does @p account hold", regardless of which slot they occupy + * on the trustline. Returns zero (with the correct issue) if no trustline + * exists. + * + * @param view Read-only ledger view to query. + * @param account The account whose balance is requested. + * @param issuer The IOU issuer. + * @param currency The currency of the trustline. + * @return The balance expressed from @p account's perspective, or zero if + * no trustline exists. + */ /** @{ */ STAmount creditBalance( @@ -58,12 +99,20 @@ creditBalance( Currency const& currency); /** @} */ -//------------------------------------------------------------------------------ -// -// Freeze checking (IOU-specific) -// -//------------------------------------------------------------------------------ +// --- Freeze checks (IOU-specific) --- +/** Check whether @p issuer has individually frozen @p account's trustline. + * + * Inspects only the issuer's side flag (`lsfLowFreeze`/`lsfHighFreeze`) on + * the trustline. Does **not** check the issuer's global freeze flag — use + * `isFrozen` for that combined check. Always returns `false` for XRP. + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param currency The IOU currency. + * @param issuer The IOU issuer. + * @return `true` if the issuer has set a line-level freeze on this account. + */ [[nodiscard]] bool isIndividualFrozen( ReadView const& view, @@ -71,12 +120,34 @@ isIndividualFrozen( Currency const& currency, AccountID const& issuer); +/** Convenience overload accepting an `Issue`. + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param issue The IOU issue (currency + issuer). + * @return `true` if the issuer has set a line-level freeze on this account. + * @see isIndividualFrozen(ReadView const&, AccountID const&, Currency const&, + * AccountID const&) + */ [[nodiscard]] inline bool isIndividualFrozen(ReadView const& view, AccountID const& account, Issue const& issue) { return isIndividualFrozen(view, account, issue.currency, issue.account); } +/** Check whether @p account is frozen for @p currency issued by @p issuer. + * + * Returns `true` if either the issuer's `AccountRoot` has `lsfGlobalFreeze` + * set, or the issuer has frozen this specific trustline (`lsfLowFreeze` / + * `lsfHighFreeze`). Always returns `false` for XRP or when + * `account == issuer`. This is the check used by payment paths. + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param currency The IOU currency. + * @param issuer The IOU issuer. + * @return `true` if the account cannot move this IOU due to any freeze. + */ [[nodiscard]] bool isFrozen( ReadView const& view, @@ -84,20 +155,52 @@ isFrozen( Currency const& currency, AccountID const& issuer); +/** Convenience overload accepting an `Issue`. + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param issue The IOU issue (currency + issuer). + * @return `true` if the account cannot move this IOU due to any freeze. + * @see isFrozen(ReadView const&, AccountID const&, Currency const&, + * AccountID const&) + */ [[nodiscard]] inline bool isFrozen(ReadView const& view, AccountID const& account, Issue const& issue) { return isFrozen(view, account, issue.currency, issue.account); } -// Overload with depth parameter for uniformity with MPTIssue version. -// The depth parameter is ignored for IOUs since they don't have vault recursion. +/** Overload accepting a depth parameter for interface uniformity with MPT. + * + * IOUs do not have vault-level recursion, so the `depth` argument is + * unconditionally ignored. + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param issue The IOU issue (currency + issuer). + * @return `true` if the account cannot move this IOU due to any freeze. + */ [[nodiscard]] inline bool isFrozen(ReadView const& view, AccountID const& account, Issue const& issue, int /*depth*/) { return isFrozen(view, account, issue); } +/** Check whether @p account is deep-frozen for @p currency issued by + * @p issuer. + * + * Deep-freeze (`lsfHighDeepFreeze` / `lsfLowDeepFreeze`) is a stricter + * condition than ordinary freeze: it prevents both sending *and* receiving + * the currency. Always returns `false` for XRP, and always returns `false` + * when `issuer == account` (an issuer cannot deep-freeze their own balance + * with themselves). + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param currency The IOU currency. + * @param issuer The IOU issuer. + * @return `true` if the deep-freeze flag is set on either side of the line. + */ [[nodiscard]] bool isDeepFrozen( ReadView const& view, @@ -105,6 +208,18 @@ isDeepFrozen( Currency const& currency, AccountID const& issuer); +/** Convenience overload accepting an `Issue`, with an optional depth parameter + * for interface uniformity with the MPT equivalent. + * + * The `depth` argument is unconditionally ignored for IOUs. + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param issue The IOU issue (currency + issuer). + * @return `true` if the deep-freeze flag is set on either side of the line. + * @see isDeepFrozen(ReadView const&, AccountID const&, Currency const&, + * AccountID const&) + */ [[nodiscard]] inline bool isDeepFrozen( ReadView const& view, @@ -115,22 +230,63 @@ isDeepFrozen( return isDeepFrozen(view, account, issue.currency, issue.account); } +/** Convert a deep-freeze check into a `TER` result. + * + * Convenience wrapper for transactor preflight code that returns + * `tecFROZEN` if the account is deep-frozen and `tesSUCCESS` otherwise. + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param issue The IOU issue (currency + issuer). + * @return `tecFROZEN` if deep-frozen, `tesSUCCESS` otherwise. + */ [[nodiscard]] inline TER checkDeepFrozen(ReadView const& view, AccountID const& account, Issue const& issue) { return isDeepFrozen(view, account, issue) ? (TER)tecFROZEN : (TER)tesSUCCESS; } -//------------------------------------------------------------------------------ -// -// Trust line operations -// -//------------------------------------------------------------------------------ +// --- Trust line lifecycle --- -/** Create a trust line - - This can set an initial balance. -*/ +/** Create a new `RippleState` (trustline) SLE and insert it into both owner + * directories. + * + * This is the lowest-level entry point for trustline creation. It is called + * directly by `TrustSet` transactors and indirectly by `issueIOU` when the + * destination has no existing line. + * + * The function writes all trustline fields — limits, quality in/out, balance, + * and flag bits — using side-aware field selectors (`sfLowLimit`/`sfHighLimit` + * etc.) derived from `bSrcHigh`. The peer account's `lsfNoRipple` flag is + * initialised from the peer's `lsfDefaultRipple` setting (absent means + * noRipple is on by default). + * + * @param view Mutable ledger view. + * @param bSrcHigh `true` if `uSrcAccountID` occupies the "high" slot + * (i.e., `uSrcAccountID > uDstAccountID`). + * @param uSrcAccountID The account whose limit and flags are being + * configured. + * @param uDstAccountID The peer account on the other side of the line. + * @param uIndex Pre-calculated keylet key for the new SLE. + * @param sleAccount The `AccountRoot` SLE for the account being set + * (used to adjust owner count); must not be null. + * @param bAuth If `true`, set the authorization flag on the source + * side of the line. + * @param bNoRipple If `true`, set `lsfNoRipple` on the source side. + * @param bFreeze If `true`, set the freeze flag on the source side. + * @param bDeepFreeze If `true`, set the deep-freeze flag on the source + * side. + * @param saBalance Initial balance from the source account's + * perspective; the issuer field must be `noAccount()`. + * @param saLimit Credit limit for the source account; the issuer + * field must be `uSrcAccountID`. + * @param uQualityIn Quality-in override (0 = default/no override). + * @param uQualityOut Quality-out override (0 = default/no override). + * @param j Journal for trace/debug logging. + * @return `tesSUCCESS` on success, `tecDIR_FULL` if either owner directory + * is at capacity, `tecNO_TARGET` if the peer account does not exist, + * or `tefINTERNAL` if `sleAccount` is null or has a mismatched ID. + */ [[nodiscard]] TER trustCreate( ApplyView& view, @@ -151,6 +307,21 @@ trustCreate( std::uint32_t uQualityOut, beast::Journal j); +/** Delete a `RippleState` (trustline) SLE and remove its directory backlinks. + * + * Removes the SLE from both the low and high owner directories using the + * `sfLowNode`/`sfHighNode` deletion hints stored inside the SLE itself, + * then erases the SLE from the view. + * + * @param view Mutable ledger view. + * @param sleRippleState The trustline SLE to delete; must be obtained + * from `view.peek()`. + * @param uLowAccountID The account occupying the low slot. + * @param uHighAccountID The account occupying the high slot. + * @param j Journal for trace/debug logging. + * @return `tesSUCCESS` on success, `tefBAD_LEDGER` if either directory + * removal fails (indicating ledger corruption). + */ [[nodiscard]] TER trustDelete( ApplyView& view, @@ -159,12 +330,30 @@ trustDelete( AccountID const& uHighAccountID, beast::Journal j); -//------------------------------------------------------------------------------ -// -// IOU issuance/redemption -// -//------------------------------------------------------------------------------ +// --- IOU issuance/redemption --- +/** Issue IOUs from @p issue.account to @p account, adjusting the trustline + * balance. + * + * Debits the issuer's side of the trustline and credits the receiver. After + * adjusting the balance, calls the internal `updateTrustLine` helper: if the + * sender's balance crosses zero and seven specific cleanup conditions are met + * (zero limit, no freeze, etc.), the sender's reserve is released and the + * line may be deleted via `trustDelete`. + * + * If no trustline exists for the receiver, one is created via `trustCreate`, + * inheriting the receiver's `lsfDefaultRipple` setting for the initial + * `lsfNoRipple` state. Always invokes `view.creditHookIOU()` after mutating + * the balance. + * + * @param view Mutable ledger view. + * @param account The account receiving the IOUs (must not be the issuer). + * @param amount The amount to issue; its `Issue` must match @p issue. + * @param issue Identifies the currency and issuer. + * @param j Journal for trace/debug logging. + * @return `tesSUCCESS` on success, or a `tef`/`tec` code propagated from + * `trustCreate` or `trustDelete` if an error occurs. + */ [[nodiscard]] TER issueIOU( ApplyView& view, @@ -173,6 +362,26 @@ issueIOU( Issue const& issue, beast::Journal j); +/** Redeem IOUs held by @p account back toward the issuer, adjusting the + * trustline balance. + * + * The mirror image of `issueIOU`: credits the issuer and debits the holder. + * After adjusting the balance, calls `updateTrustLine` for the same + * automatic cleanup logic. Always invokes `view.creditHookIOU()` after + * mutating the balance. + * + * Unlike `issueIOU`, a missing trustline is treated as a fatal internal + * error (`tefINTERNAL`) because it is impossible to redeem a balance on a + * line that does not exist. + * + * @param view Mutable ledger view. + * @param account The account redeeming IOUs (must not be the issuer). + * @param amount The amount to redeem; its `Issue` must match @p issue. + * @param issue Identifies the currency and issuer. + * @param j Journal for trace/debug logging. + * @return `tesSUCCESS` on success, `tefINTERNAL` if no trustline exists, + * or a `tef`/`tec` code from `trustDelete` if cleanup triggers an error. + */ [[nodiscard]] TER redeemIOU( ApplyView& view, @@ -181,28 +390,30 @@ redeemIOU( Issue const& issue, beast::Journal j); -//------------------------------------------------------------------------------ -// -// Authorization and transfer checks (IOU-specific) -// -//------------------------------------------------------------------------------ +// --- Authorization and transfer checks (IOU-specific) --- -/** Check if the account lacks required authorization. +/** Check whether @p account is authorized to hold the IOU described by + * @p issue. * - * Return tecNO_AUTH or tecNO_LINE if it does - * and tesSUCCESS otherwise. + * Behaviour depends on @p authType: + * - **`StrongAuth`**: Returns `tecNO_LINE` immediately if no trustline + * exists. If the issuer has `lsfRequireAuth` and the line exists but is + * not authorized, returns `tecNO_AUTH`. + * - **`WeakAuth`** / **`Legacy`** (equivalent for IOUs): Returns + * `tecNO_AUTH` if `lsfRequireAuth` is set, the line exists, but is not + * authorized. Returns `tecNO_LINE` if auth is required and no line + * exists. If `lsfRequireAuth` is not set, returns `tesSUCCESS` even when + * no line exists — appropriate for payment path-finding where a line may + * be created on the fly. * - * If StrongAuth then return tecNO_LINE if the RippleState doesn't exist. Return - * tecNO_AUTH if lsfRequireAuth is set on the issuer's AccountRoot, and the - * RippleState does exist, and the RippleState is not authorized. + * Always returns `tesSUCCESS` for XRP or when `account == issue.account`. * - * If WeakAuth then return tecNO_AUTH if lsfRequireAuth is set, and the - * RippleState exists, and is not authorized. Return tecNO_LINE if - * lsfRequireAuth is set and the RippleState doesn't exist. Consequently, if - * WeakAuth and lsfRequireAuth is *not* set, this function will return - * tesSUCCESS even if RippleState does *not* exist. - * - * The default "Legacy" auth type is equivalent to WeakAuth. + * @param view Read-only ledger view. + * @param issue The IOU to check authorization for. + * @param account The account to check. + * @param authType Authorization strictness; defaults to `AuthType::Legacy` + * (equivalent to `WeakAuth` for IOUs). + * @return `tesSUCCESS`, `tecNO_AUTH`, or `tecNO_LINE`. */ [[nodiscard]] TER requireAuth( @@ -211,21 +422,53 @@ requireAuth( AccountID const& account, AuthType authType = AuthType::Legacy); -/** Check if the destination account is allowed - * to receive IOU. Return terNO_RIPPLE if rippling is - * disabled on both sides and tesSUCCESS otherwise. +/** Check whether an IOU can be transferred between @p from and @p to via the + * issuer's trustlines. + * + * Returns `tesSUCCESS` unconditionally when either endpoint is the issuer, + * or when the IOU is native (XRP). For third-party transfers, returns + * `terNO_RIPPLE` only when both the `from` and the `to` trustlines have + * `lsfNoRipple` set on the issuer's side, blocking rippling through. If a + * trustline does not exist for a given account, the issuer's + * `lsfDefaultRipple` flag is consulted as a fallback preference. + * + * @param view Read-only ledger view. + * @param issue The IOU (identifies the issuer and currency). + * @param from The sending account. + * @param to The receiving account. + * @return `tesSUCCESS` if the transfer is permitted, `terNO_RIPPLE` if + * rippling is disabled on both sides. */ [[nodiscard]] TER canTransfer(ReadView const& view, Issue const& issue, AccountID const& from, AccountID const& to); -//------------------------------------------------------------------------------ -// -// Empty holding operations (IOU-specific) -// -//------------------------------------------------------------------------------ +// --- Empty holding operations (IOU-specific) --- -/// Any transactors that call addEmptyHolding() in doApply must call -/// canAddHolding() in preflight with the same View and Asset +/** Create a zero-balance trustline for @p accountID, reserving the destination + * slot before any funds arrive. + * + * Used by transactors (e.g., DEX limit orders) that need to guarantee a + * destination line exists before settlement. Checks that @p accountID can + * cover the increased owner-count reserve before calling `trustCreate`. + * + * Returns `tesSUCCESS` immediately for XRP or when `accountID` is the + * issuer. Returns `tecDUPLICATE` if the trustline already exists. + * + * @note Any transactor that calls this function in `doApply` **must** call + * `canAddHolding()` (declared in `TokenHelpers.h`) in `preflight` with + * the same view and asset to validate the reserve precondition. + * + * @param view Mutable ledger view. + * @param accountID The account that will hold the IOU. + * @param priorBalance The account's XRP balance before the current + * transaction, used to test reserve sufficiency. + * @param issue The IOU to create a holding for. + * @param journal Journal for trace/debug logging. + * @return `tesSUCCESS` on success; `tecFROZEN` if the issuer is globally + * frozen; `tecNO_LINE_INSUF_RESERVE` if the account cannot afford the + * reserve; `tecDUPLICATE` if the line already exists; or a `tec`/`tef` + * code from `trustCreate`. + */ [[nodiscard]] TER addEmptyHolding( ApplyView& view, @@ -234,6 +477,20 @@ addEmptyHolding( Issue const& issue, beast::Journal journal); +/** Delete a zero-balance trustline previously created by `addEmptyHolding`. + * + * Validates that the balance is actually zero before deletion. Adjusts + * owner counts for both the low and high sides if their reserve flags are + * set, then calls `trustDelete`. + * + * @param view Mutable ledger view. + * @param accountID The account whose holding line should be removed. + * @param issue The IOU identifying the trustline to remove. + * @param journal Journal for trace/debug logging. + * @return `tesSUCCESS` on success; `tecHAS_OBLIGATIONS` if the balance is + * non-zero; `tecOBJECT_NOT_FOUND` if no line exists (and the account + * is not the issuer); or a `tef`/`tec` code from `trustDelete`. + */ [[nodiscard]] TER removeEmptyHolding( ApplyView& view, @@ -241,9 +498,27 @@ removeEmptyHolding( Issue const& issue, beast::Journal journal); -/** Delete trustline to AMM. The passed `sle` must be obtained from a prior - * call to view.peek(). Fail if neither side of the trustline is AMM or - * if ammAccountID is seated and is not one of the trustline's side. +/** Delete a trustline owned by an AMM pool account during AMM withdrawal. + * + * Validates that: + * - @p sleState is a non-null `ltRIPPLE_STATE` SLE. + * - Exactly one of the two trustline endpoints is an AMM account + * (identified by the presence of `sfAMMID` in the `AccountRoot`). + * - If @p ammAccountID is provided, it matches one of the endpoints. + * + * On success, calls `trustDelete` and decrements the owner count of the + * non-AMM side. + * + * @param view Mutable ledger view. + * @param sleState The `ltRIPPLE_STATE` SLE to delete; must be obtained + * from `view.peek()`. + * @param ammAccountID If provided, the expected AMM account ID; the + * function returns `terNO_AMM` if neither endpoint matches. + * @param j Journal for trace/debug logging. + * @return `tesSUCCESS` on success; `tecINTERNAL` if the SLE is null, has + * the wrong type, if both sides are AMM, or if the reserve flag is + * unexpectedly absent; `terNO_AMM` if neither endpoint is an AMM or + * the optional ID does not match; or a `tef` code from `trustDelete`. */ [[nodiscard]] TER deleteAMMTrustLine( @@ -252,8 +527,19 @@ deleteAMMTrustLine( std::optional const& ammAccountID, beast::Journal j); -/** Delete AMMs MPToken. The passed `sle` must be obtained from a prior - * call to view.peek(). +/** Delete an AMM account's `MPToken` SLE during AMM withdrawal. + * + * Removes the `MPToken` SLE from @p ammAccountID's owner directory and + * erases it from the view. The caller is responsible for any balance + * assertions before invoking this function. + * + * @param view Mutable ledger view. + * @param sleMPT The `MPToken` SLE to delete; must be obtained from + * `view.peek()`. + * @param ammAccountID The AMM account that owns the `MPToken`. + * @param j Journal for trace/debug logging. + * @return `tesSUCCESS` on success, `tefBAD_LEDGER` if the directory removal + * fails (indicating ledger corruption). */ [[nodiscard]] TER deleteAMMMPToken( diff --git a/include/xrpl/ledger/helpers/TokenHelpers.h b/include/xrpl/ledger/helpers/TokenHelpers.h index 3d41ac47cd..344b4300ba 100644 --- a/include/xrpl/ledger/helpers/TokenHelpers.h +++ b/include/xrpl/ledger/helpers/TokenHelpers.h @@ -1,3 +1,20 @@ +/** @file + * Asset-agnostic dispatcher layer for all token operations on the XRP Ledger. + * + * This header is the unified entry point for token operations that must work + * across XRPL's three asset classes: XRP, IOU (trust-line-based), and MPT + * (Multi-Party Token). It sits between transaction-processing code that wants + * to be asset-agnostic and the two type-specific leaf modules: + * `RippleStateHelpers.h` for IOU trust lines and `MPTokenHelpers.h` for + * `MPToken`/`MPTokenIssuance` objects. + * + * Callers pass an `Asset` — a `std::variant` — and the + * functions here dispatch via `std::visit` or `Asset::visit` to the correct + * lower-level function, returning consistent result types (`STAmount`, `TER`, + * `bool`) regardless of asset kind. Adding a new asset type requires only + * extending the `Asset` variant and the branches here, not modifying call + * sites. + */ #pragma once #include @@ -20,30 +37,83 @@ namespace xrpl { // //------------------------------------------------------------------------------ -/** Controls the treatment of frozen account balances */ -enum class FreezeHandling { IgnoreFreeze, ZeroIfFrozen }; - -/** Controls the treatment of unauthorized MPT balances */ -enum class AuthHandling { IgnoreAuth, ZeroIfUnauthorized }; - -/** Controls whether to include the account's full spendable balance */ -enum class SpendableHandling { SimpleBalance, FullBalance }; - -enum class WaiveTransferFee : bool { No = false, Yes }; - -/** Controls whether accountSend is allowed to overflow OutstandingAmount **/ -enum class AllowMPTOverflow : bool { No = false, Yes }; - -/* Check if MPToken (for MPT) or trust line (for IOU) exists: - * - StrongAuth - before checking if authorization is required - * - WeakAuth - * for MPT - after checking lsfMPTRequireAuth flag - * for IOU - do not check if trust line exists - * - Legacy - * for MPT - before checking lsfMPTRequireAuth flag i.e. same as StrongAuth - * for IOU - do not check if trust line exists i.e. same as WeakAuth +/** Controls how a frozen balance is reported by balance-query functions. + * + * Use `ZeroIfFrozen` in payment paths where a frozen balance must not be + * spent. Use `IgnoreFreeze` in cleanup paths that need the real value + * regardless of freeze state. */ -enum class AuthType { StrongAuth, WeakAuth, Legacy }; +enum class FreezeHandling { + IgnoreFreeze, /**< Return the actual balance even if the holding is frozen. */ + ZeroIfFrozen /**< Return zero when the holding is frozen (the spendable amount). */ +}; + +/** Controls how an unauthorized MPT balance is reported by balance-query functions. + * + * Parallel to `FreezeHandling` but for MPT authorization. Use + * `ZeroIfUnauthorized` when computing the amount an account may legally spend. + */ +enum class AuthHandling { + IgnoreAuth, /**< Return the actual balance even if the MPToken is unauthorized. */ + ZeroIfUnauthorized /**< Return zero when the MPToken is not authorized. */ +}; + +/** Controls whether `accountHolds` reports simple or full spendable balance. + * + * - `SimpleBalance`: the amount the account can spend without going into + * debt, i.e. the raw trustline balance (negated to account-centric terms) + * for IOU, or the `sfMPTAmount` for MPT. + * - `FullBalance`: for IOU, also includes the peer's credit limit so the + * account can borrow up to that limit; for the IOU issuer, returns + * `STAmount::kMAX_VALUE`; for the MPT issuer, returns + * `MaximumAmount - OutstandingAmount`. + */ +enum class SpendableHandling { + SimpleBalance, /**< Balance the account can spend without going into debt. */ + FullBalance /**< Full spendable balance including borrowable credit or issuance capacity. */ +}; + +/** Controls whether the transfer fee is skipped during a send operation. + * + * Typed as `enum class : bool` to prevent accidental transposition with + * other boolean parameters at call sites. + */ +enum class WaiveTransferFee : bool { + No = false, /**< Apply the normal transfer fee. */ + Yes /**< Skip the transfer fee entirely. */ +}; + +/** Controls whether `accountSend` permits `OutstandingAmount` to transiently + * exceed `MaximumAmount` during MPT payment-engine routing. + * + * The payment engine issues tokens first (raising `OutstandingAmount`) and + * redeems them in the same transaction (lowering it back). `Yes` raises the + * overflow ceiling to `UINT64_MAX` for that transient window. Direct sends + * use `No` and enforce the strict `MaximumAmount` cap. + */ +enum class AllowMPTOverflow : bool { + No = false, /**< Enforce the strict MaximumAmount cap. */ + Yes /**< Allow transient overflow up to UINT64_MAX during routing. */ +}; + +/** Encodes the three-way authorization-strictness contract. + * + * Determines how `requireAuth` behaves when checking whether an account may + * hold or interact with a token: + * - `StrongAuth` checks that the holding object (trust line or `MPToken`) + * exists *before* asking whether authorization is set. Returns `tecNO_LINE` + * immediately if no holding exists. + * - `WeakAuth` skips the existence check, returning `tesSUCCESS` when + * authorization is not required even if no holding exists. Appropriate for + * payment path-finding where a line may be created on the fly. + * - `Legacy` maps to `StrongAuth` for MPT and `WeakAuth` for IOU, preserving + * historical behavior at existing call sites. + */ +enum class AuthType { + StrongAuth, /**< Existence of the holding object is verified first. */ + WeakAuth, /**< Holding existence is not required when auth is not needed. */ + Legacy /**< StrongAuth for MPT; WeakAuth for IOU (historical default). */ +}; //------------------------------------------------------------------------------ // @@ -51,35 +121,126 @@ enum class AuthType { StrongAuth, WeakAuth, Legacy }; // //------------------------------------------------------------------------------ +/** Check whether the issuer of @p asset has activated a global freeze. + * + * Dispatches to the IOU or MPT leaf based on the runtime type of @p asset. + * A global freeze on the issuer's `AccountRoot` blocks all holders + * simultaneously. + * + * @param view Read-only ledger view. + * @param asset The asset to test. + * @return `true` if the issuer has a global freeze in effect. + */ [[nodiscard]] bool isGlobalFrozen(ReadView const& view, Asset const& asset); +/** Check whether @p account has an individual freeze on @p asset. + * + * Dispatches to the IOU or MPT leaf based on the runtime type of @p asset. + * For IOU, checks the issuer's per-line freeze flag. For MPT, checks the + * `lsfMPTLocked` flag on the `MPToken` SLE. Does not check global freeze. + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param asset The asset to test. + * @return `true` if the issuer has set an individual freeze on this account. + */ [[nodiscard]] bool isIndividualFrozen(ReadView const& view, AccountID const& account, Asset const& asset); -/** - * isFrozen check is recursive for MPT shares in a vault, descending to - * assets in the vault, up to maxAssetCheckDepth recursion depth. This is - * purely defensive, as we currently do not allow such vaults to be created. +/** Check whether @p account is frozen for @p asset (global or individual). + * + * Returns `true` if either `isGlobalFrozen` or `isIndividualFrozen` is true + * for the given account and asset. Dispatches to the typed IOU or MPT leaf + * via `std::visit`. + * + * The `depth` parameter enables recursive vault checking: if @p asset is an + * MPT backed by a vault, the vault's underlying asset is checked up to + * `maxAssetCheckDepth` levels deep. + * + * @note Recursion is purely defensive. The ledger currently does not allow + * nested vaults to be created, so `depth > 0` should not occur in + * practice. + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param asset The asset to test. + * @param depth Current recursion depth for vault checking; defaults to 0. + * @return `true` if the account cannot move this asset due to any freeze. */ [[nodiscard]] bool isFrozen(ReadView const& view, AccountID const& account, Asset const& asset, int depth = 0); +/** Convert a freeze check on an IOU to a `TER`. + * + * Returns `tecFROZEN` if `isFrozen` is true for the given account and issue, + * `tesSUCCESS` otherwise. + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param issue The IOU to test. + * @return `tecFROZEN` if frozen, `tesSUCCESS` otherwise. + */ [[nodiscard]] TER checkFrozen(ReadView const& view, AccountID const& account, Issue const& issue); +/** Convert a freeze check on an MPT to a `TER`. + * + * Returns `tecLOCKED` (not `tecFROZEN`) if `isFrozen` is true for the given + * account and MPT issuance, `tesSUCCESS` otherwise. The distinct error code + * reflects the separate protocol semantics of MPT locking vs IOU freezing. + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param mptIssue The MPT issuance to test. + * @return `tecLOCKED` if frozen/locked, `tesSUCCESS` otherwise. + */ [[nodiscard]] TER checkFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue); +/** Convert a freeze check on any asset to a `TER`. + * + * Dispatches to `checkFrozen(…, Issue)` or `checkFrozen(…, MPTIssue)` based + * on the runtime type of @p asset, returning the type-appropriate error code + * (`tecFROZEN` for IOU, `tecLOCKED` for MPT). + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param asset The asset to test. + * @return `tecFROZEN` (IOU) or `tecLOCKED` (MPT) if frozen, `tesSUCCESS` + * otherwise. + */ [[nodiscard]] TER checkFrozen(ReadView const& view, AccountID const& account, Asset const& asset); +/** Check whether any account in @p accounts is frozen for @p issue. + * + * Iterates the list and returns `true` on the first frozen account. Used to + * check both sides (taker and maker) of an offer with a single call. + * + * @param view Read-only ledger view. + * @param accounts The accounts to test, e.g. `{takerID, makerID}`. + * @param issue The IOU to test. + * @return `true` if any account in the list is frozen for @p issue. + */ [[nodiscard]] bool isAnyFrozen( ReadView const& view, std::initializer_list const& accounts, Issue const& issue); +/** Check whether any account in @p accounts is frozen for @p asset. + * + * Asset-dispatching overload. Delegates to the IOU or MPT leaf for each + * account in the list. The `depth` parameter passes through to `isFrozen` + * for vault-backed MPT recursion. + * + * @param view Read-only ledger view. + * @param accounts The accounts to test. + * @param asset The asset to test. + * @param depth Recursion depth for vault checking; defaults to 0. + * @return `true` if any account in the list is frozen for @p asset. + */ [[nodiscard]] bool isAnyFrozen( ReadView const& view, @@ -87,6 +248,22 @@ isAnyFrozen( Asset const& asset, int depth = 0); +/** Check whether @p account is deep-frozen for @p mptIssue. + * + * For MPT, deep-freeze semantics are identical to regular freeze: a frozen + * MPT holder cannot send or receive. This function delegates to + * `isFrozen(view, account, mptIssue, depth)`. + * + * @note For IOU, deep-freeze is a distinct state (`lsfDeepFreeze`) where the + * holder cannot send but can still receive. See `isDeepFrozen` in + * `RippleStateHelpers.h` for IOU-specific semantics. + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param mptIssue The MPT issuance to test. + * @param depth Recursion depth for vault checking; defaults to 0. + * @return `true` if the account is frozen/locked for this MPT. + */ [[nodiscard]] bool isDeepFrozen( ReadView const& view, @@ -94,17 +271,51 @@ isDeepFrozen( MPTIssue const& mptIssue, int depth = 0); -/** - * isFrozen check is recursive for MPT shares in a vault, descending to - * assets in the vault, up to maxAssetCheckDepth recursion depth. This is - * purely defensive, as we currently do not allow such vaults to be created. +/** Check whether @p account is deep-frozen for @p asset. + * + * Dispatches to the IOU or MPT leaf via `std::visit`. For MPT, deep-freeze + * is equivalent to regular freeze. For IOU, checks the `lsfDeepFreeze` flag, + * which prevents sending but allows receiving. + * + * The `depth` parameter enables recursive vault checking up to + * `maxAssetCheckDepth` levels. + * + * @note Recursion is purely defensive — nested vaults cannot currently be + * created on the ledger. + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param asset The asset to test. + * @param depth Recursion depth for vault checking; defaults to 0. + * @return `true` if the account is deep-frozen for @p asset. */ [[nodiscard]] bool isDeepFrozen(ReadView const& view, AccountID const& account, Asset const& asset, int depth = 0); +/** Convert a deep-freeze check on an MPT to a `TER`. + * + * Returns `tecLOCKED` if `isDeepFrozen` is true, `tesSUCCESS` otherwise. + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param mptIssue The MPT issuance to test. + * @return `tecLOCKED` if deep-frozen, `tesSUCCESS` otherwise. + */ [[nodiscard]] TER checkDeepFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue); +/** Convert a deep-freeze check on any asset to a `TER`. + * + * Dispatches to `checkDeepFrozen(…, Issue)` (`tecFROZEN`) or + * `checkDeepFrozen(…, MPTIssue)` (`tecLOCKED`) based on the runtime type of + * @p asset. + * + * @param view Read-only ledger view. + * @param account The account to test. + * @param asset The asset to test. + * @return `tecFROZEN` (IOU) or `tecLOCKED` (MPT) if deep-frozen, + * `tesSUCCESS` otherwise. + */ [[nodiscard]] TER checkDeepFrozen(ReadView const& view, AccountID const& account, Asset const& asset); @@ -114,19 +325,31 @@ checkDeepFrozen(ReadView const& view, AccountID const& account, Asset const& ass // //------------------------------------------------------------------------------ -// Returns the amount an account can spend. -// -// If shSIMPLE_BALANCE is specified, this is the amount the account can spend -// without going into debt. -// -// If shFULL_BALANCE is specified, this is the amount the account can spend -// total. Specifically: -// * The account can go into debt if using a trust line, and the other side has -// a non-zero limit. -// * If the account is the asset issuer the limit is defined by the asset / -// issuance. -// -// <-- saAmount: amount of currency held by account. May be negative. +/** Return the amount that @p account can spend of the given currency/issuer. + * + * This is the canonical implementation. All other `accountHolds` overloads + * ultimately delegate here for the IOU path. + * + * - For XRP: returns `xrpLiquid(view, account, 0, j)` (reserve-adjusted). + * - For IOU with `shFULL_BALANCE` when `account == issuer`: returns + * `STAmount::kMAX_VALUE` — the issuer has effectively unlimited issuance + * capacity. + * - For IOU otherwise: reads the trust-line balance from the ledger, + * negating it to account-centric terms. If `shFULL_BALANCE` is specified, + * also adds the peer's credit limit so the account can draw down that + * credit. Returns zero if the line is frozen (when `ZeroIfFrozen`) or does + * not exist. + * + * @param view Read-only ledger view. + * @param account The account whose balance is queried. + * @param currency The IOU currency. + * @param issuer The IOU issuer. + * @param zeroIfFrozen Whether to return zero for frozen balances. + * @param j Journal for trace logging. + * @param includeFullBalance Whether to include borrowable credit or max + * issuance capacity; defaults to `SimpleBalance`. + * @return The spendable balance, which may be negative (e.g. trust-line debt). + */ [[nodiscard]] STAmount accountHolds( ReadView const& view, @@ -137,6 +360,19 @@ accountHolds( beast::Journal j, SpendableHandling includeFullBalance = SpendableHandling::SimpleBalance); +/** Return the spendable balance of an IOU for @p account. + * + * Convenience adapter over the `(Currency, AccountID)` overload, extracting + * the currency and issuer from @p issue. + * + * @param view Read-only ledger view. + * @param account The account whose balance is queried. + * @param issue The IOU (currency + issuer). + * @param zeroIfFrozen Whether to return zero for frozen balances. + * @param j Journal for trace logging. + * @param includeFullBalance Balance mode; defaults to `SimpleBalance`. + * @return The spendable balance from @p account's perspective. + */ [[nodiscard]] STAmount accountHolds( ReadView const& view, @@ -146,6 +382,29 @@ accountHolds( beast::Journal j, SpendableHandling includeFullBalance = SpendableHandling::SimpleBalance); +/** Return the spendable balance of an MPT for @p account. + * + * - For the MPT issuer with `shFULL_BALANCE`: returns + * `MaximumAmount - OutstandingAmount` (available issuance capacity) via + * `availableMPTAmount`. + * - For regular holders: reads `sfMPTAmount` from the `MPToken` SLE. Returns + * zero if: the `MPToken` SLE does not exist; the token is frozen and + * `ZeroIfFrozen` is set; or the token is unauthorized and + * `ZeroIfUnauthorized` is set (with `featureSingleAssetVault` gating the + * precise auth-check path). + * - Under `featureMPTokensV2`, the result passes through + * `view.balanceHookMPT` to allow `PaymentSandbox` deferred-credit + * interception. + * + * @param view Read-only ledger view. + * @param account The account whose balance is queried. + * @param mptIssue The MPT issuance. + * @param zeroIfFrozen Whether to zero the balance when frozen/locked. + * @param zeroIfUnauthorized Whether to zero the balance when unauthorized. + * @param j Journal for trace logging. + * @param includeFullBalance Balance mode; defaults to `SimpleBalance`. + * @return The spendable MPT balance, or zero per the policy flags above. + */ [[nodiscard]] STAmount accountHolds( ReadView const& view, @@ -156,6 +415,22 @@ accountHolds( beast::Journal j, SpendableHandling includeFullBalance = SpendableHandling::SimpleBalance); +/** Return the spendable balance of any asset for @p account. + * + * Asset-dispatching overload. Delegates to the `Issue` overload (which + * ignores `zeroIfUnauthorized`) or the `MPTIssue` overload based on the + * runtime type of @p asset. + * + * @param view Read-only ledger view. + * @param account The account whose balance is queried. + * @param asset The asset to query. + * @param zeroIfFrozen Whether to zero the balance when frozen. + * @param zeroIfUnauthorized Whether to zero the balance when unauthorized + * (MPT only; ignored for IOU). + * @param j Journal for trace logging. + * @param includeFullBalance Balance mode; defaults to `SimpleBalance`. + * @return The spendable balance per the policy flags. + */ [[nodiscard]] STAmount accountHolds( ReadView const& view, @@ -166,11 +441,29 @@ accountHolds( beast::Journal j, SpendableHandling includeFullBalance = SpendableHandling::SimpleBalance); -// Returns the amount an account can spend of the currency type saDefault, or -// returns saDefault if this account is the issuer of the currency in -// question. Should be used in favor of accountHolds when questioning how much -// an account can spend while also allowing currency issuers to spend -// unlimited amounts of their own currency (since they can always issue more). +/** Return how much of @p saDefault's currency @p id can fund, treating the + * issuer as having unlimited supply of their own currency. + * + * For IOU: if `id == saDefault.getIssuer()`, returns `saDefault` directly — + * the issuer can always fund an offer for their own currency up to whatever + * amount they specify. Otherwise delegates to `accountHolds` with + * `SimpleBalance`. + * + * This is the correct semantic for offer matching; prefer `accountFunds` over + * `accountHolds` when asking "can this account fund this offer?". + * + * @note `saDefault` must hold an `Issue` (not MPT). Use the `AuthHandling` + * overload for asset-agnostic callers. + * + * @param view Read-only ledger view. + * @param id The account to query. + * @param saDefault The amount (currency + issuer) to check fundability + * for. + * @param freezeHandling Whether to zero the balance when frozen. + * @param j Journal for trace logging. + * @return `saDefault` if @p id is the issuer; otherwise the trust-line + * balance, zeroed per @p freezeHandling. + */ [[nodiscard]] STAmount accountFunds( ReadView const& view, @@ -179,7 +472,22 @@ accountFunds( FreezeHandling freezeHandling, beast::Journal j); -// Overload with AuthHandling to support IOU and MPT. +/** Asset-agnostic overload of `accountFunds` supporting both IOU and MPT. + * + * For IOU: delegates to the `FreezeHandling`-only overload above. + * For MPT: delegates to `accountHolds` with `shFULL_BALANCE`, which + * returns the issuer's available issuance capacity or the holder's + * `sfMPTAmount`. + * + * @param view Read-only ledger view. + * @param id The account to query. + * @param saDefault The amount (currency/asset + issuer) to check. + * @param freezeHandling Whether to zero the balance when frozen. + * @param authHandling Whether to zero the balance when unauthorized (MPT + * only). + * @param j Journal for trace logging. + * @return The fundable balance per the policy flags. + */ [[nodiscard]] STAmount accountFunds( ReadView const& view, @@ -189,9 +497,15 @@ accountFunds( AuthHandling authHandling, beast::Journal j); -/** Returns the transfer fee as Rate based on the type of token - * @param view The ledger view - * @param amount The amount to transfer +/** Return the transfer fee for the asset embedded in @p amount. + * + * Dispatches on `amount.asset()`: for IOU, reads the issuer's transfer rate + * from their `AccountRoot`; for MPT, reads the `sfTransferFee` field from + * the `MPTokenIssuance` SLE. Both paths return a `Rate` (parts-per-billion). + * + * @param view Read-only ledger view. + * @param amount The amount whose asset determines which fee to look up. + * @return The transfer fee as a `Rate`, or `parityRate` if no fee is set. */ [[nodiscard]] Rate transferRate(ReadView const& view, STAmount const& amount); @@ -202,9 +516,42 @@ transferRate(ReadView const& view, STAmount const& amount); // //------------------------------------------------------------------------------ +/** Check whether a new holding object (trust line or MPToken) can be created. + * + * For IOU: verifies that the issuer's `AccountRoot` has `lsfDefaultRipple` + * set; returns `terNO_RIPPLE` if not, `terNO_ACCOUNT` if the issuer does not + * exist, `tesSUCCESS` for XRP. For MPT: delegates to the MPT-specific check. + * + * @note This function is read-only (takes `ReadView`) and is intended to be + * called during `preflight`. Any transactor that calls `addEmptyHolding` + * in `doApply` must call this function in `preflight` first. + * + * @param view Read-only ledger view. + * @param asset The asset for which a holding would be created. + * @return `tesSUCCESS` if a holding can be added; `terNO_RIPPLE`, + * `terNO_ACCOUNT`, or an MPT-specific error otherwise. + */ [[nodiscard]] TER canAddHolding(ReadView const& view, Asset const& asset); +/** Create an empty holding object (trust line or MPToken) for @p accountID. + * + * Dispatches to `addEmptyHolding(…, Issue)` or `addEmptyHolding(…, MPTIssue)` + * based on the runtime type of @p asset. The holding is created with zero + * balance and consumes an owner-count reserve slot. + * + * @note The caller must have invoked `canAddHolding` in `preflight` with the + * same view and asset to validate preconditions before calling this. + * + * @param view Mutable ledger view. + * @param accountID The account that will hold the asset. + * @param priorBalance The account's XRP balance before this transaction, + * used to test reserve sufficiency. + * @param asset The asset to create a holding for. + * @param journal Journal for trace/debug logging. + * @return `tesSUCCESS` on success, or a `tec`/`tef` error from the + * type-specific leaf. + */ [[nodiscard]] TER addEmptyHolding( ApplyView& view, @@ -213,6 +560,21 @@ addEmptyHolding( Asset const& asset, beast::Journal journal); +/** Delete a zero-balance holding object (trust line or MPToken) for @p accountID. + * + * Dispatches to `removeEmptyHolding(…, Issue)` or + * `removeEmptyHolding(…, MPTIssue)` based on the runtime type of @p asset. + * The holding must have a zero balance; a non-zero balance returns + * `tecHAS_OBLIGATIONS`. + * + * @param view Mutable ledger view. + * @param accountID The account whose holding should be removed. + * @param asset The asset identifying the holding to remove. + * @param journal Journal for trace/debug logging. + * @return `tesSUCCESS` on success; `tecHAS_OBLIGATIONS` if the balance is + * non-zero; `tecOBJECT_NOT_FOUND` if no holding exists; or a `tec`/`tef` + * error from the type-specific leaf. + */ [[nodiscard]] TER removeEmptyHolding( ApplyView& view, @@ -226,6 +588,25 @@ removeEmptyHolding( // //------------------------------------------------------------------------------ +/** Check whether @p account is authorized to hold or interact with @p asset. + * + * Dispatches to `requireAuth(…, Issue, …)` or `requireAuth(…, MPTIssue, …)` + * based on the runtime type of @p asset. + * + * - `StrongAuth`: verifies the holding object exists first; returns + * `tecNO_LINE` (IOU) or `tecNO_AUTH` (MPT) if absent. + * - `WeakAuth`: skips the existence check; returns success if authorization + * is not required even when no holding exists. + * - `Legacy`: maps to `StrongAuth` for MPT and `WeakAuth` for IOU to + * preserve historical behavior. + * + * @param view Read-only ledger view. + * @param asset The asset to check authorization for. + * @param account The account to check. + * @param authType Authorization strictness; defaults to `AuthType::Legacy`. + * @return `tesSUCCESS`, `tecNO_AUTH`, or `tecNO_LINE` depending on the asset + * type and authorization state. + */ [[nodiscard]] TER requireAuth( ReadView const& view, @@ -233,6 +614,20 @@ requireAuth( AccountID const& account, AuthType authType = AuthType::Legacy); +/** Check whether @p asset can be transferred from @p from to @p to. + * + * Dispatches to the IOU or MPT leaf. For IOU, checks rippling flags on the + * trustlines (returns `terNO_RIPPLE` if both sides block rippling). For MPT, + * checks `lsfMPTCanTransfer` on the issuance and the destination's + * authorization state. + * + * @param view Read-only ledger view. + * @param asset The asset to transfer. + * @param from The sending account. + * @param to The receiving account. + * @return `tesSUCCESS` if the transfer is permitted, or an asset-specific + * error (`terNO_RIPPLE`, `tecNO_AUTH`, etc.) otherwise. + */ [[nodiscard]] TER canTransfer(ReadView const& view, Asset const& asset, AccountID const& from, AccountID const& to); @@ -242,14 +637,29 @@ canTransfer(ReadView const& view, Asset const& asset, AccountID const& from, Acc // //------------------------------------------------------------------------------ -// Direct send w/o fees: -// - Redeeming IOUs and/or sending sender's own IOUs. -// - Create trust line of needed. -// --> bCheckIssuer : normally require issuer to be involved. -// [[nodiscard]] // nodiscard commented out so DirectStep.cpp compiles. - -/** Calls static directSendNoFeeIOU if saAmount represents Issue. - * Calls static directSendNoFeeMPT if saAmount represents MPTIssue. +/** Send @p saAmount directly without applying transfer fees or limit checks. + * + * Used for IOU redemption, intra-issuer transfers, and MPT moves where the + * issuer is one of the endpoints. Dispatches to `directSendNoFeeIOU` for + * IOU and `directSendNoFeeMPT` for MPT. + * + * For IOU, @p bCheckIssuer controls whether the function asserts that the + * issuer is one of the endpoints. For MPT, the issuer check is not performed + * (`bCheckIssuer` must be `false` for MPT). + * + * @note This function is intentionally **not** marked `[[nodiscard]]` for + * compatibility with `DirectStep.cpp`, which discards the return value in + * certain control paths. All other callers should inspect the result. + * + * @param view Mutable ledger view. + * @param uSenderID The sending account. + * @param uReceiverID The receiving account. + * @param saAmount The amount to send; its asset determines the dispatch. + * @param bCheckIssuer If `true` (IOU only), asserts that the issuer is one + * of the endpoints. Must be `false` for MPT. + * @param j Journal for trace/debug logging. + * @return `tesSUCCESS` on success, or a `tec`/`tef` error from the + * type-specific leaf. */ TER directSendNoFee( @@ -260,8 +670,30 @@ directSendNoFee( bool bCheckIssuer, beast::Journal j); -/** Calls static accountSendIOU if saAmount represents Issue. - * Calls static accountSendMPT if saAmount represents MPTIssue. +/** Send @p saAmount from @p from to @p to, applying transfer fees when + * applicable. + * + * This is the main asset-transfer entry point for transactors. Dispatches to + * `accountSendIOU` or `accountSendMPT` based on the asset type embedded in + * @p saAmount. Transfer fees are applied unless `WaiveTransferFee::Yes` is + * passed. + * + * The `allowOverflow` flag is forwarded to the MPT path only and controls + * whether `OutstandingAmount` may transiently exceed `MaximumAmount` during + * the two-phase issue-then-redeem structure used by the payment engine. Direct + * sends should use `AllowMPTOverflow::No`. + * + * @param view Mutable ledger view. + * @param from The sending account. + * @param to The receiving account. + * @param saAmount The amount to send. + * @param j Journal for trace/debug logging. + * @param waiveFee Whether to skip the transfer fee; defaults to `No`. + * @param allowOverflow Whether MPT OutstandingAmount may transiently exceed + * MaximumAmount; defaults to `No`. Use `Yes` only in payment-engine + * routing. + * @return `tesSUCCESS` on success, or a `tec`/`tef` error from the + * type-specific leaf. */ [[nodiscard]] TER accountSend( @@ -273,12 +705,34 @@ accountSend( WaiveTransferFee waiveFee = WaiveTransferFee::No, AllowMPTOverflow allowOverflow = AllowMPTOverflow::No); +/** A vector of (receiver, amount) pairs used by `accountSendMulti`. */ using MultiplePaymentDestinations = std::vector>; -/** Like accountSend, except one account is sending multiple payments (with the - * same asset!) simultaneously + +/** Send the same @p asset from @p senderID to multiple @p receivers in one + * atomic operation. * - * Calls static accountSendMultiIOU if saAmount represents Issue. - * Calls static accountSendMultiMPT if saAmount represents MPTIssue. + * Dispatches to `accountSendMultiIOU` or `accountSendMultiMPT` based on + * @p asset. Batching avoids repeated round-trips through the ledger state for + * the sender's balance and the issuance's `OutstandingAmount` field. + * + * For MPT, the `fixSecurity3_1_3` amendment switches the aggregate + * `MaximumAmount` check from a per-iteration stale-snapshot check (pre-fix) + * to an exact `uint64_t` running-total check (post-fix) to prevent precision + * loss at 19-digit magnitudes near `kMAX_MP_TOKEN_AMOUNT`. + * + * @note `receivers.size()` must be greater than 1 (asserted). + * + * @param view Mutable ledger view. + * @param senderID The account sending the asset. + * @param asset The asset to send (must match the type of all receiver + * amounts). + * @param receivers List of (AccountID, Number) destination pairs. All amounts + * must be non-negative. Sender-equals-receiver entries are silently + * skipped. + * @param j Journal for trace/debug logging. + * @param waiveFee Whether to skip transfer fees; defaults to `No`. + * @return `tesSUCCESS` on success, or a `tec`/`tef` error from the + * type-specific leaf. */ [[nodiscard]] TER accountSendMulti( @@ -289,6 +743,23 @@ accountSendMulti( beast::Journal j, WaiveTransferFee waiveFee = WaiveTransferFee::No); +/** Transfer XRP directly between two accounts without reserve or fee checks. + * + * XRP has no trust lines, no transfer fees, and no authorization model, so + * it bypasses the Asset-dispatch path entirely. Both @p from and @p to must + * be non-zero and distinct. Returns `telFAILED_PROCESSING` (open ledger) or + * `tecFAILED_PROCESSING` (closed ledger) if the sender's balance is + * insufficient. + * + * @param view Mutable ledger view. + * @param from The sending account; must not be `beast::kZERO`. + * @param to The receiving account; must not be `beast::kZERO`. + * @param amount The XRP amount to transfer; must be native (XRP). + * @param j Journal for trace/debug logging. + * @return `tesSUCCESS` on success; `telFAILED_PROCESSING` or + * `tecFAILED_PROCESSING` if balance is insufficient; `tefINTERNAL` if + * either account SLE cannot be found. + */ [[nodiscard]] TER transferXRP( ApplyView& view, diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index 14b0c004cb..a7cd6e84ed 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -1,3 +1,16 @@ +/** @file + * Pure arithmetic helpers for the XLS-65d Single-Sided Vault feature. + * + * Each function converts between the two token types a vault manages: + * the underlying *asset* (XRP, IOU, or MPT that depositors contribute) and + * vault *shares* (an MPT representing proportional ownership). Because MPT + * values are always integers every function makes an explicit rounding + * decision — and those decisions differ between the deposit and withdrawal + * paths to protect vault solvency. + * + * These functions are stateless and side-effect-free; all ledger mutations + * are the caller's responsibility. + */ #pragma once #include @@ -8,53 +21,105 @@ namespace xrpl { -/** From the perspective of a vault, return the number of shares to give - depositor when they offer a fixed amount of assets. Note, since shares are - MPT, this number is integral and always truncated in this calculation. - - @param vault The vault SLE. - @param issuance The MPTokenIssuance SLE for the vault's shares. - @param assets The amount of assets to convert. - - @return The number of shares, or nullopt on error. -*/ +/** Compute the shares minted when a depositor offers a fixed asset amount. + * + * Uses `sfAssetsTotal` from `vault` directly, *without* subtracting + * `sfLossUnrealized`. Unrealized losses are a risk borne by existing + * shareholders, not a discount for new depositors. + * + * **Bootstrap case**: when `sfAssetsTotal == 0` the result is + * `assets × 10^sfScale` (truncated), establishing the initial exchange rate. + * The non-bootstrap result is `(sfOutstandingAmount × assets) / sfAssetsTotal`, + * always truncated — depositors always receive a whole number of shares, never + * more than the assets strictly warrant. + * + * @note The deposit transactor calls this first, then back-calculates the + * true asset cost via `sharesToAssetsDeposit()` to ensure it never + * extracts more than the depositor offered. + * @throws std::overflow_error if `sfScale` is large enough to overflow + * XRPL's `Number` type; callers should catch and return `tecPATH_DRY`. + * + * @param vault The vault SLE; must contain `sfAsset`, `sfAssetsTotal`, + * `sfScale`, and `sfShareMPTID`. + * @param issuance The MPTokenIssuance SLE for the vault's share token; + * must contain `sfOutstandingAmount`. + * @param assets The asset amount to convert; must be non-negative and + * must match `vault->at(sfAsset)`. + * @return The integral share amount, or `nullopt` if `assets` is negative + * or its asset type does not match the vault. + */ [[nodiscard]] std::optional assetsToSharesDeposit( std::shared_ptr const& vault, std::shared_ptr const& issuance, STAmount const& assets); -/** From the perspective of a vault, return the number of assets to take from - depositor when they receive a fixed amount of shares. Note, since shares are - MPT, they are always an integral number. - - @param vault The vault SLE. - @param issuance The MPTokenIssuance SLE for the vault's shares. - @param shares The amount of shares to convert. - - @return The number of assets, or nullopt on error. -*/ +/** Compute the asset cost for a depositor who will receive a fixed share amount. + * + * This is the inverse of `assetsToSharesDeposit()` and is used in the second + * step of the deposit calculation: after truncating the forward direction to + * determine how many whole shares are created, the transactor calls this + * function to derive the exact asset amount to collect. + * + * Uses `sfAssetsTotal` directly, without subtracting `sfLossUnrealized`, + * matching the deposit-path convention. + * + * **Bootstrap case**: when `sfAssetsTotal == 0` the result uses `sfScale` to + * reverse the bootstrap formula applied by `assetsToSharesDeposit()`. + * + * @throws std::overflow_error if `sfScale` is large enough to overflow + * XRPL's `Number` type; callers should catch and return `tecPATH_DRY`. + * + * @param vault The vault SLE. + * @param issuance The MPTokenIssuance SLE for the vault's share token. + * @param shares The share amount to convert; must be non-negative and must + * match `vault->at(sfShareMPTID)`. + * @return The asset amount, or `nullopt` if `shares` is negative or its + * asset type does not match the vault's share MPT. + */ [[nodiscard]] std::optional sharesToAssetsDeposit( std::shared_ptr const& vault, std::shared_ptr const& issuance, STAmount const& shares); -/** Controls whether to truncate shares instead of rounding. */ +/** Controls whether to truncate (floor) the share result instead of rounding. + * + * `No` (the default) rounds to nearest, ensuring the vault is never + * shortchanged when computing shares to redeem for a fixed asset withdrawal. + * `Yes` applies floor truncation, used when the caller explicitly needs + * conservative (depositor-favoring) rounding. + */ enum class TruncateShares : bool { No = false, Yes = true }; -/** From the perspective of a vault, return the number of shares to demand from - the depositor when they ask to withdraw a fixed amount of assets. Since - shares are MPT this number is integral, and it will be rounded to nearest - unless explicitly requested to be truncated instead. - - @param vault The vault SLE. - @param issuance The MPTokenIssuance SLE for the vault's shares. - @param assets The amount of assets to convert. - @param truncate Whether to truncate instead of rounding. - - @return The number of shares, or nullopt on error. -*/ +/** Compute the shares a withdrawer must redeem to receive a fixed asset amount. + * + * Unlike the deposit path, this function subtracts `sfLossUnrealized` from + * `sfAssetsTotal` before computing the exchange rate. Withdrawers receive fewer + * assets per share when the vault has recorded unrealized losses, preventing + * early withdrawers from exiting at inflated prices at the expense of remaining + * holders. + * + * The result is rounded to nearest by default (`TruncateShares::No`), ensuring + * the vault is not shortchanged. The withdraw transactor then back-calculates + * the actual assets delivered via `sharesToAssetsWithdraw()` for a precise + * two-step computation. + * + * If `sfAssetsTotal - sfLossUnrealized == 0` (fully insolvent vault), returns + * a zero-valued `STAmount` rather than dividing by zero. + * + * @throws std::overflow_error if arithmetic overflows XRPL's `Number` type; + * callers should catch and return `tecPATH_DRY`. + * + * @param vault The vault SLE; must contain `sfAsset`, `sfAssetsTotal`, + * `sfLossUnrealized`, and `sfShareMPTID`. + * @param issuance The MPTokenIssuance SLE for the vault's share token. + * @param assets The asset amount to convert; must be non-negative and must + * match `vault->at(sfAsset)`. + * @param truncate Whether to truncate instead of rounding to nearest. + * @return The integral share amount, or `nullopt` if `assets` is negative or + * its asset type does not match the vault. + */ [[nodiscard]] std::optional assetsToSharesWithdraw( std::shared_ptr const& vault, @@ -62,16 +127,25 @@ assetsToSharesWithdraw( STAmount const& assets, TruncateShares truncate = TruncateShares::No); -/** From the perspective of a vault, return the number of assets to give the - depositor when they redeem a fixed amount of shares. Note, since shares are - MPT, they are always an integral number. - - @param vault The vault SLE. - @param issuance The MPTokenIssuance SLE for the vault's shares. - @param shares The amount of shares to convert. - - @return The number of assets, or nullopt on error. -*/ +/** Compute the assets delivered when a withdrawer redeems a fixed share amount. + * + * Like `assetsToSharesWithdraw()`, this function subtracts `sfLossUnrealized` + * from `sfAssetsTotal` before computing the exchange rate, so withdrawers + * bear their proportional share of any recorded losses. + * + * If `sfAssetsTotal - sfLossUnrealized == 0` (fully insolvent vault), returns + * a zero-valued `STAmount` rather than dividing by zero. + * + * @throws std::overflow_error if arithmetic overflows XRPL's `Number` type; + * callers should catch and return `tecPATH_DRY`. + * + * @param vault The vault SLE. + * @param issuance The MPTokenIssuance SLE for the vault's share token. + * @param shares The share amount to convert; must be non-negative and must + * match `vault->at(sfShareMPTID)`. + * @return The asset amount, or `nullopt` if `shares` is negative or its + * asset type does not match the vault's share MPT. + */ [[nodiscard]] std::optional sharesToAssetsWithdraw( std::shared_ptr const& vault, diff --git a/include/xrpl/protocol/LedgerShortcut.h b/include/xrpl/protocol/LedgerShortcut.h index 68c31c4c3c..be653f2e53 100644 --- a/include/xrpl/protocol/LedgerShortcut.h +++ b/include/xrpl/protocol/LedgerShortcut.h @@ -2,20 +2,57 @@ namespace xrpl { -/** - * @brief Enumeration of ledger shortcuts for specifying which ledger to use. +/** Symbolic names for the three canonical XRPL ledger states. * - * These shortcuts provide a convenient way to reference commonly used ledgers - * without needing to specify their exact hash or sequence number. + * The XRPL consensus model maintains three distinct ledger states at any + * point in time. Rather than requiring callers to pass magic strings + * (`"current"`, `"closed"`, `"validated"`) or ad-hoc integer sentinels, + * `LedgerShortcut` gives the type system a precise vocabulary for expressing + * ledger-selection intent without a specific sequence number or hash. + * + * In `RPCLedgerHelpers.cpp`, `lookupLedger` parsing maps the JSON strings + * `"current"`, `"closed"`, and `"validated"` onto the corresponding enum + * values before dispatching to the appropriate `getLedger` overload. The + * `AccountTx` RPC handler performs the same mapping when processing the + * `ledger_index` field. The gRPC adapter maps protobuf shortcut constants to + * these values as well. + * + * `LedgerShortcut` also participates as one arm of + * `RelationalDatabase::LedgerSpecifier` — a + * `std::variant` — + * allowing symbolic ledger names to flow through the database query layer via + * `std::visit` dispatch without special-case handling. + * + * @note The scoped `enum class` form prevents implicit integer conversions and + * namespace pollution, both of which are hazards in a codebase that also + * works extensively with raw integer ledger sequence numbers. */ enum class LedgerShortcut { - /** The current working ledger (open, not yet closed) */ + /** The open, in-progress ledger still accumulating new transactions. + * + * This ledger has not been closed or validated, so its contents may + * change. Results derived from it are not final and may be rolled back + * during a reorganisation or consensus failure. + */ Current, - /** The most recently closed ledger (may not be validated) */ + /** The most recently closed ledger; stable in structure but not yet + * consensus-validated. + * + * No new transactions are accepted into this ledger, but the network has + * not yet confirmed it as the authoritative chain tip. It is more stable + * than `Current` but still not suitable for finality guarantees. + */ Closed, - /** The most recently validated ledger */ + /** The most recently validated ledger; the fully consensus-confirmed chain + * tip. + * + * This is the only state considered immutable and trustworthy for finality + * purposes. An RPC node that cannot provide a fresh validated ledger + * (i.e., it is stale) will return an error rather than serve potentially + * incorrect data. + */ Validated }; diff --git a/include/xrpl/protocol/MPTAmount.h b/include/xrpl/protocol/MPTAmount.h index b4907774d2..020b1ac180 100644 --- a/include/xrpl/protocol/MPTAmount.h +++ b/include/xrpl/protocol/MPTAmount.h @@ -1,3 +1,8 @@ +/** @file + * Defines MPTAmount, the canonical signed-integer amount type for + * Multi-Purpose Tokens (MPTs) on the XRP Ledger. + */ + #pragma once #include @@ -13,12 +18,40 @@ namespace xrpl { +/** Typed signed-integer quantity for Multi-Purpose Tokens (MPTs). + * + * MPT balances are plain whole-unit integers — no mantissa/exponent pair, + * no sub-unit naming — capped at `maxMPTokenAmount` (INT64_MAX) by the + * protocol. The class sits alongside `XRPAmount` and `IOUAmount` as one + * of the three concrete amount types that satisfy the `StepAmount` concept + * used by the payment-path and DEX engines. + * + * Arithmetic operators are composed via Boost.Operators (CRTP): + * - `boost::totally_ordered` — synthesizes `!=`, `>`, `>=`, + * `<=` from the declared `==` and `<`. + * - `boost::additive` — synthesizes binary `+`/`-` from + * `+=`/`-=`. + * - `boost::equality_comparable` — heterogeneous `!=` + * from `operator==(value_type)`. + * - `boost::additive` — heterogeneous `+`/`-` with + * raw integers. + * + * Out-of-line `+=`, `-=`, `operator-()`, `==`, and `<` perform no overflow + * detection; callers are responsible for keeping balances in range through + * the ledger constraint machinery. The safe multiplication path + * (`mulRatio`) uses 128-bit intermediates and throws on overflow. + * + * @note `value_` is `protected` (not `private`) to allow subclassing + * without exposing the raw integer to unrelated code. No subclasses + * exist in the current codebase. + */ class MPTAmount : private boost::totally_ordered, private boost::additive, private boost::equality_comparable, private boost::additive { public: + /** Underlying integer type; matches `XRPAmount::value_type`. */ using value_type = std::int64_t; protected: @@ -27,57 +60,149 @@ protected: public: MPTAmount() = default; constexpr MPTAmount(MPTAmount const& other) = default; + + /** Construct a zero amount from the `beast::Zero` sentinel. + * + * Allows idiomatic zero-initialization via `beast::zero` in generic + * code that is templated on amount type. + */ constexpr MPTAmount(beast::Zero); constexpr MPTAmount& operator=(MPTAmount const& other) = default; - // Round to nearest, even on tie. + /** Construct from a `Number`, rounding to nearest with ties to even. + * + * Provides implicit compatibility with XRPL's high-precision arithmetic + * type. The rounding mode matches IEEE 754 default (round-half-to-even). + * + * @param x The `Number` value to convert. + */ explicit MPTAmount(Number const& x) : MPTAmount(static_cast(x)) { } + /** Construct from a raw `int64_t` value. + * + * Explicit to prevent accidental implicit conversion from integers. + * The caller is responsible for ensuring `value` does not exceed + * `maxMPTokenAmount` (INT64_MAX). + * + * @param value The integer amount in whole MPT units. + */ constexpr explicit MPTAmount(value_type value); + /** Assign the `beast::Zero` sentinel, setting the amount to zero. */ constexpr MPTAmount& operator=(beast::Zero); + /** 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` after addition. + */ MPTAmount& operator+=(MPTAmount const& other); + /** 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` after subtraction. + */ MPTAmount& operator-=(MPTAmount const& other); + /** Return the arithmetic negation of this amount. + * + * Used where a credit and a debit are expressed as equal-magnitude + * amounts of opposite sign before being applied to the ledger. + * Negating `INT64_MIN` is undefined behavior; callers must avoid it. + * + * @return A new `MPTAmount` equal to `-value_`. + */ MPTAmount operator-() const; + /** Test equality with another `MPTAmount`. + * + * Together with `operator<`, satisfies `boost::totally_ordered`, + * from which `!=`, `>`, `<=`, and `>=` are synthesized. + * + * @param other The amount to compare against. + * @return `true` if both amounts hold the same integer value. + */ bool operator==(MPTAmount const& other) const; + /** Test equality with a raw `int64_t` value. + * + * Allows expressions like `amt == 0` without constructing a temporary. + * `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 operator==(value_type other) const; + /** Return `true` if this amount is strictly less than `other`. + * + * The single total-order primitive from which `boost::totally_ordered` + * derives `>`, `<=`, and `>=`. Signed comparison gives correct + * semantics for negative balances. + * + * @param other The amount to compare against. + * @return `true` if `value_` is strictly less than `other.value_`. + */ bool operator<(MPTAmount const& other) const; - /** Returns true if the amount is not zero */ + /** Returns true if the amount is not zero. */ explicit constexpr operator bool() const noexcept; + /** Implicit conversion to `Number` for use in high-precision arithmetic. + * + * Allows `MPTAmount` to be passed anywhere a `Number` is expected — + * arithmetic operations, rounding, and comparisons — without an explicit + * cast. The reverse direction (construction from `Number`) is explicit. + */ operator Number() const noexcept { return value(); } - /** Return the sign of the amount */ + /** Return the sign of the amount. + * + * @return `-1` if negative, `0` if zero, `1` if positive. + */ [[nodiscard]] constexpr int signum() const noexcept; - /** Returns the underlying value. Code SHOULD NOT call this - function unless the type has been abstracted away, - e.g. in a templated function. - */ + /** Return the underlying integer value. + * + * Code SHOULD NOT call this function unless the type has been abstracted + * away, e.g. in a templated function. Prefer operating on `MPTAmount` + * directly to keep arithmetic in the typed domain. + * + * @return The raw `int64_t` balance in whole MPT units. + */ [[nodiscard]] constexpr value_type value() const; + /** Return the smallest positive MPT amount (one indivisible unit). + * + * Provides a uniform factory interface shared with `XRPAmount` and + * `IOUAmount` so generic payment-path code can obtain the minimum + * step size without knowing the concrete amount type. + * + * @return `MPTAmount{1}`. + */ static MPTAmount minPositiveAmount(); }; @@ -98,14 +223,12 @@ MPTAmount::operator=(beast::Zero) return *this; } -/** Returns true if the amount is not zero */ constexpr MPTAmount:: operator bool() const noexcept { return value_ != 0; } -/** Return the sign of the amount */ constexpr int MPTAmount::signum() const noexcept { @@ -114,17 +237,13 @@ MPTAmount::signum() const noexcept return (value_ != 0) ? 1 : 0; } -/** Returns the underlying value. Code SHOULD NOT call this - function unless the type has been abstracted away, - e.g. in a templated function. -*/ constexpr MPTAmount::value_type MPTAmount::value() const { return value_; } -// Output MPTAmount as just the value. +/** Stream an `MPTAmount` as its raw integer value. */ template std::basic_ostream& operator<<(std::basic_ostream& os, MPTAmount const& q) @@ -132,12 +251,35 @@ operator<<(std::basic_ostream& os, MPTAmount const& q) return os << q.value(); } +/** Return the decimal string representation of an `MPTAmount`. */ inline std::string to_string(MPTAmount const& amount) { return std::to_string(amount.value()); } +/** Compute `amt * num / den` with configurable rounding direction. + * + * The intermediate product is computed in 128-bit arithmetic to avoid + * overflow when multiplying a 63-bit MPT balance by a 32-bit numerator + * (up to 95 bits required). After division, any remainder is resolved + * based on the sign of `amt` and `roundUp`: + * - Positive amounts round up when `roundUp` is `true`. + * - Negative amounts round away from zero (more negative) when `roundUp` + * is `false`. + * + * Used for fee and reserve calculations that apply percentage-style ratios + * to MPT amounts. + * + * @param amt The base amount to scale. + * @param num Numerator of the ratio (32-bit unsigned). + * @param den Denominator of the ratio (32-bit unsigned, must be > 0). + * @param roundUp If `true`, round the result toward positive infinity; + * if `false`, round toward negative infinity. + * @return The scaled `MPTAmount`. + * @throws std::runtime_error If `den` is zero. + * @throws std::overflow_error If the result exceeds `INT64_MAX`. + */ inline MPTAmount mulRatio(MPTAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundUp) { diff --git a/include/xrpl/protocol/MPTIssue.h b/include/xrpl/protocol/MPTIssue.h index f55029f50d..26cec0405f 100644 --- a/include/xrpl/protocol/MPTIssue.h +++ b/include/xrpl/protocol/MPTIssue.h @@ -5,9 +5,23 @@ namespace xrpl { -/* Adapt MPTID to provide the same interface as Issue. Enables using static - * polymorphism by Asset and other classes. MPTID is a 192-bit concatenation - * of a 32-bit account sequence and a 160-bit account id. +/** Identifies a Multi-Purpose Token issuance, adapting `MPTID` to mirror + * the public interface of `Issue`. + * + * `MPTIssue` wraps a single 192-bit `MPTID` (32-bit big-endian sequence + * concatenated with a 160-bit `AccountID`) and exposes the same accessors as + * `Issue` — `getIssuer()`, `getText()`, `setJson()`, `native()`, and + * `integral()` — allowing `Asset` and any template code constrained by + * `ValidIssueType` to treat MPTs and IOUs uniformly. + * + * Key semantic differences from `Issue`: + * - `native()` always returns `false` (MPTs are never the native currency). + * - `integral()` always returns `true` (MPT amounts are 64-bit integers, + * unlike IOUs which use multi-precision rational arithmetic). + * - Equality and ordering compare the full 192-bit `MPTID`, with no + * special-case equivalence class for any sentinel value. + * + * @see Issue, Asset, MPTID */ class MPTIssue { @@ -17,27 +31,80 @@ private: public: MPTIssue() = default; + /** Constructs an MPTIssue from a pre-formed 192-bit issuance identifier. + * + * @param issuanceID The packed MPTID (32-bit sequence ‖ 160-bit AccountID). + */ MPTIssue(MPTID const& issuanceID); + /** Constructs an MPTIssue 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(std::uint32_t sequence, AccountID const& account); + /** Implicit conversion to the underlying `MPTID`. + * + * Allows an `MPTIssue` to be passed wherever a raw `MPTID` is expected + * without an explicit cast. + * + * @return A reference to the underlying `MPTID`, valid for the lifetime + * of this object. + */ operator MPTID const&() const { return mptID_; } + /** Extracts the issuer's `AccountID` from the packed `MPTID`. + * + * `MPTID` lays out 4 bytes of sequence followed immediately by 20 bytes + * of `AccountID`. This method returns a reference into that buffer by + * pointer-casting past the leading 4 bytes. A `static_assert` on the + * total size of `MPTID` guards against layout changes breaking this + * assumption at compile time. + * + * @return A reference to the `AccountID` embedded in the `MPTID`. + * Valid for the lifetime of this `MPTIssue` object. + * @note The free function `getMPTIssuer()` achieves the same extraction + * via `std::bit_cast` and returns by value, avoiding any + * lifetime dependency on the source object. + */ [[nodiscard]] AccountID const& getIssuer() const; + /** Returns the underlying `MPTID`. + * + * @return A reference to the 192-bit issuance identifier, valid for the + * lifetime of this object. + */ [[nodiscard]] constexpr MPTID const& getMptID() const { return mptID_; } + /** Returns the hex string representation of the underlying `MPTID`. + * + * @return Uppercase hex encoding of the 192-bit issuance identifier. + */ [[nodiscard]] std::string getText() const; + /** Writes the issuance identifier into a JSON object under the key + * `mpt_issuance_id`. + * + * Serializes the `MPTID` as a hex string. Contrasted with + * `Issue::setJson()`, which writes separate `currency` and `issuer` keys. + * + * @param jv Output JSON object to write into; existing keys are not + * cleared. + */ void setJson(json::Value& jv) const; @@ -47,12 +114,30 @@ public: friend constexpr std::weak_ordering operator<=>(MPTIssue const& lhs, MPTIssue const& rhs); + /** Returns `false`; MPTs are never the native asset (XRP). + * + * Mirrors `Issue::native()` so that generic code can query XRP-ness + * without a type dispatch. `Asset::getAmountType()` relies on this flag + * to select `XRPAmount` vs `IOUAmount` vs `MPTAmount` at compile time. + * + * @return Always `false`. + */ static bool native() { return false; } + /** Returns `true`; MPT amounts are stored as 64-bit integers. + * + * Mirrors the naming of `Issue::integral()` so that generic code can + * distinguish integer (drop/MPT) amounts from multi-precision IOU + * amounts without a type dispatch. Unlike `Issue::integral()`, this is + * unconditionally `true` — all MPTs use integer arithmetic regardless of + * the token configuration. + * + * @return Always `true`. + */ static bool integral() { @@ -60,19 +145,44 @@ public: } }; +/** Returns `true` if two `MPTIssue` instances represent the same issuance. + * + * Delegates to the full 192-bit comparison of the underlying `MPTID`s. + * Both the sequence number and the issuer `AccountID` must match; there is + * no partial-equality exemption as there is in `Issue::operator==` for XRP. + * + * @param lhs Left-hand issuance. + * @param rhs Right-hand issuance. + * @return `true` iff both `MPTID`s are bitwise equal. + */ constexpr bool operator==(MPTIssue const& lhs, MPTIssue const& rhs) { return lhs.mptID_ == rhs.mptID_; } +/** Provides a strict weak ordering over `MPTIssue` values. + * + * Delegates to the 192-bit lexicographic comparison of the underlying + * `MPTID`s. The ordering is consistent with `operator==`: two issuances are + * equivalent iff their full `MPTID`s are identical. + * + * @param lhs Left-hand issuance. + * @param rhs Right-hand issuance. + * @return A `std::weak_ordering` value consistent with `operator==`. + */ constexpr std::weak_ordering operator<=>(MPTIssue const& lhs, MPTIssue const& rhs) { return lhs.mptID_ <=> rhs.mptID_; } -/** MPT is a non-native token. +/** Returns `false`; an `MPTID` never identifies the native XRP asset. + * + * Provides the same naming convention as `isXRP(Issue)`, allowing call + * sites to test XRP-ness uniformly across both issue types. + * + * @return Always `false`. */ inline bool isXRP(MPTID const&) @@ -80,6 +190,21 @@ isXRP(MPTID const&) return false; } +/** Extracts the issuer `AccountID` from a `MPTID` by value. + * + * Copies the 20 bytes that follow the leading 4-byte sequence field into a + * temporary array, then uses `std::bit_cast` to reinterpret them as an + * `AccountID`. The `static_assert` on the total size of `MPTID` ensures the + * layout assumption holds; if the type ever gains padding the build fails. + * `std::bit_cast` is typically optimized to nothing in the final assembly. + * + * @param mptid The 192-bit issuance identifier to extract from. + * @return The `AccountID` embedded in bytes 4–23 of `mptid`. + * @note Use `MPTIssue::getIssuer()` when a zero-copy reference into an + * existing `MPTIssue` object is sufficient. The rvalue overloads of + * this function are deleted to prevent dangling references from + * temporaries. + */ inline AccountID getMPTIssuer(MPTID const& mptid) { @@ -93,12 +218,21 @@ getMPTIssuer(MPTID const& mptid) return std::bit_cast(bytes); } -// Disallow temporary +// Deleted to prevent a dangling-reference bug: if a temporary MPTID were +// accepted, the returned AccountID const& would immediately dangle. AccountID const& getMPTIssuer(MPTID const&&) = delete; AccountID const& getMPTIssuer(MPTID&&) = delete; +/** Returns the `MPTID` sentinel representing "no MPT". + * + * Encodes `{ sequence=0, account=noAccount() }` — all-zero bits. + * Mirrors `noIssue()` in `Issue.h` for use in contexts where a + * missing or invalid MPT must be represented without `std::optional`. + * + * @return The all-zero 192-bit sentinel `MPTID`. + */ inline MPTID noMPT() { @@ -106,6 +240,15 @@ noMPT() return kMPT.getMptID(); } +/** Returns the `MPTID` sentinel representing a structurally invalid MPT. + * + * Encodes `{ sequence=0, account=xrpAccount() }` — sequence zero with the + * XRP account address as issuer, which is a conventionally invalid issuer + * for MPTs. `Asset`'s `BadAsset` comparison detects this sentinel by + * checking `getIssuer() == xrpAccount()`. + * + * @return The sentinel `MPTID` whose issuer is `xrpAccount()`. + */ inline MPTID badMPT() { @@ -113,6 +256,15 @@ badMPT() return kMPT.getMptID(); } +/** Appends the underlying `MPTID` to a hasher. + * + * Plugs `MPTIssue` into the Beast hashing framework, enabling use in + * Beast-aware hash maps and sets. + * + * @tparam Hasher A type satisfying the `beast::hash_append` concept. + * @param h The hasher to append to. + * @param r The issuance whose `MPTID` is appended. + */ template void hash_append(Hasher& h, MPTIssue const& r) @@ -121,15 +273,49 @@ hash_append(Hasher& h, MPTIssue const& r) hash_append(h, r.getMptID()); } +/** Returns the canonical wire-format JSON representation of an MPT issuance. + * + * Convenience wrapper around `MPTIssue::setJson()`. The returned object + * contains a single `mpt_issuance_id` field with the hex-encoded `MPTID`. + * + * @param mptIssue The issuance to serialize. + * @return A JSON object of the form `{"mpt_issuance_id": ""}`. + */ json::Value toJson(MPTIssue const& mptIssue); +/** Returns 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); +/** Parses an MPT issuance from a JSON object. + * + * Validates in strict order: `v` must be a JSON object; `currency` and + * `issuer` keys must be absent (their presence indicates IOU data routed + * to the wrong parser); `mpt_issuance_id` must be a string containing a + * valid 48-character hex-encoded `MPTID`. + * + * @param jv The JSON value to parse; must be an object. + * @return The parsed `MPTIssue`. + * @throws std::runtime_error if `jv` is not a JSON object, or if `currency` + * or `issuer` keys are present. + * @throws json::Error if `mpt_issuance_id` is absent, not a string, or not + * a valid 192-bit hex value. + * @see toJson for the inverse operation. + */ MPTIssue mptIssueFromJson(json::Value const& jv); +/** Writes the hex representation of an MPT issuance to a stream. + * + * @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); @@ -137,6 +323,13 @@ operator<<(std::ostream& os, MPTIssue const& x); namespace std { +/** Specializes `std::hash` for `xrpl::MPTID`, delegating to the type's own + * hasher. + * + * Enables `MPTID` to be used directly as a key in `std::unordered_map`, + * `std::unordered_set`, and similar standard containers without wrapping + * in `MPTIssue`. + */ template <> struct hash : xrpl::MPTID::hasher { diff --git a/include/xrpl/protocol/MultiApiJson.h b/include/xrpl/protocol/MultiApiJson.h index 5a7dfcd731..2cbba1f8c2 100644 --- a/include/xrpl/protocol/MultiApiJson.h +++ b/include/xrpl/protocol/MultiApiJson.h @@ -1,3 +1,12 @@ +/** @file + * Holds one pre-built `Json::Value` per supported API version so that a + * single ledger event can be delivered to subscribers speaking different API + * versions without re-serializing on every send. + * + * The public alias `xrpl::MultiApiJson` binds the template to the live + * version range `[kAPI_MINIMUM_SUPPORTED_VERSION, kAPI_MAXIMUM_VALID_VERSION]`. + */ + #pragma once #include @@ -14,6 +23,15 @@ namespace xrpl { namespace detail { + +/** Variable template that is `true` only for lvalue-reference-qualified + * `std::integral_constant` specializations (both cv-variants). + * + * Used as the building block for the `some_integral_constant` concept, which + * disambiguates the compile-time and runtime overloads of `VisitorT::operator()`. + * + * @tparam T The type to test. + */ template constexpr bool kIS_INTEGRAL_CONSTANT = false; template @@ -21,35 +39,94 @@ constexpr bool kIS_INTEGRAL_CONSTANT&> = true; template constexpr bool kIS_INTEGRAL_CONSTANT const&> = true; +/** Concept satisfied only by `std::integral_constant` specializations (lvalue refs). + * + * Used in `requires` clauses on `VisitorT::operator()` to prevent the + * runtime-`unsigned` overloads from being selected when a compile-time + * constant is passed, avoiding otherwise-ambiguous partial ordering. + * + * @tparam T The type to constrain. + */ template concept some_integral_constant = detail::kIS_INTEGRAL_CONSTANT; -// This class is designed to wrap a collection of _almost_ identical json::Value -// objects, indexed by version (i.e. there is some mapping of version to object -// index). It is used e.g. when we need to publish JSON data to users supporting -// different API versions. We allow manipulation and inspection of all objects -// at once with `isMember` and `set`, and also individual inspection and updates -// of an object selected by the user by version, using `visitor_t` nested type. +/** Holds one `Json::Value` per API version in a fixed-size array, enabling + * single-pass event serialization for multi-version subscriber delivery. + * + * When an XRPL server event (e.g., a validated transaction) must be published + * to subscribers that may speak different API versions, re-serializing or + * branching inside the send path would add latency proportional to subscriber + * count. `MultiApiJson` amortizes version-specific transformations to once per + * event: callers construct the object from a common base `Json::Value`, apply + * per-version mutations via `visit`, and then each subscriber's delivery path + * calls `visit(apiVersion, sender)` to pick the pre-built slot cheaply. + * + * The array has `MaxVer + 1 - MinVer` elements; version `v` maps to index + * `v - MinVer`. `set` and `isMember` operate across all slots; `visit` + * operates on a single slot selected by version. + * + * @note Prefer the `xrpl::MultiApiJson` type alias over instantiating this + * template directly. Direct instantiation is intended for tests only; all + * production code should use the alias, which is bound to the live version + * constants and automatically tracks any future version-range changes. + * + * @tparam MinVer Minimum (inclusive) supported API version. + * @tparam MaxVer Maximum (inclusive) supported API version. + */ template struct MultiApiJson { static_assert(MinVer <= MaxVer); + /** Returns `true` if `v` falls within `[MinVer, MaxVer]`. + * + * Used by `VisitorT` to guard against out-of-range version accesses. + * @param v The API version number to test. + * @return `true` iff `v` is a valid slot index. + */ static constexpr auto valid(unsigned int v) noexcept -> bool { return v >= MinVer && v <= MaxVer; } + /** Maps an API version number to its zero-based array slot. + * + * Out-of-range values below `MinVer` clamp to 0 rather than underflowing; + * the caller is responsible for checking `valid(v)` before trusting the + * result. Values above `MaxVer` are not clamped — `valid()` must be used + * to guard against those. + * + * @param v The API version number to map. + * @return The corresponding index into `val`. + */ static constexpr auto index(unsigned int v) noexcept -> std::size_t { return (v < MinVer) ? 0 : static_cast(v - MinVer); } + /** Number of API version slots stored; equals `MaxVer + 1 - MinVer`. */ constexpr static std::size_t kSIZE = MaxVer + 1 - MinVer; + + /** The per-version JSON values, indexed by `index(version)`. + * + * Public to allow direct slot access in tests and for `VisitorT` (which + * is a friend via the `static constexpr` data member). Production callers + * should use `set`, `isMember`, and `visit` rather than indexing directly. + */ std::array val = {}; + /** Constructs the object, optionally copy-initializing every slot. + * + * When `init` is the default (null) `Json::Value`, all slots remain + * default-initialized (null). When a non-null value is supplied, every + * slot is copy-initialized to it. The common pattern in `NetworkOPs.cpp` + * is to pass a shared base object and then apply per-version mutations + * via `visit`. + * + * @param init Base value to copy into every slot; omit for null slots. + */ explicit MultiApiJson(json::Value const& init = {}) { if (init == json::Value{}) @@ -58,6 +135,16 @@ struct MultiApiJson v = init; } + /** Writes a key-value pair into every slot simultaneously. + * + * Use for fields that are identical across all API versions — the majority + * of transaction fields. Cheaper than calling `visit` once per version for + * shared data. The `requires` clause restricts `v` to types from which + * `Json::Value` can be constructed, preventing silent misuse. + * + * @param key The JSON object key to set. + * @param v The value to assign; must be constructible to `Json::Value`. + */ void set(char const* key, auto const& v) requires std::constructible_from @@ -66,8 +153,26 @@ struct MultiApiJson a[key] = v; } - enum class IsMemberResult : int { None = 0, Some, All }; + /** Tri-state result of `isMember`: indicates how many version slots contain a key. + * + * Scoped to `MultiApiJson` rather than a separate class enum deliberately — + * the struct is narrow enough to serve as its own scope for this result. + */ + enum class IsMemberResult : int { + None = 0, /**< No slot contains the key. */ + Some, /**< At least one but not all slots contain the key. */ + All /**< Every slot contains the key. */ + }; + /** Queries how many version slots contain the given JSON key. + * + * Useful for asserting that version-specific mutations were (or were not) + * applied before delivery. `NetworkOPs` uses it in assertions to verify + * that certain fields are never set on a freshly-constructed object. + * + * @param key The JSON object key to look up in each slot. + * @return `IsMemberResult::None`, `Some`, or `All`. + */ [[nodiscard]] IsMemberResult isMember(char const* key) const { @@ -83,6 +188,33 @@ struct MultiApiJson return count < kSIZE ? IsMemberResult::Some : IsMemberResult::All; } + /** Stateless callable that routes invocations to the correct version slot. + * + * Provides four `operator()` overloads split along two axes: + * + * 1. **Compile-time version** (`std::integral_constant`): + * the version is checked with `static_assert`; the JSON reference and + * optional extra arguments are forwarded to `fn` at compile time. + * + * 2. **Runtime version** (any type convertible to `unsigned` that is + * *not* an `integral_constant`): the version is checked with + * `XRPL_ASSERT`; the `some_integral_constant` concept in the `requires` + * clause prevents these overloads from being selected when a + * compile-time constant is passed, resolving the otherwise-ambiguous + * partial ordering. + * + * Each axis is further split by whether extra arguments are forwarded to + * `fn` after the `Json::Value` (and possibly the version value). This + * matches the calling convention of `forAllApiVersions`/`forApiVersions`, + * which pass each version as an `integral_constant` plus any extra args + * bound at the call site. + * + * `const`-propagation is automatic: the JSON reference passed to `fn` + * mirrors the `const`-ness of the `Json&` parameter. + * + * @note Exposed as `kVISITOR` to allow direct testing; prefer `visit()` + * for all production call sites. + */ static constexpr struct VisitorT final { // integral_constant version, extra arguments @@ -145,6 +277,19 @@ struct MultiApiJson } } kVISITOR = {}; + /** Returns a closure that dispatches `kVISITOR` for this object (mutable). + * + * The returned callable captures `this` and forwards all arguments to + * `kVISITOR`. This form is composable with `forAllApiVersions` and + * `forApiVersions`: those utilities iterate the version range at compile + * time, passing each version as an `integral_constant`. The closure + * satisfies that calling convention exactly, so + * `forAllApiVersions(obj.visit(), lambda)` iterates every version with a + * single consistent lambda without any per-version conditional logic. + * + * @return A lambda `(auto... args) -> auto` that calls + * `kVISITOR(*this, args...)`. + */ auto visit() { @@ -155,6 +300,16 @@ struct MultiApiJson { return kVISITOR(*self, std::forward(args)...); }; } + /** Returns a closure that dispatches `kVISITOR` for this object (const). + * + * Identical to the mutable overload but captures `this` as `const`, + * propagating const-ness through to the `Json::Value` reference passed to + * the callable. Used when the caller only needs to read the pre-built JSON + * (e.g., subscriber delivery in `BookListeners::publish`). + * + * @return A lambda `(auto... args) -> auto` that calls + * `kVISITOR(*this, args...)` on the const object. + */ [[nodiscard]] auto visit() const { @@ -165,6 +320,20 @@ struct MultiApiJson { return kVISITOR(*self, std::forward(args)...); }; } + /** Directly invokes `kVISITOR` for a single version (mutable). + * + * Equivalent to `visit()(args...)` but avoids the closure allocation. + * Typical usage: + * ```cpp + * jvObj.visit(RPC::kAPI_VERSION<1>, [](Json::Value& jv) { + * jv["ledger_index"] = std::to_string(jv["ledger_index"].asInt()); + * }); + * ``` + * + * @param args Version (compile-time or runtime) followed by a callable + * and any extra arguments accepted by `kVISITOR`. + * @return The return value of the callable. + */ template auto visit(Args... args) -> std::invoke_result_t @@ -174,6 +343,20 @@ struct MultiApiJson return kVISITOR(*this, std::forward(args)...); } + /** Directly invokes `kVISITOR` for a single version (const). + * + * Const counterpart of the mutable `visit(args...)` overload. Used when + * the JSON slot must not be mutated — for example in the subscriber + * delivery path where each subscriber picks its pre-built slot: + * ```cpp + * jvObj.visit(subscriber->getApiVersion(), + * [&](Json::Value const& jv) { subscriber->send(jv, true); }); + * ``` + * + * @param args Version (compile-time or runtime) followed by a callable + * and any extra arguments accepted by `kVISITOR`. + * @return The return value of the callable. + */ template [[nodiscard]] auto visit(Args... args) const -> std::invoke_result_t @@ -186,7 +369,15 @@ struct MultiApiJson } // namespace detail -// Wrapper for Json for all supported API versions. +/** Holds one pre-built `Json::Value` per currently supported API version. + * + * Bound to `[kAPI_MINIMUM_SUPPORTED_VERSION, kAPI_MAXIMUM_VALID_VERSION]` + * (currently versions 1–3), so the concrete type stores exactly three + * `Json::Value` objects. Changing those constants automatically resizes + * every `MultiApiJson` instance in the server. + * + * @see detail::MultiApiJson for the full behavioral contract. + */ using MultiApiJson = detail::MultiApiJson; diff --git a/include/xrpl/protocol/NFTSyntheticSerializer.h b/include/xrpl/protocol/NFTSyntheticSerializer.h index a1d8bce985..da952c6dcf 100644 --- a/include/xrpl/protocol/NFTSyntheticSerializer.h +++ b/include/xrpl/protocol/NFTSyntheticSerializer.h @@ -1,3 +1,20 @@ +/** @file + * Aggregator entry point for injecting synthetic NFT fields into RPC + * transaction responses. + * + * "Synthetic" fields (`nftoken_ids`, `nftoken_id`, `offer_id`) are derived + * at query time from the ledger state changes recorded in `TxMeta`; they are + * not stored on-chain. Callers invoke a single function here rather than + * calling the individual NFT inserters directly, keeping call sites from + * accumulating an ever-growing list of per-type injector calls as new NFT + * transaction types are added. + * + * The underlying extraction helpers (`insertNFTokenID`, `insertNFTokenOfferID`) + * live 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 `xrpl::RPC` coupling imposed by this header. + */ + #pragma once #include @@ -8,13 +25,44 @@ namespace xrpl::RPC { -/** - Adds common synthetic fields to transaction-related JSON responses - - @{ +/** Enrich a transaction JSON response with NFT-derived synthetic fields. + * + * Delegates to two independent inserters, in order: + * + * - `insertNFTokenID` — adds `nftoken_id` (for `NFTokenMint` and + * `NFTokenAcceptOffer`) or `nftoken_ids` (for `NFTokenCancelOffer`) by + * diffing the NFToken arrays across all affected ledger nodes recorded in + * the transaction metadata. + * - `insertNFTokenOfferID` — adds `offer_id` for `NFTokenCreateOffer` (and + * mints that include an immediate sell offer) by locating the newly created + * `NFTokenOffer` node and extracting its `sfLedgerIndex`. + * + * Both delegates gate themselves on transaction type and `tesSUCCESS`, so + * this function is safe to call for any transaction type: non-NFT + * transactions produce no output. + * + * Synthetic fields are written into `response[jss::meta]`. The `meta` + * sub-object should already be populated by the caller (e.g., via + * `TxMeta::getJson`) before this function is invoked — consistent with the + * call-site pattern in `Tx.cpp`, `Simulate.cpp`, `AccountTx.cpp`, and + * `NetworkOPs.cpp`, where this call appears alongside `insertDeliveredAmount` + * and `insertMPTokenIssuanceID` as part of a fixed metadata-enrichment + * sequence. + * + * @param response 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 newly created objects. + * + * @see xrpl::insertNFTokenID, xrpl::insertNFTokenOfferID */ void -insertNFTSyntheticInJson(json::Value&, std::shared_ptr const&, TxMeta const&); -/** @} */ +insertNFTSyntheticInJson( + json::Value& response, + std::shared_ptr const& transaction, + TxMeta const& transactionMeta); } // namespace xrpl::RPC diff --git a/include/xrpl/protocol/NFTokenID.h b/include/xrpl/protocol/NFTokenID.h index f61c6bd5cb..0f6f988f60 100644 --- a/include/xrpl/protocol/NFTokenID.h +++ b/include/xrpl/protocol/NFTokenID.h @@ -1,3 +1,16 @@ +/** @file + * Helpers that reconstruct NFToken identities from transaction metadata + * and inject them into RPC JSON responses as synthetic fields. + * + * Raw ledger metadata records before/after state of `NFTokenPage` objects + * but does not directly annotate which token was created or consumed. The + * functions below bridge that gap. They are free (non-static) functions so + * that Clio (the XRPL History API server) can link against them directly + * and perform the same enrichment without duplicating the logic. + * + * @see NFTokenOfferID.h for the analogous helpers for `NFTokenOffer` IDs. + */ + #pragma once #include @@ -11,28 +24,100 @@ namespace xrpl { -/** - Add a `nftoken_ids` field to the `meta` output parameter. - The field is only added to successful NFTokenMint, NFTokenAcceptOffer, - and NFTokenCancelOffer transactions. - - Helper functions are not static because they can be used by Clio. - @{ +/** Returns true if this transaction could have produced or consumed an NFToken. + * + * Acts as a cheap early-exit guard for all downstream extraction logic. + * A transaction qualifies only when it is one of the three NFT 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 one of + * the three NFT transaction types, and the result is `tesSUCCESS`. */ bool canHaveNFTokenID(std::shared_ptr const& serializedTx, TxMeta const& transactionMeta); +/** 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 tag the newly inserted entry. This function recovers it by + * set-difference: it accumulates token IDs from all previous states into + * `prevIDs` and all final states into `finalIDs`, then uses + * `std::mismatch` to locate the first entry present in `finalIDs` but + * absent from `prevIDs`. Because `NFTokenPage` entries are stored in + * sorted order by token ID, both vectors are already ordered and + * `std::mismatch` finds the insertion point in linear time without + * additional sorting. + * + * @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 below + * 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 `finalIDs.size() != prevIDs.size() + 1` (tokens are minted one + * at a time) or if `std::mismatch` unexpectedly reaches the end of + * `finalIDs`. + */ std::optional getNFTokenIDFromPage(TxMeta const& transactionMeta); +/** 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 set-difference arithmetic. + * Results are sorted and deduplicated because a single cancel transaction + * can target multiple offers that reference the same underlying 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); +/** Injects synthetic NFToken ID field(s) into an RPC transaction response. + * + * Calls `canHaveNFTokenID` first; returns immediately without modifying + * `response` if the transaction is ineligible or extraction yields nothing. + * When eligible, dispatches by transaction type: + * + * - `ttNFTOKEN_MINT` — writes `jss::nftoken_id` (single string) derived + * from `getNFTokenIDFromPage`. + * - `ttNFTOKEN_ACCEPT_OFFER` — writes `jss::nftoken_id` (single string, + * first element) derived from `getNFTokenIDFromDeletedOffer`. + * - `ttNFTOKEN_CANCEL_OFFER` — writes `jss::nftoken_ids` (JSON array of + * all deduplicated IDs) derived from `getNFTokenIDFromDeletedOffer`. + * + * The singular/plural field-name distinction reflects a real semantic + * difference: accept and mint affect exactly one NFT, while cancel can + * affect many. + * + * @param response The JSON object to enrich; fields are written + * directly into it. The caller is responsible for scoping this to + * the `jss::meta` sub-object of the full response. + * @param transaction The executed transaction. A null pointer is + * handled gracefully via `canHaveNFTokenID`. + * @param transactionMeta Read-only metadata used for eligibility checking + * and token ID extraction. + */ void insertNFTokenID( json::Value& response, std::shared_ptr const& transaction, TxMeta const& transactionMeta); -/** @} */ } // namespace xrpl diff --git a/include/xrpl/protocol/NFTokenOfferID.h b/include/xrpl/protocol/NFTokenOfferID.h index c4a80356bf..92dfc2b2d6 100644 --- a/include/xrpl/protocol/NFTokenOfferID.h +++ b/include/xrpl/protocol/NFTokenOfferID.h @@ -1,3 +1,20 @@ +/** @file + * Helpers that recover the ledger index of a newly created `NFTokenOffer` + * from transaction metadata and inject it into RPC JSON responses as a + * synthetic `offer_id` field. + * + * The XRPL transaction format records only inputs; the ledger index of a + * newly created offer object appears solely in the `CreatedNode` entries of + * the transaction metadata. The three functions below encapsulate the scan + * once so that every API consumer — rippled RPC handlers and Clio alike — + * can obtain the offer ID without walking `AffectedNodes` manually. All + * three functions are free (non-static) so that Clio can call them directly + * without duplicating the logic. + * + * @see NFTokenID.h for the analogous helpers that inject `nftoken_id` / + * `nftoken_ids` for mint, accept-offer, and cancel-offer operations. + */ + #pragma once #include @@ -10,26 +27,76 @@ namespace xrpl { -/** - Add an `offer_id` field to the `meta` output parameter. - The field is only added to successful NFTokenCreateOffer transactions. - - Helper functions are not static because they can be used by Clio. - @{ +/** Determine whether a transaction can have an NFToken offer ID. + * + * Acts as a cheap pre-filter before the 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 offer). + * 3. The transaction succeeded (`tesSUCCESS`). A failed transaction never + * modifies 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, indicating + * that a subsequent call to `getOfferIDFromCreatedOffer` may yield a + * value. */ bool canHaveNFTokenOfferID( std::shared_ptr const& serializedTx, TxMeta const& transactionMeta); +/** Extract the ledger index of the NFToken offer created by a transaction. + * + * Scans the `AffectedNodes` array in `transactionMeta` for a `CreatedNode` + * whose `sfLedgerEntryType` is `ltNFTOKEN_OFFER`. Modified and deleted + * nodes are skipped. The first qualifying node's `sfLedgerIndex` is + * returned; because at most one `NFTokenOffer` can be created per + * transaction, the loop exits immediately on the first match. + * + * @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 + * found. Absence is a plausible non-exceptional condition (e.g., when + * processing historical or externally sourced transactions with + * incomplete metadata), not an error. + * + * @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); +/** Inject the NFToken offer ID into a JSON response as `jss::offer_id`. + * + * Composes `canHaveNFTokenOfferID` and `getOfferIDFromCreatedOffer`: + * returns immediately without touching `response` if the transaction is + * ineligible or the metadata contains no created offer node. When an offer + * ID is successfully extracted, it is written into `response[jss::offer_id]` + * as a hex string. + * + * 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 alongside the raw node data. + * + * @param response The JSON object to enrich; `jss::offer_id` is + * written directly into it on success. The caller is responsible for + * scoping this to `jss::meta` of the full response. + * @param transaction The executed transaction. A null pointer is + * handled gracefully (no-op) via `canHaveNFTokenOfferID`. + * @param transactionMeta Read-only transaction metadata used to locate the + * created `NFTokenOffer` node. + */ void insertNFTokenOfferID( json::Value& response, std::shared_ptr const& transaction, TxMeta const& transactionMeta); -/** @} */ } // namespace xrpl diff --git a/include/xrpl/protocol/PathAsset.h b/include/xrpl/protocol/PathAsset.h index 4c4a3f7af4..1a0d2e456e 100644 --- a/include/xrpl/protocol/PathAsset.h +++ b/include/xrpl/protocol/PathAsset.h @@ -1,3 +1,12 @@ +/** @file + * Defines `PathAsset`, the token identifier for a single hop in an XRPL + * payment path, and its associated free functions. + * + * `PathAsset` holds `std::variant` — just the *which + * currency or MPT* component of a path element, without the issuer. + * This is narrower than `Asset` (`std::variant`) because + * `STPathElement` records the issuer in a separate field. + */ #pragma once #include @@ -5,7 +14,19 @@ namespace xrpl { -/* Represent STPathElement's asset, which can be Currency or MPTID. +/** Token identifier for a single hop within an XRPL payment path. + * + * Holds `std::variant` — the *which currency/MPT* component + * of a path element, without the issuer. Issuers are stored separately in + * `STPathElement::mIssuerID` because payment-path serialization records them + * as independent fields; folding them into `PathAsset` would duplicate data + * and complicate encoding. + * + * This is intentionally narrower than `Asset`, which pairs a currency or MPTID + * with its issuer. `PathAsset` carries only the identifier half. Use + * `PathAsset(Asset const&)` to project an `Asset` down to a `PathAsset`. + * + * @see Asset, STPathElement, ValidPathAsset */ class PathAsset { @@ -14,36 +35,83 @@ private: public: PathAsset() = default; - // Enables comparing Asset and PathAsset + + /** Construct a PathAsset by projecting an Asset, discarding the issuer. + * + * For an `Issue`-bearing `Asset`, retains the `Currency`. For an + * `MPTIssue`-bearing `Asset`, retains the `MPTID`. This enables direct + * comparison between the richer `Asset` type and the path-element + * representation without manually extracting the identifier. + * + * @param asset The full asset to project. + */ PathAsset(Asset const& asset); + + /** Construct a PathAsset representing an XRP or IOU currency. */ PathAsset(Currency const& currency) : easset_(currency) { } + + /** Construct a PathAsset representing an MPT issuance. */ PathAsset(MPTID const& mpt) : easset_(mpt) { } + /** Return whether the active alternative is exactly `T`. + * + * @tparam T `Currency` or `MPTID` (enforced by `ValidPathAsset`). + * @return `true` if the held alternative is `T`, `false` otherwise. + */ template [[nodiscard]] constexpr bool holds() const; + /** Return whether this path asset represents native XRP. + * + * A `Currency` alternative delegates to `xrpl::isXRP(currency)`. An + * `MPTID` alternative always returns `false` — MPT can never be native. + * + * @return `true` if the held currency is the XRP zero-currency sentinel. + */ [[nodiscard]] constexpr bool isXRP() const; + /** Return a const reference to the held value of type `T`. + * + * @tparam T `Currency` or `MPTID` (enforced by `ValidPathAsset`). + * @return A reference to the active alternative. + * @throws std::runtime_error if the active alternative is not `T`. Call + * `holds()` or dispatch through `visit()` to avoid this. + */ template T const& get() const; + /** Return a const reference to the underlying variant. + * + * Provides direct access to `std::variant` for callers + * that need to pass it to `std::visit` or store it without going through + * the member `visit()` wrapper. + * + * @return The internal variant holding `Currency` or `MPTID`. + */ [[nodiscard]] constexpr std::variant const& value() const; - // Custom, generic visit implementation + /** Visit the active alternative with a set of per-type callables. + * + * Combines `visitors...` into a single overload set via + * `detail::CombineVisitors` and forwards to `std::visit`. Both + * alternatives (`Currency` and `MPTID`) must be covered. + * + * @tparam Visitors Callable types, one per alternative. + * @param visitors Callables to dispatch to; typically lambdas. + * @return The return value of the selected visitor. + */ template constexpr auto visit(Visitors&&... visitors) const -> decltype(auto) { - // Simple delegation to the reusable utility, passing the internal - // variant data. return detail::visit(easset_, std::forward(visitors)...); } @@ -51,9 +119,23 @@ public: operator==(PathAsset const& lhs, PathAsset const& rhs); }; +/** True when `PA` is `Currency`, false when `PA` is `MPTID`. + * + * Compile-time predicate for `if constexpr` branches in generic code that + * must distinguish XRP/IOU paths from MPT paths. + * + * @tparam PA `Currency` or `MPTID` (enforced by `ValidPathAsset`). + */ template constexpr bool kIS_CURRENCY_V = std::is_same_v; +/** True when `PA` is `MPTID`, false when `PA` is `Currency`. + * + * Compile-time predicate for `if constexpr` branches in generic code that + * must distinguish MPT paths from XRP/IOU paths. + * + * @tparam PA `Currency` or `MPTID` (enforced by `ValidPathAsset`). + */ template constexpr bool kIS_MPTID_V = std::is_same_v; @@ -94,6 +176,17 @@ PathAsset::isXRP() const [](MPTID const&) { return false; }); } +/** Compare two PathAssets for equality. + * + * Two `PathAsset` values are equal only when both hold the same alternative + * type *and* the contained values are equal. A `Currency` and an `MPTID` + * are never equal even if their raw bytes coincide, preventing cross-type + * false positives. + * + * @param lhs Left-hand operand. + * @param rhs Right-hand operand. + * @return `true` if both hold the same type and equal value, `false` otherwise. + */ constexpr bool operator==(PathAsset const& lhs, PathAsset const& rhs) { @@ -112,6 +205,16 @@ operator==(PathAsset const& lhs, PathAsset const& rhs) rhs.value()); } +/** Append a PathAsset's value to a hash state. + * + * Dispatches to the appropriate `hash_append` overload for the active + * alternative (`Currency` or `MPTID`), enabling `PathAsset` to be used as + * a key in hash-based containers built on the `beast::uhash` infrastructure. + * + * @tparam Hasher A type satisfying the `beast::hash_append` Hasher concept. + * @param h The hash accumulator to append to. + * @param pathAsset The path asset whose value is appended. + */ template void hash_append(Hasher& h, PathAsset const& pathAsset) @@ -119,15 +222,41 @@ hash_append(Hasher& h, PathAsset const& pathAsset) std::visit([&](T const& e) { hash_append(h, e); }, pathAsset.value()); } +/** Return whether a PathAsset represents native XRP. + * + * Free-function wrapper for `PathAsset::isXRP()`, provided for symmetry + * with the `isXRP()` overloads for `Currency`, `Asset`, and `STAmount`. + * + * @param asset The path asset to test. + * @return `true` if `asset` holds the XRP zero-currency sentinel. + */ inline bool isXRP(PathAsset const& asset) { return asset.isXRP(); } +/** Produce a human-readable string identifying a PathAsset. + * + * Dispatches to `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); +/** 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 to write to. + * @param x The path asset to write. + * @return `os`, for chaining. + */ std::ostream& operator<<(std::ostream& os, PathAsset const& x); diff --git a/include/xrpl/protocol/PayChan.h b/include/xrpl/protocol/PayChan.h index d8f4e0f527..6a93497d37 100644 --- a/include/xrpl/protocol/PayChan.h +++ b/include/xrpl/protocol/PayChan.h @@ -1,3 +1,13 @@ +/** @file + * Canonical serialization for payment channel claim authorizations. + * + * Defines the single function that all three call sites — channel + * authorization (RPC), channel verification (RPC), and on-ledger + * claim validation (transaction engine) — must use to build the + * signed payload. Centralizing this here ensures that a signature + * produced off-ledger is always accepted on-ledger. + */ + #pragma once #include @@ -7,6 +17,34 @@ namespace xrpl { +/** Serialize the signing payload for a payment channel claim authorization. + * + * Writes exactly three fields into @p msg in a protocol-defined order: + * the `HashPrefix::paymentChannelClaim` domain-separation tag (4 bytes), + * the 256-bit channel keylet @p key, and the authorized cumulative amount + * @p amt as a 64-bit drop count. The resulting byte sequence is what the + * channel sender signs and what the recipient or ledger verifies. + * + * This function is the single source of truth for the signed payload layout. + * It is called identically by `channel_authorize` (RPC), `channel_verify` + * (RPC), and `PaymentChannelClaim` preflight (transaction engine). Any drift + * between those sites would cause off-ledger signatures to fail on-ledger + * validation. + * + * @param msg Serializer to append the payload fields into. The caller is + * responsible for constructing the `Serializer` and, after this call, + * passing `msg.slice()` to the sign or verify primitive. + * @param key The 256-bit keylet of the payment channel ledger object. Binds + * the authorization to exactly one channel so it cannot be replayed + * against a different channel. + * @param amt The authorized cumulative ceiling in drops. The on-ledger claim + * validator rejects any claim whose running total exceeds this value. + * + * @note The `HashPrefix::paymentChannelClaim` tag (`'C','L','M',0x00`) is + * protocol-immutable. Changing it would invalidate all existing payment + * channel authorizations. + * @see HashPrefix::paymentChannelClaim + */ inline void serializePayChanAuthorization(Serializer& msg, uint256 const& key, XRPAmount const& amt) { diff --git a/include/xrpl/protocol/Permissions.h b/include/xrpl/protocol/Permissions.h index 5d56fa4461..a54495da8e 100644 --- a/include/xrpl/protocol/Permissions.h +++ b/include/xrpl/protocol/Permissions.h @@ -1,3 +1,17 @@ +/** @file + * Central definition of XRPL's account-delegation permission system, + * used by the `DelegateSet` transaction type. + * + * Two numeric ranges partition the `sfPermissionValue` field stored + * on-ledger: + * - **Transaction-level** (≤ `UINT16_MAX`): `TxType + 1`, granting + * authority over an entire transaction type. + * - **Granular** (> `UINT16_MAX`, minimum 65537): covers a specific + * sub-operation within a transaction type (e.g., freezing a trustline + * without being able to authorize it). + * + * The `Permission` singleton is the runtime authority for both ranges. + */ #pragma once #include @@ -9,12 +23,21 @@ #include namespace xrpl { -/** - * We have both transaction type permissions and granular type permissions. - * Since we will reuse the TransactionFormats to parse the Transaction - * Permissions, only the GranularPermissionType is defined here. To prevent - * conflicts with TxType, the GranularPermissionType is always set to a value - * greater than the maximum value of uint16. + +/** Granular sub-operation permission values used by the delegation system. + * + * Each enumerator targets a specific capability within a parent transaction + * type, enabling fine-grained delegation without granting broad transaction- + * level authority. For example, `TrustlineFreeze` delegates only the ability + * to freeze a trustline via `ttTRUST_SET`, not to authorize or unfreeze. + * + * All values are greater than `UINT16_MAX` (minimum 65537), which keeps them + * numerically disjoint from transaction-level permissions (≤ `UINT16_MAX`). + * This invariant is asserted at startup inside the `Permission` constructor. + * + * Generated from `detail/permissions.macro` via the X-macro pattern. Adding + * a new sub-operation requires only a single `PERMISSION(...)` entry in that + * file. */ // Macro-generated, complex // NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) @@ -30,27 +53,67 @@ enum GranularPermissionType : std::uint32_t { #pragma pop_macro("PERMISSION") }; +/** Indicates whether a transaction type may be delegated in bulk via + * a transaction-level `DelegateSet` permission. + * + * The policy for each `TxType` is encoded in `detail/transactions.macro` + * as the `delegable` parameter of every `TRANSACTION(...)` entry. + * Sensitive types such as `ttACCOUNT_SET` and `ttREGULAR_KEY_SET` are + * `NotDelegable`; most operational types are `Delegable`. + * + * @note Bare enumerators (`xrpl::Delegable` / `xrpl::NotDelegable`) are + * required by preprocessor expansions in tests and macro-generated + * code; `enum class` would break that usage. + */ // Injected bare enumerators (xrpl::delegable / xrpl::notDelegable) are required by preprocessor // tricks in tests and macro-generated code; enum class would break that. // NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) enum Delegation { Delegable, NotDelegable }; +/** Central authority for XRPL's account-delegation permission system. + * + * A Meyer's singleton populated at first call to `getInstance()`. Its + * constructor expands `transactions.macro` and `permissions.macro` to build + * five immutable lookup maps covering every known transaction type and + * granular sub-operation. After construction the maps are never mutated, + * so all concurrent read access from transaction-processing threads is safe + * without synchronization. + * + * The two principal call sites are: + * - `DelegateSet::preflight()` — calls `isDelegable()` to validate each + * `sfPermissionValue` before it is written on-ledger. + * - `DelegateUtils` / transactors — call `getGranularTxType()` and related + * helpers to enforce granular limits at execution time. + */ class Permission { private: Permission(); + /** Maps each `TxType` to the amendment required to use it, or `uint256{}` if none. */ std::unordered_map txFeatureMap_; + /** Maps each `TxType` to its `Delegable` / `NotDelegable` policy tag. */ std::unordered_map delegableTx_; + /** Maps granular permission name strings to their `GranularPermissionType` values. */ std::unordered_map granularPermissionMap_; + /** Maps `GranularPermissionType` values to their name strings (inverse of `granularPermissionMap_`). */ std::unordered_map granularNameMap_; + /** Maps each `GranularPermissionType` to its parent `TxType`. */ std::unordered_map granularTxTypeMap_; public: + /** Returns the process-wide singleton instance. + * + * Initialized on first call via a function-local `static`; C++11 + * guarantees thread-safe initialization. The instance is never mutated + * after construction. + * + * @return A `const` reference to the singleton `Permission` object. + */ static Permission const& getInstance(); @@ -58,29 +121,125 @@ public: Permission& operator=(Permission const&) = delete; + /** Resolves a raw `sfPermissionValue` to its human-readable name. + * + * Checks the granular permission table first (values > `UINT16_MAX`). + * If unrecognized there, decodes the value as a transaction-level + * permission (`value - 1` = `TxType`) and delegates to `TxFormats` for + * the canonical name. Used by `STUInt32::getText()` and + * `STUInt32::getJson()` to render any `sfPermissionValue` as a string + * instead of a raw number. + * + * @param value Raw `sfPermissionValue` from the ledger. + * @return The permission name, or `std::nullopt` if `value` is not + * recognized as either a granular or transaction-level permission. + */ [[nodiscard]] std::optional getPermissionName(std::uint32_t const value) const; + /** Looks up the numeric wire value of a granular permission by name. + * + * Used when deserializing `sfPermissionValue` from JSON (e.g., during + * `DelegateSet` preflight or RPC input parsing) to convert a + * human-readable name like `"TrustlineFreeze"` back to its `uint32_t` + * representation. + * + * @param name Case-sensitive granular permission name. + * @return The corresponding `uint32_t` wire value, or `std::nullopt` if + * `name` is not a known granular permission. + */ [[nodiscard]] std::optional getGranularValue(std::string const& name) const; + /** Looks up the name of a granular permission by its enum value. + * + * Inverse of `getGranularValue`; used when serializing a granular + * permission value 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. + */ [[nodiscard]] std::optional getGranularName(GranularPermissionType const& value) const; + /** Returns the parent transaction type for a granular permission. + * + * Multiple granular permissions share the same parent `TxType`; for + * example, `TrustlineAuthorize`, `TrustlineFreeze`, and + * `TrustlineUnfreeze` all map to `ttTRUST_SET`. Used by `isDelegable()` + * and execution-time helpers to locate the relevant transactor context + * and required amendment for a granular sub-operation. + * + * @param gpType A `GranularPermissionType` enum value. + * @return The parent `TxType`, or `std::nullopt` if `gpType` is not a + * known granular permission. + */ [[nodiscard]] std::optional getGranularTxType(GranularPermissionType const& gpType) const; + /** Returns the amendment required to use a transaction type, if any. + * + * A `uint256{}` stored in `txFeatureMap_` means the transaction type + * requires no enabling amendment. In that case `std::nullopt` is + * returned, signalling that the type is unconditionally available. + * + * @param txType A recognized transaction type. + * @return A const reference to the required amendment hash wrapped in + * `std::optional`, or `std::nullopt` if no amendment is required. + * @note Asserts in debug builds that `txType` is present in + * `txFeatureMap_`. Passing an unregistered `TxType` is a + * programming error (a transaction missing from `transactions.macro`). + */ [[nodiscard]] std::optional> getTxFeature(TxType txType) const; + /** Determines whether a permission value may appear in a `DelegateSet` + * transaction under the current ledger rules. + * + * The check differs by permission kind: + * - **Granular** (value > `UINT16_MAX`): accepted whenever the value + * resolves to a known `GranularPermissionType`; no further gate is + * applied because granular permissions are inherently narrow. + * - **Transaction-level** (value ≤ `UINT16_MAX`): accepted only when the + * decoded `TxType` is recognized, its required amendment is currently + * enabled in `rules` (or no amendment is required), and the type is + * marked `Delegable` in `transactions.macro`. + * + * @param permissionValue Raw `sfPermissionValue` to validate. + * @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. + */ [[nodiscard]] bool isDelegable(std::uint32_t const& permissionValue, Rules const& rules) const; - // for tx level permission, permission value is equal to tx type plus one + /** Converts a `TxType` to its transaction-level permission value. + * + * Transaction-level permissions are encoded as `TxType + 1`. The `+1` + * offset ensures zero is never a valid permission value and keeps the + * entire range within `uint16` (transaction-level permissions ≤ + * `UINT16_MAX`). + * + * @param type A transaction type. + * @return The corresponding `uint32_t` permission value (`TxType + 1`). + * @see permissionToTxType + */ static uint32_t txToPermissionType(TxType const& type); - // tx type value is permission value minus one + /** Converts a transaction-level permission value back to its `TxType`. + * + * Inverse of `txToPermissionType`. Callers must verify that `value` is + * in the transaction-level range (≤ `UINT16_MAX`) before calling; this + * function performs no range check. + * + * @param value A transaction-level permission value (`TxType + 1`). + * @return The decoded `TxType` (`value - 1`). + * @see txToPermissionType + */ static TxType permissionToTxType(uint32_t const& value); }; diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 50b2425016..2e07a09e33 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -1,3 +1,16 @@ +/** @file + * Canonical source of XRPL protocol constants and boundary predicates. + * + * Every hard-coded numeric limit that, if changed silently, would cause a + * **hard fork** — a ledger-state disagreement between nodes running different + * software versions — is defined here. All constants are `constexpr` and + * therefore available at compile time with zero runtime overhead. + * + * @note Changing any value in this file without pairing the change with an + * amendment-gated detection mechanism will split the network. + * + * @ingroup protocol + */ #pragma once #include @@ -8,100 +21,182 @@ namespace xrpl { -/** Protocol specific constants. - - This information is, implicitly, part of the protocol. - - @note Changing these values without adding code to the - server to detect "pre-change" and "post-change" - will result in a hard fork. - - @ingroup protocol -*/ -/** Smallest legal byte size of a transaction. */ +/** Smallest legal serialized size of a transaction, in bytes. + * + * Transactions below this threshold are trivially malformed and are rejected + * before deserialization begins. + */ std::size_t constexpr kTX_MIN_SIZE_BYTES = 32; -/** Largest legal byte size of a transaction. */ +/** Largest legal serialized size of a transaction, in bytes. + * + * The 1 MB cap protects node memory and network bandwidth. Transactions + * exceeding this limit are rejected on receipt without further processing. + */ std::size_t constexpr kTX_MAX_SIZE_BYTES = megabytes(1); -/** The maximum number of unfunded offers to delete at once */ +/** Maximum number of unfunded offers that may be removed in a single + * transaction pass. + * + * Unfunded-offer cleanup is opportunistic: stale offers are removed as a + * side-effect of offer placement. Capping the count keeps the worst-case + * execution time of a single transaction predictable. + * + * @note The asymmetry with `kEXPIRED_OFFER_REMOVE_LIMIT` (1000 vs 256) + * reflects that unfunded-offer removal was designed to handle larger + * batches; expired offers are discovered through a different, narrower + * path. + */ std::size_t constexpr kUNFUNDED_OFFER_REMOVE_LIMIT = 1000; -/** The maximum number of expired offers to delete at once */ +/** Maximum number of expired offers that may be removed in a single + * transaction pass. + * + * @see kUNFUNDED_OFFER_REMOVE_LIMIT for the rationale behind the asymmetric + * cap. + */ std::size_t constexpr kEXPIRED_OFFER_REMOVE_LIMIT = 256; -/** The maximum number of metadata entries allowed in one transaction */ +/** Maximum number of metadata entries a single transaction may produce. + * + * When a transaction would exceed this cap the transactor returns + * `tecOVERSIZE`, triggering a controlled teardown that applies the fee + * and rolls back ledger mutations rather than allowing unbounded metadata + * growth. + */ std::size_t constexpr kOVERSIZE_META_DATA_CAP = 5200; -/** The maximum number of entries per directory page */ +/** Maximum number of entries per owner-directory or offer-directory page. + * + * Keeping pages small bounds the work required to traverse a directory: + * each page hop visits at most 32 entries. + */ std::size_t constexpr kDIR_NODE_MAX_ENTRIES = 32; -/** The maximum number of pages allowed in a directory - - Made obsolete by fixDirectoryLimit amendment. -*/ +/** Historical maximum number of pages in a single directory. + * + * This limit was enforced before the `fixDirectoryLimit` amendment. + * Post-amendment, directories may grow beyond 262 144 pages; this + * constant is retained for pre-amendment replay correctness. + * + * @note Pre-amendment code returns `tecDIR_FULL` when this limit is + * reached. Post-amendment, only unsigned-integer overflow can + * produce a null page index. + */ std::uint64_t constexpr kDIR_NODE_MAX_PAGES = 262144; -/** The maximum number of items in an NFT page */ +/** Maximum number of NFToken entries per NFT directory page. */ std::size_t constexpr kDIR_MAX_TOKENS_PER_PAGE = 32; -/** The maximum number of owner directory entries for account to be deletable */ +/** Maximum number of owner-directory entries an account may hold and still + * be eligible for deletion via `AccountDelete`. + * + * Accounts with more than 1000 directory entries cannot be deleted; this + * protects against unbounded cleanup work within a single transaction. + */ std::size_t constexpr kMAX_DELETABLE_DIR_ENTRIES = 1000; -/** The maximum number of token offers that can be canceled at once */ +/** Maximum number of NFToken offers that may be cancelled in a single + * `NFTokenCancelOffer` transaction. + */ std::size_t constexpr kMAX_TOKEN_OFFER_CANCEL_COUNT = 500; -/** The maximum number of offers in an offer directory for NFT to be burnable */ +/** Maximum number of NFToken offers that must be cleaned up before an NFT + * can be burned. + * + * An NFT with more than 500 live offers cannot be burned until the excess + * offers are cancelled first. + */ std::size_t constexpr kMAX_DELETABLE_TOKEN_OFFER_ENTRIES = 500; -/** The maximum token transfer fee allowed. - - Token transfer fees can range from 0% to 50% and are specified in tenths of - a basis point; that is a value of 1000 represents a transfer fee of 1% and - a value of 10000 represents a transfer fee of 10%. - - Note that for extremely low transfer fees values, it is possible that the - calculated fee will be 0. +/** Maximum NFToken transfer fee, expressed in tenths of a basis point. + * + * Transfer fees range from 0% to 50%. A value of 1 000 represents 1% and + * a value of 50 000 represents 50%. For very low fee values the computed + * fee amount may round down to zero drops. */ std::uint16_t constexpr kMAX_TRANSFER_FEE = 50000; -/** There are 10,000 basis points (bips) in 100%. +/** Number of basis points (bips) in 100% (unity). * - * Basis points represent 0.01%. + * One basis point equals 0.01%. To compute the share of a value `X` + * corresponding to `B` bips, use `X * B / kBIPS_PER_UNITY`. To convert + * a whole-percentage `P` to bips, use `P * kBIPS_PER_UNITY / 100` + * (or simply call `percentageToBips(P)`). * - * Given a value X, to find the amount for B bps, - * use X * B / bipsPerUnity + * Example: 10% coverage on 999 XRP (999 000 000 drops) = + * `999'000'000 * 1'000 / 10'000` = 99 900 000 drops. * - * Example: If a loan broker has 999 XRP of debt, and must maintain 1,000 bps of - * that debt as cover (10%), then the minimum cover amount is 999,000,000 drops - * * 1000 / bipsPerUnity = 99,900,00 drops or 99.9 XRP. - * - * Given a percentage P, to find the number of bps that percentage represents, - * use P * bipsPerUnity. - * - * Example: 50% is 0.50 * bipsPerUnity = 5,000 bps. + * All ledger fee and rate arithmetic uses integer bips to guarantee + * bit-identical results across all validator platforms. */ Bips32 constexpr kBIPS_PER_UNITY(100 * 100); static_assert(kBIPS_PER_UNITY == Bips32{10'000}); + +/** Number of tenth-basis-points in 100% (unity). + * + * One tenth-basis-point equals 0.001%. Use `percentageToTenthBips(P)` + * to convert a whole percentage, or `tenthBipsOfValue(value, rate)` to + * apply a rate to a value. + */ TenthBips32 constexpr kTENTH_BIPS_PER_UNITY(kBIPS_PER_UNITY.value() * 10); static_assert(kTENTH_BIPS_PER_UNITY == TenthBips32(100'000)); +/** Convert a whole-percentage value to a strongly-typed `Bips32`. + * + * Uses integer division; fractional basis points are truncated. + * + * @param percentage An integer percentage in [0, 100]. + * @return The equivalent number of basis points as a `Bips32`. + */ constexpr Bips32 percentageToBips(std::uint32_t percentage) { return Bips32(percentage * kBIPS_PER_UNITY.value() / 100); } + +/** Convert a whole-percentage value to a strongly-typed `TenthBips32`. + * + * Uses integer division; fractional tenth-bips are truncated. + * + * @param percentage An integer percentage in [0, 100]. + * @return The equivalent number of tenth-basis-points as a `TenthBips32`. + */ constexpr TenthBips32 percentageToTenthBips(std::uint32_t percentage) { return TenthBips32(percentage * kTENTH_BIPS_PER_UNITY.value() / 100); } + +/** Compute the basis-point share of a value using integer arithmetic. + * + * Calculates `value * bips / kBIPS_PER_UNITY` without floating point, + * guaranteeing deterministic results on all platforms. + * + * @tparam T Numeric type of the value (must support `*` and `/`). + * @tparam TBips Underlying storage type of the `Bips` wrapper. + * @param value The base amount to take a share of. + * @param bips The rate in basis points. + * @return The share of `value` at the given rate, truncated toward zero. + */ template constexpr T bipsOfValue(T value, Bips bips) { return value * bips.value() / kBIPS_PER_UNITY.value(); } + +/** Compute the tenth-basis-point share of a value using integer arithmetic. + * + * Calculates `value * bips / kTENTH_BIPS_PER_UNITY` without floating + * point, guaranteeing deterministic results on all platforms. + * + * @tparam T Numeric type of the value (must support `*` and `/`). + * @tparam TBips Underlying storage type of the `TenthBips` wrapper. + * @param value The base amount to take a share of. + * @param bips The rate in tenth-basis-points. + * @return The share of `value` at the given rate, truncated toward zero. + */ template constexpr T tenthBipsOfValue(T value, TenthBips bips) @@ -109,202 +204,293 @@ tenthBipsOfValue(T value, TenthBips bips) return value * bips.value() / kTENTH_BIPS_PER_UNITY.value(); } +/** Rate and limit constants specific to the on-ledger lending protocol. */ namespace Lending { -/** The maximum management fee rate allowed by a loan broker in 1/10 bips. - Valid values are between 0 and 10% inclusive. -*/ +/** Maximum management fee a LoanBroker may charge, in tenth-basis-points. + * + * Valid values are in [0, 10%]. Stored as `TenthBips16` (fits in + * `uint16_t`) because 10 000 < 65 535. + */ TenthBips16 constexpr kMAX_MANAGEMENT_FEE_RATE( unsafeCast(percentageToTenthBips(10).value())); static_assert(kMAX_MANAGEMENT_FEE_RATE == TenthBips16(std::uint16_t(10'000u))); -/** The maximum coverage rate required of a loan broker in 1/10 bips. - - Valid values are between 0 and 100% inclusive. -*/ +/** Maximum coverage rate a LoanBroker must maintain, in tenth-basis-points. + * + * The coverage rate specifies the minimum fraction of outstanding loan + * debt that the broker must hold as collateral. Valid values are in + * [0, 100%]. + */ TenthBips32 constexpr kMAX_COVER_RATE = percentageToTenthBips(100); static_assert(kMAX_COVER_RATE == TenthBips32(100'000u)); -/** The maximum overpayment fee on a loan in 1/10 bips. -* - Valid values are between 0 and 100% inclusive. -*/ +/** Maximum overpayment fee on a loan, in tenth-basis-points. + * + * Applied when a borrower pays more than the scheduled amount. Valid + * values are in [0, 100%]. + */ TenthBips32 constexpr kMAX_OVERPAYMENT_FEE = percentageToTenthBips(100); static_assert(kMAX_OVERPAYMENT_FEE == TenthBips32(100'000u)); -/** Annualized interest rate of the Loan in 1/10 bips. +/** Maximum annualized interest rate on a Loan, in tenth-basis-points. * - * Valid values are between 0 and 100% inclusive. + * Valid values are in [0, 100%]. */ TenthBips32 constexpr kMAX_INTEREST_RATE = percentageToTenthBips(100); static_assert(kMAX_INTEREST_RATE == TenthBips32(100'000u)); -/** The maximum premium added to the interest rate for late payments on a loan - * in 1/10 bips. +/** Maximum late-payment interest premium on a Loan, in tenth-basis-points. * - * Valid values are between 0 and 100% inclusive. + * This rate is added to the base interest rate when payments are overdue. + * Valid values are in [0, 100%]. */ TenthBips32 constexpr kMAX_LATE_INTEREST_RATE = percentageToTenthBips(100); static_assert(kMAX_LATE_INTEREST_RATE == TenthBips32(100'000u)); -/** The maximum close interest rate charged for repaying a loan early in 1/10 - * bips. +/** Maximum early-repayment (close) interest rate on a Loan, in + * tenth-basis-points. * - * Valid values are between 0 and 100% inclusive. + * Charged when a borrower repays a loan ahead of schedule. Valid values + * are in [0, 100%]. */ TenthBips32 constexpr kMAX_CLOSE_INTEREST_RATE = percentageToTenthBips(100); static_assert(kMAX_CLOSE_INTEREST_RATE == TenthBips32(100'000u)); -/** The maximum overpayment interest rate charged on loan overpayments in 1/10 - * bips. +/** Maximum overpayment interest rate charged on loan overpayments, in + * tenth-basis-points. * - * Valid values are between 0 and 100% inclusive. + * Valid values are in [0, 100%]. */ TenthBips32 constexpr kMAX_OVERPAYMENT_INTEREST_RATE = percentageToTenthBips(100); static_assert(kMAX_OVERPAYMENT_INTEREST_RATE == TenthBips32(100'000u)); -/** LoanPay transaction cost will be one base fee per X combined payments +/** Number of loan payments per base-fee increment charged by `LoanPay`. * - * The number of payments is estimated based on the Amount paid and the Loan's - * Fixed Payment size. Overpayments (indicated with the tfLoanOverpayment flag) - * count as one more payment. + * The fee is estimated from the transaction `Amount` divided by the + * loan's fixed payment size. Overpayments (flagged with + * `tfLoanOverpayment`) count as one additional payment in the estimate. + * One base fee unit is charged for every 5 estimated payments. * - * This number was chosen arbitrarily, but should not be changed once released - * without an amendment + * @note This value was chosen arbitrarily and is amendment-locked once + * released: changing it without an amendment would alter the fee + * schedule for existing `LoanPay` transactions. + * @see kLOAN_MAXIMUM_PAYMENTS_PER_TRANSACTION */ static constexpr int kLOAN_PAYMENTS_PER_FEE_INCREMENT = 5; -/** Maximum number of combined payments that a LoanPay transaction will process +/** Hard cap on the number of combined payments processed by one `LoanPay`. * - * This limit is enforced during the loan payment process, and thus is not - * estimated. If the limit is hit, no further payments or overpayments will be - * processed, no matter how much of the transaction Amount is left, but the - * transaction will succeed with the payments that have been processed up to - * that point. + * This limit is enforced during execution, not during fee estimation. + * When the cap is reached the transaction succeeds with the payments + * processed so far; any remaining `Amount` is not applied. * - * This limit is independent of loanPaymentsPerFeeIncrement, so a transaction - * could potentially be charged for many more payments than actually get - * processed. Users should take care not to submit a transaction paying more - * than loanMaximumPaymentsPerTransaction * Loan.PeriodicPayment. Because - * overpayments are charged as a payment, if submitting - * loanMaximumPaymentsPerTransaction * Loan.PeriodicPayment, users should not - * set the tfLoanOverpayment flag. + * Because the fee is based on the *estimated* payment count (derived from + * `Amount / PeriodicPayment`) and the cap is enforced on the *actual* + * count, a transaction can be charged for more payments than it processes. + * Submitters should not exceed + * `kLOAN_MAXIMUM_PAYMENTS_PER_TRANSACTION * Loan.PeriodicPayment` in + * `Amount`, and should omit `tfLoanOverpayment` if paying exactly that + * much. * - * Even though they're independent, loanMaximumPaymentsPerTransaction should be - * a multiple of loanPaymentsPerFeeIncrement. - * - * This number was chosen arbitrarily, but should not be changed once released - * without an amendment + * @note `kLOAN_MAXIMUM_PAYMENTS_PER_TRANSACTION` must remain a multiple + * of `kLOAN_PAYMENTS_PER_FEE_INCREMENT`; this invariant is checked + * at startup via `static_assert` in LoanPay.cpp. Both values are + * amendment-locked once released. */ static constexpr int kLOAN_MAXIMUM_PAYMENTS_PER_TRANSACTION = 100; } // namespace Lending -/** The maximum length of a URI inside an NFT */ +/** Maximum byte length of a URI stored in an NFToken. */ std::size_t constexpr kMAX_TOKEN_URI_LENGTH = 256; -/** The maximum length of a Data element inside a DID */ +/** Maximum byte length of the `Data` field (DID document) in a DID object. */ std::size_t constexpr kMAX_DID_DOCUMENT_LENGTH = 256; -/** The maximum length of a URI inside a DID */ +/** Maximum byte length of the `URI` field in a DID object. */ std::size_t constexpr kMAX_DIDURI_LENGTH = 256; -/** The maximum length of an Attestation inside a DID */ +/** Maximum byte length of the `Attestation` field in a DID object. */ std::size_t constexpr kMAX_DID_DATA_LENGTH = 256; -/** The maximum length of a domain */ +/** Maximum byte length of an account `Domain` field. */ std::size_t constexpr kMAX_DOMAIN_LENGTH = 256; -/** The maximum length of a URI inside a Credential */ +/** Maximum byte length of the `URI` field in a Credential object. */ std::size_t constexpr kMAX_CREDENTIAL_URI_LENGTH = 256; -/** The maximum length of a CredentialType inside a Credential */ +/** Maximum byte length of the `CredentialType` field in a Credential object. + * + * Narrower than the 256-byte default to keep credential-type strings + * human-readable and prevent abuse of the type field as an arbitrary blob. + */ std::size_t constexpr kMAX_CREDENTIAL_TYPE_LENGTH = 64; -/** The maximum number of credentials can be passed in array */ +/** Maximum number of credentials that may appear in a transaction's + * `Credentials` array. + */ std::size_t constexpr kMAX_CREDENTIALS_ARRAY_SIZE = 8; -/** The maximum number of credentials can be passed in array for permissioned - * domain */ +/** Maximum number of credentials that a permissioned domain may reference. */ std::size_t constexpr kMAX_PERMISSIONED_DOMAIN_CREDENTIALS_ARRAY_SIZE = 10; -/** The maximum length of MPTokenMetadata */ +/** Maximum byte length of the `MPTokenMetadata` field on an MPTokenIssuance. */ std::size_t constexpr kMAX_MP_TOKEN_METADATA_LENGTH = 1024; -/** The maximum amount of MPTokenIssuance */ +/** Maximum quantity representable by an MPToken amount field. + * + * Equal to `INT64_MAX` (2^63 − 1). The `static_assert` below guarantees + * that the XRPL `Number` type can represent every valid MPToken quantity + * without overflow. + */ std::uint64_t constexpr kMAX_MP_TOKEN_AMOUNT = 0x7FFF'FFFF'FFFF'FFFFull; static_assert(Number::kMAX_REP >= kMAX_MP_TOKEN_AMOUNT); -/** The maximum length of Data payload */ +/** Maximum byte length of the `Data` payload field. */ std::size_t constexpr kMAX_DATA_PAYLOAD_LENGTH = 256; -/** Vault withdrawal policies */ +/** Vault withdrawal policy: first-come, first-served. + * + * The numeric value 1 is the wire-stable identifier for this strategy; + * it must not change once released. + */ std::uint8_t constexpr kVAULT_STRATEGY_FIRST_COME_FIRST_SERVE = 1; -/** Default IOU scale factor for a Vault */ +/** Default IOU-to-share scale exponent for a Vault. + * + * When no explicit scale is specified at Vault creation the scale + * defaults to 6, meaning one IOU unit maps to 10^6 shares. This + * applies only to IOU-backed vaults; native-asset and MPT vaults always + * use scale 0. + */ std::uint8_t constexpr kVAULT_DEFAULT_IOU_SCALE = 6; -/** Maximum scale factor for a Vault. The number is chosen to ensure that -1 IOU can be always converted to shares. -10^19 > maxMPTokenAmount (2^64-1) > 10^18 */ + +/** Maximum IOU-to-share scale exponent for a Vault. + * + * Chosen so that exactly one IOU unit can always be converted to at + * least one share: 10^19 > `kMAX_MP_TOKEN_AMOUNT` (≈ 2^63) > 10^18. + * Preflight rejects any `VaultCreate` that specifies a scale above this + * value with `temMALFORMED`. Applies only to IOU-backed vaults. + */ std::uint8_t constexpr kVAULT_MAXIMUM_IOU_SCALE = 18; -/** Maximum recursion depth for vault shares being put as an asset inside - * another vault; counted from 0 */ +/** Maximum recursion depth when checking whether a vault's asset is itself + * backed by another vault. + * + * Counted from 0, so a depth of 5 permits at most 6 levels of nesting. + * This prevents pathological chains from consuming unbounded stack space + * during asset-validation traversal. + */ std::uint8_t constexpr kMAX_ASSET_CHECK_DEPTH = 5; -/** A ledger index. */ +/** Ledger sequence number type. + * + * A named alias for `uint32_t` that makes function signatures + * self-documenting wherever ledger positions are passed. + */ using LedgerIndex = std::uint32_t; +/** Number of ledgers between consecutive flag-ledger boundaries. + * + * Every 256 ledgers the network applies accumulated validator votes for + * fee adjustments, reserve requirements, amendment activation, and + * Negative UNL reliability scoring. Both `isFlagLedger()` and + * `isVotingLedger()` test `seq % kFLAG_LEDGER_INTERVAL == 0`; the + * semantic distinction between the two predicates is resolved by callers + * via a `+1` offset on the sequence number they pass. + * + * @note This constant is an implicit part of the wire protocol. Changing + * it without an amendment-gated migration path will cause a hard fork. + */ std::uint32_t constexpr kFLAG_LEDGER_INTERVAL = 256; -/** Returns true if the given ledgerIndex is a voting ledgerIndex */ +/** Return `true` if @p seq is a voting ledger. + * + * Semantically, this asks: "will the ledger built *on top of* `seq` + * be a flag ledger?" Callers therefore pass `seq + 1` (the sequence of + * the ledger currently being assembled). `RCLConsensus` uses this + * predicate to decide whether to inject Negative UNL pseudo-transactions + * for the new consensus round. + * + * The arithmetic is identical to `isFlagLedger`; the two names exist to + * make the `+1` offset explicit at each call site without embedding it + * inside these functions. + * + * @param seq The ledger index to test (typically the previous ledger's + * sequence plus one). + * @return `true` if `seq % kFLAG_LEDGER_INTERVAL == 0`. + * @see isFlagLedger + */ bool isVotingLedger(LedgerIndex seq); -/** Returns true if the given ledgerIndex is a flag ledgerIndex */ +/** Return `true` if @p seq is a flag ledger. + * + * 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-vote + * and amendment pseudo-transactions are applied, and in which Negative + * UNL reliability updates take effect. `Change::doApply` and + * `FeeVoteImpl` gate their parameter-update logic on this predicate. + * + * Callers pass the ledger's **own** sequence number to ask "has this + * ledger already crossed the boundary?", as opposed to `isVotingLedger`, + * which is called with `seq + 1`. + * + * @param seq The ledger index to test. + * @return `true` if `seq % kFLAG_LEDGER_INTERVAL == 0`. + * @see isVotingLedger + */ bool isFlagLedger(LedgerIndex seq); -/** A transaction identifier. - The value is computed as the hash of the - canonicalized, serialized transaction object. -*/ +/** Transaction identifier type. + * + * A 256-bit hash computed over the canonicalized, serialized transaction + * object using `HashPrefix::transactionID` as the domain separator. + */ using TxID = uint256; -/** The maximum number of trustlines to delete as part of AMM account - * deletion cleanup. +/** Maximum number of AMM trust lines that may be deleted as part of an + * AMM account-deletion cleanup pass. */ std::uint16_t constexpr kMAX_DELETABLE_AMM_TRUST_LINES = 512; -/** The maximum length of a URI inside an Oracle */ +/** Maximum byte length of the `URI` field in an Oracle object. */ std::size_t constexpr kMAX_ORACLE_URI = 256; -/** The maximum length of a Provider inside an Oracle */ +/** Maximum byte length of the `Provider` field in an Oracle object. */ std::size_t constexpr kMAX_ORACLE_PROVIDER = 256; -/** The maximum size of a data series array inside an Oracle */ +/** Maximum number of price data-series entries in an Oracle object. */ std::size_t constexpr kMAX_ORACLE_DATA_SERIES = 10; -/** The maximum length of a SymbolClass inside an Oracle */ +/** Maximum byte length of the `SymbolClass` field in an Oracle object. */ std::size_t constexpr kMAX_ORACLE_SYMBOL_CLASS = 16; -/** The maximum allowed time difference between lastUpdateTime and the time - of the last closed ledger -*/ +/** Maximum allowed age of an Oracle price update, in seconds. + * + * `OracleSet` rejects updates whose `LastUpdateTime` differs from the + * last-closed-ledger close time by more than 300 seconds (5 minutes). + */ std::size_t constexpr kMAX_LAST_UPDATE_TIME_DELTA = 300; -/** The maximum price scaling factor - */ +/** Maximum price-scaling exponent accepted in an Oracle object. */ std::size_t constexpr kMAX_PRICE_SCALE = 20; -/** The maximum percentage of outliers to trim +/** Maximum percentage of outlier data points to trim in Oracle price + * aggregation. */ std::size_t constexpr kMAX_TRIM = 25; -/** The maximum number of delegate permissions an account can grant - */ +/** Maximum number of granular delegate permissions an account may grant. */ std::size_t constexpr kPERMISSION_MAX_SIZE = 10; -/** The maximum number of transactions that can be in a batch. */ +/** Maximum number of inner transactions in a single Batch transaction. + * + * Enforced during preflight; batches exceeding this count are rejected. + * The limit directly bounds the worst-case compute cost for batch + * signature validation and fee calculation. + */ std::size_t constexpr kMAX_BATCH_TX_COUNT = 8; } // namespace xrpl diff --git a/include/xrpl/protocol/PublicKey.h b/include/xrpl/protocol/PublicKey.h index 16d558d73c..353cefcc54 100644 --- a/include/xrpl/protocol/PublicKey.h +++ b/include/xrpl/protocol/PublicKey.h @@ -16,35 +16,31 @@ namespace xrpl { -/** A public key. - - Public keys are used in the public-key cryptography - system used to verify signatures attached to messages. - - The format of the public key is XRPL specific, - information needed to determine the cryptosystem - parameters used is stored inside the key. - - As of this writing two systems are supported: - - secp256k1 - ed25519 - - secp256k1 public keys consist of a 33 byte - compressed public key, with the lead byte equal - to 0x02 or 0x03. - - The ed25519 public keys consist of a 1 byte - prefix constant 0xED, followed by 32 bytes of - public key data. -*/ +/** Immutable 33-byte value type holding an XRPL public key. + * + * Supports both secp256k1 and Ed25519 cryptosystems. The lead byte acts as + * a self-describing type tag — `0x02`/`0x03` for secp256k1 compressed keys, + * `0xED` for Ed25519 keys (an XRPL-specific prefix that pads the native + * 32-byte Ed25519 key to the common 33-byte size). This uniform encoding + * allows `publicKeyType()` to identify the algorithm in O(1) from the raw + * bytes alone, with no external metadata. + * + * The default constructor is deleted; the only construction path is from a + * `Slice`. If the slice does not represent a recognized key format, + * construction calls `LogicError` (process termination) rather than + * throwing — an invalid key at this point indicates a programming error, + * not a recoverable runtime condition. Any live `PublicKey` object is + * therefore always well-formed and algorithm-identified. + * + * The implicit conversion to `Slice` is intentional: it lets `PublicKey` + * flow into serialization and hashing APIs without explicit casting. + */ class PublicKey { protected: - // All the constructed public keys are valid, non-empty and contain 33 - // bytes of data. + /** Uniform storage size in bytes for all supported key types. */ static constexpr std::size_t kSIZE = 33; - std::uint8_t buf_[kSIZE]{}; // should be large enough + std::uint8_t buf_[kSIZE]{}; public: using const_iterator = std::uint8_t const*; @@ -56,72 +52,89 @@ public: PublicKey& operator=(PublicKey const& other); - /** Create a public key. - - Preconditions: - publicKeyType(slice) != std::nullopt - */ + /** Construct from a raw byte slice. + * + * Copies exactly 33 bytes from `slice` after verifying that the bytes + * represent a recognized key format (secp256k1 or Ed25519). Calls + * `LogicError` — terminating the process — if the slice is undersized + * or does not pass `publicKeyType()`. + * + * @param slice Raw bytes to construct from; must satisfy + * `publicKeyType(slice) != std::nullopt`. + * @note Use `publicKeyType()` to validate untrusted input before + * constructing; `parseBase58` does this automatically + * for Base58-encoded keys. + */ explicit PublicKey(Slice const& slice); + /** Return a pointer to the raw 33-byte key buffer. */ [[nodiscard]] std::uint8_t const* data() const noexcept { return buf_; } + /** Return the fixed size of all `PublicKey` objects (always 33). */ static std::size_t size() noexcept { return kSIZE; } + /** Return an iterator to the first byte of the key buffer. */ [[nodiscard]] const_iterator begin() const noexcept { return buf_; } + /** Return a const iterator to the first byte of the key buffer. */ [[nodiscard]] const_iterator cbegin() const noexcept { return buf_; } + /** Return an iterator past the last byte of the key buffer. */ [[nodiscard]] const_iterator end() const noexcept { return buf_ + kSIZE; } + /** Return a const iterator past the last byte of the key buffer. */ [[nodiscard]] const_iterator cend() const noexcept { return buf_ + kSIZE; } + /** Return a `Slice` view over the 33-byte key buffer. */ [[nodiscard]] Slice slice() const noexcept { return {buf_, kSIZE}; } + /** Implicit conversion to `Slice` for use with serialization APIs. */ operator Slice() const noexcept { return slice(); } }; -/** Print the public key to a stream. - */ +/** Write the public key as a hex string to a stream. */ std::ostream& operator<<(std::ostream& os, PublicKey const& pk); +/** Return `true` if both keys hold identical 33-byte representations. */ inline bool operator==(PublicKey const& lhs, PublicKey const& rhs) { return std::memcmp(lhs.data(), rhs.data(), rhs.size()) == 0; } +/** Return `true` if `lhs` is lexicographically less than `rhs`. */ inline bool operator<(PublicKey const& lhs, PublicKey const& rhs) { @@ -129,6 +142,15 @@ operator<(PublicKey const& lhs, PublicKey const& rhs) lhs.data(), lhs.data() + lhs.size(), rhs.data(), rhs.data() + rhs.size()); } +/** Feed the raw 33-byte key into a hash algorithm. + * + * Enables `PublicKey` to be used as a key in unordered containers via + * `boost::hash` or any other `hash_append`-compatible hasher. + * + * @tparam Hasher A `hash_append`-compatible hasher type. + * @param h The hasher to feed bytes into. + * @param pk The key whose bytes are appended. + */ template void hash_append(Hasher& h, PublicKey const& pk) @@ -136,6 +158,13 @@ hash_append(Hasher& h, PublicKey const& pk) h(pk.data(), pk.size()); } +/** Serialization bridge between `STBlob` fields and `PublicKey` values. + * + * This specialization plugs `PublicKey` into XRPL's typed serialization + * framework. It allows `get` and `set` on `STBlob` + * fields in serialized ledger objects and transactions without any + * conversion boilerplate at call sites. + */ template <> struct STExchange { @@ -143,12 +172,14 @@ struct STExchange using value_type = PublicKey; + /** Read a `PublicKey` from an `STBlob` field into `t`. */ static void get(std::optional& t, STBlob const& u) { t.emplace(Slice(u.data(), u.size())); } + /** Write a `PublicKey` into a new `STBlob` for the given field. */ static std::unique_ptr set(SField const& f, PublicKey const& t) { @@ -158,55 +189,86 @@ struct STExchange //------------------------------------------------------------------------------ +/** Encode a public key as a Base58Check string with a token-type prefix. + * + * @param type The `TokenType` prefix to use (e.g. `TokenType::NodePublic` + * for validator keys, `TokenType::AccountPublic` for signing keys). + * @param pk The key to encode. + * @return The Base58Check-encoded string. + */ inline std::string toBase58(TokenType type, PublicKey const& pk) { return encodeBase58Token(type, pk.data(), pk.size()); } +/** Decode a Base58Check-encoded public key. + * + * Validates the token-type prefix and that the decoded bytes represent a + * recognized key format. Safe to call on untrusted input. + * + * @param type The expected `TokenType` prefix. + * @param s The Base58Check-encoded string to decode. + * @return A `PublicKey` on success, or `std::nullopt` if the string is + * malformed, uses the wrong token type, or the decoded bytes are not + * a valid secp256k1 or Ed25519 key. + */ template <> std::optional parseBase58(TokenType type, std::string const& s); -enum class ECDSACanonicality { Canonical, FullyCanonical }; +/** Canonicality level of a DER-encoded secp256k1 ECDSA signature. + * + * For any signed message, both `(R, S)` and `(R, G-S)` are mathematically + * valid ECDSA signatures (where G is the secp256k1 curve order). Accepting + * both enables transaction malleability attacks. XRPL prevents this by + * requiring *fully canonical* signatures — where `S ≤ G-S` — for new + * transactions. + */ +enum class ECDSACanonicality { + /** Both R and S are in `[1, G)` with no redundant zero padding, but + * `S > G/2`. Structurally valid; may be accepted in legacy contexts. */ + Canonical, + /** Both R and S are in `[1, G)` and `S ≤ G-S`, making the signature + * unique and immune to the malleability flip. Required for new XRPL + * transactions. */ + FullyCanonical +}; -/** Determines the canonicality of a signature. - - A canonical signature is in its most reduced form. - For example the R and S components do not contain - additional leading zeroes. However, even in - canonical form, (R,S) and (R,G-S) are both - valid signatures for message M. - - Therefore, to prevent malleability attacks we - define a fully canonical signature as one where: - - R < G - S - - where G is the curve order. - - This routine returns std::nullopt if the format - of the signature is invalid (for example, the - points are encoded incorrectly). - - @return std::nullopt if the signature fails - validity checks. - - @note Only the format of the signature is checked, - no verification cryptography is performed. -*/ +/** Determine the canonicality of a DER-encoded secp256k1 ECDSA signature. + * + * Validates the DER structure (`0x30 0x02 0x02 `), checks + * that R and S are properly encoded integers (no negative encoding, no + * redundant zero padding), and compares them against the secp256k1 curve + * order G. Returns `FullyCanonical` when `S ≤ G-S`, `Canonical` when + * `S > G-S` but the signature is otherwise structurally sound. + * + * @param sig DER-encoded ECDSA signature to examine. + * @return `ECDSACanonicality::FullyCanonical` if `S ≤ G-S`, + * `ECDSACanonicality::Canonical` if `S > G-S` but structurally valid, + * or `std::nullopt` if the encoding is malformed (wrong header bytes, + * invalid integer components, R or S outside the curve order, or + * trailing bytes present). + * @note Only the structure and canonicality of the encoding are checked; + * no cryptographic verification is performed. + */ std::optional ecdsaCanonicality(Slice const& sig); -/** Returns the type of public key. - - @return std::nullopt If the public key does not - represent a known type. -*/ +/** Determine the algorithm encoded in a public key. + * + * Uses the lead byte as a self-describing type tag: `0xED` → Ed25519; + * `0x02`/`0x03` → secp256k1 compressed. Any other lead byte, or a slice + * that is not exactly 33 bytes, is unrecognized. + * + * @return The detected `KeyType`, or `std::nullopt` if the bytes do not + * match a known key format. + */ /** @{ */ [[nodiscard]] std::optional publicKeyType(Slice const& slice); +/** @copydoc publicKeyType(Slice const&) */ [[nodiscard]] inline std::optional publicKeyType(PublicKey const& publicKey) { @@ -214,7 +276,24 @@ publicKeyType(PublicKey const& publicKey) } /** @} */ -/** Verify a secp256k1 signature on the digest of a message. */ +/** Verify a secp256k1 ECDSA signature against a pre-computed digest. + * + * Validates DER structure and canonicality before calling libsecp256k1. + * When `mustBeFullyCanonical` is `false` and the signature is merely + * canonical (S > G/2), the S component is normalized to its low form via + * `secp256k1_ecdsa_signature_normalize` before verification — preserving + * backward compatibility without accepting truly malformed encodings. + * + * @param publicKey A secp256k1 public key. Passing an Ed25519 key calls + * `LogicError` (programming error). + * @param digest The 256-bit digest over which the signature was produced. + * @param sig DER-encoded ECDSA signature. + * @param mustBeFullyCanonical If `true` (default), reject signatures where + * `S > G/2`. If `false`, accept them after S normalization. + * @return `true` if the signature is cryptographically valid for the given + * key and digest; `false` for any structural, canonicality, or + * cryptographic failure. + */ [[nodiscard]] bool verifyDigest( PublicKey const& publicKey, @@ -222,22 +301,65 @@ verifyDigest( Slice const& sig, bool mustBeFullyCanonical = true) noexcept; -/** Verify a signature on a message. - With secp256k1 signatures, the data is first hashed with - SHA512-Half, and the resulting digest is signed. -*/ +/** Verify a signature over a raw message for either supported key type. + * + * Dispatches on the cryptosystem detected from `publicKey`: + * - **secp256k1**: hashes `m` with SHA512-Half (256-bit digest) and + * delegates to `verifyDigest` with `mustBeFullyCanonical = true`. + * - **Ed25519**: checks that the signature scalar S is below the Ed25519 + * subgroup order, then calls the underlying `ed25519_sign_open` library + * after stripping the XRPL-specific `0xED` prefix byte that the library + * does not understand. + * + * @param publicKey The public key to verify against. + * @param m The message that was signed (raw bytes, not pre-hashed). + * @param sig The signature to verify. + * @return `true` if the signature is valid; `false` for any failure + * including unrecognized key type, non-canonical signature, or + * cryptographic mismatch. + */ [[nodiscard]] bool verify(PublicKey const& publicKey, Slice const& m, Slice const& sig) noexcept; -/** Calculate the 160-bit node ID from a node public key. */ +/** Derive the 160-bit node identity from a public key. + * + * Applies RIPEMD-160(SHA-256(pubkey)) to produce the `NodeID` used in the + * peer-to-peer layer for validator routing and consensus tracking. + * + * @param pk The validator's public key (secp256k1 or Ed25519). + * @return The 160-bit `NodeID` identifying the validator on the network. + */ NodeID calcNodeID(PublicKey const&); +/** Derive the 160-bit on-ledger account address from a public key. + * + * Applies RIPEMD-160(SHA-256(pubkey)) — the same algorithm used in + * Bitcoin — to produce the `AccountID` that identifies the account on the + * XRP Ledger. + * + * @param pk The account's public key. + * @return The `AccountID` corresponding to `pk`. + * @note The implementation lives in `AccountID.cpp` rather than + * `PublicKey.cpp` due to header dependency ordering constraints. + */ // VFALCO This belongs in AccountID.h but // is here because of header issues AccountID calcAccountID(PublicKey const& pk); +/** Format a human-readable peer fingerprint for diagnostic logging. + * + * Produces a string of the form + * `"IP Address: [, Public Key: ][, Id: ]"` suitable + * for audit and connection-lifecycle log messages. + * + * @param address The peer's IP endpoint (always included). + * @param publicKey The peer's node public key, encoded as `NodePublic` + * Base58; omitted if not yet known (e.g., before the handshake). + * @param id An optional session identifier string; omitted if absent. + * @return A formatted fingerprint string. + */ inline std::string getFingerprint( beast::IP::Endpoint const& address, @@ -260,7 +382,22 @@ getFingerprint( //------------------------------------------------------------------------------ -namespace json { +/** Deserialize a `PublicKey` from a JSON field value. + * + * Accepts three formats in order: + * 1. Lowercase hex string of the raw 33-byte key. + * 2. `NodePublic` Base58Check encoding (validator keys). + * 3. `AccountPublic` Base58Check encoding (signing keys). + * + * This covers the variety of formats that appear in RPC requests and + * configuration files. + * + * @param v The JSON object to read from. + * @param field The field whose value is decoded. + * @return The decoded `PublicKey`. + * @throws `JsonTypeMismatchError` if the field value does not match any + * recognized format. + */ template <> inline xrpl::PublicKey getOrThrow(json::Value const& v, xrpl::SField const& field) diff --git a/include/xrpl/protocol/Quality.h b/include/xrpl/protocol/Quality.h index 115e4498df..e77335fa0f 100644 --- a/include/xrpl/protocol/Quality.h +++ b/include/xrpl/protocol/Quality.h @@ -1,3 +1,14 @@ +/** @file + * Defines `Quality` and `TAmounts`, the core exchange-rate abstractions + * used by XRPL's on-ledger decentralized exchange (DEX). + * + * `Quality` is the sortable representation of a currency exchange rate. + * The offer-crossing engine — ranking offers, scaling partial fills, and + * composing multi-hop paths — is expressed entirely in terms of these types. + * + * @see QualityFunction.h for the continuous AMM price-function extension. + */ + #pragma once #include @@ -12,35 +23,50 @@ namespace xrpl { -/** Represents a pair of input and output currencies. - - The input currency can be converted to the output - currency by multiplying by the rate, represented by - Quality. - - For offers, "in" is always TakerPays and "out" is - always TakerGets. -*/ +/** A typed pair of input and output amounts representing one side of a trade. + * + * For offers on the DEX, `in` is always `TakerPays` and `out` is always + * `TakerGets`. The template parameters allow instantiation over + * `STAmount`, `IOUAmount`, `XRPAmount`, and `MPTAmount`. + * + * @tparam In Type of the input (paying) amount. + * @tparam Out Type of the output (receiving) amount. + */ template struct TAmounts { TAmounts() = default; + /** Construct a zero-valued pair. */ TAmounts(beast::Zero, beast::Zero) : in(beast::kZERO), out(beast::kZERO) { } + /** Construct from explicit in and out amounts. + * + * @param in The input (TakerPays) amount. + * @param out The output (TakerGets) amount. + */ TAmounts(In in, Out out) : in(std::move(in)), out(std::move(out)) { } - /** Returns `true` if either quantity is not positive. */ + /** Returns `true` if either quantity is not positive. + * + * Used by the offer-crossing engine to skip exhausted or invalid offers + * without further computation. + */ [[nodiscard]] bool empty() const noexcept { return in <= beast::kZERO || out <= beast::kZERO; } + /** Adds `rhs` component-wise to this pair. + * + * @param rhs The amounts to add. + * @return Reference to `*this`. + */ TAmounts& operator+=(TAmounts const& rhs) { @@ -49,6 +75,11 @@ struct TAmounts return *this; } + /** Subtracts `rhs` component-wise from this pair. + * + * @param rhs The amounts to subtract. + * @return Reference to `*this`. + */ TAmounts& operator-=(TAmounts const& rhs) { @@ -57,12 +88,14 @@ struct TAmounts return *this; } - In in{}; - Out out{}; + In in{}; /**< Input (TakerPays) amount. */ + Out out{}; /**< Output (TakerGets) amount. */ }; +/** Canonical `TAmounts` alias used by the `STAmount`-based offer-crossing path. */ using Amounts = TAmounts; +/** Returns `true` when both sides of two `TAmounts` pairs are equal. */ template bool operator==(TAmounts const& lhs, TAmounts const& rhs) noexcept @@ -70,6 +103,7 @@ operator==(TAmounts const& lhs, TAmounts const& rhs) noexcept return lhs.in == rhs.in && lhs.out == rhs.out; } +/** Returns `true` when either side of two `TAmounts` pairs differs. */ template bool operator!=(TAmounts const& lhs, TAmounts const& rhs) noexcept @@ -79,54 +113,107 @@ operator!=(TAmounts const& lhs, TAmounts const& rhs) noexcept //------------------------------------------------------------------------------ -// XRPL specific constant used for parsing qualities and other things +/** Unity exchange rate (1:1), scaled to XRPL's 9-decimal fixed-point precision. + * + * Appears throughout offer parsing and fee calculations wherever a 1:1 + * exchange rate must be expressed as a raw integer. + */ #define QUALITY_ONE 1'000'000'000 -/** Represents the logical ratio of output currency to input currency. - Internally this is stored using a custom floating point representation, - as the inverse of the ratio, so that quality will be descending in - a sequence of actual values that represent qualities. -*/ +/** The exchange rate of an offer, stored as an inverted packed floating-point + * integer so that higher-quality offers sort first under plain integer comparison. + * + * A `Quality` encodes the ratio `out / in` (TakerGets / TakerPays): how much + * output the taker receives per unit of input. Higher quality is better for + * the taker (more output per unit of input). + * + * The internal `uint64_t` uses the same bit layout as `STAmount` IOU encoding: + * the top 8 bits hold a biased exponent (stored value = actual exponent + 100) + * and the lower 56 bits hold an unsigned mantissa. Critically, the integer + * value is **inverted** relative to the economic concept — a *higher* quality + * corresponds to a *lower* `uint64_t` — so that ascending integer order in the + * ledger's offer directories corresponds to descending quality, allowing the + * best offers to be processed first. + * + * @note The increment/decrement operators navigate the discrete floating-point + * grid by modifying `value_` by one ULP. The representation may become + * non-canonical after such operations. + * + * @see composedQuality() for two-hop path composition. + * @see QualityFunction.h for the continuous AMM extension of this type. + */ class Quality { public: - // Type of the internal representation. Higher qualities - // have lower unsigned integer representations. + /** Underlying storage type. Higher qualities have lower integer values. */ using value_type = std::uint64_t; + /** Minimum valid tick size (significant decimal digits) for `round()`. */ static int const kMIN_TICK_SIZE = 3; + + /** Maximum valid tick size (significant decimal digits) for `round()`. */ static int const kMAX_TICK_SIZE = 16; private: - // This has the same representation as STAmount, see the comment on the - // STAmount. However, this class does not always use the canonical - // representation. In particular, the increment and decrement operators may - // cause a non-canonical representation. + // Packed 64-bit encoding: bits [63:56] = biased exponent (actual + 100), + // bits [55:0] = mantissa. Identical to the STAmount IOU wire format. + // May be non-canonical after operator++ / operator--. value_type value_; public: Quality() = default; - /** Create a quality from the integer encoding of an STAmount */ + /** Construct from a raw packed integer in STAmount encoding. + * + * The top 8 bits are the biased exponent (actual exponent + 100) and + * the bottom 56 bits are the mantissa. Higher integers denote lower + * (worse) quality because the internal ordering is inverted. + * + * @param value Packed 64-bit quality value. + */ explicit Quality(std::uint64_t value); - /** Create a quality from the ratio of two amounts. */ + /** Construct from an `STAmount` in/out pair encoding `out / in`. + * + * Calls `getRate(amount.out, amount.in)` to produce the packed value. + * Neither side should be zero. + * + * @param amount Offer amounts: `in` = TakerPays, `out` = TakerGets. + */ explicit Quality(Amounts const& amount); - /** Create a quality from the ratio of two amounts. */ + /** Construct from a typed in/out pair by converting to `STAmount` first. + * + * @tparam In Input amount type (e.g., `XRPAmount`, `IOUAmount`). + * @tparam Out Output amount type. + * @param amount The typed offer amounts. + */ template explicit Quality(TAmounts const& amount) : Quality(Amounts(toSTAmount(amount.in), toSTAmount(amount.out))) { } - /** Create a quality from the ratio of two amounts. */ + /** Construct from explicit out and in amounts by converting to `STAmount`. + * + * @tparam In Input amount type. + * @tparam Out Output amount type. + * @param out The output (TakerGets) amount. + * @param in The input (TakerPays) amount. + */ template Quality(Out const& out, In const& in) : Quality(Amounts(toSTAmount(in), toSTAmount(out))) { } - /** Advances to the next higher quality level. */ + /** Advance to the next higher quality level. + * + * Because the internal encoding is inverted, this decrements the stored + * integer by one ULP. Used during offer-book traversal to step the + * crossing price up by the smallest representable increment. + * + * @pre `value_ > 0`; underflow is asserted. + */ /** @{ */ Quality& operator++(); @@ -135,7 +222,13 @@ public: operator++(int); /** @} */ - /** Advances to the next lower quality level. */ + /** Retreat to the next lower quality level. + * + * Because the internal encoding is inverted, this increments the stored + * integer by one ULP. + * + * @pre `value_ < UINT64_MAX`; overflow is asserted. + */ /** @{ */ Quality& operator--(); @@ -144,65 +237,184 @@ public: operator--(int); /** @} */ - /** Returns the quality as STAmount. */ + /** Decode the packed quality value into an `STAmount` exchange rate. + * + * The returned amount represents the rate `out / in` in the IOU + * floating-point format. Callers use this when passing the quality + * to `mulRound` / `divRound` for proportional scaling. + * + * @return The exchange rate as an `STAmount`. + */ [[nodiscard]] STAmount rate() const { return amountFromQuality(value_); } - /** Returns the quality rounded up to the specified number - of decimal digits. - */ + /** Round the quality's mantissa up to `tickSize` significant decimal digits. + * + * Used for tick-size enforcement: coarsens the price grid so that offers + * differing only in low-order digits are treated as equivalent. Rounding + * is always upward (ceiling), which makes the encoded rate slightly higher + * (worse for the taker) and prevents a rounded quality from being mistakenly + * ranked better than the original. + * + * @param tickSize Number of significant digits to retain. Must be in + * `[kMIN_TICK_SIZE, kMAX_TICK_SIZE]`; enforcement is the caller's + * responsibility. + * @return A new `Quality` with a rounded-up mantissa and unchanged exponent. + */ [[nodiscard]] Quality round(int tickSize) const; - /** Returns the scaled amount with in capped. - Math is avoided if the result is exact. The output is clamped - to prevent money creation. - */ + /** Scale an offer's amounts down so that the input does not exceed `limit`. + * + * If `amount.in > limit`, sets `in = limit` and recomputes `out` + * proportionally via `divRound`. The computed output is clamped to + * `amount.out` if arithmetic would produce a larger value, preventing + * money creation due to rounding. Returns `amount` unchanged when + * `amount.in <= limit`. + * + * @param amount Current offer amounts (`in` = TakerPays, `out` = TakerGets). + * @param limit Maximum allowed input amount. + * @return Scaled amounts satisfying `in <= limit` and `out <= amount.out`. + * @note Uses `divRound` (legacy rounding that ignores low-order bits). + * Use `ceilInStrict` when full-precision rounding is required. + */ [[nodiscard]] Amounts ceilIn(Amounts const& amount, STAmount const& limit) const; + /** Scale a typed offer's amounts down so that the input does not exceed `limit`. + * + * Converts both sides to `STAmount`, delegates to the `STAmount` overload, + * then converts the result back to the typed amounts. + * + * @tparam In Input amount type. + * @tparam Out Output amount type. + * @param amount Current offer amounts. + * @param limit Maximum allowed input amount. + * @return Scaled amounts satisfying `in <= limit` and `out <= amount.out`. + */ template [[nodiscard]] TAmounts ceilIn(TAmounts const& amount, In const& limit) const; - // Some of the underlying rounding functions called by ceil_in() ignored - // low order bits that could influence rounding decisions. This "strict" - // method uses underlying functions that pay attention to all the bits. + /** Scale an offer's amounts down so that the input does not exceed `limit`, + * using full-precision rounding. + * + * Identical to `ceilIn` except it delegates to `divRoundStrict`, which + * considers all low-order bits that `divRound` ignores. Introduced to + * fix subtle rounding bugs where a borderline result could influence + * whether an offer crosses. + * + * @param amount Current offer amounts. + * @param limit Maximum allowed input amount. + * @param roundUp Whether to round the recomputed output up (`true`) or + * down (`false`). + * @return Scaled amounts satisfying `in <= limit` and `out <= amount.out`. + */ [[nodiscard]] Amounts ceilInStrict(Amounts const& amount, STAmount const& limit, bool roundUp) const; + /** Scale a typed offer's amounts down so that the input does not exceed `limit`, + * using full-precision rounding. + * + * @tparam In Input amount type. + * @tparam Out Output amount type. + * @param amount Current offer amounts. + * @param limit Maximum allowed input amount. + * @param roundUp Whether to round the recomputed output up or down. + * @return Scaled amounts satisfying `in <= limit` and `out <= amount.out`. + */ template [[nodiscard]] TAmounts ceilInStrict(TAmounts const& amount, In const& limit, bool roundUp) const; - /** Returns the scaled amount with out capped. - Math is avoided if the result is exact. The input is clamped - to prevent money creation. - */ + /** Scale an offer's amounts down so that the output does not exceed `limit`. + * + * If `amount.out > limit`, sets `out = limit` and recomputes `in` + * proportionally via `mulRound`. The computed input is clamped to + * `amount.in` if arithmetic would produce a larger value, preventing + * money creation due to rounding. Returns `amount` unchanged when + * `amount.out <= limit`. + * + * @param amount Current offer amounts. + * @param limit Maximum allowed output amount. + * @return Scaled amounts satisfying `out <= limit` and `in <= amount.in`. + * @note Uses `mulRound` (legacy rounding that ignores low-order bits). + * Use `ceilOutStrict` when full-precision rounding is required. + */ [[nodiscard]] Amounts ceilOut(Amounts const& amount, STAmount const& limit) const; + /** Scale a typed offer's amounts down so that the output does not exceed `limit`. + * + * Converts both sides to `STAmount`, delegates to the `STAmount` overload, + * then converts the result back to the typed amounts. + * + * @tparam In Input amount type. + * @tparam Out Output amount type. + * @param amount Current offer amounts. + * @param limit Maximum allowed output amount. + * @return Scaled amounts satisfying `out <= limit` and `in <= amount.in`. + */ template [[nodiscard]] TAmounts ceilOut(TAmounts const& amount, Out const& limit) const; - // Some of the underlying rounding functions called by ceil_out() ignored - // low order bits that could influence rounding decisions. This "strict" - // method uses underlying functions that pay attention to all the bits. + /** Scale an offer's amounts down so that the output does not exceed `limit`, + * using full-precision rounding. + * + * Identical to `ceilOut` except it delegates to `mulRoundStrict`, which + * considers all low-order bits that `mulRound` ignores. + * + * @param amount Current offer amounts. + * @param limit Maximum allowed output amount. + * @param roundUp Whether to round the recomputed input up (`true`) or + * down (`false`). + * @return Scaled amounts satisfying `out <= limit` and `in <= amount.in`. + */ [[nodiscard]] Amounts ceilOutStrict(Amounts const& amount, STAmount const& limit, bool roundUp) const; + /** Scale a typed offer's amounts down so that the output does not exceed `limit`, + * using full-precision rounding. + * + * @tparam In Input amount type. + * @tparam Out Output amount type. + * @param amount Current offer amounts. + * @param limit Maximum allowed output amount. + * @param roundUp Whether to round the recomputed input up or down. + * @return Scaled amounts satisfying `out <= limit` and `in <= amount.in`. + */ template [[nodiscard]] TAmounts ceilOutStrict(TAmounts const& amount, Out const& limit, bool roundUp) const; private: - // The ceil_in and ceil_out methods that deal in TAmount all convert - // their arguments to STAmount and convert the result back to TAmount. - // This helper function takes care of all the conversion operations. + /** Shared implementation for all typed `ceilIn`/`ceilOut` overloads. + * + * Converts `amount` and `limit` to `STAmount`, calls `ceilFunction` (a + * member-function pointer to one of the `STAmount`-based overloads), and + * converts the result back to `TAmounts`. Returns `amount` + * unchanged when `limitCmp <= limit` (i.e., the limit is not binding). + * + * The variadic `Round...` pack forwards an optional `bool roundUp` argument + * to strict variants without requiring separate instantiations. + * + * @tparam In Input amount type. + * @tparam Out Output amount type. + * @tparam Lim Limit amount type (same as `In` or `Out`). + * @tparam FnPtr Pointer to the `STAmount`-based ceil member function. + * @tparam Round Empty or `{bool}` — forwarded as `roundUp`. + * @param amount Current typed offer amounts. + * @param limit The cap to apply. + * @param limitCmp The side of `amount` to compare against `limit` + * (either `amount.in` or `amount.out`). + * @param ceilFunction Member-function pointer to dispatch to. + * @param round Optional rounding direction (strict variants only). + * @return Scaled `TAmounts`. + */ template ... Round> [[nodiscard]] TAmounts ceilTAmountsHelper( @@ -213,46 +425,53 @@ private: Round... round) const; public: - /** Returns `true` if lhs is lower quality than `rhs`. - Lower quality means the taker receives a worse deal. - Higher quality is better for the taker. - */ + /** Returns `true` if `lhs` is lower quality (worse for the taker) than `rhs`. + * + * Because the internal encoding is inverted, a lower quality corresponds + * to a *higher* stored integer, so this compares `lhs.value_ > rhs.value_`. + */ friend bool operator<(Quality const& lhs, Quality const& rhs) noexcept { return lhs.value_ > rhs.value_; } + /** Returns `true` if `lhs` is higher quality (better for the taker) than `rhs`. */ friend bool operator>(Quality const& lhs, Quality const& rhs) noexcept { return lhs.value_ < rhs.value_; } + /** Returns `true` if `lhs` is lower or equal quality to `rhs`. */ friend bool operator<=(Quality const& lhs, Quality const& rhs) noexcept { return !(lhs > rhs); } + /** Returns `true` if `lhs` is higher or equal quality to `rhs`. */ friend bool operator>=(Quality const& lhs, Quality const& rhs) noexcept { return !(lhs < rhs); } + /** Returns `true` if both qualities encode the same exchange rate. */ friend bool operator==(Quality const& lhs, Quality const& rhs) noexcept { return lhs.value_ == rhs.value_; } + /** Returns `true` if the two qualities encode different exchange rates. */ friend bool operator!=(Quality const& lhs, Quality const& rhs) noexcept { return !(lhs == rhs); } + /** Write the raw packed integer value of the quality to an output stream. */ friend std::ostream& operator<<(std::ostream& os, Quality const& quality) { @@ -260,8 +479,18 @@ public: return os; } - // return the relative distance (relative error) between two qualities. This - // is used for testing only. relative distance is abs(a-b)/min(a,b) + /** Return the relative error between two quality values: `|a - b| / min(a, b)`. + * + * Extracts the exponent and mantissa from each packed value, scales them + * to a common exponent, and returns the normalized distance. Used only + * in unit tests to verify that two qualities are sufficiently close. + * + * @param q1 First quality; must be non-zero (asserted). + * @param q2 Second quality; must be non-zero (asserted). + * @return `|q1 - q2| / min(q1, q2)` as a `double`. + * @note For testing only. Production code should compare with the + * relational operators. + */ friend double relativeDistance(Quality const& q1, Quality const& q2) { @@ -284,9 +513,8 @@ public: double const maxVD = (expDiff != 0) ? maxVMantissa * pow(10, expDiff) : static_cast(maxVMantissa); - // maxVD and minVD are scaled so they have the same exponents. Dividing - // cancels out the exponents, so we only need to deal with the (scaled) - // mantissas + // maxVD and minVD are scaled so they have the same exponent; dividing + // cancels out the exponents, leaving only the normalized mantissa difference. return (maxVD - minVD) / minVD; } }; @@ -315,7 +543,6 @@ template TAmounts Quality::ceilIn(TAmounts const& amount, In const& limit) const { - // Construct a function pointer to the function we want to call. static constexpr Amounts (Quality::*kCEIL_IN_FN_PTR)(Amounts const&, STAmount const&) const = &Quality::ceilIn; @@ -326,7 +553,6 @@ template TAmounts Quality::ceilInStrict(TAmounts const& amount, In const& limit, bool roundUp) const { - // Construct a function pointer to the function we want to call. static constexpr Amounts (Quality::*kCEIL_IN_FN_PTR)(Amounts const&, STAmount const&, bool) const = &Quality::ceilInStrict; @@ -337,7 +563,6 @@ template TAmounts Quality::ceilOut(TAmounts const& amount, Out const& limit) const { - // Construct a function pointer to the function we want to call. static constexpr Amounts (Quality::*kCEIL_OUT_FN_PTR)(Amounts const&, STAmount const&) const = &Quality::ceilOut; @@ -348,17 +573,26 @@ template TAmounts Quality::ceilOutStrict(TAmounts const& amount, Out const& limit, bool roundUp) const { - // Construct a function pointer to the function we want to call. static constexpr Amounts (Quality::*kCEIL_OUT_FN_PTR)(Amounts const&, STAmount const&, bool) const = &Quality::ceilOutStrict; return ceilTAmountsHelper(amount, limit, amount.out, kCEIL_OUT_FN_PTR, roundUp); } -/** Calculate the quality of a two-hop path given the two hops. - @param lhs The first leg of the path: input to intermediate. - @param rhs The second leg of the path: intermediate to output. -*/ +/** Compute the effective end-to-end exchange rate for a two-hop path. + * + * If the first hop converts A→B at rate `lhs` and the second converts B→C + * at rate `rhs`, the composed quality is their product, re-encoded into the + * packed 64-bit format. Used by the pathfinding engine to rank multi-hop + * routes against single-hop offers on a common scale. + * + * @param lhs Quality of the first leg (input → intermediate currency). + * @param rhs Quality of the second leg (intermediate → output currency). + * @return Composed quality representing the overall A→C exchange rate. + * @note Both input rates must be non-zero (asserted at runtime). The + * composed exponent must fit in 8 bits (i.e., actual exponent in + * [-99, 155]); astronomically large or small paths will assert. + */ Quality composedQuality(Quality const& lhs, Quality const& rhs); diff --git a/include/xrpl/protocol/QualityFunction.h b/include/xrpl/protocol/QualityFunction.h index f7f92e50da..6549eb6038 100644 --- a/include/xrpl/protocol/QualityFunction.h +++ b/include/xrpl/protocol/QualityFunction.h @@ -6,52 +6,152 @@ namespace xrpl { -/** Average quality of a path as a function of `out`: q(out) = m * out + b, - * where m = -1 / poolGets, b = poolPays / poolGets. If CLOB offer then - * `m` is equal to 0 `b` is equal to the offer's quality. The function - * is derived by substituting `in` in q = out / in with the swap out formula - * for `in`: - * in = [(poolGets * poolPays) / (poolGets - out) - poolPays] / (1 - tfee) - * and combining the function for multiple steps. The function is used - * to limit required output amount when quality limit is provided in one - * path optimization. +/** Average quality of a payment strand expressed as a linear function of output. + * + * Models the relationship `q(out) = m_ * out + b_`, where `q` is the average + * exchange rate (quality) that a strand delivers when it produces `out` units. + * This analytical model lets `StrandFlow::limitOut()` compute — without + * simulation — the maximum output the strand may produce before AMM price + * impact degrades the average quality below a caller-supplied limit. + * + * **Derivation.** For an AMM step with pool balances `poolGets` (input side) + * and `poolPays` (output side) and fee multiplier `cfee = 1 - tfee`, the + * constant-product swap formula gives: + * @code + * in = [(poolGets * poolPays) / (poolGets - out) - poolPays] / cfee + * @endcode + * Substituting into `q = out / in` and linearising yields: + * @code + * m = -cfee / poolGets (always negative for a valid AMM step) + * b = poolPays * cfee / poolGets + * @endcode + * + * **Multi-hop composition.** For strands with sequential steps (e.g. a + * transfer-fee hop preceding an AMM hop), `combine()` chains two quality + * functions analytically. `StrandFlow::limitOut()` calls `combine()` in a + * loop over all steps to accumulate a single QF representing the whole strand. + * + * **Two construction modes** are selected via tag dispatch: + * - `AMMTag` — variable quality; slope and intercept derived from pool balances. + * - `CLOBLikeTag` — constant quality (`m_ = 0`); used for plain CLOB orders and + * for AMM offers in multi-path mode, where each path's AMM allocation is fixed. + * + * @note The linear approximation is exact for *average* quality but not for + * instantaneous (marginal) quality, which is quadratic. Using averages + * keeps composition algebraically simple while still providing a + * conservative, analytically tractable bound. + * + * @see StrandFlow.h `limitOut()` — primary consumer of this class. */ class QualityFunction { private: - // slope + /** Slope of the quality–output line (`-cfee / poolGets` for AMM; 0 for CLOB). */ Number m_; - // intercept + /** Intercept of the quality–output line (`poolPays * cfee / poolGets` for AMM; + * `1 / quality.rate()` for CLOB). */ Number b_; - // seated if QF is for CLOB offer. + /** Cached constant quality; seated only when `m_ == 0` (CLOB-like function). */ std::optional quality_; public: + /** Tag type that selects the AMM constructor (variable-quality path step). */ struct AMMTag { }; - // AMMOffer for multi-path is like CLOB, i.e. the offer size - // changes proportionally to its quality. + /** Tag type that selects the CLOB-like constructor (constant-quality path step). + * + * Used for both plain CLOB orders and AMM offers operating in multi-path + * mode, where the AMM offer size scales proportionally with quality just + * like a CLOB, making the effective quality constant from this sub-path's + * perspective. + */ struct CLOBLikeTag { }; + + /** Construct a constant-quality (CLOB-like) quality function. + * + * Sets `m_ = 0` and `b_ = 1 / quality.rate()`. `quality_` is seated so + * that `isConst()` returns `true` and `StrandFlow::limitOut()` skips the + * output cap. + * + * @param quality The fixed exchange rate of this path step. + * @throws std::runtime_error if `quality.rate()` is zero, which would + * make the intercept infinite. + */ QualityFunction(Quality const& quality, CLOBLikeTag); + + /** Construct a variable-quality (AMM) quality function from pool balances. + * + * Derives the slope and intercept from the constant-product swap formula: + * @code + * m_ = -cfee / amounts.in + * b_ = amounts.out * cfee / amounts.in + * @endcode + * where `cfee = feeMult(tfee)`. `quality_` is left empty; `isConst()` + * returns `false`. + * + * @tparam TIn Input amount type (e.g. `XRPAmount`, `IOUAmount`). + * @tparam TOut Output amount type. + * @param amounts Current AMM pool balances: `amounts.in` is the input-side + * pool depth, `amounts.out` is the output-side pool depth. + * @param tfee AMM trading fee in the same units as `feeMult()` expects. + * @throws std::runtime_error if either pool balance is zero or negative, + * which would cause division-by-zero in downstream arithmetic. + */ template QualityFunction(TAmounts const& amounts, std::uint32_t tfee, AMMTag); - /** Combines QF with the next step QF + /** Chain this quality function with the next path step's quality function. + * + * Applies linear function composition in reciprocal-rate space: + * @code + * m_ += b_ * qf.m_; + * b_ *= qf.b_; + * @endcode + * If the combined slope becomes nonzero, `quality_` is cleared to reflect + * that the resulting function is no longer constant and `outFromAvgQ()` + * must be used rather than a simple pass/fail quality check. + * + * @param qf Quality function for the next step to compose in. */ void combine(QualityFunction const& qf); - /** Find output to produce the requested - * average quality. - * @param quality requested average quality (quality limit) + /** Solve for the maximum output at which average quality meets the given limit. + * + * Inverts `q(out) = m_ * out + b_` by substituting `q = 1 / quality.rate()`: + * @code + * out = (1 / quality.rate() - b_) / m_ + * @endcode + * The rounding mode is set to `Upward` during the calculation so the + * returned bound is conservative: because `m_` is negative, dividing an + * upward-rounded numerator by a negative slope yields a result that rounds + * down, ensuring the engine never requests marginally more output than the + * quality constraint allows. + * + * Returns `std::nullopt` in three cases: + * - `m_ == 0`: the function is constant (CLOB-like); quality either passes + * or fails uniformly, so no output cap is meaningful. + * - `quality.rate() == 0`: guards against division-by-zero when forming + * `1 / rate`. + * - `out <= 0`: the quality limit cannot be achieved at any positive output; + * the strand is effectively dead for this constraint. + * + * @param quality The minimum acceptable average exchange rate (quality limit). + * @return The output amount at which the strand's average quality equals + * `quality`, or `std::nullopt` if the cap is inapplicable or infeasible. */ std::optional outFromAvgQ(Quality const& quality); - /** Return true if the quality function is constant + /** Return `true` if this quality function is constant (CLOB-like). + * + * A constant function has `m_ == 0`: the average quality is the same + * regardless of output size. `StrandFlow::limitOut()` treats a constant + * function as a signal to skip the output cap and return `remainingOut` + * unchanged. */ [[nodiscard]] bool isConst() const @@ -59,6 +159,13 @@ public: return quality_.has_value(); } + /** Return the cached constant quality, if any. + * + * Seated only when `isConst() == true` (i.e., this is a CLOB-like + * function constructed via `CLOBLikeTag`). Returns `std::nullopt` for + * variable-quality (AMM) functions and for any combined function whose + * slope became nonzero after `combine()`. + */ [[nodiscard]] std::optional const& quality() const { diff --git a/include/xrpl/protocol/RPCErr.h b/include/xrpl/protocol/RPCErr.h index 1439146e0e..4e34b29006 100644 --- a/include/xrpl/protocol/RPCErr.h +++ b/include/xrpl/protocol/RPCErr.h @@ -1,13 +1,49 @@ #pragma once +/** @file + * Deprecated compatibility shim for the XRPL RPC error API. + * + * Declares `rpcError()` and `isRpcError()` — legacy entry points in the + * `xrpl` namespace that predate the richer `RPC`-namespaced error + * infrastructure in `ErrorCodes.h`. New code should use `RPC::makeError()` + * and `RPC::containsError()` from `ErrorCodes.h` directly. + */ + #include #include namespace xrpl { -// VFALCO NOTE these are deprecated +/** Return `true` if @p jvResult represents an RPC error response. + * + * Duck-types the JSON value by checking for the presence of the `"error"` + * key — the same structural sentinel that `RPC::containsError()` tests, + * making it the direct modern equivalent. + * + * @param jvResult The JSON value to inspect (taken by value, not + * `const` reference — an inefficiency inherited from the original + * implementation that was never corrected given this function's + * deprecated status). + * @return `true` if @p jvResult is a JSON object containing an `"error"` + * member. + * @deprecated Use `RPC::containsError(json)` from `ErrorCodes.h` instead. + */ bool isRpcError(json::Value jvResult); + +/** Construct a fresh JSON error object for the given error code. + * + * Delegates to `RPC::injectError()`, which populates a new `Json::Value` + * object with the canonical `error` token, `error_code`, and + * `error_message` fields drawn from the static `ErrorInfo` registry. + * The return-by-value produces a self-contained error object ready for + * direct return from an RPC handler. + * + * @param iError The RPC error code to encode. + * @return A new `Json::Value` object with `error`, `error_code`, and + * `error_message` populated. + * @deprecated Use `RPC::makeError(code)` from `ErrorCodes.h` instead. + */ json::Value rpcError(ErrorCodeI iError); diff --git a/include/xrpl/protocol/Rate.h b/include/xrpl/protocol/Rate.h index 5dcd62a295..9f7fad56a6 100644 --- a/include/xrpl/protocol/Rate.h +++ b/include/xrpl/protocol/Rate.h @@ -1,3 +1,13 @@ +/** @file + * Defines the `Rate` struct and its arithmetic free functions for applying + * XRPL transfer fees to `STAmount` values. + * + * Transfer rates are billion-scale fractions: `1,000,000,000` is parity + * (no fee). `kPARITY_RATE` is the sentinel for the fee-free common case; + * all six arithmetic functions short-circuit on it without entering the + * `STAmount` multiply/divide path. + */ + #pragma once #include @@ -10,14 +20,27 @@ namespace xrpl { -/** Represents a transfer rate - - Transfer rates are specified as fractions of 1 billion. - For example, a transfer rate of 1% is represented as - 1,010,000,000. -*/ +/** Protocol-level transfer rate, expressed as a fraction of one billion. + * + * A value of `1,000,000,000` means 1:1 — no fee. A value of + * `1,010,000,000` means the sender must deliver 1.01 units for every 1 unit + * the recipient receives (a 1% fee). This scale matches `QUALITY_ONE` in + * `Quality.h`, tying transfer fees directly to the ledger's price + * representation. + * + * `boost::totally_ordered` generates `!=`, `>`, `<=`, and `>=` from + * the manually provided `==` and `<`, keeping the struct concise while + * remaining fully ordered. + * + * @note The default constructor is deleted: a `Rate` with an unspecified + * value is meaningless, and zero would violate the nonzero precondition + * asserted by every arithmetic function in this header. The constructor + * is `explicit` to prevent accidental implicit conversion from the + * large integers that rate values resemble. + */ struct Rate : private boost::totally_ordered { + /** The raw billion-scale rate value as stored in `sfTransferRate`. */ std::uint32_t value; Rate() = delete; @@ -27,18 +50,21 @@ struct Rate : private boost::totally_ordered } }; +/** Returns `true` if both rates have the same billion-scale value. */ inline bool operator==(Rate const& lhs, Rate const& rhs) noexcept { return lhs.value == rhs.value; } +/** Returns `true` if `lhs` is a strictly smaller rate than `rhs`. */ inline bool operator<(Rate const& lhs, Rate const& rhs) noexcept { return lhs.value < rhs.value; } +/** Writes the raw billion-scale rate value to `os`. */ inline std::ostream& operator<<(std::ostream& os, Rate const& rate) { @@ -46,32 +72,126 @@ operator<<(std::ostream& os, Rate const& rate) return os; } +/** Scale an amount by a transfer rate, preserving its asset. + * + * Computes `amount × (rate / 10^9)`. Returns `amount` unchanged when + * `rate == kPARITY_RATE`, avoiding the `STAmount` arithmetic path for the + * common fee-free case. + * + * @param amount The value to scale. + * @param rate The transfer rate to apply; must be nonzero. + * @return The scaled `STAmount` denominated in the same asset as `amount`. + * @pre `rate.value != 0`; asserted in debug builds. + */ STAmount multiply(STAmount const& amount, Rate const& rate); +/** Scale an amount by a transfer rate with controlled rounding, preserving its asset. + * + * Like `multiply()`, but the caller controls rounding direction. Used in + * IOU payment routing where fee calculations stay in a single currency. + * + * @param amount The value to scale. + * @param rate The transfer rate to apply; must be nonzero. + * @param roundUp If `true`, round fractional results toward positive + * infinity; otherwise round toward zero. + * @return The scaled `STAmount` denominated in the same asset as `amount`. + * @pre `rate.value != 0`; asserted in debug builds. + */ STAmount multiplyRound(STAmount const& amount, Rate const& rate, bool roundUp); +/** Scale an amount by a transfer rate with controlled rounding, emitting a specified asset. + * + * Overload for offer-crossing and cross-currency paths where the output + * must be denominated in a different asset than the input. + * + * @param amount The value to scale. + * @param rate The transfer rate to apply; must be nonzero. + * @param asset The asset type of the returned `STAmount`. + * @param roundUp If `true`, round fractional results toward positive + * infinity; otherwise round toward zero. + * @return The scaled `STAmount` denominated in `asset`. + * @pre `rate.value != 0`; asserted in debug builds. + */ STAmount multiplyRound(STAmount const& amount, Rate const& rate, Asset const& asset, bool roundUp); +/** Scale an amount by the inverse of a transfer rate, preserving its asset. + * + * Computes `amount / (rate / 10^9)` — the inverse of `multiply()`. Used + * when back-calculating the gross send amount needed to deliver a given net + * amount after fees. Returns `amount` unchanged for `kPARITY_RATE`. + * + * @param amount The value to scale. + * @param rate The transfer rate to invert; must be nonzero. + * @return The scaled `STAmount` denominated in the same asset as `amount`. + * @pre `rate.value != 0`; asserted in debug builds. + */ STAmount divide(STAmount const& amount, Rate const& rate); +/** Scale an amount by the inverse of a transfer rate with controlled rounding, preserving its asset. + * + * Like `divide()`, but the caller controls rounding direction. Used in + * IOU payment routing for single-currency gross-amount back-calculation. + * + * @param amount The value to scale. + * @param rate The transfer rate to invert; must be nonzero. + * @param roundUp If `true`, round fractional results toward positive + * infinity; otherwise round toward zero. + * @return The scaled `STAmount` denominated in the same asset as `amount`. + * @pre `rate.value != 0`; asserted in debug builds. + */ STAmount divideRound(STAmount const& amount, Rate const& rate, bool roundUp); +/** Scale an amount by the inverse of a transfer rate with controlled rounding, emitting a specified asset. + * + * Overload for offer-crossing and cross-currency paths where the output + * must be denominated in a different asset than the input. + * + * @param amount The value to scale. + * @param rate The transfer rate to invert; must be nonzero. + * @param asset The asset type of the returned `STAmount`. + * @param roundUp If `true`, round fractional results toward positive + * infinity; otherwise round toward zero. + * @return The scaled `STAmount` denominated in `asset`. + * @pre `rate.value != 0`; asserted in debug builds. + */ STAmount divideRound(STAmount const& amount, Rate const& rate, Asset const& asset, bool roundUp); namespace nft { -/** Given a transfer fee (in basis points) convert it to a transfer rate. */ + +/** Convert an NFT transfer fee in basis points to a billion-scale `Rate`. + * + * NFT royalties are stored as a `uint16_t` in basis points (0–50,000 + * representing 0%–50%). Because `Rate` uses `10^9` as its unit, the + * conversion multiplies by `10,000`: a maximum fee of `50,000 bp` becomes + * `500,000,000`, safely below `QUALITY_ONE` and within `uint32_t` range. + * + * @param fee NFT transfer fee in basis points (0–50,000); validated by + * transaction processing before reaching this function. + * @return A `Rate` suitable for passing to `multiply()` or `multiplyRound()`. + * @note Do not call this for ordinary IOU transfer rates — those are already + * billion-scale and should be wrapped in `Rate` directly. + */ Rate transferFeeAsRate(std::uint16_t fee); } // namespace nft -/** A transfer rate signifying a 1:1 exchange */ +/** The 1:1 transfer rate — sender pays exactly what the recipient receives. + * + * Equal to `QUALITY_ONE` (`1,000,000,000`). Every arithmetic function in + * this header returns `amount` unchanged when it detects this value, so + * payment paths through accounts with no transfer fee never enter the + * `STAmount` multiply/divide path. `transferRate()` returns this sentinel + * when an account's `sfTransferRate` field is absent. + * + * @see transferRate() in AccountRootHelpers.h + */ extern Rate const kPARITY_RATE; } // namespace xrpl diff --git a/include/xrpl/protocol/RippleLedgerHash.h b/include/xrpl/protocol/RippleLedgerHash.h index 9dab644663..a1a4ae1759 100644 --- a/include/xrpl/protocol/RippleLedgerHash.h +++ b/include/xrpl/protocol/RippleLedgerHash.h @@ -1,9 +1,31 @@ +/** @file + * Defines the `LedgerHash` type alias used throughout the ledger stack to + * identify closed ledgers by their cryptographic digest. + */ + #pragma once #include namespace xrpl { +/** The SHA-512/256 digest that uniquely identifies a closed XRP Ledger. + * + * A ledger hash is computed over the serialized `LedgerHeader` — covering the + * account-state hash, transaction-set hash, sequence number, close time, drop + * totals, and parent ledger hash — and is 32 bytes wide. + * + * The alias over bare `uint256` serves two purposes: it makes interfaces + * self-documenting at call sites (e.g., `CanonicalTXSet(LedgerHash const&)`), + * and it isolates all ledger-hash usage behind a single name so that a + * tagged variant (`base_uint<256, struct LedgerHashTag>`) can be introduced + * later to prevent cross-domain substitution with transaction hashes or + * account IDs without touching every call site. + * + * @note `LedgerHeader` stores its hash fields as bare `uint256` for historical + * reasons; higher-level APIs (`CanonicalTXSet`, `LedgerHistory`, + * `InboundLedgers`, `RCLValidations`) consistently use this alias. + */ using LedgerHash = uint256; } // namespace xrpl diff --git a/include/xrpl/protocol/Rules.h b/include/xrpl/protocol/Rules.h index fbbd3d8805..609d6b9e3e 100644 --- a/include/xrpl/protocol/Rules.h +++ b/include/xrpl/protocol/Rules.h @@ -8,27 +8,55 @@ namespace xrpl { -/** Check whether a feature is enabled in the current ledger rules +/** Query whether a feature is enabled, with an explicit fallback value. * - * @param feature The feature to be tested. - * @param resultIfNoRules What to return if called from outside a Transactor context. + * Delegates to the thread-local current `Rules` installed by + * `CurrentTransactionRulesGuard`. Use this overload when the desired + * behavior outside a transaction context differs from `false`. + * + * @param feature The amendment ID to test. + * @param resultIfNoRules Value returned when called outside any transaction + * context (i.e. no `CurrentTransactionRulesGuard` is on the call stack). + * @return `true` if the feature is enabled in the current rules; + * `resultIfNoRules` if no rules are installed. */ bool isFeatureEnabled(uint256 const& feature, bool resultIfNoRules); -/** Check whether a feature is enabled in the current ledger rules +/** Query whether a feature is enabled in the current transaction context. * - * @param feature The feature to be tested. + * Delegates to the thread-local current `Rules` installed by + * `CurrentTransactionRulesGuard`. Lower-level protocol code that cannot + * accept a `Rules` parameter (e.g. `STAmount`, `AMMHelpers`) uses this + * function instead. The implicit reliance on thread-local state means + * callers must ensure a `CurrentTransactionRulesGuard` is active on the + * call stack; calling it outside a transaction context silently returns + * `false`. * - * Returns false if no global Rules object is available. i.e. Outside of - * a Transactor context + * @param feature The amendment ID to test. + * @return `true` if the feature is enabled; `false` if no rules are + * installed or the feature is absent from the current ledger's + * amendment set. */ bool isFeatureEnabled(uint256 const& feature); class DigestAwareReadView; -/** Rules controlling protocol behavior. */ +/** Authoritative snapshot of which protocol amendments are active for a ledger. + * + * Every behavioral branch in the transaction engine that depends on a + * conditionally-enabled feature gates through `Rules::enabled()`. `Rules` is + * a value type backed by a `shared_ptr` (pimpl), so copying is + * cheap — only an atomic refcount bump — regardless of how many amendments are + * active. Instances are typically constructed via `makeRulesGivenLedger` and + * installed on the call stack by `CurrentTransactionRulesGuard`. + * + * @note The default constructor is deleted. Every `Rules` instance must carry + * an explicit preset set to prevent accidentally propagating a zero-feature + * state into transaction processing. + * @see makeRulesGivenLedger, CurrentTransactionRulesGuard + */ class Rules { private: @@ -51,11 +79,15 @@ public: Rules() = delete; - /** Construct an empty rule set. - - These are the rules reflected by - the genesis ledger. - */ + /** Construct an empty rule set from a preset collection. + * + * Intended for the genesis ledger, which has no on-ledger amendments yet. + * The preset features are treated as unconditionally enabled and checked + * first by `enabled()`. + * + * @param presets Features that are always enabled regardless of ledger + * state (e.g. features forced on in test or devnet configurations). + */ explicit Rules(std::unordered_set> const& presets); private: @@ -77,28 +109,83 @@ private: presets() const; public: - /** Returns `true` if a feature is enabled. */ + /** Returns `true` if a feature is enabled in this rule set. + * + * Checks the preset collection first (always-on features), then the + * on-ledger amendment set populated from `sfAmendments`. + * + * @param feature The amendment ID to test. + * @return `true` if the feature is in the preset collection or was + * active in the ledger's amendment set at the time this `Rules` + * was constructed. + */ [[nodiscard]] bool enabled(uint256 const& feature) const; /** Returns `true` if two rule sets are identical. - - @note This is for diagnostics. - */ + * + * Comparison is O(1) when both instances carry a digest (the common case + * for ledgers with an amendments SLE): differing digests are immediately + * unequal. Two instances without a digest (genesis state) are considered + * equal. An assertion guards against comparing instances with identical + * digests but differing presets. + * + * @note Intended for diagnostics only, not for load-bearing equality + * decisions in transaction processing. + */ bool operator==(Rules const&) const; + /** Returns `true` if two rule sets differ. + * + * Derived from `operator==`; see its documentation for comparison + * semantics. + */ bool operator!=(Rules const& other) const; }; +/** Returns the active `Rules` for the current thread's transaction context. + * + * The returned reference is valid until the next call to + * `setCurrentTransactionRules` on this thread. Prefer using + * `CurrentTransactionRulesGuard` over calling these functions directly. + * + * @return The currently installed rules, or an empty `optional` if no + * `CurrentTransactionRulesGuard` is on the call stack. + * @see CurrentTransactionRulesGuard + */ std::optional const& getCurrentTransactionRules(); +/** Install `r` as the active rules for the current thread's transaction context. + * + * Beyond storing `r` in the thread-local slot, this function also calls + * `Number::setMantissaScale()` to push the appropriate numeric precision mode: + * `MantissaRange::large` when `featureSingleAssetVault` or + * `featureLendingProtocol` is enabled, `small` otherwise. This push strategy + * avoids per-operation rule lookups inside hot arithmetic paths. + * + * Prefer `CurrentTransactionRulesGuard` over calling this directly, as it + * ensures the previous rules are always restored on scope exit. + * + * @param r The rules to install, or `std::nullopt` to clear the slot. + * @see CurrentTransactionRulesGuard + */ void setCurrentTransactionRules(std::optional r); -/** RAII class to set and restore the current transaction rules +/** RAII guard that installs a `Rules` into the thread-local transaction context. + * + * The constructor calls `setCurrentTransactionRules` with the supplied rules, + * saving the previously active value. The destructor restores that saved value, + * ensuring the thread-local state is always reset even on exception paths. + * Non-copyable to prevent accidental aliasing of the saved state. + * + * Production callers are `Transactor::operator()` and `applySteps.cpp`; test + * code uses this guard to bracket individual feature checks. + * + * @see setCurrentTransactionRules, getCurrentTransactionRules */ class CurrentTransactionRulesGuard { diff --git a/include/xrpl/protocol/SField.h b/include/xrpl/protocol/SField.h index 26f52cd6a9..b533ee832e 100644 --- a/include/xrpl/protocol/SField.h +++ b/include/xrpl/protocol/SField.h @@ -1,3 +1,20 @@ +/** @file + * Compile-time field identification and wire-type catalog for XRPL serialized + * objects. + * + * Every data field that can appear in an XRPL transaction, ledger entry, + * validation, or transaction metadata is identified by a singleton `SField` + * instance declared in this header. The `SerializedTypeID` enum defines the + * recognized wire types. `TypedField` adds a compile-time C++ type so + * that callers can interact with fields in a type-safe way. + * + * @note Some fields distinguish between the default value and the absent + * state. For example, `sfQualityIn` on a trust line with value 0 + * means "no quality set" (absent) versus 1,000,000,000 (parity rate + * when explicitly set). Keep this in mind when testing presence. + * + * @see SField, TypedField, SerializedTypeID + */ #pragma once #include @@ -9,15 +26,6 @@ namespace xrpl { -/* - -Some fields have a different meaning for their - default value versus not present. - Example: - QualityIn on a TrustLine - -*/ - //------------------------------------------------------------------------------ // Forwards @@ -35,6 +43,22 @@ class STVector256; class STCurrency; // NOLINTBEGIN(readability-identifier-naming) +/** Wire-type codes for XRPL binary serialization. + * + * Each value identifies the on-the-wire encoding family used for a group of + * protocol fields. Codes 1–11 ("common" types) fit in a single nibble and + * share a compact one-byte field-ID prefix. Codes 16+ ("uncommon" types) + * require an extra byte for the type nibble. Codes 12–13 are reserved gaps. + * Codes 10001–10004 are top-level container types (`STI_TRANSACTION`, etc.) + * that cannot be embedded inside other serialized objects. + * + * The enum and the companion string-to-int map `kS_TYPE_MAP` are both + * generated from a single `XMACRO` expansion — adding a new type requires + * only one line in the macro. + * + * @note These numeric values are protocol-stable: changing them would break + * binary serialization compatibility with existing ledger data and peers. + */ #pragma push_macro("XMACRO") #undef XMACRO @@ -92,6 +116,12 @@ class STCurrency; // NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) enum SerializedTypeID { XMACRO(TO_ENUM) }; +/** String-to-integer map of all `SerializedTypeID` values. + * + * Generated by the same `XMACRO` expansion as `SerializedTypeID`, so the two + * are always in sync. Used to resolve type names arriving as text (e.g. from + * JSON or RPC) to their integer wire codes. + */ static std::map const kS_TYPE_MAP = {XMACRO(TO_MAP)}; #undef XMACRO @@ -102,58 +132,127 @@ static std::map const kS_TYPE_MAP = {XMACRO(TO_MAP)}; #pragma pop_macro("TO_MAP") // NOLINTEND(readability-identifier-naming) -// constexpr +/** Pack a `SerializedTypeID` and per-type index into a single field code. + * + * The resulting integer is the canonical sort key used for deterministic + * binary serialization: the upper 16 bits hold the type family and the lower + * 16 bits hold the field's position within that family. Fields are always + * serialized in ascending `fieldCode` order. + * + * @param id The wire-type family (e.g. `STI_UINT32`). + * @param index The per-type field index (e.g. 4 for `sfSequence`). + * @return The packed field code `(id << 16) | index`. + */ inline int fieldCode(SerializedTypeID id, int index) { return (safeCast(id) << 16) | index; } -// constexpr +/** Pack a raw integer type ID and per-type index into a single field code. + * + * Overload for callers that already have the type as a plain `int` (e.g. + * when deserializing an unknown type from the wire). + * + * @param id The wire-type family as a raw integer. + * @param index The per-type field index. + * @return The packed field code `(id << 16) | index`. + */ inline int fieldCode(int id, int index) { return (id << 16) | index; } -/** Identifies fields. - - Fields are necessary to tag data in signed transactions so that - the binary format of the transaction can be canonicalized. All - SFields are created at compile time. - - Each SField, once constructed, lives until program termination, and there - is only one instance per fieldType/fieldValue pair which serves the - entire application. -*/ +/** Identifies a single named field in XRPL's binary serialization protocol. + * + * Every field that can appear in a transaction, ledger entry, validation, or + * transaction metadata is represented by exactly one `SField` singleton. All + * instances are created at static-initialization time in `SField.cpp` and + * live until program termination; copy, move, and assignment are deleted to + * enforce the singleton guarantee. + * + * Each field carries a packed `fieldCode` (`(SerializedTypeID << 16) | + * fieldValue`) that serves as both the registry key and the canonical + * comparison value for determining binary serialization order. Fields are + * always serialized in ascending `fieldCode` order — required for + * deterministic transaction signing. + * + * Construction is restricted to `SField.cpp` via `PrivateAccessTagT`: the + * tag type is forward-declared public here but defined only in that + * translation unit, so external code can only look up existing fields through + * `getField()`. + * + * @note Debug builds assert that no two fields share the same code or name at + * registration time. Release builds do not check; a duplicate would + * silently shadow the earlier field. + * + * @see TypedField, fieldCode(), SerializedTypeID + */ class SField { public: + /** Never capture this field's value in transaction metadata. */ static constexpr auto kSMD_NEVER = 0x00; - static constexpr auto kSMD_CHANGE_ORIG = 0x01; // original value when it changes - static constexpr auto kSMD_CHANGE_NEW = 0x02; // new value when it changes - static constexpr auto kSMD_DELETE_FINAL = 0x04; // final value when it is deleted - static constexpr auto kSMD_CREATE = 0x08; // value when it's created - static constexpr auto kSMD_ALWAYS = 0x10; // value when node containing it is affected at all - static constexpr auto kSMD_BASE_TEN = 0x20; // value is treated as base 10, overriding behavior - static constexpr auto kSMD_PSEUDO_ACCOUNT = 0x40; // if this field is set in an ACCOUNT_ROOT - // _only_, then it is a pseudo-account - static constexpr auto kSMD_NEEDS_ASSET = 0x80; // This field needs to be associated with an - // asset before it is serialized as a ledger - // object. Intended for STNumber. + /** Capture the original value when the field changes. */ + static constexpr auto kSMD_CHANGE_ORIG = 0x01; + /** Capture the new value when the field changes. */ + static constexpr auto kSMD_CHANGE_NEW = 0x02; + /** Capture the final value when the enclosing object is deleted. */ + static constexpr auto kSMD_DELETE_FINAL = 0x04; + /** Capture the value when the enclosing object is first created. */ + static constexpr auto kSMD_CREATE = 0x08; + /** Capture the value whenever the enclosing ledger node is touched, + * regardless of whether the field itself changed (used by `sfRootIndex`). */ + static constexpr auto kSMD_ALWAYS = 0x10; + /** Display the value in base-10 rather than hex in JSON metadata + * (used by MPT amount fields such as `sfMaximumAmount`). */ + static constexpr auto kSMD_BASE_TEN = 0x20; + /** The field holds a 256-bit hash that identifies a pseudo-account + * (AMM, Vault, LoanBroker). Used by `sfAMMID`, `sfVaultID`, + * `sfLoanBrokerID`. */ + static constexpr auto kSMD_PSEUDO_ACCOUNT = 0x40; + /** The field is an `STNumber` that must have `associateAsset()` called + * before the enclosing ledger object is serialized. The association + * rounds the value to the asset's precision and removes it if it becomes + * zero (pairs with `kSMD_DEFAULT`). */ + static constexpr auto kSMD_NEEDS_ASSET = 0x80; + /** Default metadata flags: record original value, new value, deletion + * value, and creation value (`kSMD_CHANGE_ORIG | kSMD_CHANGE_NEW | + * kSMD_DELETE_FINAL | kSMD_CREATE`). */ static constexpr auto kSMD_DEFAULT = kSMD_CHANGE_ORIG | kSMD_CHANGE_NEW | kSMD_DELETE_FINAL | kSMD_CREATE; + /** Controls whether a field is included in a transaction's signing payload. + * + * Fields that carry signatures (`sfTxnSignature`, `sfSigners`, + * `sfMasterSignature`, `sfSignature`, `sfCounterpartySignature`) are + * marked `No` to prevent the bootstrap paradox of a signature covering + * itself. + */ enum class IsSigning : unsigned char { No, Yes }; + + /** Convenience constant for the non-signing value. */ static IsSigning const kNOT_SIGNING = IsSigning::No; - int const fieldCodeMem; // (type<<16)|index // TODO: rename, clashes with function - SerializedTypeID const fieldType; // STI_* - int const fieldValue; // Code number for protocol + /** Packed field code: `(SerializedTypeID << 16) | fieldValue`. + * This is the canonical sort key for binary serialization order. + * Sentinel values: -1 for `kSF_INVALID`, 0 for `kSF_GENERIC`. */ + int const fieldCodeMem; + /** Wire-type family for this field (e.g. `STI_UINT32`). */ + SerializedTypeID const fieldType; + /** Per-type field index. Values < 256 are binary-serializable; + * values > 256 are JSON-only (discardable). */ + int const fieldValue; + /** Human-readable field name without the `sf` prefix (e.g. `"Sequence"`). */ std::string const fieldName; + /** Bitmask of `kSMD_*` flags controlling transaction metadata capture. */ int const fieldMeta; + /** Monotonically increasing registration ordinal (1-based). */ int const fieldNum; + /** Whether this field is included in the signing payload. */ IsSigning const signingField; + /** JSON key for this field as a `StaticString` (pointer-stable). */ json::StaticString const jsonName; SField(SField const&) = delete; @@ -164,9 +263,29 @@ public: operator=(SField&&) = delete; public: - struct PrivateAccessTagT; // public, but still an implementation detail + /** Construction access guard — public type, private definition. + * + * Forward-declared here so the constructor signatures are visible, but + * the struct body (and its constructor) is defined only in `SField.cpp`. + * Consequently, only `SField.cpp` can construct `SField` instances. + */ + struct PrivateAccessTagT; - // These constructors can only be called from SField.cpp + /** Construct a typed, named protocol field and register it globally. + * + * Computes `fieldCode = (tid << 16) | fv` and inserts this field into + * the `knownCodeToField` and `knownNameToField` lookup tables. Only + * callable from `SField.cpp` (enforced by `PrivateAccessTagT`). + * + * @param tid Serialized type family (e.g. `STI_UINT32`). + * @param fv Per-type field index; must be < 256 to be + * binary-serializable. + * @param fn Human-readable field name (`sf` prefix already stripped + * by the calling macro). + * @param meta Bitmask of `kSMD_*` flags; defaults to `kSMD_DEFAULT`. + * @param signing Whether this field appears in signing payloads; defaults + * to `IsSigning::Yes`. + */ SField( PrivateAccessTagT, SerializedTypeID tid, @@ -174,118 +293,224 @@ public: char const* fn, int meta = kSMD_DEFAULT, IsSigning signing = IsSigning::Yes); + + /** Construct a special-purpose field from a raw field code. + * + * Used only for the four historical outlier fields (`kSF_INVALID`, + * `kSF_GENERIC`, `kSF_HASH`, `kSF_INDEX`) whose codes cannot be derived + * from the standard `(tid << 16) | fv` formula. Sets `fieldType` to + * `STI_UNKNOWN` and `fieldMeta` to `kSMD_NEVER`. + * + * @param fc Raw field code; -1 for `kSF_INVALID`, 0 for `kSF_GENERIC`. + * @param fn Human-readable field name. + */ explicit SField(PrivateAccessTagT, int fc, char const* fn); + /** Look up a registered field by its packed field code. + * + * @param fieldCode Packed code `(SerializedTypeID << 16) | fieldValue`. + * @return The matching `SField`, or `kSF_INVALID` if none is registered + * with that code. + */ static SField const& getField(int fieldCode); + + /** Look up a registered field by its human-readable name. + * + * Names are stored without the `sf` prefix (e.g. `"Sequence"` not + * `"sfSequence"`). + * + * @param fieldName The name to search for (no `sf` prefix). + * @return The matching `SField`, or `kSF_INVALID` if none is registered + * with that name. + */ static SField const& getField(std::string const& fieldName); + + /** Look up a registered field by raw integer type ID and field index. + * + * @param type Wire-type family as a raw integer. + * @param value Per-type field index. + * @return The matching `SField`, or `kSF_INVALID` if not found. + */ static SField const& getField(int type, int value) { return getField(fieldCode(type, value)); } + /** Look up a registered field by `SerializedTypeID` and field index. + * + * @param type Wire-type family. + * @param value Per-type field index. + * @return The matching `SField`, or `kSF_INVALID` if not found. + */ static SField const& getField(SerializedTypeID type, int value) { return getField(fieldCode(type, value)); } + /** Return the human-readable field name (without the `sf` prefix). */ [[nodiscard]] std::string const& getName() const { return fieldName; } + /** Return true if this field has a meaningful name and positive field code. + * + * Returns false for `kSF_INVALID` (`fieldCode == -1`) and `kSF_GENERIC` + * (`fieldCode == 0`). + */ [[nodiscard]] bool hasName() const { return fieldCodeMem > 0; } + /** Return the JSON key for this field as a pointer-stable `StaticString`. */ [[nodiscard]] json::StaticString const& getJsonName() const { return jsonName; } + /** Implicit conversion to `json::StaticString` for use as a JSON key. */ operator json::StaticString const&() const { return jsonName; } + /** Return true if this field is the `kSF_INVALID` sentinel (`fieldCode == -1`). + * + * `getField()` returns `kSF_INVALID` on a lookup miss. + */ [[nodiscard]] bool isInvalid() const { return fieldCodeMem == -1; } + /** Return true if this field has a positive field code and can carry data. + * + * Equivalent to `!isInvalid() && hasName()`; false for `kSF_INVALID` and + * `kSF_GENERIC`. + */ [[nodiscard]] bool isUseful() const { return fieldCodeMem > 0; } + /** Return true if this field can be round-tripped through binary serialization. + * + * A field is binary-serializable when `fieldValue < 256`. Fields with + * `fieldValue >= 256` (e.g. `kSF_HASH`, `kSF_INDEX`) exist only in JSON + * representations and are excluded from binary encoding. + */ [[nodiscard]] bool isBinary() const { return fieldValue < 256; } - // A discardable field is one that cannot be serialized, and - // should be discarded during serialization,like 'hash'. - // You cannot serialize an object's hash inside that object, - // but you can have it in the JSON representation. + /** Return true if this field must be silently dropped during binary serialization. + * + * Discardable fields (e.g. `sfHash`, `sfIndex`) have `fieldValue > 256` + * and exist only in the JSON form of an object. A round-trip through + * binary will lose them. + */ [[nodiscard]] bool isDiscardable() const { return fieldValue > 256; } + /** Return the packed field code `(SerializedTypeID << 16) | fieldValue`. */ [[nodiscard]] int getCode() const { return fieldCodeMem; } + + /** Return the 1-based registration ordinal assigned at static-init time. */ [[nodiscard]] int getNum() const { return fieldNum; } + + /** Return the total number of `SField` instances registered so far. */ static int getNumFields() { return num; } + /** Return true if any of the bits in `c` are set in this field's metadata mask. + * + * @param c A bitmask of one or more `kSMD_*` constants. + */ [[nodiscard]] bool shouldMeta(int c) const { return (fieldMeta & c) != 0; } + /** Return true if this field should be included in a serialization pass. + * + * A field is included when it is binary-serializable (`fieldValue < 256`) + * and either the caller wants all fields (`withSigningField == true`) or + * this field is marked `IsSigning::Yes`. Passing `withSigningField == + * false` excludes non-signing fields (used when building the signing + * payload for a transaction). + * + * @param withSigningField If false, fields marked `IsSigning::No` are + * excluded. + */ [[nodiscard]] bool shouldInclude(bool withSigningField) const { return (fieldValue < 256) && (withSigningField || (signingField == IsSigning::Yes)); } + /** Equality based on packed field code. */ bool operator==(SField const& f) const { return fieldCodeMem == f.fieldCodeMem; } + /** Inequality based on packed field code. */ bool operator!=(SField const& f) const { return fieldCodeMem != f.fieldCodeMem; } + /** Compare two fields by canonical binary-serialization order. + * + * Fields are ordered by `fieldCode = (SerializedTypeID << 16) | + * fieldValue`, sorting first by wire-type family and then by per-type + * index — matching the canonical XRPL binary format required for + * deterministic transaction signing. + * + * @param f1 First field. + * @param f2 Second field. + * @return -1 if `f1` precedes `f2`, 1 if `f1` follows `f2`, or 0 if + * the comparison is illegal because either field has a non-positive + * code (`kSF_INVALID` or `kSF_GENERIC`). + */ static int compare(SField const& f1, SField const& f2); + /** Return a read-only reference to the global code-to-field registry. + * + * The map key is the packed field code `(SerializedTypeID << 16) | + * fieldValue`. Intended for diagnostic and introspection use only; + * prefer `getField()` for ordinary lookups. + */ static std::unordered_map const& getKnownCodeToField() { @@ -298,7 +523,21 @@ private: static std::unordered_map knownNameToField; }; -/** A field with a type known at compile time. */ +/** An `SField` whose associated C++ type is known at compile time. + * + * Extends `SField` with a `type` alias so callers can statically verify that + * a field is read or written with the correct serialized C++ type. For + * example, `SF_UINT32` is `TypedField>`, making it a + * compile error to read it as an `STAmount`. + * + * All `TypedField` instances are singletons constructed in `SField.cpp`; + * external code cannot create new instances. + * + * @tparam T The serialized C++ type for this field (e.g. `STAmount`, + * `STInteger`). + * + * @see OptionaledField, operator~ + */ template struct TypedField : SField { @@ -308,7 +547,16 @@ struct TypedField : SField explicit TypedField(PrivateAccessTagT pat, Args&&... args); }; -/** Indicate std::optional field semantics. */ +/** Wrapper indicating that a `TypedField` may be absent in a given object. + * + * Obtained via `operator~(TypedField const&)`. The `STObject` proxy + * access pattern uses this to return `std::optional` instead of throwing + * when the field is missing. + * + * @tparam T The serialized C++ type of the underlying field. + * + * @see operator~ + */ template struct OptionaledField { @@ -319,6 +567,15 @@ struct OptionaledField } }; +/** Construct an `OptionaledField` from a `TypedField`, expressing optional semantics. + * + * Allows callers to write `~sfAmount` instead of `OptionaledField(sfAmount)`. + * The resulting value is used with the `STObject` proxy access API to obtain + * an `std::optional` that is empty when the field is absent. + * + * @param f The typed field to treat as optional. + * @return An `OptionaledField` wrapping `f`. + */ template inline OptionaledField operator~(TypedField const& f) @@ -328,13 +585,18 @@ operator~(TypedField const& f) //------------------------------------------------------------------------------ -//------------------------------------------------------------------------------ - -using SF_UINT8 = TypedField>; -using SF_UINT16 = TypedField>; -using SF_UINT32 = TypedField>; -using SF_UINT64 = TypedField>; -using SF_UINT96 = TypedField>; +/** @defgroup SFieldTypeAliases Typed SField aliases + * Convenience type aliases pairing each `SerializedTypeID` wire family with + * its C++ serialized type. Use these as the type of `extern` field + * declarations so that the field carries full type information at compile + * time. + * @{ + */ +using SF_UINT8 = TypedField>; +using SF_UINT16 = TypedField>; +using SF_UINT32 = TypedField>; +using SF_UINT64 = TypedField>; +using SF_UINT96 = TypedField>; using SF_UINT128 = TypedField>; using SF_UINT160 = TypedField>; using SF_UINT192 = TypedField>; @@ -342,17 +604,18 @@ using SF_UINT256 = TypedField>; using SF_UINT384 = TypedField>; using SF_UINT512 = TypedField>; -using SF_INT32 = TypedField>; -using SF_INT64 = TypedField>; +using SF_INT32 = TypedField>; +using SF_INT64 = TypedField>; -using SF_ACCOUNT = TypedField; -using SF_AMOUNT = TypedField; -using SF_ISSUE = TypedField; -using SF_CURRENCY = TypedField; -using SF_NUMBER = TypedField; -using SF_VL = TypedField; -using SF_VECTOR256 = TypedField; +using SF_ACCOUNT = TypedField; +using SF_AMOUNT = TypedField; +using SF_ISSUE = TypedField; +using SF_CURRENCY = TypedField; +using SF_NUMBER = TypedField; +using SF_VL = TypedField; +using SF_VECTOR256 = TypedField; using SF_XCHAIN_BRIDGE = TypedField; +/** @} */ //------------------------------------------------------------------------------ @@ -365,7 +628,19 @@ using SF_XCHAIN_BRIDGE = TypedField; #define UNTYPED_SFIELD(sfName, stiSuffix, fieldValue, ...) extern SField const sfName; #define TYPED_SFIELD(sfName, stiSuffix, fieldValue, ...) extern SF_##stiSuffix const sfName; +/** Sentinel returned by `SField::getField()` on a lookup miss. + * + * `fieldCode == -1`; `isInvalid()` returns true. Callers that receive this + * value should treat the requested field as unrecognized. + */ extern SField const kSF_INVALID; + +/** Catch-all field for untyped serialization contexts. + * + * `fieldCode == 0`; `isUseful()` and `hasName()` return false. Used + * internally when a context requires an `SField` reference but no specific + * field is applicable. + */ extern SField const kSF_GENERIC; #include diff --git a/include/xrpl/protocol/SOTemplate.h b/include/xrpl/protocol/SOTemplate.h index 72e0573d29..46546836e8 100644 --- a/include/xrpl/protocol/SOTemplate.h +++ b/include/xrpl/protocol/SOTemplate.h @@ -1,3 +1,13 @@ +/** @file + * Schema definitions for XRPL serialized objects. + * + * Provides `SOElement` (a single field's schema entry) and `SOTemplate` (the + * complete ordered schema for one transaction, ledger entry, or inner object + * type). Templates are constructed once at startup by the `KnownFormats` + * singletons and are thereafter read-only, enabling lock-free O(1) field + * lookup during every serialization and deserialization call. + */ + #pragma once #include @@ -10,26 +20,70 @@ namespace xrpl { -/** Kind of element in each entry of an SOTemplate. */ +/** Field-presence semantics for a single entry in an `SOTemplate`. + * + * Controls how `STObject` treats a field during deserialization, validation, + * and serialization: + * + * - `SoeRequired` — the field must be present; absence is a fatal error. + * - `SoeOptional` — the field may be absent; if present it may carry the + * type's default value (presence with default has distinct protocol meaning). + * - `SoeDefault` — the field may be absent; if present it must NOT carry the + * type's default value. Inner objects that contain `SoeDefault` fields must + * be created via `STObject::makeInnerObject()` to preserve this invariant. + * - `SoeInvalid` — sentinel returned by `STObject::getFieldStyle()` when the + * object has no associated template; never used in a live schema. + * + * @note `SoeOptional` and `SoeDefault` are subtly different: for some fields + * (e.g., `QualityIn` on a trust line) having the field present with its + * default value and having it absent carry different protocol semantics. + * Use `SoeDefault` when the field must not encode redundant default state. + */ // 2026 usages, 129 files // NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) enum SOEStyle { SoeInvalid = -1, - SoeRequired = 0, // required - SoeOptional = 1, // optional, may be present with default value - SoeDefault = 2, // optional, if present, must not have default value - // inner object with the default fields has to be - // constructed with STObject::makeInnerObject() + SoeRequired = 0, ///< Field must be present. + SoeOptional = 1, ///< Field may be absent; if present, may hold default value. + SoeDefault = 2, ///< Field may be absent; if present, must not hold default value. }; -// Part of a Python-parsed DSL (transactions.macro); bare enumerator names required by the parser -/** Amount fields that can support MPT */ +/** Multi-Purpose Token (MPT) awareness annotation for amount and issue fields. + * + * Applied only to `STAmount` and `STIssue` typed fields (enforced by the + * constrained `SOElement` constructor). Allows the validation layer in + * `STObject` and `STTx` to check MPT compatibility at the schema level rather + * than in scattered per-transaction code. + * + * - `SoeMptNone` — field does not carry an amount or issue; MPT check + * is never performed. Default for all non-amount fields. + * - `SoeMptSupported` — the transaction format allows MPT in this field. + * - `SoeMptNotSupported` — the transaction format explicitly forbids MPT in + * this field; validation rejects any MPT value. + * + * @note Bare enumerator names (without a class scope) are required because + * these values are parsed by the Python DSL that processes + * `transactions.macro`. + */ // NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) enum SOETxMPTIssue { SoeMptNone, SoeMptSupported, SoeMptNotSupported }; //------------------------------------------------------------------------------ -/** An element in a SOTemplate. */ +/** One field's schema entry inside an `SOTemplate`. + * + * Pairs an `SField` reference with its `SOEStyle` presence semantics and, + * for amount/issue fields, an `SOETxMPTIssue` MPT-awareness tag. + * + * `SField` instances are immovable, non-copyable process-lifetime singletons. + * Storing a `std::reference_wrapper` rather than a raw pointer communicates + * the non-owning relationship clearly and allows `SOElement` to be held in a + * `std::vector` (which requires copyable/movable elements). + * + * @note Both constructors call the private `init()` helper, which throws if + * the field is not "useful" (i.e., `fieldCode <= 0`, as for `sfInvalid` + * or `sfGeneric`). This catches schema bugs at application startup. + */ class SOElement { // Use std::reference_wrapper so SOElement can be stored in a std::vector. @@ -38,6 +92,12 @@ class SOElement SOETxMPTIssue supportMpt_ = SoeMptNone; private: + /** Validate that the wrapped field is a known, named, serializable field. + * + * @param fieldName The field to validate. + * @throws std::runtime_error if `fieldName.isUseful()` returns false + * (i.e., `fieldCode <= 0`), indicating a sentinel or placeholder field. + */ void init(SField const& fieldName) const { @@ -51,11 +111,31 @@ private: } public: + /** Construct a schema entry for any serializable field. + * + * @param fieldName The field this entry describes; must satisfy + * `isUseful()` (positive field code). + * @param style Presence semantics: required, optional, or default. + * @throws std::runtime_error if @p fieldName is not a useful field. + */ SOElement(SField const& fieldName, SOEStyle style) : sField_(fieldName), style_(style) { init(fieldName); } + /** Construct a schema entry for an `STAmount` or `STIssue` field with MPT annotation. + * + * The `requires` constraint restricts this overload to `STAmount` and + * `STIssue` typed fields, enforcing that MPT support annotations can only + * appear on fields that actually carry amounts or asset specifiers. + * + * @tparam T Must be `STAmount` or `STIssue`. + * @param fieldName The typed amount or issue field this entry describes. + * @param style Presence semantics: required, optional, or default. + * @param supportMpt Whether this field accepts MPT values. Defaults to + * `SoeMptNotSupported` so new amount fields must explicitly opt in. + * @throws std::runtime_error if @p fieldName is not a useful field. + */ template requires(std::is_same_v || std::is_same_v) SOElement( @@ -67,18 +147,26 @@ public: init(fieldName); } + /** Return the `SField` this entry describes. */ [[nodiscard]] SField const& sField() const { return sField_.get(); } + /** Return the field's presence semantics within its containing object type. */ [[nodiscard]] SOEStyle style() const { return style_; } + /** Return the MPT-awareness annotation for this amount or issue field. + * + * @note Returns `SoeMptNone` for all non-amount, non-issue fields; callers + * should only interpret the result when the field type is `STAmount` + * or `STIssue`. + */ [[nodiscard]] SOETxMPTIssue supportMPT() const { @@ -88,67 +176,125 @@ public: //------------------------------------------------------------------------------ -/** Defines the fields and their attributes within a STObject. - Each subclass of SerializedObject will provide its own template - describing the available fields and their metadata attributes. -*/ +/** Immutable field schema for one serialized object type in the XRP Ledger. + * + * Holds the ordered list of `SOElement` entries for a single transaction, + * ledger entry, or inner object type, together with a dense reverse-lookup + * table that maps `SField::getNum()` to the element's position in O(1). + * + * Templates are constructed once at process startup by `KnownFormats` + * subclasses (`TxFormats`, `LedgerFormats`, `InnerObjectFormats`) and are + * thereafter immutable. All consumers hold a `const*` or `const&`; no + * copying is ever required. Consequently the copy constructor and copy + * assignment operator are deleted — the type is move-only. + * + * @note The constructor snapshots `SField::getNumFields()` to size the index + * table. Fields registered after the template is constructed cannot be + * looked up and will cause `getIndex()` to throw. In practice this is + * never an issue because all `SField` singletons are registered before + * `main()` runs, ahead of the `KnownFormats` singletons. + * + * @see SOElement, SOEStyle, STObject::applyTemplate(), STObject::set() + */ class SOTemplate { public: + SOTemplate(SOTemplate const&) = delete; + SOTemplate& + operator=(SOTemplate const&) = delete; + // Copying vectors is expensive. Make this a move-only type until // there is motivation to change that. SOTemplate(SOTemplate&& other) = default; SOTemplate& operator=(SOTemplate&& other) = default; - /** Create a template populated with all fields. - After creating the template fields cannot be added, modified, or removed. - */ + /** Build the schema from a type-specific and a shared field list. + * + * Concatenates @p uniqueFields followed by @p commonFields into a single + * ordered element sequence, then constructs the O(1) index table. + * + * @param uniqueFields Fields specific to this object type; placed first in + * the element sequence. + * @param commonFields Fields shared across all object types of this kind + * (e.g., `Fee`, `Sequence`, `SigningPubKey` for transactions); appended + * after unique fields. + * @throws std::runtime_error if any field has an out-of-range field number + * or appears more than once across both lists. + */ SOTemplate(std::vector uniqueFields, std::vector commonFields = {}); - /** Create a template populated with all fields. - Note: Defers to the vector constructor above. - */ + /** Convenience overload accepting initializer lists; delegates to the vector constructor. + * + * @param uniqueFields Fields specific to this object type. + * @param commonFields Fields shared across all object types of this kind. + * @throws std::runtime_error forwarded from the vector constructor. + */ SOTemplate( std::initializer_list uniqueFields, std::initializer_list commonFields = {}); - /* Provide for the enumeration of fields */ + /** Return an iterator to the first `SOElement` in the schema. */ [[nodiscard]] std::vector::const_iterator begin() const { return elements_.cbegin(); } + /** Return an iterator to the first `SOElement` in the schema. */ [[nodiscard]] std::vector::const_iterator cbegin() const { return begin(); } + /** Return a past-the-end iterator for the element sequence. */ [[nodiscard]] std::vector::const_iterator end() const { return elements_.cend(); } + /** Return a past-the-end iterator for the element sequence. */ [[nodiscard]] std::vector::const_iterator cend() const { return end(); } - /** The number of entries in this template */ + /** Return the number of field entries in this schema. */ [[nodiscard]] std::size_t size() const { return elements_.size(); } - /** Retrieve the position of a named field. */ + /** Return the position of @p sField in the element sequence, or -1 if absent. + * + * Uses a direct array subscript into the internal index table for O(1) + * cost. This is the hot path called on every field access during + * serialization and deserialization. + * + * @param sField The field to look up. + * @return Index into the element sequence, or -1 if the field is not part + * of this schema. + * @throws std::runtime_error if @p sField has a non-positive or + * out-of-range field number (i.e., a sentinel field or one registered + * after this template was constructed). + */ [[nodiscard]] int getIndex(SField const&) const; + /** Return the presence-style of @p sf within this schema. + * + * @param sf The field whose style to retrieve; must be present in this + * template (i.e., `getIndex(sf) != -1`). + * @return The `SOEStyle` declared for this field in the schema. + * @note Calling this with a field that is not in the template results in + * undefined behavior (out-of-bounds array access via the `-1` sentinel + * returned by `getIndex()`). Use `getIndex()` to check presence first + * when the field may be absent. + */ [[nodiscard]] SOEStyle style(SField const& sf) const { @@ -157,7 +303,7 @@ public: private: std::vector elements_; - std::vector indices_; // field num -> index + std::vector indices_; ///< Dense lookup table: field num -> index into elements_. }; } // namespace xrpl diff --git a/include/xrpl/protocol/STAccount.h b/include/xrpl/protocol/STAccount.h index 65f404d58d..949776f7da 100644 --- a/include/xrpl/protocol/STAccount.h +++ b/include/xrpl/protocol/STAccount.h @@ -1,5 +1,15 @@ #pragma once +/** @file + * Defines `STAccount`, the serialized-type wrapper for 160-bit XRPL account + * identifiers used inside transactions and ledger objects. + * + * The internal storage is a plain `AccountID` (`base_uint<160>`) — no heap + * allocation — while the wire format deliberately preserves the + * variable-length (VL) blob encoding of the original `STBlob`-based + * implementation for byte-for-byte ledger compatibility. + */ + #include #include #include @@ -8,10 +18,28 @@ namespace xrpl { +/** Serialized-type wrapper for a 160-bit XRPL account identifier. + * + * `STAccount` stores an `AccountID` value in a fixed-size `uint160` (no + * heap allocation) while serializing and deserializing using the + * VL-prefixed blob encoding of the legacy `STBlob` implementation, keeping + * the wire format byte-for-byte compatible with all existing ledger data. + * + * A `bool default_` flag tracks whether the field has ever been explicitly + * assigned. A default field serializes as a zero-length VL blob, which is + * distinct from a field explicitly set to the all-zeros pseudo-account + * (`noAccount()`). Any call to `setValue()` or `operator=` clears the flag, + * even when the assigned value is zero. + * + * Inherits `CountedObject` for lock-free diagnostic instance + * counting, and is `final` — no further derivation is expected. + * + * @see STBase, CountedObject + */ class STAccount final : public STBase, public CountedObject { private: - // The original implementation of STAccount kept the value in an STBlob. + // The original implementation kept the value in an STBlob. // But an STAccount is always 160 bits, so we can store it with less // overhead in an xrpl::uint160. However, so the serialized format of the // STAccount stays unchanged, we serialize and deserialize like an STBlob. @@ -21,40 +49,154 @@ private: public: using value_type = AccountID; + /** Construct an anonymous, unset account field. + * + * Sets the stored value to zero and marks the field as default (unset). + * A default field serializes as a zero-length VL blob and returns an + * empty string from `getText()`. + */ STAccount(); + /** Construct a named but unset account field. + * + * Binds the field to `n` but leaves it in the default (unset) state. + * Typical use: pre-populating an `STObject` slot before the account + * address is known. + * + * @param n The `SField` descriptor identifying this field (e.g. `sfAccount`). + */ STAccount(SField const& n); + + /** Construct from a raw VL-blob byte buffer. + * + * An empty buffer is the canonical round-trip encoding of a default + * (unset) field and leaves the object in the default state. A non-empty + * buffer must be exactly 20 bytes; any other size throws. + * + * @param n The `SField` descriptor for this field. + * @param v Raw bytes from a VL-blob read. Must be empty or exactly 20 bytes. + * @throws std::runtime_error if `v` is non-empty and not exactly 20 bytes. + */ STAccount(SField const& n, Buffer const& v); + + /** Deserialize an account field from a wire-format byte stream. + * + * Extracts the next VL-prefixed blob from `sit` and delegates to the + * `Buffer` constructor for size validation and value assignment. + * + * @param sit Forward cursor over the serialized byte stream; advanced + * past the VL blob on return. + * @param name The `SField` descriptor for this field. + * @throws std::runtime_error if the extracted blob is not empty or 20 bytes. + */ STAccount(SerialIter& sit, SField const& name); + + /** Construct from a known `AccountID` value. + * + * Marks the field as non-default regardless of whether `v` is the + * zero account. This is the standard path when the account address + * is already available at construction time. + * + * @param n The `SField` descriptor for this field. + * @param v The 160-bit account identifier to store. + */ STAccount(SField const& n, AccountID const& v); + /** Return the `SerializedTypeID` constant for this type (`STI_ACCOUNT`). */ [[nodiscard]] SerializedTypeID getSType() const override; + /** Return the account address as a Base58Check string, or empty if unset. + * + * A default (unset) field returns `""` rather than the Base58 encoding + * of the all-zeros pseudo-account, preserving the distinction between an + * unset field and one explicitly set to `noAccount()`. + * + * @return Base58Check-encoded address, or `""` when `isDefault()` is true. + */ [[nodiscard]] std::string getText() const override; + /** Append this field to `s` using VL-blob wire encoding. + * + * A default (unset) field serializes as a zero-length VL blob (one + * `0x00` byte on the wire). A non-default field serializes as a 20-byte + * VL blob. This preserves byte-for-byte compatibility with the legacy + * `STBlob`-based encoding and distinguishes "unset" from "explicitly set + * to the zero account." + * + * @param s The `Serializer` to append to. + * @note Asserts (debug builds only) that the associated `SField` is a + * binary field of type `STI_ACCOUNT`. + */ void add(Serializer& s) const override; + /** Check semantic equivalence with another serialized field. + * + * Two `STAccount` objects are equivalent only when both their `default_` + * flags and their 160-bit values agree. The `SField` name is ignored — + * equivalence is purely about stored account state, not which field slot + * the object occupies. + * + * @param t The field to compare against. + * @return `true` if `t` is an `STAccount` with the same default flag and + * value; `false` if `t` is a different type or either attribute differs. + * @note Callers that need to compare only the address (ignoring default + * state) should use `operator==` on the `value()` accessors directly. + */ [[nodiscard]] bool isEquivalent(STBase const& t) const override; + /** Return `true` if this field has never been explicitly assigned. + * + * A default field serializes as a zero-length VL blob. Assigning any + * `AccountID` — including the zero account — clears the default flag. + */ [[nodiscard]] bool isDefault() const override; + /** Assign an `AccountID` value, clearing the default flag. + * + * @param value The account identifier to store. + * @return `*this`, to support chained assignments. + */ STAccount& operator=(AccountID const& value); + /** Return the stored 160-bit account identifier. + * + * Returns the underlying `AccountID` regardless of whether the field is + * in the default state. Callers that need to distinguish "unset" from + * a real zero account should check `isDefault()` first. + */ [[nodiscard]] AccountID const& value() const noexcept; + /** Store `v` and mark this field as explicitly set. + * + * Unconditionally clears the default flag, even when `v` is the zero + * account, so that `isDefault()` returns `false` after any call. + * + * @param v The 160-bit account identifier to store. + */ void setValue(AccountID const& v); private: + /** Place a copy of this object into `buf` (if it fits within `n` bytes) + * or heap-allocate a copy via `STBase::emplace()`. + * + * Used by `detail::STVar` for the small-object optimization. + */ STBase* copy(std::size_t n, void* buf) const override; + + /** Place a moved instance into `buf` (if it fits within `n` bytes) + * or heap-allocate via `STBase::emplace()`. + * + * Used by `detail::STVar` for the small-object optimization. + */ STBase* move(std::size_t n, void* buf) override; @@ -81,30 +223,39 @@ STAccount::setValue(AccountID const& v) default_ = false; } +/** Return `true` if both `STAccount` objects hold the same 160-bit value. + * + * @note The default flag is not considered; use `isEquivalent()` when + * "set-ness" must also match. + */ inline bool operator==(STAccount const& lhs, STAccount const& rhs) { return lhs.value() == rhs.value(); } +/** Three-way-comparable less-than for two `STAccount` values. */ inline auto operator<(STAccount const& lhs, STAccount const& rhs) { return lhs.value() < rhs.value(); } +/** Return `true` if the `STAccount` holds the same 160-bit value as `rhs`. */ inline bool operator==(STAccount const& lhs, AccountID const& rhs) { return lhs.value() == rhs; } +/** Less-than comparison between an `STAccount` and a raw `AccountID`. */ inline auto operator<(STAccount const& lhs, AccountID const& rhs) { return lhs.value() < rhs; } +/** Less-than comparison between a raw `AccountID` and an `STAccount`. */ inline auto operator<(AccountID const& lhs, STAccount const& rhs) { diff --git a/include/xrpl/protocol/STAmount.h b/include/xrpl/protocol/STAmount.h index f05d44441d..d6aa261e65 100644 --- a/include/xrpl/protocol/STAmount.h +++ b/include/xrpl/protocol/STAmount.h @@ -1,3 +1,12 @@ +/** @file + * Canonical on-ledger amount type unifying XRP, IOU, and MPT quantities. + * + * `STAmount` is the serializable amount type used throughout the XRP Ledger. + * It stores XRP drops, IOU floating-point amounts, and Multi-Purpose Token + * (MPT) integers behind a single interface that integrates with the ledger's + * typed-field system via `STBase`. + */ + #pragma once #include @@ -16,21 +25,47 @@ namespace xrpl { -// Internal form: -// 1: If amount is zero, then value is zero and offset is -100 -// 2: Otherwise: -// legal offset range is -96 to +80 inclusive -// value range is 10^15 to (10^16 - 1) inclusive -// amount = value * [10 ^ offset] - -// Wire form: -// High 8 bits are (offset+142), legal range is, 80 to 22 inclusive -// Low 56 bits are value, legal range is 10^15 to (10^16 - 1) inclusive +/** Unified serializable amount for XRP, IOU, and MPT assets. + * + * `STAmount` is the canonical on-ledger amount type. It stores three + * fundamentally different quantity kinds — XRP drops, IOU floating-point + * amounts, and Multi-Purpose Token integers — behind a single interface + * that integrates with the ledger's typed-field system via `STBase`. + * + * ## Internal representation + * + * For **IOU** amounts the value is stored as normalized scientific notation: + * `amount = value × 10^offset`. The mantissa is in `[kMIN_VALUE, kMAX_VALUE]` + * i.e. `[10^15, 10^16 − 1]`, and the exponent is in `[kMIN_OFFSET, kMAX_OFFSET]` + * i.e. `[-96, +80]`. Zero is encoded as `value = 0, offset = −100`; the + * sentinel −100 ensures that zero sorts below every positive IOU with a + * large-negative exponent. + * + * For **XRP and MPT** (`integral()` types) `offset` is always 0 and `value` + * directly holds the raw drop or token count. XRP is bounded by `kMAX_NATIVE_N` + * (10^17 drops); MPT is bounded by `INT64_MAX`. + * + * ## Wire encoding + * + * Amounts are serialised into a packed 64-bit word: + * - Bit 63 = 0 → native (XRP or MPT); bit 61 further distinguishes them. + * - Bit 63 = 1 → issued currency (IOU). + * - Bit 62 = sign (1 = positive). + * - For IOU: bits 55–62 = `offset + 97`; bits 0–53 = mantissa. + * + * @note `canonicalize()` normalises the mantissa into `[kMIN_VALUE, kMAX_VALUE]` + * on every checked construction path. Constructors tagged `Unchecked` skip + * this step and require the caller to guarantee the representation is + * already canonical. + */ class STAmount final : public STBase, public CountedObject { public: + /** Unsigned integer type used to store the IOU mantissa or integral amount value. */ using mantissa_type = std::uint64_t; + /** Signed integer type used to store the IOU base-10 exponent. */ using exponent_type = int; + /** Pair of (mantissa, exponent) for use in serialization and arithmetic helpers. */ using rep = std::pair; private: @@ -42,34 +77,82 @@ private: public: using value_type = STAmount; + /** Minimum legal IOU exponent (offset). Zero and integral types always use 0. */ constexpr static int kMIN_OFFSET = -96; + /** Maximum legal IOU exponent (offset). */ constexpr static int kMAX_OFFSET = 80; - // Maximum native value supported by the code + /** Minimum normalized IOU mantissa (10^15). Mantissas below this are scaled up. */ constexpr static std::uint64_t kMIN_VALUE = 1'000'000'000'000'000ull; static_assert(isPowerOfTen(kMIN_VALUE)); + /** Maximum normalized IOU mantissa (10^16 − 1). Mantissas above this are scaled down. */ constexpr static std::uint64_t kMAX_VALUE = (kMIN_VALUE * 10) - 1; static_assert(kMAX_VALUE == 9'999'999'999'999'999ull); + /** Absolute maximum XRP/MPT value that the code will store internally + * (9 × 10^18 drops). Enforcement happens in the wire decoder and + * network-validity check (@ref isLegalNet). */ constexpr static std::uint64_t kMAX_NATIVE = 9'000'000'000'000'000'000ull; - // Max native value on network. + /** Maximum XRP drop value permitted on the network (10^17 = 100 billion XRP). + * Validated by @ref isLegalNet; amounts above this are consensus-invalid. */ constexpr static std::uint64_t kMAX_NATIVE_N = 100'000'000'000'000'000ull; + + // --- Wire-format flag bits (bit 63 is MSB) --- + + /** Wire bit 63: set for IOU amounts, clear for native (XRP or MPT). */ constexpr static std::uint64_t kISSUED_CURRENCY = 0x8'000'000'000'000'000ull; + /** Wire bit 62: sign bit — set means positive. */ constexpr static std::uint64_t kPOSITIVE = 0x4'000'000'000'000'000ull; + /** Wire bit 61: distinguishes MPT (set) from XRP (clear) for native amounts. */ constexpr static std::uint64_t kMP_TOKEN = 0x2'000'000'000'000'000ull; + /** Mask that strips the `kPOSITIVE` and `kMP_TOKEN` flag bits, leaving the + * raw value word for MPT amounts. */ constexpr static std::uint64_t kVALUE_MASK = ~(kPOSITIVE | kMP_TOKEN); + /** Wire encoding of a unit quality offer (rate = 1.0). */ static std::uint64_t const kU_RATE_ONE; //-------------------------------------------------------------------------- + // + // Constructors + // + //-------------------------------------------------------------------------- + + /** Deserialize an STAmount from a byte stream. + * + * Decodes the compact 64-bit wire word plus any trailing currency/issuer + * or MPTID bytes. Throws `std::runtime_error` on malformed input + * (negative zero, mantissa out of range, invalid currency/account). + * + * @param sit Source iterator positioned at the first byte of the amount. + * @param name The SField that names this field in the parent STObject. + */ STAmount(SerialIter& sit, SField const& name); + /** Tag type that bypasses `canonicalize()` on construction. + * + * Use only when the caller can guarantee the representation is already + * in canonical form (e.g. inside arithmetic helpers that maintain + * invariants, or when reading from a known-good source). Prefer the + * checked constructors for all other call sites. + */ struct Unchecked { explicit Unchecked() = default; }; - // Do not call canonicalize + /** Construct a named STAmount with a pre-canonical representation. + * + * Stores `mantissa × 10^exponent` (with sign) verbatim — `canonicalize()` + * is **not** called. The caller must ensure the values satisfy the IOU + * invariants or, for integral assets, that `exponent == 0`. + * + * @param name SField associated with this amount. + * @param asset Asset type (Issue or MPTIssue). + * @param mantissa Raw unsigned mantissa. + * @param exponent Base-10 exponent. + * @param negative True if the amount is negative. + */ template STAmount( SField const& name, @@ -79,6 +162,15 @@ public: bool negative, Unchecked); + /** Construct an anonymous STAmount with a pre-canonical representation. + * + * Anonymous (no SField) variant of the `Unchecked` constructor above. + * + * @param asset Asset type (Issue or MPTIssue). + * @param mantissa Raw unsigned mantissa. + * @param exponent Base-10 exponent. + * @param negative True if the amount is negative. + */ template STAmount( A const& asset, @@ -87,7 +179,18 @@ public: bool negative, Unchecked); - // Call canonicalize + /** Construct a named STAmount, calling `canonicalize()` afterward. + * + * Normalises the mantissa into `[kMIN_VALUE, kMAX_VALUE]` by adjusting + * the exponent. Throws `std::runtime_error` on overflow. Subnormals + * (exponent below `kMIN_OFFSET` after scaling) are silently zeroed. + * + * @param name SField associated with this amount. + * @param asset Asset type (Issue or MPTIssue). + * @param mantissa Unsigned mantissa (defaults to 0 → zero amount). + * @param exponent Base-10 exponent (defaults to 0). + * @param negative True if the amount is negative (defaults to false). + */ template STAmount( SField const& name, @@ -96,14 +199,44 @@ public: exponent_type exponent = 0, bool negative = false); + /** Construct a named XRP amount from a signed 64-bit drop count. + * + * Negative values set the sign flag; the stored mantissa is the absolute value. + * + * @param name SField associated with this amount. + * @param mantissa Signed drop count. + */ STAmount(SField const& name, std::int64_t mantissa); + /** Construct a named XRP amount from an unsigned 64-bit drop count. + * + * @param name SField associated with this amount. + * @param mantissa Unsigned drop count (defaults to 0). + * @param negative True if the amount is negative (defaults to false). + */ STAmount(SField const& name, std::uint64_t mantissa = 0, bool negative = false); + /** Construct an anonymous XRP amount from an unsigned 64-bit drop count. + * + * @param mantissa Unsigned drop count (defaults to 0). + * @param negative True if the amount is negative (defaults to false). + */ explicit STAmount(std::uint64_t mantissa = 0, bool negative = false); + /** Construct a named copy of an existing STAmount, preserving asset and value. + * + * @param name SField to attach to the copy. + * @param amt Source amount. + */ explicit STAmount(SField const& name, STAmount const& amt); + /** Construct an anonymous STAmount with the given asset, calling `canonicalize()`. + * + * @param asset Asset type (Issue or MPTIssue). + * @param mantissa Unsigned mantissa (defaults to 0). + * @param exponent Base-10 exponent (defaults to 0). + * @param negative True if the amount is negative (defaults to false). + */ template STAmount(A const& asset, std::uint64_t mantissa = 0, int exponent = 0, bool negative = false) : asset_(asset), value_(mantissa), offset_(exponent), isNegative_(negative) @@ -111,25 +244,84 @@ public: canonicalize(); } + /** Construct an anonymous STAmount from a 32-bit unsigned mantissa. + * + * Widens to `uint64_t` then delegates to the canonical constructor. + * + * @param asset Asset type (Issue or MPTIssue). + * @param mantissa 32-bit unsigned mantissa. + * @param exponent Base-10 exponent (defaults to 0). + * @param negative True if the amount is negative (defaults to false). + */ // VFALCO Is this needed when we have the previous signature? template STAmount(A const& asset, std::uint32_t mantissa, int exponent = 0, bool negative = false); + /** Construct an anonymous STAmount from a signed 64-bit mantissa. + * + * Negative values set the sign flag; the stored mantissa is the absolute value. + * + * @param asset Asset type (Issue or MPTIssue). + * @param mantissa Signed mantissa; sign extracted via `set()`. + * @param exponent Base-10 exponent (defaults to 0). + */ template STAmount(A const& asset, std::int64_t mantissa, int exponent = 0); + /** Construct an anonymous STAmount from a plain `int` mantissa. + * + * Widens to `int64_t` then delegates to the signed constructor. + * + * @param asset Asset type (Issue or MPTIssue). + * @param mantissa Signed integer mantissa. + * @param exponent Base-10 exponent (defaults to 0). + */ template STAmount(A const& asset, int mantissa, int exponent = 0); + /** Construct an STAmount from a `Number`, rounding to the asset's precision. + * + * Converts the high-precision `Number` into the appropriate internal + * representation. For integral assets (XRP, MPT) the fractional part is + * dropped; for IOU assets the mantissa is normalised into + * `[kMIN_VALUE, kMAX_VALUE]`. + * + * @param asset Asset type (Issue or MPTIssue). + * @param number High-precision value to convert. + */ template STAmount(A const& asset, Number const& number) : STAmount(fromNumber(asset, number)) { } - // Legacy support for new-style amounts + /** Construct from a lean `IOUAmount` and its associated `Issue`. + * + * Bridges from the lightweight `IOUAmount` representation to the + * serializable `STAmount` form. + * + * @param amount Lean IOU amount (mantissa + exponent). + * @param issue Currency/issuer identity for the resulting STAmount. + */ STAmount(IOUAmount const& amount, Issue const& issue); + + /** Construct from a lean `XRPAmount`. + * + * @param amount XRP drop count. + */ STAmount(XRPAmount const& amount); + + /** Construct from a lean `MPTAmount` and its associated `MPTIssue`. + * + * @param amount Lean MPT amount (raw integer token count). + * @param mptIssue MPT issuance identity. + */ STAmount(MPTAmount const& amount, MPTIssue const& mptIssue); + + /** Convert to a high-precision `Number`. + * + * Dispatches via `Asset::visit()` to the appropriate lean extractor + * (`xrp()`, `iou()`, or `mpt()`) and constructs a `Number` from it. + */ operator Number() const; //-------------------------------------------------------------------------- @@ -138,39 +330,83 @@ public: // //-------------------------------------------------------------------------- + /** Return the base-10 exponent. + * + * For IOU amounts this is in `[kMIN_OFFSET, kMAX_OFFSET]`, or −100 when + * the amount is zero. For XRP and MPT amounts this is always 0. + */ [[nodiscard]] int exponent() const noexcept; + /** True if this amount is an integral (non-floating-point) type. + * + * Returns true for both XRP and MPT; false for IOU. Integral types store + * `offset == 0` and a raw integer token count in `value`. + */ [[nodiscard]] bool integral() const noexcept; + /** True if this amount represents native XRP. + * + * Returns false for IOU and MPT amounts. + */ [[nodiscard]] bool native() const noexcept; + /** True if the embedded asset is of type `TIss`. + * + * @tparam TIss Either `Issue` (covers both XRP and IOU) or `MPTIssue`. + */ template [[nodiscard]] constexpr bool holds() const noexcept; + /** True if this amount is negative. + * + * A canonical zero amount is never negative. + */ [[nodiscard]] bool negative() const noexcept; + /** Return the raw unsigned mantissa. + * + * For IOU amounts this is in `[kMIN_VALUE, kMAX_VALUE]` (or 0 for zero). + * For XRP and MPT amounts this is the raw drop or token count. + */ [[nodiscard]] std::uint64_t mantissa() const noexcept; + /** Return the asset (Issue or MPTIssue) carried by this amount. */ [[nodiscard]] Asset const& asset() const; + /** Return the embedded asset as the specific issue type `TIss`. + * + * @tparam TIss Either `Issue` or `MPTIssue`. + * @throws std::logic_error if the asset is not of type `TIss`. + */ template constexpr TIss const& get() const; + /** Mutable variant of `get()`. + * + * @tparam TIss Either `Issue` or `MPTIssue`. + * @throws std::logic_error if the asset is not of type `TIss`. + */ template TIss& get(); + /** Return the issuer account for IOU amounts; `noAccount()` for XRP; + * the MPT issuer account for MPT amounts. */ [[nodiscard]] AccountID const& getIssuer() const; + /** Return the sign as −1, 0, or +1. + * + * A canonical zero always returns 0 regardless of the `negative` flag. + */ [[nodiscard]] int signum() const noexcept; @@ -178,9 +414,16 @@ public: [[nodiscard]] STAmount zeroed() const; + /** Populate a JSON object with the amount's fields (value, currency, issuer / mpt_issuance_id). */ void setJson(json::Value&) const; + /** Returns a const reference to `*this`. + * + * Provided so that `STAmount` satisfies the same `value()` accessor + * pattern as the lean amount types (`XRPAmount`, `IOUAmount`, `MPTAmount`), + * enabling generic template code that calls `.value()` uniformly. + */ [[nodiscard]] STAmount const& value() const noexcept; @@ -190,19 +433,34 @@ public: // //-------------------------------------------------------------------------- + /** True if the amount is non-zero. */ explicit operator bool() const noexcept; + /** Add `rhs` to this amount in place. + * + * @pre Both amounts must have the same asset; mixing asset types is + * undefined behaviour and will produce a wrong result at runtime. + */ STAmount& operator+=(STAmount const&); + + /** Subtract `rhs` from this amount in place. + * + * @pre Both amounts must have the same asset; mixing asset types is + * undefined behaviour and will produce a wrong result at runtime. + */ STAmount& operator-=(STAmount const&); + /** Zero this amount, preserving its asset identity. */ STAmount& operator=(beast::Zero); + /** Assign from a lean `XRPAmount`, preserving the XRP asset identity. */ STAmount& operator=(XRPAmount const& amount); + /** Assign from a `Number`, rounding to the current asset's precision. */ STAmount& operator=(Number const&); @@ -212,17 +470,32 @@ public: // //-------------------------------------------------------------------------- + /** Flip the sign; a canonical zero amount is left unchanged. */ void negate(); + /** Reset to zero while keeping the current asset identity. + * + * For IOU amounts sets `offset` to −100 (the canonical zero sentinel so + * that zero sorts below small positive IOUs). For integral types sets + * `offset` to 0. + */ void clear(); - // Zero while copying currency and issuer. + /** Reset to zero with a new asset identity. + * + * Equivalent to `setIssue(asset); clear();`. + * + * @param asset The asset to adopt. + */ void clear(Asset const& asset); - /** Set the Issue for this amount. */ + /** Replace the asset identity without changing the value representation. + * + * @param asset New asset (Issue or MPTIssue). + */ void setIssue(Asset const& asset); @@ -232,30 +505,68 @@ public: // //-------------------------------------------------------------------------- + /** Returns `STI_AMOUNT`. */ [[nodiscard]] SerializedTypeID getSType() const override; + /** Returns a human-readable string including the field name and formatted value. */ [[nodiscard]] std::string getFullText() const override; + /** Returns a formatted string representation of the numeric value. */ [[nodiscard]] std::string getText() const override; + /** Serialize to JSON. + * + * XRP amounts are emitted as a plain decimal string (drop count). + * IOU amounts produce `{value, currency, issuer}`. + * MPT amounts produce `{value, mpt_issuance_id}`. + */ [[nodiscard]] json::Value getJson(JsonOptions = JsonOptions::Values::None) const override; + /** Append the wire-format encoding to `s`. + * + * Writes the compact 64-bit word plus any trailing currency/issuer + * bytes (IOU) or 192-bit MPTID (MPT). + */ void add(Serializer& s) const override; + /** Returns true if `t` is an `STAmount` with the same asset and value. + * + * Comparison is performed on the binary representation, so canonical + * equivalence is checked, not numeric equality. + */ [[nodiscard]] bool isEquivalent(STBase const& t) const override; + /** Returns true when the amount is zero. + * + * A field whose presence is governed by `soeDEFAULT` is omitted from + * ledger serialisation when `isDefault()` is true. + */ [[nodiscard]] bool isDefault() const override; + /** Extract the value as a lean `XRPAmount`. + * + * @throws std::logic_error if this is not a native XRP amount. + */ [[nodiscard]] XRPAmount xrp() const; + + /** Extract the value as a lean `IOUAmount`. + * + * @throws std::logic_error if this is not an IOU amount. + */ [[nodiscard]] IOUAmount iou() const; + + /** Extract the value as a lean `MPTAmount`. + * + * @throws std::logic_error if this is not an MPT amount. + */ [[nodiscard]] MPTAmount mpt() const; @@ -354,7 +665,6 @@ STAmount::STAmount(A const& asset, int mantissa, int exponent) { } -// Legacy support for new-style amounts inline STAmount::STAmount(IOUAmount const& amount, Issue const& issue) : asset_(issue), offset_(amount.exponent()), isNegative_(amount < beast::kZERO) { @@ -391,21 +701,70 @@ inline STAmount::STAmount(MPTAmount const& amount, MPTIssue const& mptIssue) // //------------------------------------------------------------------------------ -// VFALCO TODO The parameter type should be Quality not uint64_t +/** Reconstruct an offer quality (rate) as a displayable STAmount. + * + * Decodes the packed `uint64_t` quality word produced by `getRate()` back + * into a human-readable IOU-denominated amount (no issuer). + * + * @param rate Encoded quality word (exponent in high byte, mantissa in low bits). + * @return An STAmount suitable for display or JSON output. + * @note The parameter type should eventually be `Quality` rather than `uint64_t`. + */ STAmount amountFromQuality(std::uint64_t rate); +/** Parse an amount from a decimal string for the given asset. + * + * Accepts a plain decimal string (possibly with an exponent suffix for IOU) + * or a drop-count string for XRP. Throws on malformed input. + * + * @param asset Target asset type. + * @param amount Decimal string representation. + * @return The parsed STAmount. + * @throws std::runtime_error on malformed input. + */ STAmount amountFromString(Asset const& asset, std::string const& amount); +/** Parse an STAmount from a JSON value, associating it with a named SField. + * + * Accepts three formats: + * - Plain string (XRP drop count). + * - `{value, currency, issuer}` object (IOU). + * - `{value, mpt_issuance_id}` object (MPT). + * + * Also accepts the legacy slash-delimited string format used in some RPC + * responses for historical compatibility. + * + * @param name SField to associate with the resulting STAmount. + * @param v JSON value to parse. + * @return The parsed STAmount. + * @throws std::runtime_error if the JSON is malformed or the values are out of range. + */ STAmount amountFromJson(SField const& name, json::Value const& v); +/** Non-throwing variant of `amountFromJson`. + * + * Parses a JSON value as an STAmount. On success writes to `result` and + * returns true; on any error leaves `result` unchanged and returns false. + * + * @param result Output parameter filled on success. + * @param jvSource JSON value to parse. + * @return True on success, false on any parse error. + */ bool amountFromJsonNoThrow(STAmount& result, json::Value const& jvSource); -// IOUAmount and XRPAmount define toSTAmount, defining this -// trivial conversion here makes writing generic code easier +/** Identity conversion so generic code can call `toSTAmount()` uniformly. + * + * `IOUAmount` and `XRPAmount` provide their own `toSTAmount()` overloads. + * This overload completes the set so that templates need not special-case + * `STAmount`. + * + * @param a The STAmount to pass through. + * @return A const reference to `a`. + */ inline STAmount const& toSTAmount(STAmount const& a) { @@ -555,8 +914,6 @@ STAmount::negate() inline void STAmount::clear() { - // The -100 is used to allow 0 to sort less than a small positive values - // which have a negative exponent. offset_ = integral() ? 0 : -100; value_ = 0; isNegative_ = false; @@ -575,6 +932,14 @@ STAmount::value() const noexcept return *this; } +/** Returns true if the amount is a legal network value. + * + * For non-native amounts this is always true. For XRP amounts, the mantissa + * must not exceed `STAmount::kMAX_NATIVE_N` (10^17 drops = 100 billion XRP). + * Amounts that fail this check must not be included in consensus transactions. + * + * @param value The amount to test. + */ inline bool isLegalNet(STAmount const& value) { @@ -587,35 +952,55 @@ isLegalNet(STAmount const& value) // //------------------------------------------------------------------------------ +/** Compare two STAmounts for equality. + * + * Two amounts are equal when they have identical asset, mantissa, exponent, + * and sign. Amounts of different asset types are never equal. + */ bool operator==(STAmount const& lhs, STAmount const& rhs); + +/** Less-than comparison for STAmount. + * + * Defines a total order within the same asset type. Amounts of different + * asset types compare by asset identity first (implementation-defined stable + * order) so that STAmount can be used in ordered containers. + */ bool operator<(STAmount const& lhs, STAmount const& rhs); +/** Returns `!(lhs == rhs)`. */ inline bool operator!=(STAmount const& lhs, STAmount const& rhs) { return !(lhs == rhs); } +/** Returns `rhs < lhs`. */ inline bool operator>(STAmount const& lhs, STAmount const& rhs) { return rhs < lhs; } +/** Returns `!(rhs < lhs)`. */ inline bool operator<=(STAmount const& lhs, STAmount const& rhs) { return !(rhs < lhs); } +/** Returns `!(lhs < rhs)`. */ inline bool operator>=(STAmount const& lhs, STAmount const& rhs) { return !(lhs < rhs); } +/** Return the arithmetic negation of `value`. + * + * A zero amount is returned unchanged (canonical zero has no sign). + */ STAmount operator-(STAmount const& value); @@ -625,36 +1010,110 @@ operator-(STAmount const& value); // //------------------------------------------------------------------------------ +/** Add two same-asset STAmounts. + * + * @pre `v1` and `v2` must have the same asset. + */ STAmount operator+(STAmount const& v1, STAmount const& v2); + +/** Subtract two same-asset STAmounts. + * + * @pre `v1` and `v2` must have the same asset. + */ STAmount operator-(STAmount const& v1, STAmount const& v2); +/** Divide `v1` by `v2`, expressing the result in `asset`. + * + * Designed for cross-currency calculations where the result naturally belongs + * to a third asset (e.g. quality calculations). Uses the amendment-gated + * arithmetic path (`getSTNumberSwitchover()`) for precision. + * + * @param v1 Dividend. + * @param v2 Divisor (must be non-zero). + * @param asset Asset type for the result. + * @return Quotient expressed as an STAmount with `asset`. + */ STAmount divide(STAmount const& v1, STAmount const& v2, Asset const& asset); +/** Multiply `v1` by `v2`, expressing the result in `asset`. + * + * @param v1 First factor. + * @param v2 Second factor. + * @param asset Asset type for the result. + * @return Product expressed as an STAmount with `asset`. + */ STAmount multiply(STAmount const& v1, STAmount const& v2, Asset const& asset); -// multiply rounding result in specified direction +/** Multiply with legacy fixed-direction rounding. + * + * Uses the legacy rounding approach: rounds up when the fractional + * remainder is ≥ 0.1 of the smallest representable unit. + * Prefer `mulRoundStrict` for new code that needs accurate rounding. + * + * @param v1 First factor. + * @param v2 Second factor. + * @param asset Asset type for the result. + * @param roundUp True to round up, false to round down. + * @return Rounded product expressed as an STAmount with `asset`. + */ STAmount mulRound(STAmount const& v1, STAmount const& v2, Asset const& asset, bool roundUp); -// multiply following the rounding directions more precisely. +/** Multiply following the thread-local `Number::rounding_mode` precisely. + * + * Respects the `NumberRoundModeGuard` rounding mode for accurate remainder + * tracking, rather than the fixed legacy approximation used by `mulRound`. + * + * @param v1 First factor. + * @param v2 Second factor. + * @param asset Asset type for the result. + * @param roundUp True to round up, false to round down. + * @return Rounded product expressed as an STAmount with `asset`. + */ STAmount mulRoundStrict(STAmount const& v1, STAmount const& v2, Asset const& asset, bool roundUp); -// divide rounding result in specified direction +/** Divide with legacy fixed-direction rounding. + * + * Uses the legacy rounding approach. Prefer `divRoundStrict` for new code. + * + * @param v1 Dividend. + * @param v2 Divisor (must be non-zero). + * @param asset Asset type for the result. + * @param roundUp True to round up, false to round down. + * @return Rounded quotient expressed as an STAmount with `asset`. + */ STAmount divRound(STAmount const& v1, STAmount const& v2, Asset const& asset, bool roundUp); -// divide following the rounding directions more precisely. +/** Divide following the thread-local `Number::rounding_mode` precisely. + * + * @param v1 Dividend. + * @param v2 Divisor (must be non-zero). + * @param asset Asset type for the result. + * @param roundUp True to round up, false to round down. + * @return Rounded quotient expressed as an STAmount with `asset`. + */ STAmount divRoundStrict(STAmount const& v1, STAmount const& v2, Asset const& asset, bool roundUp); -// Someone is offering X for Y, what is the rate? -// Rate: smaller is better, the taker wants the most out: in/out -// VFALCO TODO Return a Quality object +/** Encode an offer quality (in/out ratio) as a compact `uint64_t`. + * + * The rate represents `offerIn / offerOut`. A **smaller** value is better + * for the taker (more output per unit input). The encoding packs the + * base-10 exponent in the high byte and the mantissa in the remaining bits, + * making the values directly comparable as integers — which is the sort + * order used for offer-book directories. + * + * @param offerOut Amount the offer gives out. + * @param offerIn Amount the offer takes in. + * @return Packed quality word, or 0 if the result underflows. + * @note The return type should eventually be `Quality`. + */ std::uint64_t getRate(STAmount const& offerOut, STAmount const& offerIn); @@ -722,26 +1181,63 @@ roundToAsset( //------------------------------------------------------------------------------ +/** Returns true if `amount` represents native XRP. + * + * Convenience wrapper around `STAmount::native()` for use in generic code + * that checks the asset type before dispatching. + */ inline bool isXRP(STAmount const& amount) { return amount.native(); } +/** Pre-flight check: returns true if `amt1 + amt2` is representable. + * + * For XRP and MPT amounts this performs 64-bit overflow/underflow bounds + * tests without executing the addition. + * + * For IOU amounts a relative-precision metric is used: both operands are + * reconstructed after a round-trip through addition and the combined + * relative error must not exceed 10^-4. This guards against silently + * losing significant digits when the operands' exponents differ by more + * than 15 (the mantissa precision limit). + * + * @param amt1 First operand. + * @param amt2 Second operand. + * @return True if the addition can be performed safely; false if it would + * overflow or produce an unacceptably imprecise result. + */ bool canAdd(STAmount const& amt1, STAmount const& amt2); +/** Pre-flight check: returns true if `amt1 - amt2` is representable. + * + * Equivalent to `canAdd(amt1, -amt2)`. Performs 64-bit underflow/overflow + * bounds tests for XRP and MPT; uses the relative-precision metric for IOU. + * + * @param amt1 Minuend. + * @param amt2 Subtrahend. + * @return True if the subtraction can be performed safely. + */ bool canSubtract(STAmount const& amt1, STAmount const& amt2); -/** Get the scale of a Number for a given asset. +/** Return the STAmount exponent that would result from converting `number` + * to an STAmount for the given asset. * - * "scale" is similar to "exponent", but from the perspective of STAmount, which has different rules - * and mantissa ranges for determining the exponent than Number. + * "Scale" is the base-10 exponent after STAmount normalization, which + * differs from `Number::exponent()` because STAmount enforces a narrower + * mantissa range (`[kMIN_VALUE, kMAX_VALUE]`) and asset-specific rules + * (integral assets always have exponent 0). This function constructs a + * temporary STAmount purely to read back the normalized exponent. * - * @param number The Number to get the scale of. - * @param asset The asset to use for determining the scale. - * @return The scale of this Number for the given asset. + * Used by `roundToAsset` to determine the precision boundary before + * shedding sub-precision dust via `roundToScale`. + * + * @param number The high-precision value to inspect. + * @param asset The asset that governs normalization rules. + * @return The base-10 exponent of the normalized STAmount. */ inline int scale(Number const& number, Asset const& asset) @@ -753,6 +1249,20 @@ scale(Number const& number, Asset const& asset) //------------------------------------------------------------------------------ namespace json { + +/** Extract an STAmount from a JSON object by SField name. + * + * Specialisation of `json::getOrThrow` for `xrpl::STAmount`. Looks up + * the field by its JSON key name in `v`, then delegates to + * `xrpl::amountFromJson` for full parsing (handles XRP string, IOU object, + * and MPT object formats). + * + * @param v JSON object containing the field. + * @param field SField whose JSON name is used as the lookup key. + * @return Parsed STAmount. + * @throws JsonMissingKeyError if the key is absent in `v`. + * @throws std::runtime_error if the value cannot be parsed as an STAmount. + */ template <> inline xrpl::STAmount getOrThrow(json::Value const& v, xrpl::SField const& field) diff --git a/include/xrpl/protocol/STArray.h b/include/xrpl/protocol/STArray.h index 61753c52dc..8b25046d71 100644 --- a/include/xrpl/protocol/STArray.h +++ b/include/xrpl/protocol/STArray.h @@ -5,6 +5,28 @@ namespace xrpl { +/** An ordered, variable-length sequence of `STObject` instances. + * + * `STArray` is the protocol's container for repeated structured sub-fields + * within transactions and ledger entries — for example `sfMemos` (per-tx + * memo objects), `sfSigners` (multi-sign signer list), and `sfNFTokens` (NFT + * page entries). It participates fully in the XRPL binary wire format and + * the JSON/RPC layer. + * + * The binary format is sentinel-terminated: elements are encoded sequentially + * and the stream ends with an `(STI_ARRAY, 1)` marker rather than a length + * prefix. Each element must be an `STObject`; non-object field types and + * misplaced terminators throw `std::runtime_error` during deserialization. + * + * Instances are tracked by `CountedObject` for diagnostic purposes + * (see `GetCounts`). The tracking cost is a single atomic + * increment/decrement per object lifetime. + * + * @note An empty `STArray` is considered the default value (`isDefault()` + * returns `true`), so enclosing `STObject` serializers will omit it from + * the wire encoding entirely — consistent with how absent optional array + * fields are represented in the ledger. + */ class STArray final : public STBase, public CountedObject { private: @@ -21,12 +43,30 @@ public: STArray() = default; STArray(STArray const&) = default; + /** Construct an anonymous STArray from an iterator range of `STObject`s. + * + * The resulting array has no `SField` association. Use the two-argument + * overload when the array must be bound to a named field. + * + * @tparam Iter Forward iterator whose reference type is convertible to + * `STObject`. + * @param first Beginning of the source range. + * @param last One-past-the-end of the source range. + */ template < class Iter, class = std::enable_if_t< std::is_convertible_v::reference, STObject>>> explicit STArray(Iter first, Iter last); + /** Construct an STArray bound to a field, initialized from an iterator range. + * + * @tparam Iter Forward iterator whose reference type is convertible to + * `STObject`. + * @param f The `SField` that names this array in its parent object. + * @param first Beginning of the source range. + * @param last One-past-the-end of the source range. + */ template < class Iter, class = std::enable_if_t< @@ -35,34 +75,131 @@ public: STArray& operator=(STArray const&) = default; + + /** Move constructor. + * + * Explicitly copies the `SField` name from @p other before moving the + * element vector. `STBase` stores the field-name pointer separately from + * the data, so without this explicit transfer the moved-into object would + * carry a stale field association, causing field-ID mismatches during + * serialization. + * + * @param other The array to move from; left in a valid but unspecified state. + */ STArray(STArray&&); + + /** Move assignment operator. + * + * Same field-name transfer requirement as the move constructor. + * + * @param other The array to move from; left in a valid but unspecified state. + * @return `*this` + */ STArray& operator=(STArray&&); + /** Construct an STArray bound to a field with pre-allocated capacity. + * + * @param f The `SField` that names this array in its parent object. + * @param n Number of elements to reserve storage for. + */ STArray(SField const& f, std::size_t n); + + /** Deserializing constructor — decodes a sentinel-terminated sequence of + * inner objects from a binary stream. + * + * Loops over `(type, field)` pairs from @p sit until the canonical + * end-of-array marker (`STI_ARRAY, field == 1`) is encountered. Each + * iteration validates the next token and constructs an `STObject` element + * in place. After construction, `applyTemplateFromSField` validates the + * element against the registered schema for its field type (e.g. `sfMemo`, + * `sfSigner`). + * + * @param sit Forward cursor over the binary payload. Advanced in place. + * @param f The `SField` naming this array in its parent object. + * @param depth Current nesting depth threaded from the parent `STObject`. + * Incremented before each child `STObject` is constructed; `STObject` + * enforces a maximum depth of 10 to prevent stack exhaustion from + * crafted payloads. + * @throws std::runtime_error with message `"Illegal terminator in array"` + * if a misplaced end-of-object marker `(STI_OBJECT, 1)` is found. + * @throws std::runtime_error with message `"Unknown field"` if an + * unrecognized `(type, field)` pair is encountered. + * @throws std::runtime_error with message `"Non-object in array"` if a + * non-`STI_OBJECT` element type appears in the stream. + * @throws std::runtime_error if `applyTemplateFromSField` rejects an + * element; the partially constructed array is abandoned entirely. + */ STArray(SerialIter& sit, SField const& f, int depth = 0); + + /** Construct an anonymous STArray with pre-allocated capacity. + * + * Creates an array with no `SField` association but with storage reserved + * for @p n elements, avoiding early reallocations when the size is known + * up front. + * + * @param n Number of elements to reserve space for. + */ explicit STArray(int n); + + /** Construct an empty STArray bound to the given field. + * + * @param f The `SField` that names this array in its parent object. + */ explicit STArray(SField const& f); + /** Access element at index @p j without bounds checking. + * + * @param j Zero-based index; behaviour is undefined if `j >= size()`. + * @return Reference to the element at position @p j. + */ STObject& operator[](std::size_t j); + /** Access element at index @p j without bounds checking (const overload). + * + * @param j Zero-based index; behaviour is undefined if `j >= size()`. + * @return Const reference to the element at position @p j. + */ STObject const& operator[](std::size_t j) const; + /** Access the last element without bounds checking. + * + * @return Reference to the last element; behaviour is undefined if the + * array is empty. + */ STObject& back(); + /** Access the last element without bounds checking (const overload). + * + * @return Const reference to the last element; behaviour is undefined if + * the array is empty. + */ [[nodiscard]] STObject const& back() const; + /** Construct an `STObject` in place at the end of the array. + * + * @tparam Args Constructor argument types forwarded to `STObject`. + * @param args Arguments forwarded to the `STObject` constructor. + */ template void emplaceBack(Args&&... args); + /** Append a copy of @p object to the end of the array. + * + * @param object The element to copy-append. + */ void pushBack(STObject const& object); + /** Append @p object to the end of the array by move. + * + * @param object The element to move-append. + */ void pushBack(STObject&& object); @@ -81,72 +218,187 @@ public: pushBack(std::move(object)); } + /** Return an iterator to the first element. */ iterator begin(); + /** Return an iterator to one past the last element. */ iterator end(); + /** Return a const iterator to the first element. */ [[nodiscard]] const_iterator begin() const; + /** Return a const iterator to one past the last element. */ [[nodiscard]] const_iterator end() const; + /** Return the number of elements in the array. */ [[nodiscard]] size_type size() const; + /** Return `true` when the array contains no elements. */ [[nodiscard]] bool empty() const; + /** Remove all elements, leaving an empty array. */ void clear(); + /** Reserve storage for at least @p n elements without changing the size. + * + * @param n Minimum capacity to reserve. + */ void reserve(std::size_t n); + /** Swap contents with @p a in constant time. + * + * @param a The other array to swap with. + */ void swap(STArray& a) noexcept; + /** Return a bracket-delimited, comma-separated string including field names. + * + * Each element is rendered via `STObject::getFullText()`. Intended for + * debugging and logging. + * + * @return Human-readable representation such as `[fieldA = ..., fieldB = ...]`. + */ [[nodiscard]] std::string getFullText() const override; + /** Return a bracket-delimited, comma-separated string of element values only. + * + * Each element is rendered via `STObject::getText()`, which omits field-name + * prefixes. Intended for debugging. + * + * @return Human-readable value-only representation of the array. + */ [[nodiscard]] std::string getText() const override; + /** Serialize this array to a JSON array value. + * + * Each element that is not `STI_NOTPRESENT` is appended as a JSON object + * with a single key — the element's field name — mapping to the element's + * own JSON representation: + * @code + * [ { "Memo": { "MemoData": "..." } }, ... ] + * @endcode + * Elements with type `STI_NOTPRESENT` (absent optional fields in a + * template-bound context) are silently skipped. + * + * @param index JSON rendering options forwarded to each element. + * @return A `json::Value` of array type. + */ [[nodiscard]] json::Value getJson(JsonOptions index) const override; + /** Append the binary encoding of every element to @p s. + * + * For each element, writes: field ID, element content, per-element object + * terminator (`STI_OBJECT, 1`). The outer array's own field ID and the + * end-of-array terminator (`STI_ARRAY, 1`) are written by the enclosing + * `STObject`, not here — each level is responsible only for its own body. + * + * @param s The serializer to append to. + */ void add(Serializer& s) const override; + /** Sort elements in place using a caller-supplied strict-weak-order comparator. + * + * Used to impose canonical ordering before serialization. Key callers: + * - `TxMeta::addRaw()` — sorts `AffectedNodes` by `sfLedgerIndex`; a + * deviation is a consensus-fork risk. + * - NFToken helpers — sorts `sfNFTokens` entries by `sfNFTokenID` when + * managing NFT pages. + * + * @note Multi-sign transactions require `sfSigners` to be sorted in + * ascending `AccountID` order before submission. That sort is expected + * to be performed by the signing client, not by the protocol layer. + * + * @param compare Function pointer returning `true` when the first argument + * should precede the second. Must satisfy strict-weak-ordering. + */ void sort(bool (*compare)(STObject const& o1, STObject const& o2)); + /** Test element-wise equality with another `STArray`. + * + * @param s The array to compare against. + * @return `true` if both arrays have the same number of elements and each + * pair of corresponding elements compares equal via `STObject::operator==`. + */ bool operator==(STArray const& s) const; + /** Test element-wise inequality with another `STArray`. + * + * @param s The array to compare against. + * @return `true` if the arrays differ in size or any element pair is unequal. + */ bool operator!=(STArray const& s) const; + /** Erase the element at @p pos. + * + * @param pos Iterator to the element to remove. + * @return Iterator to the element following the erased one. + */ iterator erase(iterator pos); + /** Erase the element at @p pos (const_iterator overload). + * + * @param pos Const iterator to the element to remove. + * @return Iterator to the element following the erased one. + */ iterator erase(const_iterator pos); + /** Erase the elements in the range `[first, last)`. + * + * @param first Iterator to the first element to remove. + * @param last Iterator to one past the last element to remove. + * @return Iterator to the element following the last erased element. + */ iterator erase(iterator first, iterator last); + /** Erase the elements in the range `[first, last)` (const_iterator overload). + * + * @param first Const iterator to the first element to remove. + * @param last Const iterator to one past the last element to remove. + * @return Iterator to the element following the last erased element. + */ iterator erase(const_iterator first, const_iterator last); + /** Return `STI_ARRAY` — the serialized type ID for this class. */ [[nodiscard]] SerializedTypeID getSType() const override; + /** Test deep equality with another `STBase`. + * + * Performs a `dynamic_cast` to confirm @p t is also an `STArray`, then + * delegates to vector equality, which cascades through `STObject::operator==`. + * + * @param t The object to compare against. + * @return `true` if @p t is an `STArray` whose elements are pairwise equal + * to this array's elements. + */ [[nodiscard]] bool isEquivalent(STBase const& t) const override; + /** Return `true` when the array is empty. + * + * An empty `STArray` is the default value; the enclosing `STObject` + * serializer will omit a default-valued field from the wire encoding. + */ [[nodiscard]] bool isDefault() const override; diff --git a/include/xrpl/protocol/STBase.h b/include/xrpl/protocol/STBase.h index bfcc50d1ff..4661bf58ea 100644 --- a/include/xrpl/protocol/STBase.h +++ b/include/xrpl/protocol/STBase.h @@ -12,34 +12,48 @@ namespace xrpl { -/// Note, should be treated as flags that can be | and & +/** Bitmask controlling how an ST type renders to JSON. + * + * Combines named flag bits defined in `Values`. Supports `|`, `&`, and `~` + * for composing and masking option sets. The complement operator `~` is + * bounded by `Values::All` so it never sets reserved future bits. + * + * @note Treat instances as flag sets — bitwise operators are the intended + * interface; do not compare or store the raw `value` field directly. + */ struct JsonOptions { using underlying_t = unsigned int; underlying_t value; + /** Named option bits for JSON rendering. */ enum class Values : underlying_t { None = 0b0000'0000, - IncludeDate = 0b0000'0001, - DisableApiPriorV2 = 0b0000'0010, + IncludeDate = 0b0000'0001, /**< Include a date field in the output. */ + DisableApiPriorV2 = 0b0000'0010, /**< Suppress legacy pre-API-v2 formatting. */ // IMPORTANT `All` must be union of all of the above; see also operator~ All = IncludeDate | DisableApiPriorV2 // 0b0000'0011 }; + /** Construct from a raw bitmask value. */ constexpr JsonOptions(underlying_t v) noexcept : value(v) { } + /** Construct from a named `Values` enumerator. */ constexpr JsonOptions(Values v) noexcept : value(static_cast(v)) { } + /** Convert to the underlying unsigned integer. */ [[nodiscard]] constexpr explicit operator underlying_t() const noexcept { return value; } + + /** Return `true` if any option bit is set. */ [[nodiscard]] constexpr explicit operator bool() const noexcept { @@ -50,22 +64,26 @@ struct JsonOptions [[nodiscard]] constexpr auto friend operator!=(JsonOptions lh, JsonOptions rh) noexcept -> bool = default; - /// Returns JsonOptions union of lh and rh + /** Return the union (bitwise OR) of two option sets. */ [[nodiscard]] constexpr JsonOptions friend operator|(JsonOptions lh, JsonOptions rh) noexcept { return {lh.value | rh.value}; } - /// Returns JsonOptions intersection of lh and rh + /** Return the intersection (bitwise AND) of two option sets. */ [[nodiscard]] constexpr JsonOptions friend operator&(JsonOptions lh, JsonOptions rh) noexcept { return {lh.value & rh.value}; } - /// Returns JsonOptions binary negation, can be used with & (above) for set - /// difference e.g. `(options & ~JsonOptions::kINCLUDE_DATE)` + /** Return the complement, bounded to the known `Values::All` mask. + * + * Use with `&` for set-difference, e.g. + * `options & ~JsonOptions(JsonOptions::Values::IncludeDate)`. + * Bits beyond `Values::All` are never set in the result. + */ [[nodiscard]] constexpr JsonOptions friend operator~(JsonOptions v) noexcept { @@ -73,6 +91,17 @@ struct JsonOptions } }; +/** ADL-accessible JSON conversion for any type that exposes `getJson(JsonOptions)`. + * + * Calls `t.getJson(JsonOptions::Values::None)`. Provides a uniform, + * options-free entry point for generic code that needs to render an ST value + * without caring about per-call rendering flags. + * + * @tparam T A type whose `getJson` method returns a value convertible to + * `json::Value`. + * @param t The object to convert. + * @return A `json::Value` representation of @p t. + */ template requires requires(T const& t) { { t.getJson(JsonOptions::Values::None) } -> std::convertible_to; @@ -100,85 +129,214 @@ class STVar; //------------------------------------------------------------------------------ -/** A type which can be exported to a well known binary format. - - A STBase: - - Always a field - - Can always go inside an eligible enclosing STBase - (such as STArray) - - Has a field name - - Like JSON, a SerializedObject is a basket which has rules - on what it can hold. - - @note "ST" stands for "Serialized Type." -*/ +/** Abstract base class for every serialized field type in the XRPL protocol. + * + * "ST" stands for "Serialized Type." Every field that appears in a + * transaction, ledger entry, or validation — integers, amounts, account IDs, + * blobs, arrays, nested objects — is represented as a class derived from + * `STBase`. Each instance carries a field identity (an `SField` pointer) that + * binds a human-readable name and a numeric type+field code used in the binary + * wire format. + * + * The virtual interface (`getSType`, `add`, `isEquivalent`, `isDefault`, + * `getJson`, `getText`, `getFullText`) must be overridden by every concrete + * subclass. + * + * @note `operator=` deliberately does **not** copy the field name when the + * destination already holds a meaningful name (see implementation). This + * design supports element slide-down in `STObject`/`STArray` without + * corrupting field identities. As a consequence, do **not** store + * `STBase`-derived objects directly in `std::vector` or similar owning + * containers — use `boost::ptr_*` containers or `detail::STVar` instead. + */ class STBase { SField const* fName_; public: virtual ~STBase() = default; + + /** Construct with the generic (placeholder) field name. */ STBase(); + STBase(STBase const&) = default; + + /** Copy-assign the value; conditionally copies the field name. + * + * The field name (`fName_`) is updated from @p t only when the current + * name is not useful (e.g., `sfGeneric`). This allows slot initialisation + * to pick up the source's protocol identity while preventing element + * slide-down operations inside `STObject` from overwriting already-valid + * field names. + * + * @param t The source object whose value (and optionally name) to copy. + * @return `*this` + */ STBase& operator=(STBase const& t); + /** Construct with a specific field identity. + * + * @param n The `SField` descriptor that identifies this field on the wire. + */ explicit STBase(SField const& n); + /** Value equality: same concrete type and `isEquivalent` holds. + * + * Field names are ignored; only values are compared. + */ bool operator==(STBase const& t) const; + + /** Value inequality: opposite of `operator==`. */ bool operator!=(STBase const& t) const; + /** Narrow the static type to `D`, throwing on failure. + * + * Performs `dynamic_cast(this)`. Prefer this over a raw + * `dynamic_cast` at call sites — it guarantees that a failed cast throws + * `std::bad_cast` rather than yielding a null pointer that may be + * silently dereferenced. + * + * @tparam D The target derived type. + * @return A reference to `*this` as `D`. + * @throws std::bad_cast if `*this` is not an instance of `D`. + */ template D& downcast(); + /** Const overload of `downcast()`. + * + * @tparam D The target derived type. + * @return A const reference to `*this` as `D`. + * @throws std::bad_cast if `*this` is not an instance of `D`. + */ template D const& downcast() const; + /** Return the `SerializedTypeID` enum value for this concrete type. + * + * The base implementation returns `STI_NOTPRESENT`. Every concrete + * subclass overrides this to return its own type tag. + */ [[nodiscard]] virtual SerializedTypeID getSType() const; + /** Return a human-readable string that includes the field name. + * + * Typically formatted as `" = "`. Returns an empty + * string when `getSType() == STI_NOTPRESENT`. + */ [[nodiscard]] virtual std::string getFullText() const; + /** Return a human-readable string representation of the value only. + * + * Unlike `getFullText()`, the field name is not included. The base + * implementation returns an empty string. + */ [[nodiscard]] virtual std::string getText() const; + /** Render to a JSON value, respecting the given rendering options. + * + * @param options Bitmask controlling date inclusion and API version + * formatting. Defaults to `JsonOptions::Values::None`. + * @return A `json::Value` representation. + */ [[nodiscard]] virtual json::Value getJson(JsonOptions = JsonOptions::Values::None) const; + /** Serialize the field's binary payload into @p s. + * + * Writes only the value bytes; the field-ID header must be written + * separately via `addFieldID()`. The base implementation is an + * unreachable stub — every concrete subclass must override this. + * + * @param s The `Serializer` accumulator to write into. + */ virtual void add(Serializer& s) const; + /** Value equivalence check, ignoring field names. + * + * Used by `operator==` and by `detail::STVar::operator==`. The base + * implementation asserts that this instance has type `STI_NOTPRESENT` + * and returns `true` only if @p t does as well. All concrete subclasses + * must override this. + * + * @param t The object to compare against. + * @return `true` if the two objects hold equivalent values. + */ [[nodiscard]] virtual bool isEquivalent(STBase const& t) const; + /** Return `true` if the field holds its default (zero-equivalent) value. + * + * Used during serialization to omit optional fields whose value is + * the type default. The base implementation always returns `true`. + */ [[nodiscard]] virtual bool isDefault() const; - /** A STBase is a field. - This sets the name. - */ + /** Set the field identity for this instance. + * + * @param n The `SField` descriptor to associate with this object. + */ void setFName(SField const& n); + /** Return the `SField` descriptor that identifies this field. */ [[nodiscard]] SField const& getFName() const; + /** Write the type+field ID prefix bytes to @p s. + * + * Encodes the combined type code and field code as 1–3 bytes per the + * XRPL binary format, forming the header that precedes the value bytes + * written by `add()`. Asserts that the current field is a binary + * (wire-representable) field. + * + * @param s The `Serializer` to write the field ID into. + */ void addFieldID(Serializer& s) const; protected: + /** Placement helper for the small-object optimization used by `detail::STVar`. + * + * If `sizeof(U) <= n`, constructs a `U` in @p buf via placement-new and + * returns a pointer to it. Otherwise heap-allocates a `U` with `new`. + * Concrete subclasses delegate to this from their `copy()` and `move()` + * overrides so that `detail::STVar` can store small types inline without + * a separate heap allocation. + * + * @tparam T The value type to construct (deduced); `U = std::decay_t`. + * @param n Size of the inline buffer in bytes. + * @param buf Pointer to inline storage of at least @p n bytes. + * @param val The value to forward-construct into the buffer or heap. + * @return Pointer to the newly constructed `U` object. + */ template static STBase* emplace(std::size_t n, void* buf, T&& val); private: + /** Copy this object into @p buf (or heap) and return a pointer to it. + * + * Called exclusively by `detail::STVar` to implement copy construction. + * Delegates to `emplace(n, buf, *this)`. + */ virtual STBase* copy(std::size_t n, void* buf) const; + + /** Move this object into @p buf (or heap) and return a pointer to it. + * + * Called exclusively by `detail::STVar` to implement move construction. + * Delegates to `emplace(n, buf, std::move(*this))`. + */ virtual STBase* move(std::size_t n, void* buf); @@ -187,6 +345,14 @@ private: //------------------------------------------------------------------------------ +/** Stream an `STBase` as its full-text representation (field name and value). + * + * Equivalent to `out << t.getFullText()`. + * + * @param out The output stream to write to. + * @param t The serialized-type object to render. + * @return @p out, to allow chaining. + */ std::ostream& operator<<(std::ostream& out, STBase const& t); diff --git a/include/xrpl/protocol/STBitString.h b/include/xrpl/protocol/STBitString.h index 8a7e5a6030..4eb709be6c 100644 --- a/include/xrpl/protocol/STBitString.h +++ b/include/xrpl/protocol/STBitString.h @@ -1,3 +1,10 @@ +/** @file + * Defines `STBitString`, the serialization-layer representation of + * fixed-width opaque bit arrays, and the four concrete aliases used + * throughout the protocol: `STUInt128`, `STUInt160`, `STUInt192`, and + * `STUInt256`. + */ + #pragma once #include @@ -6,16 +13,34 @@ namespace xrpl { -// The template parameter could be an unsigned type, however there's a bug in -// gdb (last checked in gdb 12.1) that prevents gdb from finding the RTTI -// information of a template parameterized by an unsigned type. This RTTI -// information is needed to write gdb pretty printers. +/** Serialized fixed-width bit string field in the XRPL protocol type system. + * + * Bridges `BaseUInt` — used for transaction hashes, account IDs, + * ledger indices, and similar opaque identifiers — and the `STBase` + * serialization framework. Despite the underlying type supporting + * arithmetic, this class treats its value as an opaque sequence of bits: + * only identity comparison, serialization, and value access are exposed. + * + * Each concrete instantiation (128, 160, 192, 256 bits) returns a + * distinct wire-type code from `getSType()` (`STI_UINT128` through + * `STI_UINT256`), so field metadata and codec behavior are type-correct at + * the protocol level. + * + * `CountedObject>` maintains a per-width live instance + * counter for diagnostic reporting; it carries no functional overhead. + * + * @tparam Bits Number of bits in the value. Declared `int` rather than + * `unsigned int` to work around a GDB 12.1 bug that prevents locating + * RTTI for templates instantiated over unsigned types; a `static_assert` + * enforces that the value is positive. + */ template class STBitString final : public STBase, public CountedObject> { static_assert(Bits > 0, "Number of bits must be positive"); public: + /** The underlying tag-free bit-string type (`BaseUInt`). */ using value_type = BaseUInt; private: @@ -24,47 +49,159 @@ private: public: STBitString() = default; + /** Construct a named field with a zero-initialized value. + * + * Used when building objects programmatically before the value is known. + * + * @param n The `SField` that identifies this field on the wire. + */ STBitString(SField const& n); + + /** Construct an anonymous value, discarding field identity. + * + * Intended for temporary computations where only the raw value matters. + * + * @param v The initial bit-string value. + */ STBitString(value_type const& v); + + /** Construct a fully specified named field with a given value. + * + * @param n The `SField` that identifies this field on the wire. + * @param v The initial bit-string value. + */ STBitString(SField const& n, value_type const& v); + + /** Deserialize a named field from a byte stream. + * + * Reads exactly `Bits/8` bytes from `sit` at the current cursor + * position via `SerialIter::getBitString()`, centralizing + * deserialization logic in `SerialIter`. + * + * @param sit The input cursor; advanced by `Bits/8` bytes on success. + * @param name The `SField` that identifies this field on the wire. + * @throws std::runtime_error if the stream has fewer than `Bits/8` + * bytes remaining. + */ STBitString(SerialIter& sit, SField const& name); + /** Return the wire-type identifier for this bit width. + * + * Specialized for each concrete alias: `STI_UINT128`, `STI_UINT160`, + * `STI_UINT192`, and `STI_UINT256`. + * + * @return The `SerializedTypeID` matching this instantiation's bit width. + */ [[nodiscard]] SerializedTypeID getSType() const override; + /** Return the hex-encoded string representation of the stored value. + * + * @return A lowercase hex string with no prefix. + */ [[nodiscard]] std::string getText() const override; + /** Test whether this field holds the same value as another `STBitString`. + * + * @param t The object to compare against. + * @return `true` if `t` is the same concrete bit width and both values + * are equal; `false` otherwise (including when `t` has a different + * bit width). + */ [[nodiscard]] bool isEquivalent(STBase const& t) const override; + /** Serialize the value into a `Serializer` byte buffer. + * + * Writes exactly `Bits/8` bytes to `s` via `Serializer::addBitString`. + * + * @param s The accumulator to write into. + * @pre `getFName().isBinary()` must be `true`. + * @pre `getFName().fieldType` must equal `getSType()`. + * @note Both preconditions are checked with `XRPL_ASSERT`; violations + * indicate a field/type metadata mismatch that would cause silent + * protocol corruption. + */ void add(Serializer& s) const override; + /** Return `true` when the stored value is the all-zeros bit string. + * + * The serialization layer uses this to decide whether a field may be + * omitted from canonical binary encoding. + * + * @return `true` if the value equals `beast::zero`. + */ [[nodiscard]] bool isDefault() const override; + /** Assign a new value, accepting any tag variant of `BaseUInt`. + * + * The free `Tag` template parameter allows cross-tag assignment (e.g., + * assigning a raw `uint256` to an `sfTransactionID` field) when the + * caller explicitly intends it, while making accidental mixing visible + * at the call site. Tag information is erased on storage. + * + * @tparam Tag The source tag type; any `BaseUInt` is accepted. + * @param v The new value. + */ template void setValue(BaseUInt const& v); + /** Return a const reference to the stored tag-free value. + * + * @return Reference to the internal `value_type`; valid for the lifetime + * of this object. + */ [[nodiscard]] value_type const& value() const; + /** Implicit conversion to the tag-free `value_type`. + * + * Erases any tag information from the underlying `BaseUInt` on the way + * out. Prefer `value()` in generic code to make the conversion explicit. + */ operator value_type() const; private: + /** Place-construct a copy into `buf` if it fits within `n` bytes; + * otherwise heap-allocate. Called by `detail::STVar` for the + * small-object optimisation inside `STObject` containers. + */ STBase* copy(std::size_t n, void* buf) const override; + + /** Place-construct a moved instance into `buf` if it fits within `n` + * bytes; otherwise heap-allocate. Called by `detail::STVar`. + */ STBase* move(std::size_t n, void* buf) override; friend class detail::STVar; }; +/** Serialized 128-bit opaque bit string (wire type `STI_UINT128`). */ using STUInt128 = STBitString<128>; + +/** Serialized 160-bit opaque bit string (wire type `STI_UINT160`). + * + * Used for `AccountID` fields and similar 20-byte identifiers. + */ using STUInt160 = STBitString<160>; + +/** Serialized 192-bit opaque bit string (wire type `STI_UINT192`). + * + * Used for `MPTID` fields (32-bit sequence number ‖ 160-bit issuer). + */ using STUInt192 = STBitString<192>; + +/** Serialized 256-bit opaque bit string (wire type `STI_UINT256`). + * + * The most commonly used alias; carries transaction hashes, ledger + * indices, node IDs, and other 32-byte protocol identifiers. + */ using STUInt256 = STBitString<256>; template diff --git a/include/xrpl/protocol/STBlob.h b/include/xrpl/protocol/STBlob.h index 84f44f1b78..dc2187e2f3 100644 --- a/include/xrpl/protocol/STBlob.h +++ b/include/xrpl/protocol/STBlob.h @@ -10,58 +10,180 @@ namespace xrpl { -// variable length byte string +/** Serialized-type representation of a variable-length binary field. + * + * `STBlob` backs any ledger or transaction field whose wire type is + * `STI_VL` (variable-length) **or** `STI_ACCOUNT` (20-byte account ID). + * Both types share identical VL-prefixed binary encoding on the wire; + * the semantic distinction is carried by the `SField` descriptor rather + * than this class. + * + * Storage is an owned `Buffer` (heap-backed `unique_ptr`). + * Read access is always through a non-owning `Slice`, keeping the + * ownership model explicit: holding a `Slice` confers no ownership claim. + * + * @note Do not store `STBlob` (or any `STBase`-derived type) in + * `std::vector` or other standard containers — `STBase::operator=` + * intentionally does not copy field names, which breaks slide-down + * semantics. Use `detail::STVar` (via `STObject`) instead. + */ class STBlob : public STBase, public CountedObject { Buffer value_; public: + /** Non-owning view type used for all read access to the payload. */ using value_type = Slice; STBlob() = default; + + /** Copy-construct, duplicating the owned byte buffer. */ STBlob(STBlob const& rhs); + /** Construct by copying @p size bytes from @p data into an owned buffer. + * + * @param f The `SField` descriptor identifying this field. + * @param data Pointer to the source bytes (must not be null if + * @p size is non-zero). + * @param size Number of bytes to copy. + */ STBlob(SField const& f, void const* data, std::size_t size); - STBlob(SField const& f, Buffer&& b); - STBlob(SField const& n); - STBlob(SerialIter&, SField const& name = kSF_GENERIC); + /** Construct by taking ownership of an existing buffer. + * + * @param f The `SField` descriptor identifying this field. + * @param b The buffer to move from; left empty after this call. + */ + STBlob(SField const& f, Buffer&& b); + + /** Construct an empty (default) blob associated with field @p n. + * + * `isDefault()` returns `true` until the payload is set. + * + * @param n The `SField` descriptor identifying this field. + */ + STBlob(SField const& n); + + /** Deserialize a variable-length blob from a byte stream. + * + * Reads the VL-prefixed byte sequence from @p st via + * `SerialIter::getVLBuffer()`. + * + * @param st Forward-only cursor over the serialized byte stream; + * advanced past the VL prefix and payload bytes on return. + * @param name The `SField` descriptor identifying this field. + */ + STBlob(SerialIter& st, SField const& name = kSF_GENERIC); + + /** Return the number of bytes in the payload. */ [[nodiscard]] std::size_t size() const; + /** Return a pointer to the first byte of the payload, or null if empty. */ [[nodiscard]] std::uint8_t const* data() const; + /** Return `STI_VL`, the wire type tag for variable-length fields. + * + * @note Returns `STI_VL` even when the associated `SField` has type + * `STI_ACCOUNT`. Both types share identical VL-prefixed encoding; + * the field-ID byte written by the enclosing `STObject` carries the + * semantic distinction. + */ [[nodiscard]] SerializedTypeID getSType() const override; + /** Return the payload as an uppercase hex string for logging and JSON output. */ [[nodiscard]] std::string getText() const override; + /** Serialize the payload into @p s with a VL length prefix. + * + * Writes the byte count followed by the raw bytes via + * `Serializer::addVL`, the exact inverse of the deserialization path. + * + * @param s The `Serializer` accumulator to append to. + * @note Asserts that the associated `SField` is a binary field and that + * its `fieldType` is `STI_VL` or `STI_ACCOUNT`. A field of any other + * type indicates a construction-time programming error and would + * produce a malformed wire encoding. + */ void add(Serializer& s) const override; + /** Return `true` if @p t is an `STBlob` with byte-identical content. + * + * Field names are not compared — only the raw payload bytes. + * + * @param t The other serialized-type object to compare against. + */ [[nodiscard]] bool isEquivalent(STBase const& t) const override; + /** Return `true` if this blob holds no bytes (empty buffer). + * + * `STObject` uses this to omit optional fields whose payload has not + * been set, keeping wire representations compact. + */ [[nodiscard]] bool isDefault() const override; + /** Replace the payload with a copy of @p slice. + * + * Allocates a fresh `Buffer` and copies the bytes from @p slice. + * Use `operator=(Buffer&&)` or `setValue(Buffer&&)` to transfer + * ownership without a copy. + * + * @param slice Non-owning view of the source bytes. + * @return `*this` + */ STBlob& operator=(Slice const& slice); + /** Return a non-owning view of the payload. + * + * The returned `Slice` is valid only for the lifetime of this `STBlob` + * and is invalidated by any mutation (`operator=`, `setValue`). + */ [[nodiscard]] value_type value() const noexcept; + /** Transfer ownership of @p buffer to this blob in O(1). + * + * @p buffer is left empty after this call. + * + * @param buffer The buffer whose ownership is transferred. + * @return `*this` + */ STBlob& operator=(Buffer&& buffer); + /** Transfer ownership of @p b to this blob in O(1). + * + * Named alternative to `operator=(Buffer&&)` for call sites where an + * explicit setter reads more clearly than an assignment expression. + * @p b is left empty after this call. + * + * @param b The buffer whose ownership is transferred. + */ void setValue(Buffer&& b); private: + /** Place-construct a copy into an `STVar` inline buffer or heap. + * + * Called exclusively by `detail::STVar`. Delegates to + * `STBase::emplace(n, buf, *this)`. + */ STBase* copy(std::size_t n, void* buf) const override; + + /** Place-construct a moved instance into an `STVar` inline buffer or heap. + * + * Called exclusively by `detail::STVar`. Delegates to + * `STBase::emplace(n, buf, std::move(*this))`, transferring `Buffer` + * ownership without copying the payload bytes. + */ STBase* move(std::size_t n, void* buf) override; diff --git a/include/xrpl/protocol/STCurrency.h b/include/xrpl/protocol/STCurrency.h index 55d1ab1e74..c97c18b019 100644 --- a/include/xrpl/protocol/STCurrency.h +++ b/include/xrpl/protocol/STCurrency.h @@ -1,3 +1,8 @@ +/** @file + * Defines `STCurrency`, the serialized-type wrapper for 160-bit XRPL + * currency identifiers used inside transactions and ledger objects. + */ + #pragma once #include @@ -8,6 +13,24 @@ namespace xrpl { +/** Serialized-type wrapper for a 160-bit XRPL currency identifier. + * + * `STCurrency` carries a `Currency` value (`base_uint<160, + * detail::CurrencyTag>`) inside the XRPL serialized-type field framework. + * Every field in a serialized transaction or ledger object must be an + * `STBase` subclass; `STCurrency` is the required adaptor for raw + * `Currency` values. + * + * The default (zero) value represents native XRP: `isDefault()` returns + * `true` whenever `isXRP(currency_)` is true, which causes the field to + * be omitted from canonical serialization when it carries no information + * beyond "this is XRP." + * + * Unlike `STAccount`, this class does not mix in `CountedObject`, so + * instance counts are not tracked for diagnostic purposes. + * + * @see STAccount, STIssue, Currency + */ class STCurrency final : public STBase { private: @@ -16,52 +39,164 @@ private: public: using value_type = Currency; + /** Construct an anonymous default (XRP) currency field. */ STCurrency() = default; + /** Deserialize a currency field from a binary wire stream. + * + * Reads exactly 160 bits from `sit` via `SerialIter::get160()`. No + * semantic validation is performed — binary data arriving from a + * consensus-validated ledger stream is assumed well-formed. + * + * @param sit Forward-only cursor over the serialized byte buffer; + * advanced by 20 bytes on return. + * @param name The `SField` descriptor for this field. + */ explicit STCurrency(SerialIter& sit, SField const& name); + /** Construct a currency field with a known value. + * + * The standard programmatic constructor used when the `Currency` is + * already available (e.g., when building a transaction in memory). + * + * @param name The `SField` descriptor for this field. + * @param currency The 160-bit currency identifier to store. + */ explicit STCurrency(SField const& name, Currency const& currency); + /** Construct a named but default (XRP) currency field. + * + * Binds the field to `name` and leaves the stored currency as the + * all-zeroes XRP value. Used when an `STObject` allocates a slot + * before the currency is known. + * + * @param name The `SField` descriptor for this field. + */ explicit STCurrency(SField const& name); + /** Return the stored 160-bit currency identifier. */ [[nodiscard]] Currency const& currency() const; + /** Return the stored 160-bit currency identifier. + * + * Alias for `currency()`, provided so generic code that expects a + * `value()` accessor on all ST wrapper types works uniformly. + */ [[nodiscard]] Currency const& value() const noexcept; + /** Replace the stored currency with `currency`. + * + * @param currency The new 160-bit currency identifier. + */ void setCurrency(Currency const& currency); + /** Return the `STI_CURRENCY` type tag used for field-dispatch and wire + * encoding. + */ [[nodiscard]] SerializedTypeID getSType() const override; + /** Return a human-readable currency string. + * + * Delegates to `to_string(currency_)`: returns `""` for XRP (zero), + * a three-character ISO-4217-style ticker (e.g., `"USD"`) for + * well-known tokens, or a hex string for opaque 160-bit custom + * currencies. + * + * @return String representation of the stored currency. + */ [[nodiscard]] std::string getText() const override; + /** Return a JSON string representation of the currency. + * + * The output is identical to `getText()`. The `JsonOptions` argument + * is ignored — a currency code is always a plain string with no + * optional decoration. + * + * @return JSON string value of the currency. + */ [[nodiscard]] json::Value getJson(JsonOptions) const override; + /** Append the currency to `s` as a raw 20-byte bit string. + * + * Writes the 160-bit value verbatim via `Serializer::addBitString`, + * with no VL prefix or other framing — consistent with all + * fixed-width scalar ST types. + * + * @param s The `Serializer` accumulator to append to. + */ void add(Serializer& s) const override; + /** Check semantic equivalence with another serialized field. + * + * Uses `dynamic_cast` to confirm `t` is also an `STCurrency`, then + * compares the stored 160-bit values. Returns `false` for any other + * `STBase` subtype. + * + * @param t The field to compare against. + * @return `true` if `t` is an `STCurrency` holding the same currency. + */ [[nodiscard]] bool isEquivalent(STBase const& t) const override; + /** Return `true` when the stored currency is XRP (all-zeroes). + * + * In `STBase` semantics, fields at their default value are omitted + * from canonical serialization. An `STCurrency` whose value is XRP + * need not carry an explicit currency code on the wire. + */ [[nodiscard]] bool isDefault() const override; private: + /** Factory called by `detail::STVar` when the wire type tag resolves + * to `STI_CURRENCY`. Delegates to the `SerialIter` constructor. + */ static std::unique_ptr construct(SerialIter&, SField const& name); + /** Place a copy of this object into `buf` (if it fits within `n` + * bytes) or heap-allocate via `STBase::emplace()`. + * Used by `detail::STVar` for the small-object optimization. + */ STBase* copy(std::size_t n, void* buf) const override; + + /** Place a moved instance into `buf` (if it fits within `n` bytes) + * or heap-allocate via `STBase::emplace()`. + * Used by `detail::STVar` for the small-object optimization. + */ STBase* move(std::size_t n, void* buf) override; friend class detail::STVar; }; +/** Parse and validate a currency field from a JSON value. + * + * Acts as the defensive input validation gateway for API-sourced + * currency strings. Unlike binary deserialization, which trusts + * consensus-validated data, this function validates strictly because + * JSON input arrives from untrusted API consumers. + * + * Accepts only JSON string values. The string is converted via + * `toCurrency()` and then checked against two sentinel values that + * `toCurrency()` may silently return for invalid input: + * - `noCurrency()` — syntactically invalid string. + * - `badCurrency()` — the string `"XRP"` used as an IOU ticker, which + * is explicitly prohibited to prevent confusion with native XRP. + * + * @param name The `SField` descriptor for the resulting field. + * @param v The JSON value to parse; must be a string. + * @return An `STCurrency` holding the validated, non-reserved currency. + * @throws std::runtime_error if `v` is not a string, or if the string + * resolves to `badCurrency()` or `noCurrency()`. + */ STCurrency currencyFromJson(SField const& name, json::Value const& v); @@ -83,30 +218,41 @@ STCurrency::setCurrency(Currency const& currency) currency_ = currency; } +/** Return `true` if both `STCurrency` objects hold the same 160-bit value. */ inline bool operator==(STCurrency const& lhs, STCurrency const& rhs) { return lhs.currency() == rhs.currency(); } +/** Return `true` if the two `STCurrency` objects hold different values. */ inline bool operator!=(STCurrency const& lhs, STCurrency const& rhs) { return !operator==(lhs, rhs); } +/** Less-than comparison between two `STCurrency` values, enabling use in + * sorted containers. + */ inline bool operator<(STCurrency const& lhs, STCurrency const& rhs) { return lhs.currency() < rhs.currency(); } +/** Return `true` if the `STCurrency` holds the same 160-bit value as `rhs`. + * + * Avoids constructing a temporary `STCurrency` when comparing a wrapped + * field directly against an unwrapped `Currency` value. + */ inline bool operator==(STCurrency const& lhs, Currency const& rhs) { return lhs.currency() == rhs; } +/** Less-than comparison between an `STCurrency` and a raw `Currency` value. */ inline bool operator<(STCurrency const& lhs, Currency const& rhs) { diff --git a/include/xrpl/protocol/STExchange.h b/include/xrpl/protocol/STExchange.h index c733df37cf..51b8bbcb1a 100644 --- a/include/xrpl/protocol/STExchange.h +++ b/include/xrpl/protocol/STExchange.h @@ -1,3 +1,15 @@ +/** @file + * Type-safe bridge between serialized (`STBase`-derived) types and native C++ + * types for reading and writing `STObject` fields. + * + * Application code works with plain C++ types (integers, `Slice`, `Buffer`), + * while the wire protocol stores everything in serialized form (`STInteger`, + * `STBlob`, etc.). The `STExchange` traits struct centralizes the conversion + * mappings so callers never need to perform manual `dynamic_cast` or construct + * heap-allocated serialized objects directly. The free functions `get`, `set`, + * and `erase` are the primary interface for field access on `STObject`. + */ + #pragma once #include @@ -17,23 +29,60 @@ namespace xrpl { -/** Convert between serialized type U and C++ type T. */ +/** Traits adapter that maps a serialized type @p U to a native C++ type @p T. + * + * Each specialization provides: + * - `value_type` — the canonical C++ representation for the serialized type. + * - `get(optional&, U const&)` — extracts a native value from the + * serialized object. + * - `set(field, T const&)` — constructs a heap-allocated serialized object + * ready for insertion into an `STObject`. + * + * All conversions are resolved at compile time; there is no runtime + * polymorphism in the type mapping itself. Adding support for a new C++ view + * of an existing wire type requires only a new specialization here — the + * serialization infrastructure is not touched. + * + * @tparam U The serialized type (e.g. `STInteger`, `STBlob`). + * @tparam T The desired native C++ type (e.g. `uint32_t`, `Slice`, `Buffer`). + */ template struct STExchange; +/** `STExchange` specialization covering the full family of integer types. + * + * A single partial specialization handles `STUInt8`, `STUInt16`, `STUInt32`, + * `STUInt64`, and `STInt32` uniformly. `get` extracts the integer via + * `STInteger::value()` and `set` constructs a new `STInteger` on the heap. + * + * @tparam U The underlying integer type (e.g. `uint32_t`, `uint64_t`). + * @tparam T The desired C++ integer type to convert to/from. + */ template struct STExchange, T> { explicit STExchange() = default; + /** The canonical C++ integer type for this serialized field. */ using value_type = U; + /** Populate @p t with the integer value stored in @p u. + * + * @param t Output optional to receive the extracted value. + * @param u The serialized integer object to read from. + */ static void get(std::optional& t, STInteger const& u) { t = u.value(); } + /** Construct a heap-allocated `STInteger` initialized to @p t. + * + * @param f The field descriptor for the new serialized object. + * @param t The integer value to store. + * @return Owning pointer to the newly constructed serialized integer. + */ static std::unique_ptr> set(SField const& f, T const& t) { @@ -41,19 +90,37 @@ struct STExchange, T> } }; +/** `STExchange` specialization for reading an `STBlob` field as a `Slice`. + * + * `Slice` is a non-owning view, so both `get` and `set` always copy the + * underlying bytes — `get` via `emplace(data, size)` and `set` via the + * `STBlob(field, data, size)` constructor. + */ template <> struct STExchange { explicit STExchange() = default; + /** Non-owning byte view. */ using value_type = Slice; + /** Populate @p t with a `Slice` pointing into a copy of @p u's bytes. + * + * @param t Output optional to receive the `Slice`. + * @param u The serialized blob object to read from. + */ static void get(std::optional& t, STBlob const& u) { t.emplace(u.data(), u.size()); } + /** Construct a heap-allocated `STBlob` by copying the bytes of @p t. + * + * @param f The field descriptor for the new serialized object. + * @param t The source byte view to copy into the blob. + * @return Owning pointer to the newly constructed `STBlob`. + */ static std::unique_ptr set(TypedField const& f, Slice const& t) { @@ -61,25 +128,53 @@ struct STExchange } }; +/** `STExchange` specialization for reading an `STBlob` field as a `Buffer`. + * + * `Buffer` owns its memory. The lvalue `set` overload copies bytes into the + * new `STBlob`; the rvalue `set` overload moves the `Buffer` directly into + * the `STBlob`, avoiding an extra heap allocation on hot paths that build + * transaction objects. + */ template <> struct STExchange { explicit STExchange() = default; + /** Owning byte container. */ using value_type = Buffer; + /** Populate @p t with a `Buffer` containing a copy of @p u's bytes. + * + * @param t Output optional to receive the `Buffer`. + * @param u The serialized blob object to read from. + */ static void get(std::optional& t, STBlob const& u) { t.emplace(u.data(), u.size()); } + /** Construct a heap-allocated `STBlob` by copying the bytes of @p t. + * + * @param f The field descriptor for the new serialized object. + * @param t The source buffer to copy into the blob. + * @return Owning pointer to the newly constructed `STBlob`. + */ static std::unique_ptr set(TypedField const& f, Buffer const& t) { return std::make_unique(f, t.data(), t.size()); } + /** Construct a heap-allocated `STBlob` by moving @p t's storage. + * + * Preferred over the lvalue overload when the caller no longer needs + * the `Buffer`, as it avoids an extra heap allocation. + * + * @param f The field descriptor for the new serialized object. + * @param t The source buffer to move into the blob. + * @return Owning pointer to the newly constructed `STBlob`. + */ static std::unique_ptr set(TypedField const& f, Buffer&& t) { @@ -89,7 +184,26 @@ struct STExchange //------------------------------------------------------------------------------ -/** Return the value of a field in an STObject as a given type. */ +/** Read a field from an `STObject` as native C++ type @p T. + * + * Uses `STObject::peekAtPField` (non-mutating — does not insert a default for + * absent fields) and checks two distinct absence conditions: a null pointer + * (the field was never registered in the object's schema) and + * `STI_NOTPRESENT` (the field exists in the schema but has been explicitly + * marked absent). A `dynamic_cast` failure on the non-null, present field + * indicates a programming error and throws rather than returning empty. + * + * @tparam T The desired native C++ type to extract (e.g. `Slice` or `Buffer` + * for an `STBlob` field). + * @tparam U The serialized field type, inferred from @p f. + * @param st The object to read from. + * @param f The typed field descriptor identifying the field. + * @return The field value, or `std::nullopt` if the field is absent. + * @throws std::runtime_error If the field is present but its dynamic type + * does not match @p U — this indicates a programming error. + * @see get(STObject const&, TypedField const&) for the type-inferring + * overload that avoids spelling out @p T explicitly. + */ /** @{ */ template std::optional @@ -110,6 +224,19 @@ get(STObject const& st, TypedField const& f) return t; } +/** Read a field from an `STObject`, inferring the native type from the field + * descriptor's `value_type`. + * + * This is the ergonomic default: callers write `get(st, sfSequence)` rather + * than `get(st, sfSequence)`. Use the explicit-`T` overload when a + * different C++ view of the same wire type is needed (e.g. reading an `STBlob` + * as `Slice` for temporary inspection vs. `Buffer` for ownership). + * + * @tparam U The serialized field type, inferred from @p f. + * @param st The object to read from. + * @param f The typed field descriptor identifying the field. + * @return The field value as `U::value_type`, or `std::nullopt` if absent. + */ template std::optional::value_type> get(STObject const& st, TypedField const& f) @@ -118,7 +245,21 @@ get(STObject const& st, TypedField const& f) } /** @} */ -/** Set a field value in an STObject. */ +/** Write a value into a field of an `STObject`. + * + * Uses `std::decay` to strip cv-qualifiers and references before selecting the + * `STExchange` specialization, and `std::forward` to preserve value category + * so the move-semantic `Buffer&&` overload fires when an rvalue is passed. + * + * @tparam U The serialized field type, inferred from @p f. + * @tparam T The native C++ value type, inferred from @p t. May be an rvalue + * reference to trigger move-optimized specializations (e.g. + * `STExchange::set(f, Buffer&&)`). + * @param st The object to write into. + * @param f The typed field descriptor identifying the field. + * @param t The value to store; forwarded to the appropriate `STExchange` + * specialization. + */ template void set(STObject& st, TypedField const& f, T&& t) @@ -126,7 +267,18 @@ set(STObject& st, TypedField const& f, T&& t) st.set(STExchange>::set(f, std::forward(t))); } -/** Set a blob field using an init function. */ +/** Write a blob field whose contents are populated by a callable. + * + * Constructs an `STBlob` of @p size bytes and invokes @p init to fill it + * in-place, avoiding an intermediate copy for large blobs. + * + * @tparam Init A callable with signature compatible with the `STBlob` + * in-place initialization constructor. + * @param st The object to write into. + * @param f The field descriptor for the blob field. + * @param size The desired byte length of the blob. + * @param init Callable invoked to populate the blob's storage. + */ template void set(STObject& st, TypedField const& f, std::size_t size, Init&& init) @@ -134,7 +286,16 @@ set(STObject& st, TypedField const& f, std::size_t size, Init&& init) st.set(std::make_unique(f, size, init)); } -/** Set a blob field from data. */ +/** Write a blob field from a raw pointer and length. + * + * Convenience overload for C-style interop. Copies @p size bytes from + * @p data into a newly constructed `STBlob`. + * + * @param st The object to write into. + * @param f The field descriptor for the blob field. + * @param data Pointer to the source bytes. + * @param size Number of bytes to copy. + */ template void set(STObject& st, TypedField const& f, void const* data, std::size_t size) @@ -142,7 +303,17 @@ set(STObject& st, TypedField const& f, void const* data, std::size_t siz st.set(std::make_unique(f, data, size)); } -/** Remove a field in an STObject. */ +/** Mark a field as absent in an `STObject` without removing it from the schema. + * + * Delegates to `STObject::makeFieldAbsent`, which sets the field's type to + * `STI_NOTPRESENT`. The field slot is retained in the object's declared + * schema but contributes nothing to the wire encoding or canonical + * serialization. + * + * @tparam U The serialized field type, inferred from @p f. + * @param st The object to modify. + * @param f The typed field descriptor identifying the field to erase. + */ template void erase(STObject& st, TypedField const& f) diff --git a/include/xrpl/protocol/STInteger.h b/include/xrpl/protocol/STInteger.h index 4e3c9a8923..d029d72e05 100644 --- a/include/xrpl/protocol/STInteger.h +++ b/include/xrpl/protocol/STInteger.h @@ -5,62 +5,208 @@ namespace xrpl { +/** Wraps a plain integer inside the XRPL Serialized Type (ST) framework. + * + * Every integer-valued field in a ledger entry, transaction, or metadata + * object — sequence numbers, flags, fees, timestamps, transaction types — + * is represented at runtime as one of the five concrete aliases defined + * below (`STUInt8`, `STUInt16`, `STUInt32`, `STUInt64`, `STInt32`). + * + * The generic template supplies all methods that behave identically across + * integer widths (`add`, `isDefault`, `isEquivalent`, `operator=`, + * `copy`/`move` plumbing). Per-type specializations in `STInteger.cpp` + * provide `getSType()`, `getText()`, `getJson()`, and the deserialization + * constructor; these carry semantic knowledge that varies per instantiation + * (e.g., mapping `sfTransactionResult` bytes to TER strings, or rendering + * `STUInt64` as a JSON string to avoid IEEE 754 precision loss). + * + * `CountedObject>` adds a lock-free per-instantiation + * instance counter, so the diagnostic system can report live `STUInt32` and + * `STUInt64` counts separately. + * + * @tparam Integer The underlying C++ integer type (e.g., `std::uint32_t`). + */ template class STInteger : public STBase, public CountedObject> { public: + /** The underlying integer type wrapped by this instantiation. */ using value_type = Integer; private: Integer value_; public: + /** Construct an anonymous field holding @p v. + * + * The field has no associated `SField` name (uses the generic placeholder). + * Prefer the two-argument constructor when the field will be stored in an + * `STObject`, so the protocol field identity is preserved. + * + * @param v Initial value. + */ explicit STInteger(Integer v); + + /** Construct a named field holding @p v. + * + * @param n The `SField` descriptor that identifies this field on the wire. + * @param v Initial value; defaults to zero. + */ STInteger(SField const& n, Integer v = 0); + + /** Deserialize from a wire byte stream. + * + * Reads exactly `sizeof(Integer)` bytes from @p sit in big-endian order. + * Full specializations in `STInteger.cpp` provide the correct `sitGet*()` + * call for each instantiation width. + * + * @param sit Forward byte-stream cursor; advanced by `sizeof(Integer)`. + * @param name The `SField` descriptor to bind to the new object. + * @throws ripple::STObject::InvalidField or similar if @p sit underruns. + */ STInteger(SerialIter& sit, SField const& name); + /** Return the `SerializedTypeID` tag for this integer width. + * + * Full specializations return `STI_UINT8`, `STI_UINT16`, `STI_UINT32`, + * `STI_UINT64`, or `STI_INT32` as appropriate for `Integer`. + */ [[nodiscard]] SerializedTypeID getSType() const override; + /** Render the value to JSON, with field-identity-aware formatting. + * + * Most instantiations emit the raw integer. Notable exceptions: + * - `STUInt8` on `sfTransactionResult` → short TER token string (e.g., + * `"tesSUCCESS"`); raw integer on unrecognized codes. + * - `STUInt16` on `sfLedgerEntryType`/`sfTransactionType` → registered + * format name string (e.g., `"AccountRoot"`, `"Payment"`). + * - `STUInt32` on `sfPermissionValue` → permission name string. + * - `STUInt64` → always a JSON *string* (never a number) to avoid IEEE 754 + * precision loss; hex by default, decimal if `SField::sMD_BaseTen` is set. + * + * @param options Rendering flags (e.g., `JsonOptions::Values::None`). + * @return A `json::Value` representation of this field. + */ [[nodiscard]] json::Value getJson(JsonOptions) const override; + /** Return a human-readable string representation of the value. + * + * Applies the same field-identity-aware logic as `getJson()`, but returns + * a `std::string` suitable for diagnostics and logs rather than a JSON + * value. `STUInt8` on `sfTransactionResult` yields the long-form human + * description; `STUInt16` on `sfLedgerEntryType`/`sfTransactionType` yields + * the registered name; all others yield a decimal string. + * + * @return Human-readable string for the field's value. + */ [[nodiscard]] std::string getText() const override; + /** Serialize the integer value into @p s. + * + * Calls `s.addInteger(value_)`, which writes `sizeof(Integer)` bytes in + * big-endian order. Two `XRPL_ASSERT` guards fire in debug builds: one + * confirms the field is marked binary (`isBinary()`), and one confirms + * the field's declared type tag matches `getSType()`. A failing assert + * indicates a mis-wired field definition. + * + * @param s The `Serializer` accumulator to write into. + */ void add(Serializer& s) const override; + /** Return `true` if the wrapped value equals zero. + * + * The ST framework uses this to omit optional fields whose value is the + * type default, keeping wire representations canonical and compact. + */ [[nodiscard]] bool isDefault() const override; + /** Return `true` if @p t holds the same concrete type and the same value. + * + * Uses `dynamic_cast` to guard against comparing, say, an `STUInt32` + * with an `STUInt64` that happen to share the same bit pattern. Field + * names are ignored; only values are compared. + * + * @param t The object to compare against. + * @return `true` if @p t is the same `STInteger` instantiation + * and holds the same wrapped value. + */ [[nodiscard]] bool isEquivalent(STBase const& t) const override; + /** Assign a new raw value, preserving the field identity. + * + * @param v The new value to store. + * @return `*this`. + */ STInteger& operator=(value_type const& v); + /** Return the wrapped value without implicit conversion. + * + * Prefer this over `operator Integer()` when the intent is an explicit + * read; it is clearer at the call site that a raw integer is being + * extracted. + */ [[nodiscard]] value_type value() const noexcept; + /** Replace the wrapped value. + * + * @param v The new value to store. + */ void setValue(Integer v); + /** Implicit conversion to the underlying integer type. + * + * Allows `STInteger` to be passed to functions expecting `T` without + * an explicit `.value()` call. Use `.value()` when clarity at the call + * site matters more than brevity. + */ operator Integer() const; private: + /** Copy this object into @p buf (or heap) via `STBase::emplace()`. + * + * Called exclusively by `detail::STVar` to implement copy construction + * with the small-object optimization. + */ STBase* copy(std::size_t n, void* buf) const override; + + /** Move this object into @p buf (or heap) via `STBase::emplace()`. + * + * Called exclusively by `detail::STVar` to implement move construction + * with the small-object optimization. + */ STBase* move(std::size_t n, void* buf) override; friend class xrpl::detail::STVar; }; +/** 8-bit unsigned serialized integer; used for `sfTransactionResult`. */ using STUInt8 = STInteger; + +/** 16-bit unsigned serialized integer; used for `sfLedgerEntryType` and `sfTransactionType`. */ using STUInt16 = STInteger; + +/** 32-bit unsigned serialized integer; the most common integer field width. */ using STUInt32 = STInteger; + +/** 64-bit unsigned serialized integer. + * + * Always rendered as a JSON string (never a JSON number) to avoid IEEE 754 + * precision loss. Fields annotated with `SField::sMD_BaseTen` render as + * decimal; all others render as lowercase hexadecimal. + */ using STUInt64 = STInteger; +/** 32-bit signed serialized integer. */ using STInt32 = STInteger; template diff --git a/include/xrpl/protocol/STIssue.h b/include/xrpl/protocol/STIssue.h index f5e1f61168..359a69c9c1 100644 --- a/include/xrpl/protocol/STIssue.h +++ b/include/xrpl/protocol/STIssue.h @@ -8,6 +8,25 @@ namespace xrpl { +/** Serialized representation of a fungible asset identifier (XRP, IOU, or MPT). + * + * `STIssue` is the canonical `STBase` subtype for embedding an `Asset` inside + * a ledger object or transaction field. It bridges the polymorphic serialization + * framework and the `Asset` variant that unifies all three asset species. + * + * The wire format is type-multiplexed without a separate tag byte: XRP is + * a single 160-bit all-zeros currency sentinel; IOU is a 160-bit currency + * followed by a 160-bit issuer AccountID; MPT is a 160-bit issuer AccountID + * followed by the 160-bit `noAccount()` sentinel and a 32-bit sequence. + * The `noAccount()` sentinel is the discriminator between IOU and MPT — it is + * otherwise an illegal issuer address and will never appear in real IOU data. + * + * The class is declared `final`; the `STBase` hierarchy is complete without + * further inheritance. `CountedObject` instruments construction and + * destruction for runtime diagnostics. + * + * @see Asset, Issue, MPTIssue + */ class STIssue final : public STBase, CountedObject { private: @@ -19,56 +38,155 @@ public: STIssue() = default; STIssue(STIssue const& rhs) = default; + /** Deserialize an STIssue from a byte stream, detecting XRP, IOU, or MPT. + * + * Reads a 160-bit slot. If all-zeros (XRP currency sentinel), the asset is + * XRP and deserialization is complete. Otherwise reads a second 160-bit slot: + * if it equals `noAccount()`, the asset is an MPT — a 32-bit sequence number + * follows and the three values are assembled into an `MPTID`. Any other + * second slot forms the `(currency, account)` pair of an IOU `Issue`. + * + * @param sit Forward cursor over the serialized byte buffer; advanced in place. + * @param name The SField identifying this field within its parent STObject. + * @throws std::runtime_error if an IOU's currency/account native-flag + * combination is invalid (e.g., XRP currency paired with a non-XRP account). + */ explicit STIssue(SerialIter& sit, SField const& name); + /** Construct an STIssue tagged to a specific field and holding the given asset. + * + * Accepts any type satisfying the `AssetType` concept (`Issue`, `MPTIssue`, + * `MPTID`, or `Asset`). For `Issue`-typed assets, the currency/account + * native-flag combination is validated via `isConsistent()`; MPT issuances + * are always considered consistent. + * + * @tparam A An `AssetType` — `Issue`, `MPTIssue`, `MPTID`, or `Asset`. + * @param name The SField that names this field within a parent STObject. + * @param issue The asset to wrap. + * @throws std::runtime_error if the asset is an `Issue` and its currency + * and account native flags are inconsistent. + */ template explicit STIssue(SField const& name, A const& issue); + /** Construct an XRP STIssue tagged to a specific field. + * + * Convenience constructor producing the default (XRP) asset bound to + * the given field name. Equivalent to `STIssue(name, xrpIssue())`. + * + * @param name The SField identifying this field within its parent STObject. + */ explicit STIssue(SField const& name); STIssue& operator=(STIssue const& rhs) = default; + /** Return the held asset as the concrete type `TIss`. + * + * @tparam TIss The requested type — `Issue` or `MPTIssue`. + * @return A const reference to the underlying `TIss` value. + * @throws std::runtime_error if the variant does not hold `TIss`. + */ template TIss const& get() const; + /** Return whether the held asset is of type `TIss`. + * + * @tparam TIss The type to query — `Issue` or `MPTIssue`. + * @return `true` if the underlying `Asset` variant holds `TIss`. + */ template [[nodiscard]] bool holds() const; + /** Return the underlying `Asset` without copying or throwing. + * + * @return A const reference to the wrapped `Asset`. + */ [[nodiscard]] value_type const& value() const noexcept; + /** Replace the held asset, re-running the consistency check for IOU issues. + * + * If the current asset is an `Issue`, `isConsistent()` is applied to the + * incoming value before assignment. MPT assets always pass through. + * + * @param issue The new asset to store. + * @throws std::runtime_error if `issue` is an `Issue` with mismatched + * native-flag and account. + */ void setIssue(Asset const& issue); + /** @return The serialized type identifier `STI_ISSUE` for generic field dispatch. */ [[nodiscard]] SerializedTypeID getSType() const override; + /** @return A human-readable string for the asset (e.g., `"XRP"`, + * `"USD/r..."`, or the raw hex of an MPTID). + */ [[nodiscard]] std::string getText() const override; + /** Serialize the asset to a JSON value suitable for RPC responses. + * + * @return A `json::Value` representing the asset, formatted per asset type. + */ [[nodiscard]] json::Value getJson(JsonOptions) const override; + /** Append the binary encoding of this asset to `s`. + * + * XRP: 160-bit zero currency sentinel only. + * IOU: 160-bit currency + 160-bit issuer AccountID. + * MPT: 160-bit issuer AccountID + 160-bit `noAccount()` sentinel + 32-bit sequence. + * + * @param s The Serializer to append bytes to. + */ void add(Serializer& s) const override; + /** Return `true` if `t` is an STIssue holding an equivalent asset. + * + * @param t The STBase to compare; downcast to STIssue internally. + * @return `true` if `t` is an STIssue whose asset compares equal to this one. + */ [[nodiscard]] bool isEquivalent(STBase const& t) const override; + /** Return `true` if this field holds the default asset (XRP). + * + * A field absent from a ledger object is implicitly XRP, so `xrpIssue()` + * is the canonical default. MPT issuances are never considered default. + * + * @return `true` iff the held asset is `xrpIssue()`. + */ [[nodiscard]] bool isDefault() const override; + /** Compare two STIssue objects for equality. Delegates to `Asset::operator==`. */ friend constexpr bool operator==(STIssue const& lhs, STIssue const& rhs); + /** Three-way comparison of two STIssue objects. Delegates to `Asset::operator<=>`. + * + * @note Across asset types, `MPTIssue` sorts before `Issue` (variant index order). + */ friend constexpr std::weak_ordering operator<=>(STIssue const& lhs, STIssue const& rhs); + /** Compare an STIssue directly to a raw Asset for equality. + * + * Avoids the need to unwrap the STIssue when comparing against an `Asset` + * value elsewhere in the engine. + */ friend constexpr bool operator==(STIssue const& lhs, Asset const& rhs); + /** Three-way comparison of an STIssue against a raw Asset. + * + * @note Across asset types, `MPTIssue` sorts before `Issue` (variant index order). + */ friend constexpr std::weak_ordering operator<=>(STIssue const& lhs, Asset const& rhs); @@ -88,6 +206,17 @@ STIssue::STIssue(SField const& name, A const& asset) : STBase{name}, asset_{asse Throw("Invalid asset: currency and account native mismatch"); } +/** Construct an STIssue by parsing a JSON asset representation. + * + * Delegates to `assetFromJson()` to resolve the JSON into the appropriate + * `Asset` variant (`xrpIssue()`, an IOU `Issue`, or an `MPTIssue`), then + * wraps it in an STIssue bound to `name`. This is the canonical entry point + * when deserializing an issue field from an API request. + * + * @param name The SField to attach to the resulting STIssue. + * @param v A JSON value encoding an asset (XRP object, IOU object, or MPT object). + * @return An STIssue bound to `name` holding the parsed asset. + */ STIssue issueFromJson(SField const& name, json::Value const& v); diff --git a/include/xrpl/protocol/STLedgerEntry.h b/include/xrpl/protocol/STLedgerEntry.h index aa87411ae6..b3b07b1e29 100644 --- a/include/xrpl/protocol/STLedgerEntry.h +++ b/include/xrpl/protocol/STLedgerEntry.h @@ -10,50 +10,176 @@ namespace test { class Invariants_test; } // namespace test +/** The C++ representation of a single object in the XRPL ledger state (universally aliased as `SLE`). + * + * Each ledger entry lives in a `SHAMap` keyed by a 256-bit `key_`. The + * `type_` member names what the object is (account root, offer, escrow, + * trust line, etc.) and determines which `SOTemplate` governs its field + * layout. Construction from a `Keylet` looks up the registered + * `LedgerFormats` schema and throws immediately if the type is unknown, + * ensuring that an SLE always has a valid, self-consistent type. + * + * Declared `final`: ledger-entry type diversity is handled entirely through + * the `LedgerFormats` registration system at runtime, not through C++ + * subclass hierarchies. + * + * @see SLE (alias below), Keylet, LedgerFormats + */ class STLedgerEntry final : public STObject, public CountedObject { uint256 key_; LedgerEntryType type_; public: + /** Shared-pointer to a mutable ledger entry. */ using pointer = std::shared_ptr; + /** Const reference to a shared-pointer to a mutable ledger entry. */ using ref = std::shared_ptr const&; + /** Shared-pointer to an immutable ledger entry. */ using const_pointer = std::shared_ptr; + /** Const reference to a shared-pointer to an immutable ledger entry. */ using const_ref = std::shared_ptr const&; - /** Create an empty object with the given key and type. */ + /** Construct a new, empty ledger entry for the given keylet. + * + * Looks up the `LedgerFormats` schema for `k.type`, applies the + * `SOTemplate` (populating all declared fields at their default values), + * and writes `sfLedgerEntryType` so the wire encoding is self-describing. + * + * @param k Keylet carrying the SHAMap key and the desired entry type. + * @throws std::runtime_error if `k.type` is not registered in + * `LedgerFormats`. + */ explicit STLedgerEntry(Keylet const& k); + + /** Convenience constructor that delegates to the `Keylet` path. + * + * Equivalent to `STLedgerEntry(Keylet(type, key))`. Prefer the + * `Keylet` overload where one is already available. + * + * @param type The ledger entry type. + * @param key SHAMap key for this entry. + * @throws std::runtime_error if `type` is not registered in + * `LedgerFormats`. + */ STLedgerEntry(LedgerEntryType type, uint256 const& key); + + /** Deserialize a ledger entry from a byte stream. + * + * Reads all fields from `sit` into the underlying `STObject`, then + * resolves `sfLedgerEntryType` to set `type_` and enforces the matching + * `SOTemplate` via `setSLEType()`. + * + * @param sit Wire-format byte cursor; consumed in place. + * @param index SHAMap key that addresses this entry in the ledger state. + * @throws std::runtime_error if the deserialized type is unrecognized or + * the field set does not conform to the declared template. + */ STLedgerEntry(SerialIter& sit, uint256 const& index); + + /** Convenience rvalue overload that forwards to the lvalue `SerialIter` constructor. + * + * `SerialIter` is consumed by position rather than by move semantics, so + * this overload simply binds the rvalue to an lvalue reference and + * delegates. + * + * @param sit Wire-format byte cursor; consumed in place. + * @param index SHAMap key that addresses this entry in the ledger state. + * @throws std::runtime_error if the deserialized type is unrecognized or + * the field set does not conform to the declared template. + */ STLedgerEntry(SerialIter&& sit, uint256 const& index); + + /** Promote a pre-populated `STObject` to a typed ledger entry. + * + * Used when fields have already been parsed into a generic `STObject` + * and need to be re-interpreted with a concrete `LedgerEntryType`. + * Delegates to `setSLEType()` for type resolution and template + * conformance. + * + * @param object The source object, copied into this entry. + * @param index SHAMap key that addresses this entry in the ledger state. + * @throws std::runtime_error if `sfLedgerEntryType` is absent, + * unrecognized, or the field set does not conform to the declared + * template. + */ STLedgerEntry(STObject const& object, uint256 const& index); + /** Return the serialized type identifier for ledger entries (`STI_LEDGERENTRY`). */ [[nodiscard]] SerializedTypeID getSType() const override; + /** Return a verbose diagnostic string containing the type name, key, and all field values. + * + * Re-validates `type_` against `LedgerFormats` as a defensive invariant + * check before emitting output. + * + * @throws std::runtime_error if `type_` is no longer recognized + * (indicates in-memory corruption). + */ [[nodiscard]] std::string getFullText() const override; + /** Return a compact diagnostic string containing the hex key and field contents. */ [[nodiscard]] std::string getText() const override; + /** Serialize this entry to JSON, augmenting the base `STObject` output. + * + * Injects `"index"` (the hex-encoded SHAMap key) because `key_` is not + * stored as a serialized field. For `ltMPTOKEN_ISSUANCE` objects, also + * injects `"mpt_issuance_id"` computed from `sfSequence` and `sfIssuer` + * — this derived identifier is not stored on-ledger and is recomputed + * on every read to keep consensus-critical storage non-redundant. + * + * @param options Controls JSON formatting (e.g., binary vs. human-readable). + * @return A `Json::Value` object with all fields plus the injected keys. + */ [[nodiscard]] json::Value getJson(JsonOptions options = JsonOptions::Values::None) const override; - /** Returns the 'key' (or 'index') of this item. - The key identifies this entry's position in - the SHAMap associative container. - */ + /** Return the 256-bit SHAMap key that locates this entry in the ledger state. */ [[nodiscard]] uint256 const& key() const; + /** Return the `LedgerEntryType` that identifies what kind of object this is. */ [[nodiscard]] LedgerEntryType getType() const; - // is this a ledger entry that can be threaded + /** Determine whether this entry participates in transaction threading. + * + * Threading links each ledger entry to the transaction that last modified + * it via `sfPreviousTxnID` / `sfPreviousTxnLgrSeq`. Five types + * (`ltDIR_NODE`, `ltAMENDMENTS`, `ltFEE_SETTINGS`, `ltNEGATIVE_UNL`, + * `ltAMM`) only gained `sfPreviousTxnID` support when the + * `fixPreviousTxnID` amendment activated; before that, this method + * returns `false` for those types even if the field technically exists in + * the template, preventing premature use of threading on objects that + * historically lacked it. + * + * @param rules The active amendment rules for the current ledger. + * @return `true` if this entry carries `sfPreviousTxnID` and threading + * is permitted under the current rules. + */ [[nodiscard]] bool isThreadedType(Rules const& rules) const; + /** Update the threading fields to record that `txID` last modified this entry. + * + * Reads the current `sfPreviousTxnID`; if it already equals `txID`, the + * transaction has already been applied to this entry — asserts that + * `sfPreviousTxnLgrSeq` also matches and returns `false` (idempotency + * guard against double-application). Otherwise writes `txID` and + * `ledgerSeq` into the object and captures the old values in the output + * parameters so callers can reconstruct the modification chain. + * + * @param txID Hash of the transaction that is modifying this entry. + * @param ledgerSeq Sequence number of the ledger containing `txID`. + * @param prevTxID [out] The previous value of `sfPreviousTxnID`. + * @param prevLedgerID [out] The previous value of `sfPreviousTxnLgrSeq`. + * @return `true` if the fields were updated; `false` if `txID` was + * already threaded and the entry is unchanged. + */ bool thread( uint256 const& txID, @@ -62,9 +188,17 @@ public: std::uint32_t& prevLedgerID); private: - /* Make STObject comply with the template for this SLE type - Can throw - */ + /** Resolve `type_` from the embedded `sfLedgerEntryType` field and enforce + * template conformance on an already-populated object. + * + * Post-hoc counterpart to the `set(SOTemplate)` call in the `Keylet` + * constructor: instead of initializing an empty object, it validates + * and conforms an already-populated one. Called after wire + * deserialization and after `STObject` promotion. + * + * @throws std::runtime_error if `sfLedgerEntryType` names an unrecognized + * type, or if `applyTemplate()` rejects the field set. + */ void setSLEType(); @@ -79,6 +213,7 @@ private: friend class detail::STVar; }; +/** Canonical short alias for `STLedgerEntry`, used pervasively throughout the codebase. */ using SLE = STLedgerEntry; inline STLedgerEntry::STLedgerEntry(LedgerEntryType type, uint256 const& key) @@ -93,10 +228,6 @@ inline STLedgerEntry::STLedgerEntry( { } -/** Returns the 'key' (or 'index') of this item. - The key identifies this entry's position in - the SHAMap associative container. -*/ inline uint256 const& STLedgerEntry::key() const { diff --git a/include/xrpl/protocol/STNumber.h b/include/xrpl/protocol/STNumber.h index 8594a292f4..e2749aea2b 100644 --- a/include/xrpl/protocol/STNumber.h +++ b/include/xrpl/protocol/STNumber.h @@ -9,27 +9,26 @@ namespace xrpl { -/** - * A serializable number. +/** Serializable asset-contextual numeric field for XRPL ledger objects. * - * This type is-a `Number`, and can be used everywhere that is accepted. - * This type simply integrates `Number` with the serialization framework, - * letting it be used for fields in ledger entries and transactions. - * It is effectively an `STAmount` sans `Asset`: - * it can represent a value of any token type (XRP, IOU, or MPT) - * without paying the storage cost of duplicating asset information - * that may be deduced from the context. + * `STNumber` is effectively an `STAmount` without embedded `Asset` metadata. + * It stores only a `Number` (signed 64-bit mantissa + 32-bit exponent, 12 + * bytes on the wire) and defers asset identity to runtime via the + * `STTakesAsset` mixin. This eliminates the per-field storage cost of + * duplicating asset information that is already present in the containing + * ledger entry (Vault, LoanBroker, Loan). All `NUMBER`-type SFields carry + * the `sMD_NeedsAsset` metadata flag; the free function + * `associateAsset(STLedgerEntry&, Asset const&)` walks a ledger entry and + * calls `associateAsset` on each such field near the end of `doApply()`. * - * STNumber derives from STTakesAsset, so that it can be associated with the - * related Asset during transaction processing. Which asset is relevant depends - * on the object and transaction. As of this writing, only Vault, LoanBroker, - * and Loan objects use STNumber fields. All of those fields represent amounts - * of the Vault's Asset, so they should be associated with the Vault's Asset. + * Because `STNumber` provides `operator Number() const`, it can be passed + * directly wherever a `Number` is expected. * - * e.g. - * associateAsset(*loanSle, asset); - * associateAsset(*brokerSle, asset); - * associateAsset(*vaultSle, asset); + * @note After `associateAsset()` is called, the stored value is rounded to + * the asset's canonical precision. Calling `setValue()` afterward + * without re-associating violates the two-phase rounding contract and + * will trigger an assertion in `add()`. + * @see STTakesAsset, STAmount, associateAsset(STLedgerEntry&, Asset const&) */ class STNumber : public STTakesAsset, public CountedObject { @@ -40,21 +39,72 @@ public: using value_type = Number; STNumber() = default; + + /** Construct an STNumber bound to the given SField with an initial value. + * + * @param field The SField that identifies this value in its containing + * object. Must have `fieldType == STI_NUMBER`. + * @param value Initial numeric value; defaults to `Number()` (zero with + * sentinel exponent `std::numeric_limits::lowest()`). + */ explicit STNumber(SField const& field, Number const& value = Number()); + + /** Deserialize an STNumber from a byte stream. + * + * Reads a 64-bit signed mantissa and a 32-bit signed exponent (12 bytes + * total) from @p sit. The two reads are issued as separate statements to + * guarantee evaluation order — merging them into a single call expression + * would produce undefined behavior because C++ does not sequence function + * arguments. + * + * @param sit Forward cursor positioned at the first byte of the payload. + * @param field The SField that identifies this value in its containing + * object. + */ STNumber(SerialIter& sit, SField const& field); + /** @return `STI_NUMBER`. */ [[nodiscard]] SerializedTypeID getSType() const override; + + /** @return Decimal string representation of the stored `Number`. */ [[nodiscard]] std::string getText() const override; + + /** Serialize the stored value as 12 bytes (int64 mantissa, int32 exponent). + * + * For `sMD_NeedsAsset` fields this is Phase 2 of the two-phase rounding + * contract. When an asset has been associated, the value is re-rounded and + * asserted equal to the stored value, confirming that `associateAsset()` + * was called after the last `setValue()`. When no asset is present, a + * debug-only assertion verifies that `MantissaRange::Large` is active, + * because serializing under the small mantissa scale would silently + * truncate XRP/MPT integer values larger than 15 digits. + * + * @param s Serializer accumulator to append to. + */ void add(Serializer& s) const override; + /** @return Read-only reference to the stored `Number`. */ [[nodiscard]] Number const& value() const; + + /** Replace the stored value without re-associating an asset. + * + * @param v New value. + * @note If `associateAsset()` has already been called on this field, + * calling `setValue()` afterward without re-associating violates the + * two-phase rounding contract and will trigger an assertion in `add()`. + */ void setValue(Number const& v); + /** Assign a new value; delegates to `setValue()`. + * + * @param rhs New value. + * @return `*this`. + */ STNumber& operator=(Number const& rhs) { @@ -62,14 +112,38 @@ public: return *this; } + /** @return `true` if the other `STBase` is an `STNumber` holding the same + * `Number` value. + */ [[nodiscard]] bool isEquivalent(STBase const& t) const override; + + /** @return `true` if the stored value equals the default-constructed + * `Number()` (zero with its sentinel exponent), ensuring zero-valued + * fields round-trip correctly without false positives. + */ [[nodiscard]] bool isDefault() const override; + /** Bind an asset and immediately round the stored value to its precision. + * + * Phase 1 of the two-phase rounding contract. Stores @p a via + * `STTakesAsset::associateAsset` then calls `roundToAsset(a, value_)`. + * For XRP and MPT this truncates fractional drops; for IOU this normalises + * to 15 significant decimal digits. After this call, `add()` will assert + * idempotency on the rounded value. + * + * @param a Asset whose precision governs rounding. + * @note The field must carry the `sMD_NeedsAsset` metadata flag; a debug + * assertion fires if it does not. + */ void associateAsset(Asset const& a) override; + /** Implicit conversion to `Number`, enabling use in numeric expressions. + * + * @return A copy of the stored `Number`. + */ operator Number() const { return value_; @@ -82,19 +156,68 @@ private: move(std::size_t n, void* buf) override; }; +/** Write the decimal string representation of @p rhs to @p out. + * + * @param out Output stream to write to. + * @param rhs The value to render. + * @return @p out. + */ std::ostream& operator<<(std::ostream& out, STNumber const& rhs); +/** Raw parsed components of a decimal number string. + * + * Produced by `partsFromString()` before normalization. The mantissa is + * always unsigned; sign is carried separately in `negative`. + */ struct NumberParts { + /** Unsigned integer formed by concatenating integer and fractional digits. */ std::uint64_t mantissa = 0; + /** Exponent adjusted for fractional digit count and any explicit `e` suffix. */ int exponent = 0; + /** `true` if the original string had a leading `'-'`. */ bool negative = false; }; +/** Parse a decimal string into its raw mantissa/exponent/sign components. + * + * Accepts an optional leading sign, a non-empty integer part (no leading + * zeroes unless the value is exactly `"0"`), an optional fractional part, + * and an optional `e`/`E` exponent suffix. No normalization is applied — + * the caller receives the raw parsed representation. + * + * @param number Decimal string to parse (e.g., `"3.14e2"`, `"-42"`, `"0"`). + * @return `NumberParts` with unsigned mantissa, adjusted exponent, and sign. + * @throws std::runtime_error if @p number does not match the expected format + * (e.g., empty string, leading zeroes, bare `"e"`, trailing decimal point). + * @throws std::bad_cast (via `boost::lexical_cast`) if the digit string + * overflows `uint64_t`. + * @note The backing regex is compiled once as a `static` local with the + * `optimize` flag to amortize construction cost across calls. + */ NumberParts partsFromString(std::string const& number); +/** Construct an STNumber from a JSON integer or decimal string. + * + * Dispatches on the JSON value type: + * - **Integer** (`isInt`/`isUInt`): reads the native integer value directly. + * - **String**: delegates to `partsFromString()`; this path asserts that no + * active transaction rules are present, restricting use to pre-transactor + * JSON deserialization (e.g., `STParsedJSON`). + * - Anything else throws. + * + * @param field The SField that identifies the resulting STNumber. + * @param value JSON node containing the numeric value. + * @return A new STNumber holding the parsed value, not yet asset-rounded. + * @throws std::runtime_error if @p value is not an integer or string, or if + * a string fails to parse as a valid decimal. + * @throws std::bad_cast (via `boost::lexical_cast`) if a string mantissa + * overflows `uint64_t`. + * @note String-format numbers are forbidden during active transaction + * processing; only numeric JSON types are accepted in that context. + */ STNumber numberFromJson(SField const& field, json::Value const& value); diff --git a/include/xrpl/protocol/STObject.h b/include/xrpl/protocol/STObject.h index c635e8ce22..2d7cab6e85 100644 --- a/include/xrpl/protocol/STObject.h +++ b/include/xrpl/protocol/STObject.h @@ -1,3 +1,15 @@ +/** @file + * Defines `STObject`, the heterogeneous field container that underlies every + * XRPL transaction, ledger entry, and inner object. + * + * `STObject` supports two operating modes: *free mode* (no schema, insertion + * order preserved) and *template mode* (schema enforced via `SOTemplate`, + * O(1) field lookup). Both `STTx` and `STLedgerEntry` are `final` subclasses. + * + * The proxy system (`ValueProxy`, `OptionalProxy`) provides type-safe, + * compile-time-checked field access via `operator[]` and `at()`, replacing + * the older `getFieldU32()`/`setFieldU32()` family for new code. + */ #pragma once #include @@ -27,15 +39,39 @@ namespace xrpl { class STArray; +/** Throw a `std::runtime_error` indicating a missing field. + * + * Used by the legacy typed-accessor family (`getFieldByValue`, + * `getFieldByConstRef`, `setFieldUsingSetValue`) when `peekAtPField` returns + * null. Not intended for direct use by callers outside `STObject`. + * + * @param field The field that could not be found. + * @throws std::runtime_error always. + */ inline void throwFieldNotFound(SField const& field) { Throw("Field not found: " + field.getName()); } +/** Heterogeneous, field-keyed container for XRPL protocol objects. + * + * Stores an ordered sequence of `STBase`-derived fields, keyed by `SField`. + * Operates in one of two modes: + * - *Free mode* (`isFree() == true`): no schema; fields are stored in + * insertion order and any field may be added. + * - *Template mode* (`isFree() == false`): an `SOTemplate` constrains + * which fields are present, enforces `soeREQUIRED`/`soeOPTIONAL`/ + * `soeDEFAULT` semantics, and enables O(1) field lookup. + * + * `STTx` and `STLedgerEntry` are the primary `final` subclasses. + * Field access is available via the modern proxy API (`operator[]`, `at()`) + * or the legacy typed-accessor family (`getFieldU32()`, etc.). + * + * @note `operator==` compares only wire-representable (`isBinary()`) fields. + */ class STObject : public STBase, public CountedObject { - // Proxy value for a STBase derived class template class Proxy; template @@ -43,6 +79,7 @@ class STObject : public STBase, public CountedObject template class OptionalProxy; + /** Functor used by `boost::transform_iterator` to project `STVar→STBase`. */ struct Transform { explicit Transform() = default; @@ -60,11 +97,22 @@ class STObject : public STBase, public CountedObject SOTemplate const* type_{}; public: + /** Forward iterator over the fields of this object as `STBase const&`. */ using iterator = boost::transform_iterator; ~STObject() override = default; STObject(STObject const&) = default; + /** Construct a templated `STObject` from a schema, field name, and + * an initializer callable. + * + * Delegates to `STObject(type, name)` then calls `f(*this)`, allowing + * fields to be populated inline at construction time. + * + * @param type The SOTemplate that defines the object's layout. + * @param name The SField identifying this object within its parent. + * @param f Callable `void(STObject&)` invoked after template init. + */ template STObject(SOTemplate const& type, SField const& name, F&& f) : STObject(type, name) { @@ -73,128 +121,328 @@ public: STObject& operator=(STObject const&) = default; + /** Move-construct, transferring field storage and template pointer. */ STObject(STObject&&); + /** Move-assign, transferring field storage and template pointer. */ STObject& operator=(STObject&& other); + /** Construct a templated `STObject` pre-populated from a schema. + * + * Every slot in `type` is initialized: `soeREQUIRED` fields receive their + * type-default value; `soeOPTIONAL` and `soeDEFAULT` fields receive the + * `STI_NOTPRESENT` sentinel. + * + * @param type The SOTemplate that defines the object's layout. + * @param name The SField identifying this object within its parent. + */ STObject(SOTemplate const& type, SField const& name); + + /** Construct a templated `STObject` by deserializing from a byte stream. + * + * Reads fields in free mode from `sit`, then calls `applyTemplate(type)` + * to reorder and validate them against the schema. + * + * @param type The SOTemplate to enforce after deserialization. + * @param sit The byte stream to deserialize from. + * @param name The SField identifying this object within its parent. + * @throws FieldErr if a required field is missing or an unknown + * non-discardable field is present. + */ STObject(SOTemplate const& type, SerialIter& sit, SField const& name); + + /** Construct a free-mode `STObject` by deserializing from a byte stream. + * + * The `depth` parameter guards against stack exhaustion when parsing + * deeply nested structures from untrusted input; nesting beyond 10 + * throws `std::runtime_error`. + * + * @param sit The byte stream to deserialize from. + * @param name The SField identifying this object within its parent. + * @param depth Current nesting depth (default 0); capped at 10. + * @throws std::runtime_error if depth exceeds 10 or data is malformed. + */ STObject(SerialIter& sit, SField const& name, int depth = 0); + + /** Construct from an rvalue `SerialIter`; delegates to the lvalue overload. */ STObject(SerialIter&& sit, SField const& name); + + /** Construct a free-mode (schema-less) `STObject` with the given field name. */ explicit STObject(SField const& name); + /** Create a free-mode inner object, conditionally binding a schema template. + * + * Checks the ambient `getCurrentTransactionRules()` to determine whether + * the `fixInnerObjTemplate` or `fixInnerObjTemplate2` amendments are + * active, and applies the corresponding `SOTemplate` from + * `InnerObjectFormats` when they are. This amendment-gated behaviour + * preserves replay compatibility with historical ledger data serialized + * before schemas existed. + * + * @param name The SField identifying the type of inner object to create. + * @return A new `STObject`, bound to its schema when the active rules permit. + */ static STObject makeInnerObject(SField const& name); + /** Return an iterator to the first field in this object. */ [[nodiscard]] iterator begin() const; + /** Return a past-the-end iterator for this object's fields. */ [[nodiscard]] iterator end() const; + /** Return `true` when this object contains no fields. */ [[nodiscard]] bool empty() const; + /** Reserve storage for at least `n` fields in the underlying vector. */ void reserve(std::size_t n); + /** Validate and reorder fields against a schema after free-mode deserialization. + * + * Rebuilds the internal storage in template order. `soeREQUIRED` fields + * that are missing throw; `soeDEFAULT` fields whose serialized value equals + * the type's zero value are rejected (explicit defaults are forbidden); + * unknown non-discardable fields throw. + * + * @param type The SOTemplate to enforce. + * @throws FieldErr on required-field missing, explicit default value, or + * unknown non-discardable field. + */ void applyTemplate(SOTemplate const& type); + /** Look up and apply the schema registered for `sField` in `InnerObjectFormats`. + * + * No-op when no template is registered for the given field. + * + * @param sField The SField whose registered SOTemplate should be applied. + * @throws FieldErr (from `applyTemplate`) if the object does not conform. + */ void applyTemplateFromSField(SField const&); + /** Return `true` when no schema template is associated with this object. */ [[nodiscard]] bool isFree() const; + /** Initialize this object from a template, pre-populating every slot. + * + * Clears existing fields and rebuilds in template order. `soeREQUIRED` + * fields receive their type-default value; all other fields receive the + * `STI_NOTPRESENT` sentinel. + * + * @param type The SOTemplate that defines the layout of this object. + */ void set(SOTemplate const&); + /** Deserialize fields from a byte stream into this object (free mode). + * + * Reads `(type, field)` ID pairs from `u`, constructs each child `STVar` + * at `depth+1`, and calls `applyTemplateFromSField()` on nested + * `STObject` children. Stops at an inner-object terminator byte. + * + * @param u The byte stream to read from. + * @param depth Current nesting depth; guards against deeply nested input. + * @return `true` if an inner-object terminator was consumed; `false` + * at top-level end-of-stream. + * @throws std::runtime_error on malformed data or duplicate fields. + */ bool set(SerialIter& u, int depth = 0); + /** Return `STI_OBJECT`. */ [[nodiscard]] SerializedTypeID getSType() const override; + /** Return `true` when the objects have the same wire-representable fields. + * + * Only fields where `SField::isBinary()` is true participate in the + * comparison. Non-binary (JSON-only) fields are ignored. + * + * @param t The other serialized type to compare against. + */ [[nodiscard]] bool isEquivalent(STBase const& t) const override; + /** Return `true` when this object holds no fields (empty storage). */ [[nodiscard]] bool isDefault() const override; + /** Serialize all fields (including signing fields) into `s`. */ void add(Serializer& s) const override; + /** Return a human-readable, field-by-field description of this object. */ [[nodiscard]] std::string getFullText() const override; + /** Return a brief textual description of this object. */ [[nodiscard]] std::string getText() const override; // TODO(tom): options should be an enum. + /** Convert this object to a JSON value. + * + * @param options Controls formatting options (e.g., binary vs. human-readable). + */ [[nodiscard]] json::Value getJson(JsonOptions = JsonOptions::Values::None) const override; + /** Serialize signing-eligible fields only into `s`. + * + * Excludes fields whose `SField::shouldInclude(false)` returns `false` + * (e.g., `sfTxnSignature`, `sfSigners`). Used to produce the payload + * that is hashed and signed or verified. + * + * @param s The serializer to append to. + */ void addWithoutSigningFields(Serializer& s) const; + /** Return a `Serializer` containing all fields (including signing fields). + * + * @note Produces a full copy of the serialized form; prefer + * `add(Serializer&)` when appending to an existing buffer. + */ [[nodiscard]] Serializer getSerializer() const; + /** Append a new field directly to the internal storage vector. + * + * Used during deserialization and by the proxy system when adding new + * fields to a free-mode object. Arguments are forwarded to + * `detail::STVar`'s constructor. + * + * @return The zero-based index of the newly added field. + */ template std::size_t emplaceBack(Args&&... args); + /** Return the number of fields currently stored in this object. */ [[nodiscard]] int getCount() const; + /** Set a flag bit in `sfFlags`, creating the field if absent. + * + * @param flag The flag bit(s) to set (OR-ed into existing flags). + * @return `true` if the flags field was changed. + */ bool setFlag(std::uint32_t); + + /** Clear a flag bit in `sfFlags`. + * + * @param flag The flag bit(s) to clear. + * @return `true` if the flags field was changed. + */ bool clearFlag(std::uint32_t); + + /** Return `true` when all bits in `flag` are set in `sfFlags`. */ [[nodiscard]] bool isFlag(std::uint32_t) const; + /** Return the value of `sfFlags`, or 0 if the field is absent. */ [[nodiscard]] std::uint32_t getFlags() const; + /** Compute a domain-separated hash of all fields (including signing fields). + * + * Prepends `prefix` before serializing all fields, then returns the + * `sha512Half` of the result. + * + * @param prefix The `HashPrefix` discriminator for this hash domain. + * @return The 256-bit hash. + */ [[nodiscard]] uint256 getHash(HashPrefix prefix) const; + /** Compute a domain-separated hash of signing-eligible fields only. + * + * Equivalent to `getHash` but uses `addWithoutSigningFields` to exclude + * signature-carrying fields. Used by single-sig and multi-sig verification. + * + * @param prefix The `HashPrefix` discriminator for this hash domain. + * @return The 256-bit hash. + */ [[nodiscard]] uint256 getSigningHash(HashPrefix prefix) const; + /** Return the field at `offset` by const reference; no bounds check. */ [[nodiscard]] STBase const& peekAtIndex(int offset) const; + /** Return the field at `offset` by mutable reference; no bounds check. */ STBase& getIndex(int offset); + /** Return a const pointer to the field at `offset`; no bounds check. */ [[nodiscard]] STBase const* peekAtPIndex(int offset) const; + /** Return a mutable pointer to the field at `offset`; no bounds check. */ STBase* getPIndex(int offset); + /** Return the storage index of `field`, or -1 if not present. */ [[nodiscard]] int getFieldIndex(SField const& field) const; + /** Return the `SField` descriptor for the field at storage index `index`. */ [[nodiscard]] SField const& getFieldSType(int index) const; + /** Return the field identified by `field` by const reference. + * + * @throws std::runtime_error if the field is not present. + */ [[nodiscard]] STBase const& peekAtField(SField const& field) const; + /** Return the field identified by `field` by mutable reference. + * + * @throws std::runtime_error if the field is not present. + */ STBase& getField(SField const& field); + /** Return a const pointer to the field identified by `field`, or `nullptr`. + * + * Does not create the field. Returns `nullptr` for absent optional fields + * in free mode; returns a pointer to the `STI_NOTPRESENT` sentinel in + * template mode. + */ [[nodiscard]] STBase const* peekAtPField(SField const& field) const; + /** Core field-lookup primitive, optionally creating the field. + * + * In template mode with `createOkay == false`, returns the stored slot + * pointer (which may be an `STI_NOTPRESENT` sentinel). With + * `createOkay == true`, promotes `STI_NOTPRESENT` sentinels and + * appends missing free-mode fields. Returns `nullptr` only when the + * field is absent in free mode and `createOkay` is false. + * + * @param field The field to locate. + * @param createOkay When `true`, create the field if absent. + * @return Pointer to the stored `STBase`, or `nullptr`. + */ STBase* getPField(SField const& field, bool createOkay = false); - // these throw if the field type doesn't match, or return default values - // if the field is optional but not present + /** @name Legacy typed field accessors + * + * These methods return field values by their native C++ type. They throw + * `std::runtime_error` if the field type does not match the expected type, + * and return a default-constructed value when an optional field is absent. + * Prefer the proxy API (`operator[]`, `at()`) for new code. + * @{ + */ [[nodiscard]] unsigned char getFieldU8(SField const& field) const; [[nodiscard]] std::uint16_t @@ -205,7 +453,6 @@ public: getFieldU64(SField const& field) const; [[nodiscard]] uint128 getFieldH128(SField const& field) const; - [[nodiscard]] uint160 getFieldH160(SField const& field) const; [[nodiscard]] uint192 @@ -216,7 +463,6 @@ public: getFieldI32(SField const& field) const; [[nodiscard]] AccountID getAccountID(SField const& field) const; - [[nodiscard]] Blob getFieldVL(SField const& field) const; [[nodiscard]] STAmount const& @@ -225,7 +471,12 @@ public: getFieldPathSet(SField const& field) const; [[nodiscard]] STVector256 const& getFieldV256(SField const& field) const; - // If not found, returns an object constructed with the given field + /** Return the nested `STObject` for `field` by value. + * + * If the field is absent, returns a default-constructed `STObject` + * initialized with `field` as its name. Modifications to the returned + * copy do not propagate back; use `peekFieldObject()` for in-place access. + */ [[nodiscard]] STObject getFieldObject(SField const& field) const; [[nodiscard]] STArray const& @@ -234,6 +485,7 @@ public: getFieldCurrency(SField const& field) const; [[nodiscard]] STNumber const& getFieldNumber(SField const& field) const; + /** @} */ /** Get the value of a field. @param A TypedField built from an SField value representing the desired @@ -329,15 +581,31 @@ public: OptionalProxy at(OptionaledField const& of); - /** Set a field. - if the field already exists, it is replaced. - */ + /** Replace or insert a field from a heap-allocated `STBase`. + * + * If a field with the same `SField` already exists, it is replaced. + * + * @param v The field to store; ownership is transferred. + */ void set(std::unique_ptr v); + /** Replace or insert a field by move. + * + * If a field with the same `SField` already exists, it is replaced. + * + * @param v The field to move into this object. + */ void set(STBase&& v); + /** @name Legacy typed field mutators + * + * Set a named field to the given value. Throws `std::runtime_error` if + * the stored field has a different type than the value being set. + * Prefer the proxy API (`operator[]`, `at()`) for new code. + * @{ + */ void setFieldU8(SField const& field, unsigned char); void @@ -358,10 +626,8 @@ public: setFieldVL(SField const& field, Blob const&); void setFieldVL(SField const& field, Slice const&); - void setAccountID(SField const& field, AccountID const&); - void setFieldAmount(SField const& field, STAmount const&); void @@ -379,40 +645,122 @@ public: void setFieldObject(SField const& field, STObject const& v); + /** Set a 160-bit hash field from any `BaseUInt<160, Tag>` value. + * + * @tparam Tag The phantom tag type of the `BaseUInt` specialization. + * @param field The field to set. + * @param v The 160-bit value to store. + * @throws std::runtime_error if the stored field has a different type. + */ template void setFieldH160(SField const& field, BaseUInt<160, Tag> const& v); + /** @} */ + /** Return a mutable reference to the nested `STObject` for `field`. + * + * Unlike `getFieldObject()`, the returned reference is into internal + * storage; mutations propagate back to this object. + * + * @throws std::runtime_error if the field is absent or has the wrong type. + */ STObject& peekFieldObject(SField const& field); + + /** Return a mutable reference to the nested `STArray` for `field`. + * + * The returned reference is into internal storage; mutations propagate + * back to this object. + * + * @throws std::runtime_error if the field is absent or has the wrong type. + */ STArray& peekFieldArray(SField const& field); + /** Return `true` when `field` is present and not an `STI_NOTPRESENT` sentinel. */ [[nodiscard]] bool isFieldPresent(SField const& field) const; + + /** Promote an `STI_NOTPRESENT` sentinel field to a live default value. + * + * In template mode, converts an optional/default slot from the sentinel + * state to a type-correct default value, making the field "present". + * In free mode, appends a new default-constructed field. + * + * @param field The field to make present. + * @return Pointer to the now-live field, or `nullptr` if not found. + */ STBase* makeFieldPresent(SField const& field); + + /** Demote a live optional field back to the `STI_NOTPRESENT` sentinel. + * + * In template mode, replaces the field's value with the sentinel. + * In free mode, removes the field entry entirely. + * Only valid for `soeOPTIONAL` fields; called by the proxy system + * when assigning a `soeDEFAULT` field its zero value. + * + * @param field The field to make absent. + */ void makeFieldAbsent(SField const& field); + + /** Remove the field identified by `field` unconditionally. + * + * @param field The field to remove. + * @return `true` if the field was found and removed. + */ bool delField(SField const& field); + + /** Remove the field at storage index `index` unconditionally. */ void delField(int index); + /** Return the `SOEStyle` (`soeREQUIRED`, `soeOPTIONAL`, `soeDEFAULT`) + * for `field` according to the associated template. + * + * @param field The field to query. + * @return The style, or `SoeInvalid` if the object is in free mode. + */ [[nodiscard]] SOEStyle getStyle(SField const& field) const; + /** Return `true` when any stored field compares equal to `entry`. + * + * Used to test membership in collections such as `STArray`. + */ [[nodiscard]] bool hasMatchingEntry(STBase const&) const; + /** Compare two `STObject` instances for equality. + * + * Only wire-representable (`isBinary()`) fields participate. + * This comparison is O(n²) by design. + * + * @note For fast same-template comparison, use `isEquivalent()` which + * short-circuits when both objects share the same `mType` pointer. + */ bool operator==(STObject const& o) const; bool operator!=(STObject const& o) const; + /** Exception thrown by the proxy and `at()` accessors on field errors. + * + * Raised when a required field is absent, a template constraint is + * violated, or an invalid field access is attempted on a free-mode object. + */ class FieldErr; private: + /** Selects which fields are included during serialization. + * + * The underlying `bool` values alias directly to `SField::shouldInclude(bool)` + * so they can be passed without translation: + * - `OmitSigningFields` (false) — exclude fields not intended for signing. + * - `WithAllFields` (true) — include every field. + */ enum class WhichFields : bool { // These values are carefully chosen to do the right thing if passed // to SField::shouldInclude (bool) @@ -474,15 +822,34 @@ private: //------------------------------------------------------------------------------ +/** Common base for `ValueProxy` and `OptionalProxy`. + * + * Stores a back-pointer to the owning `STObject`, the `SOEStyle` of the + * field, and a typed descriptor pointer. Provides the read path + * (`value()`, `operator*`, `operator->`) and the write primitive `assign()`. + * + * `assign()` enforces `soeDEFAULT` canonicalization: assigning the zero + * value calls `makeFieldAbsent` rather than storing an explicit default, + * preserving canonical wire format. + * + * @tparam T The concrete `STBase`-derived type carrying the field's value. + */ template class STObject::Proxy { public: using value_type = typename T::value_type; + /** Return the field's current value. + * + * For `soeDEFAULT` fields that are absent, returns a default-constructed + * `value_type`. Throws `FieldErr` for absent `soeOPTIONAL` or required + * fields, and when called on a free-mode object with no template. + */ [[nodiscard]] value_type value() const; + /** Dereference operator; equivalent to `value()`. */ value_type operator*() const; @@ -500,36 +867,65 @@ protected: Proxy(STObject* st, TypedField const* f); + /** Locate the field via `dynamic_cast`; returns `nullptr` when absent. */ [[nodiscard]] T const* find() const; + /** Write `u` into the field, applying `soeDEFAULT` canonicalization. */ template void assign(U&& u); }; -// Constraint += and -= ValueProxy operators -// to value types that support arithmetic operations +/** Satisfied by scalar arithmetic types, `Number`, and `STAmount`. + * + * Used to gate `ValueProxy::operator+=` and `operator-=` so they are only + * available for field types that support arithmetic operations. + */ template concept IsArithmeticNumber = std::is_arithmetic_v || std::is_same_v || std::is_same_v; + +/** Satisfied by phantom-typed `unit::ValueUnit` wrappers + * whose `Value` satisfies `IsArithmeticNumber`. + */ template < typename U, typename Value = typename U::value_type, typename Unit = typename U::unit_type> concept IsArithmeticValueUnit = std::is_same_v> && IsArithmeticNumber && std::is_class_v; + +/** Satisfied by ST wrapper types (e.g., `STAmount`) that are not + * `ValueUnit` but whose `value_type` satisfies `IsArithmeticNumber`. + */ template concept IsArithmeticST = !IsArithmeticValueUnit && IsArithmeticNumber; + +/** Union of `IsArithmeticNumber`, `IsArithmeticST`, and `IsArithmeticValueUnit`. */ template concept IsArithmetic = IsArithmeticNumber || IsArithmeticST || IsArithmeticValueUnit; +/** Satisfied when `T + U` compiles and the result is assignable back to `T`. */ template concept Addable = requires(T t, U u) { t = t + u; }; + +/** Satisfied when `T`'s `value_type` is arithmetic and supports addition with `U`. */ template concept IsArithmeticCompatible = IsArithmetic && Addable; +/** Mutable proxy for a non-optional (`soeREQUIRED` or `soeDEFAULT`) field. + * + * Returned by the mutable overloads of `STObject::operator[]` and + * `STObject::at()`. Supports assignment, arithmetic `+=`/`-=` for + * compatible value types, and implicit conversion to `value_type` for + * transparent read-through. + * + * Copy-constructible but not copy-assignable; constructed only by `STObject`. + * + * @tparam T The concrete `STBase`-derived type carrying the field's value. + */ template class STObject::ValueProxy : public Proxy { @@ -541,22 +937,24 @@ public: ValueProxy& operator=(ValueProxy const&) = delete; + /** Assign `u` to the field, delegating to `Proxy::assign()`. */ template std::enable_if_t, ValueProxy&> operator=(U&& u); - // Convenience operators for value types supporting - // arithmetic operations + /** Add `u` to the current field value and write back. */ template requires IsArithmeticCompatible ValueProxy& operator+=(U const& u); + /** Subtract `u` from the current field value and write back. */ template requires IsArithmeticCompatible ValueProxy& operator-=(U const& u); + /** Implicit conversion to `value_type` for transparent read-through. */ operator value_type() const; template @@ -572,6 +970,22 @@ private: ValueProxy(STObject* st, TypedField const* f); }; +/** Mutable proxy for an optional (`soeOPTIONAL`) field. + * + * Returned by the mutable overloads of `STObject::operator[]` and + * `STObject::at()` when called with an `OptionaledField`. Supports + * assignment from a value, `std::optional`, or `std::nullopt` (to remove + * the field). Implicit conversion to `optional_type` enables use in + * standard optional contexts. + * + * Assigning `std::nullopt` to a `soeREQUIRED` or `soeDEFAULT` field throws + * `FieldErr`; required fields cannot be removed and default-value fields are + * semantically always present. + * + * Copy-constructible but not copy-assignable; constructed only by `STObject`. + * + * @tparam T The concrete `STBase`-derived type carrying the field's value. + */ template class STObject::OptionalProxy : public Proxy { @@ -593,6 +1007,7 @@ public: explicit operator bool() const noexcept; + /** Implicit conversion to `std::optional`. */ operator optional_type() const; /** Explicit conversion to std::optional */ @@ -665,17 +1080,26 @@ public: return !(lhs == rhs); } - // Emulate std::optional::value_or + /** Return the field's value if present, otherwise `val`. */ [[nodiscard]] value_type valueOr(value_type val) const; + /** Remove the field (make it absent). + * + * @throws FieldErr if the field is `soeREQUIRED` or `soeDEFAULT`. + */ OptionalProxy& operator=(std::nullopt_t const&); + + /** Assign from an rvalue `std::optional`; removes the field if `nullopt`. */ OptionalProxy& operator=(optional_type&& v); // NOLINT(cppcoreguidelines-rvalue-reference-param-not-moved) + + /** Assign from a const `std::optional`; removes the field if `nullopt`. */ OptionalProxy& operator=(optional_type const& v); + /** Assign a value directly to the field. */ template std::enable_if_t, OptionalProxy&> operator=(U&& u); @@ -685,12 +1109,15 @@ private: OptionalProxy(STObject* st, TypedField const* f); + /** Return `true` when the field is present (not the `STI_NOTPRESENT` sentinel). */ [[nodiscard]] bool engaged() const noexcept; + /** Remove the field, enforcing `soeREQUIRED`/`soeDEFAULT` constraints. */ void disengage(); + /** Return the current value as `optional_type`, or `nullopt` if absent. */ [[nodiscard]] optional_type optionalValue() const; }; diff --git a/include/xrpl/protocol/STParsedJSON.h b/include/xrpl/protocol/STParsedJSON.h index d5b4f33be7..24d197d165 100644 --- a/include/xrpl/protocol/STParsedJSON.h +++ b/include/xrpl/protocol/STParsedJSON.h @@ -6,19 +6,50 @@ namespace xrpl { -/** Holds the serialized result of parsing an input JSON object. - This does validation and checking on the provided JSON. -*/ +/** Single-use converter from a JSON object to an @ref STObject. + * + * Sits at the boundary between the JSON/RPC layer and the binary-canonical + * Serialized Type (ST) system. In a single constructor call it validates + * every field name against the XRPL protocol schema, coerces each value to + * its wire type, recurses into nested objects and arrays up to 64 levels + * deep, and applies the field template for the transaction or ledger-entry + * type discovered during parsing. + * + * Outcomes are communicated through two public members rather than via + * exceptions or a return value, which lets RPC handlers forward `error` + * directly to the client without additional formatting work: + * - On success: `object` holds the populated `STObject`; `error` is empty. + * - On failure: `object` is `std::nullopt`; `error` is an + * `rpcINVALID_PARAMS` JSON value with a dot-separated field path + * (e.g. `"tx_json.Signers[0].Signer.Account"`) pinpointing the offending + * field. + * + * The class is non-copyable and not default-constructible; every instance + * represents exactly one completed parse attempt. + * + * @see TransactionSign.cpp for the primary production call-site. + */ class STParsedJSONObject { public: - /** Parses and creates an STParsedJSON object. - The result of the parsing is stored in object and error. - Exceptions: - Does not throw. - @param name The name of the JSON field, used in diagnostics. - @param json The JSON-RPC to parse. - */ + /** Parse @p json into a strongly-typed @ref STObject. + * + * Iterates every member of the JSON object, resolves each field name + * via `SField::getField()`, recurses into nested objects and arrays, + * and finally calls `applyTemplateFromSField()` to enforce the field + * template for the detected transaction or ledger-entry type. + * + * All internal exceptions are caught and translated into a structured + * `rpcINVALID_PARAMS` error stored in `error`; nothing propagates to + * the caller. + * + * @param name The logical name of the top-level field being parsed + * (e.g. `"tx_json"`); used as the root of dot-separated field-path + * strings in `error` messages. + * @param json The JSON object to parse. Must be a JSON object value; + * non-object input produces an `rpcINVALID_PARAMS` error. + * @note Does not throw. + */ STParsedJSONObject(std::string const& name, json::Value const& json); STParsedJSONObject() = delete; @@ -27,10 +58,15 @@ public: operator=(STParsedJSONObject const&) = delete; ~STParsedJSONObject() = default; - /** The STObject if the parse was successful. */ + /** The parsed object on success, or `std::nullopt` on any parse error. */ std::optional object; - /** On failure, an appropriate set of error values. */ + /** Structured `rpcINVALID_PARAMS` error on failure; empty on success. + * + * The JSON value is suitable for forwarding directly to an RPC client. + * The `"error_message"` field contains a dot-separated field path + * identifying the first field that failed to parse. + */ json::Value error; }; diff --git a/include/xrpl/protocol/STPathSet.h b/include/xrpl/protocol/STPathSet.h index a1891164f6..b0b6c6d092 100644 --- a/include/xrpl/protocol/STPathSet.h +++ b/include/xrpl/protocol/STPathSet.h @@ -1,3 +1,14 @@ +/** @file + * Defines the three-class hierarchy for payment path representation in XRPL + * transactions. + * + * `STPathElement` is a single hop; `STPath` is an ordered sequence of hops; + * `STPathSet` is the collection of alternate candidate paths carried in the + * `Paths` field of a `Payment` transaction on the wire. Together they encode + * how a cross-currency payment routes through the order book and the trust-line + * graph from source to destination. + */ + #pragma once #include @@ -14,6 +25,24 @@ namespace xrpl { +/** A single hop in a payment path. + * + * A node is either an *account node* (rippling through trust lines) or an + * *offer node* (matching against a DEX order book). `isOffer()` returns `true` + * when `mAccountID` is the XRP "no account" sentinel; otherwise the element + * represents an account. The `Type` bitmask drives both on-wire encoding and + * runtime dispatch. + * + * The asset field holds a `PathAsset` variant (`Currency` for legacy IOU hops, + * `MPTID` for MPT hops). `TypeCurrency` and `TypeMpt` are mutually exclusive. + * + * A non-cryptographic hash of the account, asset, and issuer fields is + * pre-computed at construction (`hash_value_`). `operator==` short-circuits on + * this hash before performing field-by-field comparison, making duplicate + * detection in the pathfinder fast over the small vectors used in practice. + * + * @see STPath, STPathSet + */ class STPathElement final : public CountedObject { unsigned int type_; @@ -25,97 +54,217 @@ class STPathElement final : public CountedObject std::size_t hash_value_; public: - // Bitwise values (typeCurrency | typeMPT) + /** Bitmask constants that govern on-wire encoding and runtime dispatch. + * + * Each hop's type byte is the OR of the applicable constants. The + * deserializer rejects any byte with bits outside `TypeAll` as malformed. + * `TypeCurrency` and `TypeMpt` are mutually exclusive; `TypeAsset` is a + * convenience mask to test either without caring which. + */ // NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) enum Type { - TypeNone = 0x00, - TypeAccount = 0x01, // Rippling through an account (vs taking an offer). - TypeCurrency = 0x10, // Currency follows. - TypeIssuer = 0x20, // Issuer follows. - TypeMpt = 0x40, // MPT follows. - TypeBoundary = 0xFF, // Boundary between alternate paths. - TypeAsset = TypeCurrency | TypeMpt, - TypeAll = TypeAccount | TypeCurrency | TypeIssuer | TypeMpt, - // Combination of all types. + TypeNone = 0x00, /**< Path terminator (0x00 byte ends the PathSet). */ + TypeAccount = 0x01, /**< Account field is present; node ripples through trust lines. */ + TypeCurrency = 0x10, /**< Legacy IOU Currency (160-bit) follows. Mutually exclusive with TypeMpt. */ + TypeIssuer = 0x20, /**< Issuer AccountID (160-bit) follows. */ + TypeMpt = 0x40, /**< MPT issuance ID (192-bit MPTID) follows. Mutually exclusive with TypeCurrency. */ + TypeBoundary = 0xFF, /**< Separator between consecutive paths within the PathSet. */ + TypeAsset = TypeCurrency | TypeMpt, /**< Either asset kind; tests presence without distinguishing IOU vs MPT. */ + TypeAll = TypeAccount | TypeCurrency | TypeIssuer | TypeMpt, /**< Union of all valid type bits; used to validate incoming bytes. */ }; + /** Construct a `TypeNone` (path-terminator / empty) element. + * + * The resulting element has `is_offer_ = true` and all fields zero. + * Used as a sentinel and by default-constructed STPath entries. + */ STPathElement(); STPathElement(STPathElement const&) = default; STPathElement& operator=(STPathElement const&) = default; + /** Construct an element from optional fields, setting type bits automatically. + * + * The type bitmask is derived from which optionals are non-null: + * `TypeAccount` if `account` is set, `TypeCurrency`/`TypeMpt` from the + * `PathAsset` variant if `asset` is set, and `TypeIssuer` if `issuer` is + * set. Asserts (debug builds) that account and issuer are not `noAccount()` + * when provided. + * + * @param account AccountID of the hop; absent means this is an offer node. + * @param asset PathAsset (Currency or MPTID) for the hop; absent means + * no asset constraint. + * @param issuer Issuer AccountID; absent means no issuer constraint. + */ STPathElement( std::optional const& account, std::optional const& asset, std::optional const& issuer); + /** Construct an element from explicit non-optional fields. + * + * `TypeAccount` is set when `account` is not the XRP sentinel; the asset + * type bit (`TypeCurrency` or `TypeMpt`) is set when the asset is not XRP + * (or unconditionally when `forceAsset` is `true`); `TypeIssuer` is set + * when `issuer` is not the XRP sentinel. + * + * @param account AccountID of the hop; XRP sentinel (`xrpAccount()`) + * means offer node. + * @param asset PathAsset describing the hop's currency or MPT. + * @param issuer Issuer AccountID. + * @param forceAsset When `true`, always set the asset type bit even if the + * asset is XRP. Used to preserve currency information in offer nodes + * whose asset happens to be XRP. + */ STPathElement( AccountID const& account, PathAsset const& asset, AccountID const& issuer, bool forceAsset = false); + /** Construct an element from an explicit wire-format type byte and fields. + * + * Used by the deserializer. The type byte is accepted verbatim and then + * sanitised: the actual `PathAsset` variant is inspected to clear the + * contradictory bit (`TypeMpt` when holding a `Currency`, or + * `TypeCurrency` when holding an `MPTID`), so a caller cannot pass a + * self-contradictory bitmask. + * + * @param uType Wire-format type bitmask from the stream. + * @param account AccountID of the hop. + * @param asset PathAsset (Currency or MPTID) for the hop. + * @param issuer Issuer AccountID. + */ STPathElement( unsigned int uType, AccountID const& account, PathAsset const& asset, AccountID const& issuer); + /** Return the raw type bitmask for this element. + * + * The result is the OR of the applicable `Type` constants and can be + * inspected with `isType()` or tested directly against the `Type` enum. + */ [[nodiscard]] auto getNodeType() const; + /** Return `true` if this element represents a DEX offer node. + * + * An element is an offer node when its account field is the XRP "no + * account" sentinel, meaning the hop matches against the order book + * rather than rippling through a trust line. + */ [[nodiscard]] bool isOffer() const; + /** Return `true` if this element represents a trust-line account node. + * + * Equivalent to `!isOffer()`. + */ [[nodiscard]] bool isAccount() const; + /** Return `true` if the `TypeIssuer` bit is set. */ [[nodiscard]] bool hasIssuer() const; + /** Return `true` if the `TypeCurrency` bit is set (legacy IOU hop). */ [[nodiscard]] bool hasCurrency() const; + /** Return `true` if the `TypeMpt` bit is set (MPT hop). */ [[nodiscard]] bool hasMPT() const; + /** Return `true` if any asset type bit (`TypeCurrency` or `TypeMpt`) is set. */ [[nodiscard]] bool hasAsset() const; + /** Return `true` if this element is a path terminator (`TypeNone`). */ [[nodiscard]] bool isNone() const; - // Nodes are either an account ID or a offer prefix. Offer prefixs denote a - // class of offers. + /** Return the account for this hop. + * + * For account nodes this is the AccountID through which the payment + * ripples. For offer nodes the field holds the XRP "no account" sentinel + * and callers should use `isOffer()` to distinguish the two cases before + * interpreting this value. + */ [[nodiscard]] AccountID const& getAccountID() const; + /** Return the `PathAsset` (Currency or MPTID) for this hop. */ [[nodiscard]] PathAsset const& getPathAsset() const; + /** Return the `Currency` for this hop. + * + * @note Only valid when `hasCurrency()` is `true`; the underlying + * `PathAsset::get()` throws on type mismatch in debug builds. + */ [[nodiscard]] Currency const& getCurrency() const; + /** Return the `MPTID` for this hop. + * + * @note Only valid when `hasMPT()` is `true`; the underlying + * `PathAsset::get()` throws on type mismatch in debug builds. + */ [[nodiscard]] MPTID const& getMPTID() const; + /** Return the issuer AccountID for this hop. */ [[nodiscard]] AccountID const& getIssuerID() const; + /** Return `true` if any bit of `pe` is set in the element's type bitmask. + * + * @param pe Type mask to test; typically a single `Type` constant or a + * bitwise OR of several. + */ [[nodiscard]] bool isType(Type const& pe) const; + /** Return `true` if the two elements have identical account, asset, and issuer fields. + * + * Short-circuits first on the `TypeAccount` bit and the pre-computed hash + * before performing full field comparison, making deduplication fast over + * the small path vectors used in practice. + */ bool operator==(STPathElement const& t) const; + /** Return `true` if the two elements differ in any field. */ bool operator!=(STPathElement const& t) const; private: + /** Compute the non-cryptographic hash stored in `hash_value_`. + * + * Uses FNV-style multiply-XOR with distinct primes (257, 509, 911) for + * the account, asset, and issuer fields respectively, then XORs the three + * sub-hashes together. Reads the actual `PathAsset` variant via `visit()` + * rather than the type bitmask, because the bitmask may be partially set + * during pathfinder construction. Speed dominates; cryptographic strength + * is not required. + * + * @param element The element to hash. + * @return Non-cryptographic hash combining account, asset, and issuer. + */ static std::size_t getHash(STPathElement const& element); }; +/** An ordered sequence of `STPathElement` hops describing one candidate payment path. + * + * Wraps a `std::vector` with a standard container interface. + * The XRPL protocol caps path length, so the underlying vector is short in + * practice (typically 2–6 elements); linear scans are therefore acceptable. + * + * @see STPathElement, STPathSet + */ class STPath final : public CountedObject { std::vector path_; @@ -123,54 +272,111 @@ class STPath final : public CountedObject public: STPath() = default; + /** Construct a path from an existing vector of elements. + * + * @param p Elements to populate the path; moved into internal storage. + */ STPath(std::vector p); + /** Return the number of hops in this path. */ [[nodiscard]] std::vector::size_type size() const; + /** Return `true` if the path contains no hops. */ [[nodiscard]] bool empty() const; + /** Append a copy of `e` to the end of this path. */ void pushBack(STPathElement const& e); + /** Emplace a new element at the end of this path. + * + * @tparam Args Argument types forwarded to `STPathElement`'s constructor. + * @param args Arguments forwarded to the new element's constructor. + */ template void emplaceBack(Args&&... args); + /** Return `true` if any hop in this path matches the given (account, asset, issuer) triple. + * + * Used by the pathfinder for cycle detection: before extending a path, + * `Pathfinder::addLink()` calls this to ensure the candidate hop has not + * already appeared earlier in the path. A linear scan is acceptable + * because XRPL path lengths are protocol-bounded. + * + * @param account AccountID of the hop to search for. + * @param asset PathAsset (Currency or MPTID) to match. + * @param issuer Issuer AccountID to match. + * @return `true` if any existing element equals all three arguments. + */ [[nodiscard]] bool hasSeen(AccountID const& account, PathAsset const& asset, AccountID const& issuer) const; + /** Serialize the path to a JSON array of hop objects. + * + * Each hop object always includes a `type` field. Optional `account`, + * `currency`, `mpt_issuance_id`, and `issuer` keys are present only when + * the corresponding type bit is set. + * + * @return JSON array where each element describes one hop. + */ [[nodiscard]] json::Value getJson(JsonOptions) const; + /** Return an iterator to the first element. */ [[nodiscard]] std::vector::const_iterator begin() const; + /** Return a past-the-end iterator. */ [[nodiscard]] std::vector::const_iterator end() const; + /** Return `true` if both paths contain identical elements in identical order. */ bool operator==(STPath const& t) const; + /** Return a reference to the last element. */ [[nodiscard]] std::vector::const_reference back() const; + /** Return a reference to the first element. */ [[nodiscard]] std::vector::const_reference front() const; + /** Return a mutable reference to the element at index `i`. */ STPathElement& operator[](int i); + /** Return a const reference to the element at index `i`. */ STPathElement const& operator[](int i) const; + /** Reserve capacity for `s` elements, avoiding reallocations during path construction. + * + * @param s Minimum capacity to reserve. + */ void reserve(size_t s); }; //------------------------------------------------------------------------------ -// A set of zero or more payment paths +/** The serialized `Paths` field of a Payment transaction — a collection of alternate payment paths. + * + * Inherits from `STBase` and participates in the ledger type system via + * `STI_PATHSET`. The binary wire format encodes each `STPath` as a sequence + * of type-tagged hop records; consecutive paths are delimited by + * `TypeBoundary` (0xFF) and the entire field is terminated by `TypeNone` + * (0x00). Deserialization throws `std::runtime_error` on malformed input + * (empty paths or unknown type bits). + * + * The `isDefault()` override returns `true` when the set is empty, allowing + * the serialization layer to elide the field from transactions that have no + * explicit paths. + * + * @see STPath, STPathElement + */ class STPathSet final : public STBase, public CountedObject { std::vector value_; @@ -178,55 +384,140 @@ class STPathSet final : public STBase, public CountedObject public: STPathSet() = default; + /** Construct an empty STPathSet named by `n`. */ STPathSet(SField const& n); + + /** Deserialize an STPathSet from a binary stream. + * + * Reads the wire format produced by `add()`: hop records delimited by + * `TypeBoundary` (0xFF) and terminated by `TypeNone` (0x00). + * + * @param sit Binary cursor positioned at the first type byte; advanced + * past the terminating `TypeNone` on return. + * @param name SField that names this field in the enclosing object. + * @throws std::runtime_error "empty path" if a boundary or terminator is + * encountered before any hop is accumulated. + * @throws std::runtime_error "bad path element" if a type byte contains + * bits outside `TypeAll`. + */ STPathSet(SerialIter& sit, SField const& name); + /** Serialize the path set to its canonical binary wire format. + * + * Emits each hop as a type byte followed by its optional account (20B), + * MPTID (24B), currency (20B), and/or issuer (20B) payloads. Consecutive + * paths are separated by `TypeBoundary` (0xFF); the set ends with + * `TypeNone` (0x00). + * + * @param s Serializer accumulator to which bytes are appended. + */ void add(Serializer& s) const override; - [[nodiscard]] json::Value getJson(JsonOptions) const override; + /** Serialize the path set to a JSON array of path arrays. + * + * @param options JSON rendering options forwarded to each path. + * @return Nested JSON array: `[[hop, ...], [hop, ...], ...]`. + */ + [[nodiscard]] json::Value getJson(JsonOptions options) const override; + /** Return `STI_PATHSET`, identifying this field to the serialization framework. */ [[nodiscard]] SerializedTypeID getSType() const override; + /** Append `base` extended by `tail` to the set, unless an identical path already exists. + * + * Used by the pathfinder to build candidate paths incrementally. The + * candidate is pushed onto `value_` and then scanned against existing + * paths in reverse order (newest-first) to detect duplicates; if found, + * the candidate is popped and `false` is returned. Reverse iteration is a + * micro-optimisation because duplicates are most likely among recently + * added paths. + * + * @param base Prefix path to extend. + * @param tail Single hop appended to `base` before comparison. + * @return `true` if the path was added; `false` if it was a duplicate. + */ bool assembleAdd(STPath const& base, STPathElement const& tail); + /** Return `true` if `t` is an STPathSet with identical path contents. + * + * @param t Object to compare; returns `false` immediately if not an STPathSet. + */ [[nodiscard]] bool isEquivalent(STBase const& t) const override; + /** Return `true` when the set contains no paths. + * + * The serialization layer uses this to elide the `Paths` field from + * transactions that require no explicit pathfinding routes. + */ [[nodiscard]] bool isDefault() const override; - // std::vector like interface: + // std::vector-like interface for iterating and indexing paths. + + /** Return a const reference to the path at index `n`. */ std::vector::const_reference operator[](std::vector::size_type n) const; + /** Return a mutable reference to the path at index `n`. */ std::vector::reference operator[](std::vector::size_type n); + /** Return an iterator to the first path. */ [[nodiscard]] std::vector::const_iterator begin() const; + /** Return a past-the-end iterator. */ [[nodiscard]] std::vector::const_iterator end() const; + /** Return the number of paths in the set. */ [[nodiscard]] std::vector::size_type size() const; + /** Return `true` if the set contains no paths. */ [[nodiscard]] bool empty() const; + /** Append a copy of `e` to the set. + * + * @note Does not deduplicate; use `assembleAdd()` when deduplication is needed. + */ void pushBack(STPath const& e); + /** Emplace a new path at the end of the set. + * + * @tparam Args Argument types forwarded to `STPath`'s constructor. + * @param args Arguments forwarded to the new path's constructor. + */ template void emplaceBack(Args&&... args); private: + /** Copy-construct this STPathSet into `buf` via placement-new. + * + * Plugs STPathSet into the `detail::STVar` small-object storage scheme. + * + * @param n Byte size of `buf`; must be at least `sizeof(STPathSet)`. + * @param buf Aligned destination buffer. + * @return Pointer to the newly constructed object. + */ STBase* copy(std::size_t n, void* buf) const override; + + /** Move-construct this STPathSet into `buf` via placement-new. + * + * Plugs STPathSet into the `detail::STVar` small-object storage scheme. + * + * @param n Byte size of `buf`; must be at least `sizeof(STPathSet)`. + * @param buf Aligned destination buffer. + * @return Pointer to the newly constructed object. + */ STBase* move(std::size_t n, void* buf) override; @@ -369,8 +660,6 @@ STPathElement::isNone() const return getNodeType() == STPathElement::TypeNone; } -// Nodes are either an account ID or a offer prefix. Offer prefixs denote a -// class of offers. inline AccountID const& STPathElement::getAccountID() const { @@ -499,7 +788,6 @@ inline STPathSet::STPathSet(SField const& n) : STBase(n) { } -// std::vector like interface: inline std::vector::const_reference STPathSet::operator[](std::vector::size_type n) const { diff --git a/include/xrpl/protocol/STTakesAsset.h b/include/xrpl/protocol/STTakesAsset.h index bf75ffccf7..a86601ee95 100644 --- a/include/xrpl/protocol/STTakesAsset.h +++ b/include/xrpl/protocol/STTakesAsset.h @@ -5,28 +5,48 @@ namespace xrpl { -/** Intermediate class for any STBase-derived class to store an Asset. +/** Mixin base that lets a serializable field receive an `Asset` at runtime. * - * In the class definition, this class should be specified as a base class - * _instead_ of STBase. + * Derived classes inherit from `STTakesAsset` _instead of_ `STBase` when + * they store a numeric quantity whose precision depends on the enclosing + * ledger entry's asset type (XRP, IOU, or MPT). The asset identity is + * already present in the containing ledger object and must not be duplicated + * in each field; `STTakesAsset` carries it at runtime without serializing it. * - * Specifically, the Asset is only stored and used at runtime. It should not be - * serialized to the ledger. + * The only current concrete user is `STNumber`, which overrides + * `associateAsset()` to round its stored `Number` to the asset's canonical + * precision immediately upon association and again during serialization. * - * The derived class decides what to do with the Asset, and when. It will not - * necessarily be set at any given time. As of this writing, only STNumber uses - * it to round the stored Number to the Asset's precision both when associated, - * and when serializing the Number. + * @note `asset_` is intentionally `std::optional`: during deserialization from + * disk no transactor context is available, so no asset can be supplied. + * The value still round-trips correctly because it was already rounded when + * originally written. + * @see STNumber, associateAsset(STLedgerEntry&, Asset const&) */ class STTakesAsset : public STBase { protected: + /** Runtime asset identity used for precision rounding. + * + * Absent (`std::nullopt`) on the deserialization path; set by a call to + * `associateAsset()` inside `doApply()` before the SLE is serialized. + */ std::optional asset_; public: using STBase::STBase; using STBase::operator=; + /** Record @p a as the asset governing this field's precision. + * + * The base implementation stores @p a in `asset_` via `emplace` and + * returns. Derived classes override this method to act on the asset + * immediately — for example, `STNumber` also rounds its stored value to + * the asset's canonical precision. + * + * @param a The asset to associate. Must be the same asset used by all + * other `sMD_NeedsAsset`-flagged fields in the enclosing SLE. + */ virtual void associateAsset(Asset const& a); }; @@ -39,20 +59,26 @@ STTakesAsset::associateAsset(Asset const& a) class STLedgerEntry; -/** Associate an Asset with all sMD_NeedsAsset fields in a ledger entry. +/** Associate an asset with every `sMD_NeedsAsset`-flagged field in @p sle. * - * This function iterates over all fields in the given ledger entry. For each - * field that is set and has the SField::sMD_NeedsAsset metadata flag, it calls - * `associateAsset` on that field with the given Asset. Such field must be - * derived from STTakesAsset - if it is not, the conversion will throw. + * Iterates over all fields in @p sle by offset (the only path that yields + * mutable `STBase&` references). For each field that is present and carries + * `SField::kSMD_NEEDS_ASSET`, calls `associateAsset(asset)` on it, triggering + * derived-class rounding logic (e.g., `STNumber` rounds to the asset's + * canonical precision). After rounding, any `soeDEFAULT`-style field whose + * value has become the default (e.g., rounded down to zero) is removed from + * the SLE via `makeFieldAbsent` so that zero defaults are not persisted in the + * ledger. * - * Typically, associateAsset should be called near the end of doApply() of any - * Transactor classes on the SLEs of any new or modified ledger entries - * containing STNumber fields, after doing all of the modifications t the SLEs. - * - * @param sle The ledger entry whose fields will be updated. - * @param asset The Asset to associate with the relevant fields. + * Call this near the end of `doApply()` in any transactor that creates or + * modifies an SLE containing `STNumber` fields, after all other mutations to + * the SLE are complete. Rounding before computations finish may distort + * intermediate values. * + * @param sle The ledger entry whose `sMD_NeedsAsset` fields will be updated. + * @param asset The asset that governs precision for all such fields in @p sle. + * @throws std::bad_cast if any field carrying `kSMD_NEEDS_ASSET` is not + * derived from `STTakesAsset` — this indicates a field schema error. */ void associateAsset(STLedgerEntry& sle, Asset const& asset); diff --git a/include/xrpl/protocol/STTx.h b/include/xrpl/protocol/STTx.h index d1bd32848f..3e16a447af 100644 --- a/include/xrpl/protocol/STTx.h +++ b/include/xrpl/protocol/STTx.h @@ -1,3 +1,8 @@ +/** @file + * Declares `STTx`, the canonical in-memory representation of an XRP Ledger + * transaction, together with the free functions that operate on it + * (`passesLocalChecks`, `sterilize`, `isPseudoTx`). + */ #pragma once #include @@ -15,67 +20,171 @@ namespace xrpl { +/** Status codes used to tag a transaction row in the local SQLite + * `Transactions` table. + * + * Each enumerator maps to the single-character `Status` column value + * stored by `getMetaSQL`. + */ enum class TxnSql : char { - New = 'N', - Conflict = 'C', - Held = 'H', - Validated = 'V', - Included = 'I', - Unknown = 'U' + New = 'N', /**< Transaction has just been received and not yet processed. */ + Conflict = 'C', /**< Transaction conflicts with a previously applied transaction. */ + Held = 'H', /**< Transaction is queued but not yet eligible for inclusion. */ + Validated = 'V', /**< Transaction is in a validated ledger. */ + Included = 'I', /**< Transaction is included in a pending ledger. */ + Unknown = 'U' /**< Transaction status cannot be determined. */ }; +/** The canonical in-memory representation of an XRP Ledger transaction. + * + * `STTx` extends `STObject` with transaction-specific identity, typing, + * signing, and persistence semantics. It caches the transaction ID (`tid_`, + * a SHA-512 half-hash prefixed with `HashPrefix::transactionID`) and the + * decoded transaction type (`tx_type_`) so that hot paths avoid repeated + * field lookups and hash recomputation. + * + * Three construction paths exist: wire deserialization (`SerialIter&`), + * object promotion (`STObject&&`), and programmatic assembly + * (`TxType, assembler`). Copy construction is allowed; copy assignment is + * deleted to prevent invariant violations on re-assignment. + * + * The class is `final`: transaction-type-specific behavior lives in the + * transactor subsystem, not in subclasses of `STTx`. + * + * @note `CountedObject` tracks live instance counts for diagnostics. + */ class STTx final : public STObject, public CountedObject { uint256 tid_; TxType tx_type_; public: + /** Minimum number of signers allowed in a multi-sign signer list. */ static constexpr std::size_t kMIN_MULTI_SIGNERS = 1; + /** Maximum number of signers allowed in a multi-sign signer list. */ static constexpr std::size_t kMAX_MULTI_SIGNERS = 32; STTx() = delete; STTx(STTx const& other) = default; + /** Deleted to prevent re-assignment from invalidating the cached ID and type. */ STTx& operator=(STTx const& other) = delete; + /** Deserialize a transaction from a wire-format byte stream. + * + * Validates the remaining byte count against the + * `kTX_MIN_SIZE_BYTES`/`kTX_MAX_SIZE_BYTES` protocol bounds, parses + * the field stream, applies the `SOTemplate` for the decoded + * `TxType`, and caches the transaction ID. This is the hottest + * construction path: every inbound peer transaction and every + * transaction loaded from the node store passes through here. + * + * @param sit A `SerialIter` positioned at the first byte of the + * serialized transaction. The iterator is advanced in place. + * @throws std::runtime_error if the byte count is outside protocol + * bounds, if an object-terminator byte is encountered at the top + * level, if the transaction type is unregistered, or if + * `applyTemplate` rejects the field layout. + */ explicit STTx(SerialIter& sit); + + /** Rvalue-reference overload that delegates to the lvalue constructor. + * + * `SerialIter` is consumed by value semantics internally, so the + * rvalue is not actually moved; the `// NOLINT` in the inline + * definition acknowledges this. + * + * @param sit A temporary `SerialIter`; forwarded to `STTx(SerialIter&)`. + * @throws std::runtime_error (same conditions as the lvalue overload). + */ explicit STTx(SerialIter&& sit); + + /** Promote a generic `STObject` to a fully typed transaction. + * + * Used when a transaction arrives as a raw parsed object (e.g., from + * JSON deserialization) and must be graduated to a fully validated + * `STTx`. No wire-size checks are performed; `applyTemplate` enforces + * field conformance against the registered `SOTemplate` for the + * transaction type. + * + * @param object An rvalue `STObject` that must already contain + * `sfTransactionType`. Consumed by the move. + * @throws std::runtime_error if the transaction type is unregistered + * or if `applyTemplate` rejects the field layout. + */ explicit STTx(STObject&& object); - /** Constructs a transaction. - - The returned transaction will have the specified type and - any fields that the callback function adds to the object - that's passed in. - */ + /** Programmatically construct a transaction of the given type. + * + * Installs the `SOTemplate` for `type` and sets `sfTransactionType`, + * then invokes `assembler` to populate remaining fields. After the + * assembler returns, the transaction ID is computed and cached. + * + * @param type The transaction type; must be registered in + * `TxFormats`. + * @param assembler A callable invoked with a mutable reference to the + * newly templated `STObject`. Must not mutate `sfTransactionType`. + * @throws std::runtime_error if `type` is not registered. + * @note Fires `logicError` (not a thrown exception) if `assembler` + * mutates `sfTransactionType` — this is a programming error, not a + * data error. + */ STTx(TxType type, std::function assembler); - // STObject functions. + /** @return The serialized type ID `STI_TRANSACTION`. */ SerializedTypeID getSType() const override; + /** @return A human-readable string of the form `"" = { ... }`. */ std::string getFullText() const override; - // Outer transaction functions / signature functions. + /** Extract the raw `sfTxnSignature` bytes from an arbitrary object. + * + * @param sigObject The object to read `sfTxnSignature` from; typically + * `*this` or a multi-sign signer sub-object. + * @return The signature bytes, or an empty `Blob` if the field is absent + * or an exception occurs during field access. + */ static Blob getSignature(STObject const& sigObject); + /** Extract the `sfTxnSignature` bytes from this transaction. */ Blob getSignature() const { return getSignature(*this); } + /** Compute the single-sign hash of this transaction. + * + * Prepends `HashPrefix::TxSign` to the serialized form (without signing + * fields) and returns the SHA-512 half-hash. Use this to verify a + * signature without calling `checkSign`. + * + * @return The 256-bit signing hash. + */ uint256 getSigningHash() const; + /** @return The decoded transaction type, cached at construction time. */ TxType getTxnType() const; + /** @return The raw bytes of `sfSigningPubKey`. Empty for multi-signed transactions. */ Blob getSigningPubKey() const; + /** Return a unified sequence proxy abstracting classic sequence and ticket modes. + * + * When `sfSequence` is non-zero the transaction uses classic sequence + * ordering and a `SeqProxy::Sequence` is returned. When `sfSequence` is + * zero and `sfTicketSequence` is present, a `SeqProxy::Ticket` is returned. + * Sequence-type proxies always sort before ticket-type proxies, which the + * protocol relies on for correct processing order. + * + * @return A `SeqProxy` of type `Sequence` or `Ticket` as appropriate. + */ SeqProxy getSeqProxy() const; @@ -83,44 +192,150 @@ public: std::uint32_t getSeqValue() const; + /** Resolve the account whose balance pays the transaction fee. + * + * Returns `sfDelegate` if present, otherwise `sfAccount`. Authorization + * of the delegate relationship is enforced separately in the transactor + * layer; this method performs no validation. + * + * @return The `AccountID` of the fee-paying account. + */ AccountID getFeePayer() const; + /** Collect every `AccountID` referenced by top-level fields of this transaction. + * + * Walks top-level `STAccount` fields and non-XRP `STAmount` issuers. + * Used to determine which accounts are touched by a transaction for + * indexing and fee purposes. + * + * @return A flat, sorted set of all referenced account IDs. + * @note Only top-level fields are examined; nested objects (e.g., + * inner multi-sign signers) are not descended into. + */ boost::container::flat_set getMentionedAccounts() const; + /** @return The cached transaction ID, computed at construction time. */ uint256 getTransactionID() const; + /** Return the transaction as a JSON object, optionally including the hash. + * + * Includes the `"hash"` key unless `options` has + * `JsonOptions::DisableApiPriorV2` set (API v2+). + * + * @param options JSON rendering options. + * @return A `json::Value` object representing the transaction. + */ json::Value getJson(JsonOptions options) const override; + /** Return the transaction as JSON, with an optional binary representation. + * + * When `binary` is `true`, the transaction body is hex-encoded. Under + * API v1 the result wraps the hex in `{"tx": "...", "hash": "..."}`; + * under API v2+ it returns the raw hex string. When `binary` is `false`, + * behaves identically to `getJson(options)`. + * + * @param options JSON rendering options controlling API version behavior. + * @param binary If `true`, serialize the transaction to hex instead of + * expanding fields into JSON. + * @return A `json::Value` containing the transaction representation. + */ json::Value getJson(JsonOptions options, bool binary) const; + /** Sign this transaction with the given key pair. + * + * Computes the single-sign payload (hash-prefix + transaction body + * without signing fields), signs it, and writes the signature and public + * key into the transaction. The cached transaction ID is recomputed + * after the signature is stored. + * + * @param publicKey The signer's public key; written to + * `sfSigningPubKey`. + * @param secretKey The corresponding secret key used to produce + * the signature. + * @param signatureTarget If set, the signature is written into that + * named sub-object field (e.g., `sfCounterpartySignature`) instead + * of the transaction root. Used for two-party protocols such as + * `LoanSet`. + */ void sign( PublicKey const& publicKey, SecretKey const& secretKey, std::optional> signatureTarget = {}); - /** Check the signature. - @param rules The current ledger rules. - @return `true` if valid signature. If invalid, the error message string. - */ + /** Verify the primary signature and, if present, the counterparty signature. + * + * Dispatches to single-sign or multi-sign verification based on whether + * `sfSigningPubKey` is empty. If `sfCounterpartySignature` is present, + * it is verified with the same dispatch; errors from the counterparty + * check are prefixed with `"Counterparty: "`. + * + * @param rules The current ledger rules. + * @return An empty `Expected` on success, or an error string on failure. + */ Expected checkSign(Rules const& rules) const; + /** Verify all batch-signing signatures on a `ttBATCH` transaction. + * + * Iterates over `sfBatchSigners`, dispatching each entry to single- or + * multi-sign batch verification. The signed payload is the output of + * `serializeBatch()` — a batch-specific hash prefix, the outer + * transaction's flags, and the IDs of the inner transactions — which + * binds each signer to the exact set of inner transactions. + * + * @param rules The current ledger rules. + * @return An empty `Expected` on success, or an error string on failure. + * @note Asserts and returns an error if called on a non-batch transaction. + */ Expected checkBatchSign(Rules const& rules) const; - // SQL Functions with metadata. + /** Return the static SQL `INSERT OR REPLACE INTO Transactions` header. + * + * The returned string is the constant prefix used by `getMetaSQL` to + * build persistence statements for the local SQLite `Transactions` table. + * + * @return A reference to a process-lifetime static string. + */ static std::string const& getMetaSQLInsertReplaceHeader(); + /** Produce a SQL value tuple for this transaction with `Validated` status. + * + * Serializes the transaction and delegates to the full overload with + * `TxnSql::Validated` as the status code. + * + * @param inLedger The ledger sequence number containing this + * transaction. + * @param escapedMetaData Pre-escaped binary metadata string for the + * `TxnMeta` column. + * @return A SQL value tuple string suitable for appending to + * `getMetaSQLInsertReplaceHeader()`. + */ std::string getMetaSQL(std::uint32_t inLedger, std::string const& escapedMetaData) const; + /** Produce a SQL value tuple with explicit status and raw transaction bytes. + * + * Formats a parenthesized row for the `Transactions` table containing + * the transaction ID, type name, source account (Base58), sequence + * number, ledger sequence, a single-character status code, the raw + * serialized transaction blob, and pre-escaped metadata. + * + * @param rawTxn The serialized transaction bytes (by value). + * @param inLedger The ledger sequence number containing this + * transaction. + * @param status The persistence status code for the `Status` + * column. + * @param escapedMetaData Pre-escaped binary metadata for `TxnMeta`. + * @return A SQL value tuple string. + */ std::string getMetaSQL( Serializer rawTxn, @@ -128,54 +343,118 @@ public: TxnSql status, std::string const& escapedMetaData) const; + /** Return the cached IDs of the inner transactions in a `ttBATCH` transaction. + * + * On the first call, hashes each entry in `sfRawTransactions` and + * stores the result in `batchTxnIds_`. Subsequent calls return the + * cached vector directly. An assertion on every call verifies that the + * cache size still matches `sfRawTransactions`, enforcing the invariant + * that inner transactions may not be modified after the IDs have been + * observed. + * + * @return A const reference to the cached vector of inner transaction IDs. + * @note Must only be called on a `ttBATCH` transaction with a non-empty + * `sfRawTransactions` array. + */ std::vector const& getBatchTransactionIDs() const; private: - /** Check the signature. - @param rules The current ledger rules. - @param sigObject Reference to object that contains the signature fields. - Will be *this more often than not. - @return `true` if valid signature. If invalid, the error message string. - */ + /** Dispatch to single- or multi-sign verification for an arbitrary object. + * + * Inspects `sfSigningPubKey` in `sigObject`: empty → multi-sign path, + * non-empty → single-sign path. + * + * @param rules The current ledger rules. + * @param sigObject The object carrying signature fields; usually `*this` + * but may be a counterparty sub-object. + * @return An empty `Expected` on success, or an error string on failure. + */ Expected checkSign(Rules const& rules, STObject const& sigObject) const; + /** Verify a single-sign signature against the transaction body. */ Expected checkSingleSign(STObject const& sigObject) const; + /** Verify multi-sign signatures against the transaction body. */ Expected checkMultiSign(Rules const& rules, STObject const& sigObject) const; + /** Verify a single-sign batch signature for one `sfBatchSigners` entry. */ Expected checkBatchSingleSign(STObject const& batchSigner) const; + /** Verify multi-sign batch signatures for one `sfBatchSigners` entry. */ Expected checkBatchMultiSign(STObject const& batchSigner, Rules const& rules) const; + /** Placement-new copy into a pre-allocated buffer; supports `STVar` SOO. */ STBase* copy(std::size_t n, void* buf) const override; + /** Placement-new move into a pre-allocated buffer; supports `STVar` SOO. */ STBase* move(std::size_t n, void* buf) override; friend class detail::STVar; + /** Lazily populated cache of inner transaction IDs for `ttBATCH` transactions. */ mutable std::vector batchTxnIds_; }; +/** Run all local pre-submission validity checks on a transaction object. + * + * Gate-keeps local relay and submission by enforcing: + * - Memo field size (max 1024 bytes serialized) and RFC 3986 character + * legality for `MemoType`/`MemoFormat`. + * - All `STAccount` fields must carry non-zero (non-default) values. + * - Pseudo-transaction types (`ttAMENDMENT`, `ttFEE`, `ttUNL_MODIFY`) are + * rejected; they are synthesized internally by the ledger. + * - MPT amounts may only appear in fields that explicitly declare MPT support + * via `soeMPTSupported`. + * - Batch inner transactions must not themselves be `ttBATCH`, and the + * `sfRawTransactions` / `sfBatchSigners` arrays must not exceed + * `kMAX_BATCH_TX_COUNT` entries. + * + * This is a free function rather than an `STTx` method because it can run + * on any `STObject` before it is promoted to a full `STTx`. + * + * @param st The transaction object to validate. + * @param reason Populated with a human-readable failure description when + * the function returns `false`. + * @return `true` if all checks pass; `false` on the first failure. + */ bool -passesLocalChecks(STObject const& st, std::string&); +passesLocalChecks(STObject const& st, std::string& reason); -/** Sterilize a transaction. - - The transaction is serialized and then deserialized, - ensuring that all equivalent transactions are in canonical - form. This also ensures that program metadata such as - the transaction's digest, are all computed. -*/ +/** Canonicalize a transaction via a serialize-then-deserialize round trip. + * + * Serializes `stx` to bytes, then constructs a fresh `STTx` from those bytes + * via `SerialIter`. The result is in wire-canonical form: all equivalent + * in-memory representations collapse to the same byte sequence, field + * ordering is normalized, and the transaction ID is freshly computed. + * + * Any code that synthesizes a transaction from JSON or via the programmatic + * assembler constructor and then submits it to the consensus pipeline should + * call `sterilize` first. + * + * @param stx The source transaction to sterilize. + * @return A `shared_ptr` to the newly constructed canonical `STTx const`. + * @throws std::runtime_error if the round-trip deserialization fails. + */ std::shared_ptr sterilize(STTx const& stx); -/** Check whether a transaction is a pseudo-transaction */ +/** Determine whether a transaction object is a ledger-generated pseudo-transaction. + * + * Pseudo-transactions (`ttAMENDMENT`, `ttFEE`, `ttUNL_MODIFY`) are + * synthesized internally by the ledger and must never be submitted by + * external clients. `passesLocalChecks` rejects any object for which this + * returns `true`. + * + * @param tx The transaction object to test; need not be a fully constructed + * `STTx`. + * @return `true` if the object carries a pseudo-transaction type. + */ bool isPseudoTx(STObject const& tx); diff --git a/include/xrpl/protocol/STValidation.h b/include/xrpl/protocol/STValidation.h index 0b7f53eb55..a976f9021f 100644 --- a/include/xrpl/protocol/STValidation.h +++ b/include/xrpl/protocol/STValidation.h @@ -1,3 +1,19 @@ +/** @file + * Defines `STValidation`, the wire-format object for a single ledger + * validation message in the XRPL consensus protocol. + * + * Validators broadcast one of these objects each consensus round to signal + * agreement on a specific closed ledger. Peers deserialize inbound messages + * into `STValidation` instances, verify signatures, and count them toward + * quorum. The class therefore has two distinct construction paths: one for + * creation-and-signing by the local validator, one for deserialization of a + * peer's message. See the two constructors for details. + * + * `STValidation` is owned via `std::shared_ptr` and wrapped by `RCLValidation` + * in the consensus machinery; that adapter provides the concept interface + * expected by the generic quorum-counting engine without coupling this class + * to consensus-specific logic. + */ #pragma once #include @@ -13,14 +29,45 @@ namespace xrpl { -// Validation flags +// --- Wire flag constants (stored in sfFlags; part of the signed payload) --- -// This is a full (as opposed to a partial) validation +/** Bit flag indicating a full (as opposed to a partial) validation. + * + * A partial validation signals participation in the consensus round without + * fully endorsing a specific ledger hash. Validators set this flag when they + * have applied the consensus transaction set and validated the resulting + * ledger. Read via `isFull()`. + */ constexpr std::uint32_t kVF_FULL_VALIDATION = 0x00000001; -// The signature is fully canonical +/** Bit flag indicating that the DER-encoded signature uses the low-S canonical form. + * + * XRPL requires low-S ECDSA signatures to prevent signature malleability. + * The signing constructor always sets this flag, and `isValid()` passes it + * to `verifyDigest()` to enforce canonicality on inbound messages. Because + * this value is stored in `sfFlags` inside the signed payload, it cannot be + * toggled without invalidating the signature. + */ constexpr std::uint32_t kVF_FULLY_CANONICAL_SIG = 0x80000000; +/** Wire-format representation of a ledger validation message in XRPL consensus. + * + * Inherits from `STObject` for typed-field serialization (the same system + * used by transactions and ledger entries) and from `CountedObject` for + * live-instance tracking in a long-running `rippled` process. + * + * The class maintains two separate concepts that must not be conflated: + * - **Validity** (`valid_`): whether the cryptographic signature is correct. + * Lazily evaluated and cached on first call to `isValid()`. + * - **Trust** (`trusted_`): whether the issuing validator is on this node's + * current Unique Node List (UNL). Set via `setTrusted()`/`setUntrusted()`; + * can change at runtime as the UNL evolves. + * + * @note Only `secp256k1` signing keys are accepted. Passing an `Ed25519` + * public key to either constructor throws at construction time. + * @see RCLValidation — the adapter that exposes this object to the generic + * consensus engine. + */ class STValidation final : public STObject, public CountedObject { bool trusted_ = false; @@ -39,30 +86,65 @@ class STValidation final : public STObject, public CountedObject NetClock::time_point seenTime_; public: - /** Construct a STValidation from a peer from serialized data. - - @param sit Iterator over serialized data - @param lookupNodeID Invocable with signature - NodeID(PublicKey const&) - used to find the Node ID based on the public key - that signed the validation. For manifest based - validators, this should be the NodeID of the master - public key. - @param checkSignature Whether to verify the data was signed properly - - @note Throws if the object is not valid - */ + /** Deserialize a validation received from a peer. + * + * Parses the binary payload via `STObject`, then extracts the signing + * public key from `sfSigningPubKey`. The `lookupNodeID` callable + * translates the ephemeral signing key to the validator's stable master + * `NodeID` (which may differ when the validator has rotated its ephemeral + * key via the manifest mechanism). + * + * @tparam LookupNodeID Callable with signature `NodeID(PublicKey const&)`. + * For manifest-based validators this should resolve to the master key's + * `NodeID`; for static-key validators it is typically + * `calcNodeID(pk)`. + * @param sit Iterator over the raw serialized validation bytes. + * @param lookupNodeID Invocable that maps the signing `PublicKey` to a + * stable `NodeID` used for UNL membership checks. + * @param checkSignature If `true`, verifies the signature immediately and + * throws on failure. Pass `false` to defer verification to the first + * call of `isValid()` (the pattern used by `PeerImp` to avoid + * synchronous cryptographic work on the peer-message path). + * @throws std::runtime_error if the serialized data is malformed, if the + * signing public key is absent or not a `secp256k1` key, or if + * `checkSignature` is `true` and the signature does not verify. + * @note After construction `seenTime_` is zero; callers must call + * `setSeen()` to record local receipt time before storing the object. + */ template STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, bool checkSignature); - /** Construct, sign and trust a new STValidation issued by this node. - - @param signTime When the validation is signed - @param publicKey The current signing public key - @param secretKey The current signing secret key - @param nodeID ID corresponding to node's public master key - @param f callback function to "fill" the validation with necessary data - */ + /** Construct, sign, and trust a new validation issued by the local node. + * + * Sets mandatory bookkeeping fields (`sfSigningPubKey`, `sfSigningTime`), + * invokes the filler callback `f(*this)` so the caller can attach + * optional fields (ledger hash, consensus hash, fee votes, amendment + * bits, server version), then signs the result with `signDigest` and + * marks the object as trusted. The `kVF_FULLY_CANONICAL_SIG` flag is + * always set, enforcing low-S ECDSA on the produced signature. + * + * After `f` returns a format-validation sweep checks that all + * `SoeRequired` fields are present; a missing required field is a + * programming error and triggers `logicError`. + * + * `seenTime_` is initialized to `signTime`, making sign time and seen + * time identical for locally created validations. + * + * @tparam F Callable with signature `void(STValidation&)`. + * @param signTime The time at which the validation is being signed; + * stored in `sfSigningTime` and used as the initial `seenTime_`. + * @param pk The validator's current ephemeral signing public key. + * Must be a `secp256k1` key; passing any other type calls + * `logicError`. + * @param sk The secret key matching `pk`, used to produce `sfSignature`. + * @param nodeID The stable master-key `NodeID` of this validator. + * @param f Callback invoked after mandatory fields are set but before + * signing. Use it to populate `sfLedgerHash`, `sfLedgerSequence`, + * `sfConsensusHash`, `sfFlags`, and any optional advisory fields. + * @note The resulting object is immediately marked as trusted and + * `valid_` is set to `true` without re-verifying, since the node + * just produced the signature. + */ template STValidation( NetClock::time_point signTime, @@ -71,53 +153,171 @@ public: NodeID const& nodeID, F&& f); - // Hash of the validated ledger + /** Return the hash of the ledger this validation endorses. + * + * @return Value of the `sfLedgerHash` field. + */ uint256 getLedgerHash() const; - // Hash of consensus transaction set used to generate ledger + /** Return the hash of the consensus transaction set that produced the validated ledger. + * + * @return Value of the `sfConsensusHash` field. Returns a zero hash if + * the field is absent (the field is optional in the schema). + */ uint256 getConsensusHash() const; + /** Return the time at which the validator claims to have signed this validation. + * + * Reads `sfSigningTime` from the serialized payload; because that field + * is part of the signed content it cannot be forged without invalidating + * the signature. Note that this is the validator's own clock time and + * may differ from `getSeenTime()`, which is when the *local* node + * received the message. + * + * @return The signing instant as a `NetClock::time_point`. + */ NetClock::time_point getSignTime() const; + /** Return the local time at which this node received or created the validation. + * + * For peer-sourced validations this is set via `setSeen()` after receipt. + * For self-issued validations the constructor initializes it to `signTime`. + * This value is never serialized or sent over the wire. + * + * @return The local receipt time as a `NetClock::time_point`. + */ NetClock::time_point getSeenTime() const noexcept; + /** Return the ephemeral public key that signed this validation. + * + * May differ from the validator's stable master key when the validator + * has rotated its signing key via the manifest mechanism. + * + * @return A reference to the immutable `signingPubKey_`. + */ PublicKey const& getSignerPublic() const noexcept; + /** Return the stable master-key `NodeID` of the issuing validator. + * + * For manifest-based validators this is derived from the master public + * key rather than the ephemeral signing key. It is the identity used + * for UNL membership checks and quorum counting. + * + * @return A reference to the immutable `nodeID_`. + */ NodeID const& getNodeID() const noexcept; + /** Verify the cryptographic signature, caching the result for future calls. + * + * On the first call, verifies the ECDSA signature over `getSigningHash()` + * using `signingPubKey_`. The `kVF_FULLY_CANONICAL_SIG` flag is consulted + * to enforce low-S canonicality. The result is stored in `valid_` and + * returned on all subsequent calls without re-computing. + * + * For self-issued validations the signing constructor pre-sets + * `valid_ = true`, so this method never performs cryptographic work. + * + * @return `true` if the signature is valid; `false` otherwise. + */ bool isValid() const noexcept; + /** Return whether this is a full (as opposed to partial) validation. + * + * A full validation endorses a specific ledger hash. A partial validation + * only signals that the validator participated in the round. + * + * @return `true` if the `kVF_FULL_VALIDATION` bit is set in `sfFlags`. + */ bool isFull() const noexcept; + /** Return whether this validation is marked as trusted by the local node. + * + * Trust reflects whether the issuing validator is on this node's current + * UNL and is independent of cryptographic validity. Self-issued + * validations are always trusted from construction. + * + * @return The current value of the `trusted_` flag. + */ bool isTrusted() const noexcept; + /** Compute the domain-separated hash that was (or will be) signed. + * + * Prepends `HashPrefix::Validation` (`'V','A','L',0x00`) to the canonical + * serialization of all signed fields, then applies SHA-512-Half. The + * prefix prevents a validation hash from colliding with any other signed + * payload type (transactions, proposals, etc.). + * + * @return The 256-bit signing digest. + */ uint256 getSigningHash() const; + /** Mark this validation as trusted. + * + * Called when the issuing validator is confirmed to be on this node's + * current UNL. May be called multiple times; subsequent calls are no-ops. + */ void setTrusted(); + /** Mark this validation as untrusted. + * + * Called when the issuing validator is removed from this node's current + * UNL, or when the validation is being re-evaluated. Does not affect the + * cryptographic `valid_` cache. + */ void setUntrusted(); + /** Record the local time at which this node received the validation. + * + * Should be called immediately after constructing a peer-sourced + * validation, before the object is stored or forwarded. For self-issued + * validations the signing constructor sets this to `signTime` + * automatically. + * + * @param s The local receipt time. + */ void setSeen(NetClock::time_point s); + /** Serialize this validation to its complete binary wire format. + * + * The returned bytes include all fields, including `sfSignature`, and are + * suitable for network transmission or deduplication hashing. To suppress + * relay of a duplicate message, callers typically hash this output with + * `sha512Half`. + * + * @return A `Blob` containing the complete serialized validation. + */ Blob getSerialized() const; + /** Return the raw DER-encoded ECDSA signature from the serialized payload. + * + * @return Value of the `sfSignature` field as a `Blob`. + */ Blob getSignature() const; + /** Produce a human-readable summary of this validation for logging. + * + * Renders all major fields (ledger hash, consensus hash, sign/seen times, + * signer public key, node ID, validity, fullness, trust status, signing + * hash, and Base58-encoded public key) into a single-line string. + * + * @return A diagnostic string; not suitable for machine parsing or + * network transmission. + */ std::string render() const { @@ -134,6 +334,12 @@ public: } private: + /** Return the field schema for `STValidation` objects. + * + * Function-local static to guarantee that all `SField` singletons are + * initialized before the `SOTemplate` is constructed (C++ provides no + * cross-translation-unit initialization order for namespace-scope statics). + */ static SOTemplate const& validationFormat(); @@ -168,14 +374,6 @@ STValidation::STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, bool ch XRPL_ASSERT(nodeID_.isNonZero(), "xrpl::STValidation::STValidation(SerialIter) : nonzero node"); } -/** Construct, sign and trust a new STValidation issued by this node. - - @param signTime When the validation is signed - @param publicKey The current signing public key - @param secretKey The current signing secret key - @param nodeID ID corresponding to node's public master key - @param f callback function to "fill" the validation with necessary data -*/ template STValidation::STValidation( NetClock::time_point signTime, diff --git a/include/xrpl/protocol/STVector256.h b/include/xrpl/protocol/STVector256.h index ab3a2f99e3..b5c050bb35 100644 --- a/include/xrpl/protocol/STVector256.h +++ b/include/xrpl/protocol/STVector256.h @@ -1,3 +1,11 @@ +/** @file + * Declares STVector256, the serialized type for ordered lists of uint256 values. + * + * On the wire the array is encoded as a single VL-prefixed blob of concatenated + * 32-byte hashes (type identifier STI_VECTOR256, code 19). Common ledger fields + * that use this type include sfAmendments, sfIndexes, and sfHashes. + */ + #pragma once #include @@ -7,92 +15,268 @@ namespace xrpl { +/** Serialized type for an ordered list of 256-bit hash values. + * + * Wraps a `std::vector` with the `STBase` contract so that hash + * collections can be stored as named, typed fields inside `STObject` — + * giving them a wire-format identity (`STI_VECTOR256`, code 19), a + * canonical binary encoding (VL-prefixed blob of packed 32-byte values), + * and a JSON representation (array of hex strings). Typical ledger uses + * include `sfAmendments` (active amendments in a validator vote), + * `sfIndexes` (keys in a `DirectoryNode` page), and `sfHashes`. + * + * An empty `STVector256` is the canonical default state and is omitted from + * the wire encoding when the field is declared optional. + * + * The `CountedObject` mixin adds lock-free instance counting + * for diagnostic purposes, with no overhead in the fast path. + */ class STVector256 : public STBase, public CountedObject { std::vector value_; public: + /** Reference type used when this value is passed as a read-only handle. */ using value_type = std::vector const&; + /** Construct an empty, unnamed STVector256. */ STVector256() = default; + /** Construct an empty STVector256 bound to the given field name. + * + * @param n The SField that identifies this field in its parent STObject. + */ explicit STVector256(SField const& n); + + /** Construct an unnamed STVector256 pre-populated with @p vector. + * + * @param vector Initial contents; copied into the internal store. + */ explicit STVector256(std::vector const& vector); + + /** Construct an STVector256 bound to @p n and pre-populated with @p vector. + * + * @param n The SField that identifies this field in its parent STObject. + * @param vector Initial contents; copied into the internal store. + */ STVector256(SField const& n, std::vector const& vector); + + /** Deserialize an STVector256 from a wire-format stream. + * + * Reads a single VL-prefixed blob from @p sit and partitions it into + * consecutive 32-byte chunks, each becoming one `uint256` entry. The + * resulting vector retains the original wire order. + * + * @param sit Forward-only iterator positioned at the VL length prefix of + * the field. Consumed by exactly one VL-length + slice read pair. + * @param name The SField that identifies this field within its parent STObject. + * @throws std::runtime_error if the decoded blob length is not an exact + * multiple of 32 bytes, indicating corrupt or truncated data. + */ STVector256(SerialIter& sit, SField const& name); + /** Return the serialized type identifier for this field. + * + * @return `STI_VECTOR256` (code 19). + */ [[nodiscard]] SerializedTypeID getSType() const override; + /** Serialize this array into @p s as a VL-prefixed blob. + * + * Writes a length prefix followed by the concatenated raw bytes of each + * `uint256` entry (32 bytes per element, no padding or separators). + * + * @param s Accumulator to append the encoded field into. + * @note Asserts (debug builds only) that the associated SField is marked + * binary and carries type `STI_VECTOR256`. These guards catch accidental + * field-type mismatches before data reaches the wire. + */ void add(Serializer& s) const override; + /** Produce a JSON array of hex-encoded hash strings. + * + * Each `uint256` entry is rendered as a lowercase hex string via + * `to_string()`. The @p options parameter is accepted for interface + * conformance but is unused; the representation is identical across + * all API versions. + * + * @return A `json::arrayValue` with one hex string per entry. + */ [[nodiscard]] json::Value getJson(JsonOptions) const override; + /** Test deep equality with another STBase instance. + * + * Two `STVector256` objects are equivalent when they contain the same + * sequence of `uint256` values in the same order. + * + * @param t The object to compare against. + * @return `true` if @p t is an `STVector256` with identical contents; + * `false` if the types differ or the sequences do not match. + */ [[nodiscard]] bool isEquivalent(STBase const& t) const override; + /** Return whether this object holds no entries. + * + * An empty `STVector256` is the canonical default value. Per XRPL + * serialization rules, default-valued optional fields are omitted from + * the wire encoding and contribute nothing to a transaction or ledger hash. + * + * @return `true` if the internal vector is empty. + */ [[nodiscard]] bool isDefault() const override; + /** Replace the contents with a copy of @p v. + * + * @param v Source vector; copied into the internal store. + * @return Reference to this object. + */ STVector256& operator=(std::vector const& v); + /** Replace the contents by moving @p v into the internal store. + * + * @param v Source vector; left in a valid but unspecified state after the call. + * @return Reference to this object. + */ STVector256& operator=(std::vector&& v); + /** Copy the inner vector from @p v, leaving the SField name unchanged. + * + * Unlike `operator=`, this copies only the payload (`mValue`), not the + * field binding. Use this when you need to transfer values between two + * fields that have different SField identities. + * + * @param v Source object whose contents are copied. + */ void setValue(STVector256 const& v); - /** Retrieve a copy of the vector we contain */ + /** Return a copy of the internal vector. + * + * Marked `explicit` to prevent accidental implicit copies in generic + * contexts; prefer `value()` for read-only access. + */ explicit operator std::vector() const; + /** Return the number of entries in the vector. + * + * @return Entry count; 0 for an empty (default) object. + */ [[nodiscard]] std::size_t size() const; + /** Resize the internal vector to @p n entries. + * + * New entries (if any) are value-initialized to the zero `uint256`. + * + * @param n Target size. + */ void resize(std::size_t n); + /** Return whether the vector contains no entries. + * + * @return `true` if `size() == 0`. + */ [[nodiscard]] bool empty() const; + /** Return a mutable reference to the entry at index @p n. + * + * @param n Zero-based index; behavior is undefined if out of range. + */ std::vector::reference operator[](std::vector::size_type n); + /** Return a read-only reference to the entry at index @p n. + * + * @param n Zero-based index; behavior is undefined if out of range. + */ std::vector::const_reference operator[](std::vector::size_type n) const; + /** Return a read-only reference to the internal vector. + * + * Prefer this over the explicit conversion operator for non-owning access. + * + * @return Const reference to the underlying `std::vector`. + */ [[nodiscard]] std::vector const& value() const; + /** Insert @p value before @p pos. + * + * @param pos Iterator before which the new element is inserted. + * @param value Hash to insert. + * @return Iterator to the inserted element. + */ std::vector::iterator insert(std::vector::const_iterator pos, uint256 const& value); + /** Append @p v to the end of the vector. + * + * @param v Hash to append. + */ void pushBack(uint256 const& v); + /** Return a mutable iterator to the first element. */ std::vector::iterator begin(); + /** Return a read-only iterator to the first element. */ [[nodiscard]] std::vector::const_iterator begin() const; + /** Return a mutable past-the-end iterator. */ std::vector::iterator end(); + /** Return a read-only past-the-end iterator. */ [[nodiscard]] std::vector::const_iterator end() const; + /** Remove the element at @p position. + * + * @param position Iterator to the element to remove. + * @return Iterator to the element following the removed one. + */ std::vector::iterator erase(std::vector::iterator position); + /** Remove all entries, leaving the vector empty. */ void clear() noexcept; private: + /** Copy this object into a caller-supplied buffer via the STVar placement protocol. + * + * Called only by `detail::STVar`. Delegates to `STBase::emplace`, which + * constructs in-place when @p buf is large enough, or heap-allocates otherwise. + * + * @param n Size of the buffer at @p buf, in bytes. + * @param buf Destination buffer for placement construction. + * @return Pointer to the newly constructed `STVector256`. + */ STBase* copy(std::size_t n, void* buf) const override; + + /** Move this object into a caller-supplied buffer via the STVar placement protocol. + * + * Called only by `detail::STVar`. Delegates to `STBase::emplace`, which + * constructs in-place when @p buf is large enough, or heap-allocates otherwise. + * The source is left in a valid but unspecified state. + * + * @param n Size of the buffer at @p buf, in bytes. + * @param buf Destination buffer for placement construction. + * @return Pointer to the newly constructed `STVector256`. + */ STBase* move(std::size_t n, void* buf) override; @@ -132,7 +316,6 @@ STVector256::setValue(STVector256 const& v) value_ = v.value_; } -/** Retrieve a copy of the vector we contain */ inline STVector256:: operator std::vector() const { diff --git a/include/xrpl/protocol/STXChainBridge.h b/include/xrpl/protocol/STXChainBridge.h index 292ffe2767..71ce5b33f7 100644 --- a/include/xrpl/protocol/STXChainBridge.h +++ b/include/xrpl/protocol/STXChainBridge.h @@ -10,6 +10,26 @@ namespace xrpl { class Serializer; class STObject; +/** Serialized type encoding the four-field specification of an XRPL cross-chain bridge. + * + * A bridge connects two independent ledgers: a *locking chain* (where XRP or + * tokens are held in escrow) and an *issuing chain* (where a wrapped + * representation is minted). Each side is described by a door account + * (`AccountID`) and an asset (`Issue`). This class bundles those four pieces + * — `LockingChainDoor`, `LockingChainIssue`, `IssuingChainDoor`, + * `IssuingChainIssue` — into a single, typed, wire-format ledger field that + * appears in bridge-related transactions and ledger entries. + * + * Inherits `STBase` (type-ID `STI_XCHAIN_BRIDGE`) for polymorphic + * serialization and `CountedObject` for debug instance tracking. + * + * Both `operator==` and `operator<` compare all four fields in declaration + * order via `std::tie`, making `STXChainBridge` usable as a key in ordered + * associative containers. + * + * @see XChainAttestations.h for how bridges are consumed by witness and + * attestation logic. + */ class STXChainBridge final : public STBase, public CountedObject { STAccount lockingChainDoor_{sfLockingChainDoor}; @@ -18,80 +38,240 @@ class STXChainBridge final : public STBase, public CountedObject STIssue issuingChainIssue_{sfIssuingChainIssue}; public: + /** Self-alias used by template code that calls `.value()` to strip the + * ST wrapper; for compound types the value type is the type itself. */ using value_type = STXChainBridge; + /** Identifies which of the two ledgers in a bridge a given door or asset + * belongs to. */ enum class ChainType { Locking, Issuing }; + /** Returns the chain that is opposite to @p ct. + * + * @param ct The chain whose counterpart is requested. + * @return `ChainType::Issuing` when @p ct is `Locking`; `Locking` + * otherwise. + */ static ChainType otherChain(ChainType ct); + /** Maps the witness `wasLockingChainSend` flag to the originating chain. + * + * Normalizes a boolean attestation flag into a `ChainType`, removing + * scattered `if (wasLockingChainSend)` branches from callers. + * + * @param wasLockingChainSend `true` when the send originated on the + * locking chain. + * @return `ChainType::Locking` when @p wasLockingChainSend is `true`; + * `ChainType::Issuing` otherwise. + */ static ChainType srcChain(bool wasLockingChainSend); + /** Maps the witness `wasLockingChainSend` flag to the destination chain. + * + * Complement of `srcChain()`: returns the chain that receives the assets. + * + * @param wasLockingChainSend `true` when the send originated on the + * locking chain. + * @return `ChainType::Issuing` when @p wasLockingChainSend is `true`; + * `ChainType::Locking` otherwise. + */ static ChainType dstChain(bool wasLockingChainSend); + /** Constructs an empty bridge bound to `sfXChainBridge`. + * + * Used as a canonical reference instance (e.g., to obtain the known + * JSON key set for extra-field detection in the JSON constructor). + */ STXChainBridge(); + /** Constructs an empty bridge bound to the given field name. + * + * @param name The `SField` tag to associate with this object inside an + * enclosing `STObject`. + */ explicit STXChainBridge(SField const& name); STXChainBridge(STXChainBridge const& rhs) = default; + /** Extracts bridge sub-fields from an already-parsed generic `STObject`. + * + * Used during ledger deserialization when the parent has been parsed as + * an `STObject` and the four bridge fields must be projected into the + * strongly-typed form. + * + * @param o Source object; must contain `sfLockingChainDoor`, + * `sfLockingChainIssue`, `sfIssuingChainDoor`, and + * `sfIssuingChainIssue` — `STObject::operator[]` throws `FieldErr` + * if any field is absent. + */ STXChainBridge(STObject const& o); + /** Constructs a bridge from its four constituent values. + * + * @param srcChainDoor Door account on the locking chain. + * @param srcChainIssue Asset locked or released on the locking chain. + * @param dstChainDoor Door account on the issuing chain. + * @param dstChainIssue Wrapped asset minted or burned on the issuing chain. + */ STXChainBridge( AccountID const& srcChainDoor, Issue const& srcChainIssue, AccountID const& dstChainDoor, Issue const& dstChainIssue); + /** Deserializes a bridge from a JSON object, binding to `sfXChainBridge`. + * + * Delegates to the two-argument form with `sfXChainBridge`. + * + * @param v JSON object with keys `LockingChainDoor`, `LockingChainIssue`, + * `IssuingChainDoor`, `IssuingChainIssue`. + * @throws std::runtime_error if @p v is not an object, contains + * unrecognized keys, or either door field is not a valid Base58-encoded + * account. + */ explicit STXChainBridge(json::Value const& v); + /** Deserializes a bridge from a JSON object, binding to @p name. + * + * Performs a strict whitelist check against the canonical key set from a + * default-constructed bridge before parsing any values, rejecting typos + * and unknown fields at parse time rather than silently ignoring them. + * + * @param name The `SField` to associate with this object. + * @param v JSON object with the four bridge fields. + * @throws std::runtime_error if @p v is not an object, contains any key + * absent from the canonical set, or either door is not a valid + * Base58-encoded account. + */ explicit STXChainBridge(SField const& name, json::Value const& v); + /** Deserializes a bridge from a binary stream. + * + * Hot path for on-disk and network deserialization. Reads the four + * sub-fields in canonical order: locking door, locking issue, issuing + * door, issuing issue. Each sub-field consumes its own field-ID header + * and payload bytes from @p sit. + * + * @param sit Forward-only cursor positioned at the first byte of the + * bridge payload; advanced past all four fields on return. + * @param name The `SField` to associate with this object. + */ explicit STXChainBridge(SerialIter& sit, SField const& name); STXChainBridge& operator=(STXChainBridge const& rhs) = default; + /** Returns a human-readable representation of the bridge for diagnostics. + * + * Format: `{ LockingChainDoor = , LockingChainIssue = , + * IssuingChainDoor = , IssuingChainIssue = }`. + * + * @return Formatted string; intended for logging and debug output only. + */ [[nodiscard]] std::string getText() const override; + /** Converts this bridge into a generic `STObject` with the same four fields. + * + * Needed when the bridge must participate in code paths that operate on + * `STObject` graphs, such as transaction metadata construction. + * + * @return A new `STObject` bound to `sfXChainBridge` containing copies of + * all four bridge sub-fields. + */ [[nodiscard]] STObject toSTObject() const; + /** Returns the door account of the locking chain. */ [[nodiscard]] AccountID const& lockingChainDoor() const; + /** Returns the asset locked or released on the locking chain. */ [[nodiscard]] Issue const& lockingChainIssue() const; + /** Returns the door account of the issuing chain. */ [[nodiscard]] AccountID const& issuingChainDoor() const; + /** Returns the wrapped asset minted or burned on the issuing chain. */ [[nodiscard]] Issue const& issuingChainIssue() const; + /** Returns the door account for the specified chain. + * + * Allows generic code (e.g., attestation handlers) to query either side + * of a bridge without hard-coding which chain is locking vs. issuing. + * Pair with `srcChain()`/`dstChain()` to map a `wasLockingChainSend` + * boolean to the correct `ChainType`. + * + * @param ct Which side of the bridge to query. + * @return The locking-chain door when @p ct is `Locking`; the + * issuing-chain door otherwise. + */ [[nodiscard]] AccountID const& door(ChainType ct) const; + /** Returns the asset for the specified chain. + * + * @param ct Which side of the bridge to query. + * @return The locking-chain issue when @p ct is `Locking`; the + * issuing-chain issue otherwise. + */ [[nodiscard]] Issue const& issue(ChainType ct) const; + /** Returns `STI_XCHAIN_BRIDGE`, the type discriminator for this ST class. */ [[nodiscard]] SerializedTypeID getSType() const override; + /** Serializes the bridge to JSON. + * + * Produces an object with keys `LockingChainDoor`, `LockingChainIssue`, + * `IssuingChainDoor`, `IssuingChainIssue`. The canonical key set from + * this output is also used by the JSON constructor to detect extra fields. + * + * @return JSON object representation of all four bridge fields. + */ [[nodiscard]] json::Value getJson(JsonOptions) const override; + /** Appends the binary encoding of all four sub-fields to @p s. + * + * Each sub-field is written in canonical declaration order (locking door, + * locking issue, issuing door, issuing issue) and includes its own + * field-ID header, mirroring the `SerialIter` constructor's read order. + * + * @param s Serializer accumulator to append to. + */ void add(Serializer& s) const override; + /** Polymorphic equality check used by `STBase` container comparisons. + * + * Performs a `dynamic_cast` to `STXChainBridge` and delegates to + * `operator==`. Returns `false` if @p t is not an `STXChainBridge`. + * + * @param t The object to compare against. + * @return `true` iff @p t is an `STXChainBridge` with identical fields. + */ [[nodiscard]] bool isEquivalent(STBase const& t) const override; + /** Returns `true` when all four sub-fields are in their default state. */ [[nodiscard]] bool isDefault() const override; + /** Returns a reference to this object itself. + * + * Satisfies the convention that template code calling `.value()` on an + * ST type receives the unwrapped value. For compound types like + * `STXChainBridge`, `value_type` equals the type itself. + * + * @return `*this`. + */ [[nodiscard]] value_type const& value() const noexcept; @@ -111,6 +291,11 @@ private: operator<(STXChainBridge const& lhs, STXChainBridge const& rhs); }; +/** Returns `true` iff the two bridges have identical door accounts and assets. + * + * Comparison is performed via `std::tie` across all four fields in + * declaration order: locking door, locking issue, issuing door, issuing issue. + */ inline bool operator==(STXChainBridge const& lhs, STXChainBridge const& rhs) { @@ -126,6 +311,11 @@ operator==(STXChainBridge const& lhs, STXChainBridge const& rhs) rhs.issuingChainIssue_); } +/** Strict weak ordering over bridges; enables use as a `std::map`/`std::set` key. + * + * Comparison is performed via `std::tie` across all four fields in + * declaration order: locking door, locking issue, issuing door, issuing issue. + */ inline bool operator<(STXChainBridge const& lhs, STXChainBridge const& rhs) { diff --git a/include/xrpl/protocol/SecretKey.h b/include/xrpl/protocol/SecretKey.h index 9af27e9709..f5b56d2aca 100644 --- a/include/xrpl/protocol/SecretKey.h +++ b/include/xrpl/protocol/SecretKey.h @@ -13,10 +13,27 @@ namespace xrpl { -/** A secret key. */ +/** A 32-byte private key for either secp256k1 or Ed25519. + * + * The destructor unconditionally zeroes the backing buffer via `secureErase`, + * defending against cold-boot and memory-dump attacks. Intermediate buffers + * in all key-generation and derivation helpers are likewise erased. + * + * Comparison operators are deleted: comparing secret keys in application code + * is almost always a mistake (compare public keys or `AccountID`s instead), + * and any comparison implementation risks timing-observable branches that + * could leak key material through a side channel. + * + * `operator<<` is absent by design — streaming a secret key to a log or debug + * output is too easy an accident. Use `toString()` for the rare legitimate case. + * + * @note The default constructor is deleted; a `SecretKey` must always be + * initialised with actual key material. + */ class SecretKey { public: + /** Size of the raw key buffer in bytes. */ static constexpr std::size_t kSIZE = 32; private: @@ -30,54 +47,78 @@ public: SecretKey& operator=(SecretKey const&) = default; + /** Deleted: comparing secret keys risks timing side-channel leaks. */ bool operator==(SecretKey const&) = delete; + + /** Deleted: comparing secret keys risks timing side-channel leaks. */ bool operator!=(SecretKey const&) = delete; + /** Zeroes the key buffer via `secureErase` before releasing memory. */ ~SecretKey(); + /** Construct from a 32-byte array. + * + * @param data Raw key material; copied into the internal buffer. + */ SecretKey(std::array const& data); + + /** Construct from a `Slice`. + * + * @param slice Raw key material; must be exactly 32 bytes. + * @throws LogicError if `slice.size() != 32`. + */ SecretKey(Slice const& slice); + /** @return Pointer to the first byte of the raw 32-byte key material. */ [[nodiscard]] std::uint8_t const* data() const { return buf_; } + /** @return Number of bytes in the key buffer (always 32). */ [[nodiscard]] std::size_t size() const { return sizeof(buf_); } - /** Convert the secret key to a hexadecimal string. - - @note The operator<< function is deliberately omitted - to avoid accidental exposure of secret key material. - */ + /** Return the key as a hexadecimal string. + * + * Use this only where the hex representation is genuinely required + * (e.g. CLI tooling). Prefer keeping the key in its binary form + * everywhere else. `operator<<` is intentionally absent to prevent + * accidental exposure in log output. + * + * @return Hex-encoded string of the 32-byte key. + */ [[nodiscard]] std::string toString() const; + /** @return Iterator to the first byte of the key buffer. */ [[nodiscard]] const_iterator begin() const noexcept { return buf_; } + /** @return Iterator to the first byte of the key buffer. */ [[nodiscard]] const_iterator cbegin() const noexcept { return buf_; } + /** @return Past-the-end iterator for the key buffer. */ [[nodiscard]] const_iterator end() const noexcept { return buf_ + sizeof(buf_); } + /** @return Past-the-end iterator for the key buffer. */ [[nodiscard]] const_iterator cend() const noexcept { @@ -85,61 +126,157 @@ public: } }; +/** Deleted: comparing secret keys risks timing side-channel leaks. */ bool operator==(SecretKey const& lhs, SecretKey const& rhs) = delete; +/** Deleted: comparing secret keys risks timing side-channel leaks. */ bool operator!=(SecretKey const& lhs, SecretKey const& rhs) = delete; //------------------------------------------------------------------------------ -/** Parse a secret key */ +/** Decode a Base58Check-encoded secret key. + * + * Decodes the token and validates that the payload is exactly 32 bytes. + * Never throws — returns `std::nullopt` on decoding failure or length + * mismatch. + * + * @param type The expected `TokenType` prefix (e.g. `TokenType::FamilySeed`). + * @param s Base58Check-encoded string to decode. + * @return The decoded `SecretKey`, or `std::nullopt` on any error. + */ template <> std::optional parseBase58(TokenType type, std::string const& s); +/** Encode a secret key as a Base58Check string. + * + * The `TokenType` argument controls the version byte prepended during + * encoding, consistent with the XRPL token system (e.g. `TokenType::FamilySeed`). + * + * @param type Version byte selector for the Base58Check envelope. + * @param sk The secret key to encode. + * @return Base58Check-encoded string. + */ inline std::string toBase58(TokenType type, SecretKey const& sk) { return encodeBase58Token(type, sk.data(), sk.size()); } -/** Create a secret key using secure random numbers. */ +/** Generate a secret key from the platform CSPRNG. + * + * Fills 32 bytes from `crypto_prng()`, constructs the key, then immediately + * erases the temporary stack buffer. The result is not tied to any seed and + * cannot be deterministically reproduced — use `generateKeyPair` when wallet + * recovery is required. + * + * @return A freshly generated `SecretKey` backed by cryptographically secure + * random bytes. + */ SecretKey randomSecretKey(); -/** Generate a new secret key deterministically. */ +/** Derive a secret key deterministically from a seed. + * + * - **Ed25519**: the secret key is `sha512Half(seed)` directly. + * - **secp256k1**: hashes `seed || counter` with SHA512-Half, retrying with + * an incrementing counter until the result is a valid curve scalar. In + * practice this loop almost never executes more than once. + * + * All intermediate key-material buffers are erased before return. + * + * @param type Algorithm (`KeyType::Ed25519` or `KeyType::Secp256k1`). + * @param seed The 128-bit XRPL seed. + * @return The derived `SecretKey`. + * @throws std::runtime_error (secp256k1 only) if no valid scalar is found + * within 128 attempts (statistically negligible). + */ SecretKey generateSecretKey(KeyType type, Seed const& seed); -/** Derive the public key from a secret key. */ +/** Derive the public key corresponding to a secret key. + * + * - **secp256k1**: produces a 33-byte compressed curve point. + * - **Ed25519**: produces a 33-byte key where `buf[0] == 0xED` followed by + * the 32-byte Edwards-curve public key. The `0xED` prefix is the XRPL + * wire convention that `publicKeyType()` uses to distinguish Ed25519 keys + * from secp256k1 keys (which start with `0x02` or `0x03`). + * + * @param type Algorithm (`KeyType::Ed25519` or `KeyType::Secp256k1`). + * @param sk The secret key to derive from. + * @return The corresponding `PublicKey`. + */ PublicKey derivePublicKey(KeyType type, SecretKey const& sk); -/** Generate a key pair deterministically. - - This algorithm is specific to the XRPL: - - For secp256k1 key pairs, the seed is converted - to a Generator and used to compute the key pair - corresponding to ordinal 0 for the generator. -*/ +/** Generate a key pair deterministically from a seed. + * + * This is the main entry point for wallet-style key derivation. + * + * - **secp256k1**: uses XRPL's custom two-level derivation algorithm (which + * predates BIP-32). A root private key is derived from the seed, its + * compressed public key becomes the "generator point", and the child key + * at ordinal 0 is produced by tweaking the root with a SHA512-Half of + * the generator concatenated with the ordinal. Third-party wallets that + * need to import existing XRPL accounts should support this algorithm. + * - **Ed25519**: equivalent to calling `generateSecretKey` then + * `derivePublicKey` directly; no generator indirection is used. + * + * @param type Algorithm (`KeyType::Ed25519` or `KeyType::Secp256k1`). + * @param seed The 128-bit XRPL seed. + * @return `{PublicKey, SecretKey}` pair. + * @throws std::runtime_error propagated from secp256k1 root-key derivation + * if no valid scalar is found within 128 attempts (statistically + * negligible). + * @see https://xrpl.org/cryptographic-keys.html#secp256k1-key-derivation + */ std::pair generateKeyPair(KeyType type, Seed const& seed); -/** Create a key pair using secure random numbers. */ +/** Generate a key pair from the platform CSPRNG (non-deterministic). + * + * Combines `randomSecretKey()` with `derivePublicKey()`. Unlike + * `generateKeyPair()`, the result cannot be reproduced from any seed. + * Use this when wallet recovery is not needed. + * + * @param type Algorithm (`KeyType::Ed25519` or `KeyType::Secp256k1`). + * @return `{PublicKey, SecretKey}` pair backed by random key material. + */ std::pair randomKeyPair(KeyType type); -/** Generate a signature for a message digest. - This can only be used with secp256k1 since Ed25519's - security properties come, in part, from how the message - is hashed. -*/ +/** Sign a pre-computed digest with a secp256k1 key. + * + * Restricted to secp256k1: Ed25519's security proof depends on how the + * message is hashed internally by the primitive, so pre-hashed signing is + * not supported for Ed25519. Passes a `LogicError` if `pk` is not a + * secp256k1 key. + * + * The ECDSA nonce is generated deterministically per RFC 6979, eliminating + * the class of vulnerabilities caused by weak random nonces. The result is + * DER-encoded and at most 72 bytes. + * + * @param pk Public key; must be secp256k1 (used to verify the key type). + * @param sk Corresponding secret key. + * @param digest The 32-byte SHA512-Half digest to sign. + * @return DER-encoded signature in a `Buffer` (up to 72 bytes). + */ /** @{ */ Buffer signDigest(PublicKey const& pk, SecretKey const& sk, uint256 const& digest); +/** Sign a pre-computed digest, deriving the public key from `type` and `sk`. + * + * Convenience overload that calls `derivePublicKey(type, sk)` internally. + * Restricted to secp256k1 — see the primary overload for details. + * + * @param type Must be `KeyType::Secp256k1`. + * @param sk The secret key to sign with. + * @param digest The 32-byte SHA512-Half digest to sign. + * @return DER-encoded signature in a `Buffer` (up to 72 bytes). + */ inline Buffer signDigest(KeyType type, SecretKey const& sk, uint256 const& digest) { @@ -147,14 +284,35 @@ signDigest(KeyType type, SecretKey const& sk, uint256 const& digest) } /** @} */ -/** Generate a signature for a message. - With secp256k1 signatures, the data is first hashed with - SHA512-Half, and the resulting digest is signed. -*/ +/** Sign a raw message with a key of the detected type. + * + * Dispatches on the key type embedded in `pk`: + * - **Ed25519**: passes the raw message bytes directly to `ed25519_sign`, + * which incorporates its own deterministic internal hashing. Returns a + * fixed 64-byte signature. + * - **secp256k1**: applies SHA512-Half to the message then signs the + * resulting digest with RFC 6979 deterministic nonces. Returns a + * DER-encoded signature of up to 72 bytes. + * + * @param pk Public key; its type determines the signing algorithm. + * @param sk Corresponding secret key. + * @param message Raw message bytes to sign. + * @return Signature in a `Buffer` (64 bytes for Ed25519, ≤72 for secp256k1). + */ /** @{ */ Buffer sign(PublicKey const& pk, SecretKey const& sk, Slice const& message); +/** Sign a raw message, deriving the public key from `type` and `sk`. + * + * Convenience overload that calls `derivePublicKey(type, sk)` internally. + * See the primary overload for signing semantics. + * + * @param type Algorithm (`KeyType::Ed25519` or `KeyType::Secp256k1`). + * @param sk The secret key to sign with. + * @param message Raw message bytes to sign. + * @return Signature in a `Buffer` (64 bytes for Ed25519, ≤72 for secp256k1). + */ inline Buffer sign(KeyType type, SecretKey const& sk, Slice const& message) { diff --git a/include/xrpl/protocol/Seed.h b/include/xrpl/protocol/Seed.h index 0b93b84516..87ce770e2c 100644 --- a/include/xrpl/protocol/Seed.h +++ b/include/xrpl/protocol/Seed.h @@ -9,7 +9,30 @@ namespace xrpl { -/** Seeds are used to generate deterministic secret keys. */ +/** A 128-bit secret seed from which all XRPL key material is derived. + * + * A `Seed` is the root secret in the XRPL key hierarchy. From a single seed, + * a deterministic derivation produces the private key, public key, and account + * address. The class enforces two security invariants: + * + * - **No default construction.** A zero-initialized seed could be mistaken + * for valid entropy, so every `Seed` must be explicitly constructed from + * real material. + * - **Secure destruction.** The destructor calls `secure_erase()` on the + * internal buffer to overwrite key material in heap/stack memory before + * the object is released. CPU caches and registers may still retain + * remnants; this is a best-effort measure consistent with industry practice. + * + * Copy construction and assignment are allowed so seeds can be passed by + * value into key-derivation functions. Callers should minimize the number + * of live copies. + * + * Only `const` iterators and `data()` are exposed, preventing external + * mutation of the raw key material. + * + * @see randomSeed(), generateSeed(), parseGenericSeed(), parseBase58() + * @see SecretKey.h for the derivation step that consumes a Seed + */ class Seed { private: @@ -24,47 +47,60 @@ public: Seed& operator=(Seed const&) = default; - /** Destroy the seed. - The buffer will first be securely erased. - */ + /** Destroy the seed, securely erasing the internal buffer first. */ ~Seed(); - /** Construct a seed */ - /** @{ */ + /** Construct a seed from a byte slice. + * + * @param slice Raw bytes to copy into the seed buffer. + * @throws LogicError if `slice.size() != 16`. + */ explicit Seed(Slice const& slice); - explicit Seed(uint128 const& seed); - /** @} */ + /** Construct a seed from a 128-bit integer. + * + * @param seed The 128-bit value whose raw bytes are copied into the seed + * buffer. + * @throws LogicError if `seed.size() != 16`. + */ + explicit Seed(uint128 const& seed); + + /** Return a pointer to the first byte of the seed buffer. */ [[nodiscard]] std::uint8_t const* data() const { return buf_.data(); } + /** Return the size of the seed buffer in bytes (always 16). */ [[nodiscard]] std::size_t size() const { return buf_.size(); } + /** Return a const iterator to the first byte of the seed buffer. */ [[nodiscard]] const_iterator begin() const noexcept { return buf_.begin(); } + /** Return a const iterator to the first byte of the seed buffer. */ [[nodiscard]] const_iterator cbegin() const noexcept { return buf_.cbegin(); } + /** Return a const iterator past the last byte of the seed buffer. */ [[nodiscard]] const_iterator end() const noexcept { return buf_.end(); } + /** Return a const iterator past the last byte of the seed buffer. */ [[nodiscard]] const_iterator cend() const noexcept { @@ -74,42 +110,107 @@ public: //------------------------------------------------------------------------------ -/** Create a seed using secure random numbers. */ +/** Generate a cryptographically secure random seed. + * + * Fills a temporary staging buffer via `beast::rngfill()` backed by the + * global CSPRNG (`crypto_prng()`), constructs the `Seed` from it, and then + * immediately calls `secure_erase()` on the staging buffer before returning. + * The staging buffer is erased explicitly because its stack lifetime would + * otherwise extend past the point where the seed has been captured. + * + * @return A freshly generated, cryptographically random seed. + */ Seed randomSeed(); -/** Generate a seed deterministically. - - The algorithm is specific to the XRPL: - - The seed is calculated as the first 128 bits - of the SHA512-Half of the string text excluding - any terminating null. - - @note This will not attempt to determine the format of - the string (e.g. hex or base58). -*/ +/** Derive a seed deterministically from a passphrase. + * + * Implements the XRPL passphrase-to-seed algorithm: the seed is the first + * 128 bits of SHA-512-Half applied to the raw passphrase bytes (no null + * terminator included). The hasher type used (`sha512_half_hasher_s`) + * securely erases its internal state on destruction. + * + * @param passPhrase Arbitrary string treated as raw bytes; not interpreted + * as hex or Base58. + * @return The deterministic seed for the given passphrase. + * @note To parse a string that might be hex, Base58, RFC1751, or a + * passphrase, use `parseGenericSeed()` instead. + */ Seed generateSeed(std::string const& passPhrase); -/** Parse a Base58 encoded string into a seed */ +/** Decode a Base58Check-encoded seed string. + * + * Decodes a string carrying the `TokenType::FamilySeed` prefix (the + * well-known "s"-prefixed wallet seed strings). The decoded payload must + * be exactly 16 bytes; any other length yields `std::nullopt`. + * + * @param s A Base58Check-encoded string. + * @return The decoded seed, or `std::nullopt` if the string is empty, + * malformed, has an incorrect checksum, or decodes to a payload of + * the wrong size. + */ template <> std::optional parseBase58(std::string const& s); -/** Attempt to parse a string as a seed. - - @param str the string to parse - @param rfc1751 true if we should attempt RFC1751 style parsing (deprecated) - * */ +/** Parse a string in any recognized seed format. + * + * Attempts each format in order, returning on the first match: + * + * 1. **Rejection guard.** Returns `std::nullopt` if the string successfully + * parses as an `AccountID`, node public key, account public key, node + * private key, or account secret. This prevents accidentally using an + * address or public key as a seed. + * 2. **Empty string.** Returns `std::nullopt`. + * 3. **Hex.** A 32-character hex string is decoded directly as a 128-bit + * seed. + * 4. **Base58 family seed.** Delegates to `parseBase58()`. + * 5. **RFC1751 mnemonic** (only when `rfc1751 = true`). A 12-word + * English mnemonic decoded per RFC1751 with XRPL's historical + * byte-reversal convention. Parity errors cause fallthrough to the + * passphrase step rather than returning `std::nullopt`. + * 6. **Passphrase fallback.** Any non-empty string that does not match + * the above is passed to `generateSeed()`. This step always succeeds, + * so a non-empty string that is not a recognized key type will always + * produce a seed. + * + * @param str The string to parse. + * @param rfc1751 When `false`, RFC1751 mnemonic decoding is skipped. + * Pass `false` in contexts where strict format enforcement is required + * (e.g., node identity from the command line). + * @return The parsed seed, or `std::nullopt` if the string is empty or + * was recognized as a non-seed key type. + * @note The passphrase fallback means this function never returns + * `std::nullopt` for a non-empty string unless it matches a + * disallowed key type. + */ std::optional parseGenericSeed(std::string const& str, bool rfc1751 = true); -/** Encode a Seed in RFC1751 format */ +/** Encode a seed as an RFC1751 English mnemonic. + * + * Produces a 12-word phrase using the RFC1751 dictionary. XRPL reverses + * the byte order of the seed before encoding — `parseGenericSeed()` + * applies the same reversal symmetrically when decoding. + * + * @param seed The seed to encode. + * @return A space-separated 12-word RFC1751 mnemonic string. + * @note RFC1751 output is considered deprecated. `parseGenericSeed()` + * accepts it by default for backward compatibility; pass + * `rfc1751 = false` to disable that fallback. + */ std::string seedAs1751(Seed const& seed); -/** Format a seed as a Base58 string */ +/** Encode a seed as a Base58Check string with the `FamilySeed` token type. + * + * Produces the well-known "s"-prefixed wallet seed strings displayed to + * XRPL users. + * + * @param seed The seed to encode. + * @return The Base58Check-encoded seed string. + */ inline std::string toBase58(Seed const& seed) { diff --git a/include/xrpl/protocol/SeqProxy.h b/include/xrpl/protocol/SeqProxy.h index be040cceec..dd5762c278 100644 --- a/include/xrpl/protocol/SeqProxy.h +++ b/include/xrpl/protocol/SeqProxy.h @@ -1,3 +1,8 @@ +/** + * @file SeqProxy.h + * @brief Unified sequence/ticket identifier for XRPL transactions. + */ + #pragma once #include @@ -5,43 +10,65 @@ namespace xrpl { -/** A type that represents either a sequence value or a ticket value. - - We use the value() of a SeqProxy in places where a sequence was used - before. An example of this is the sequence of an Offer stored in the - ledger. We do the same thing with the in-ledger identifier of a - Check, Payment Channel, and Escrow. - - Why is this safe? If we use the SeqProxy::value(), how do we know that - each ledger entry will be unique? - - There are two components that make this safe: - - 1. A "TicketCreate" transaction carefully avoids creating a ticket - that corresponds with an already used Sequence or Ticket value. - The transactor does this by referring to the account root's - sequence number. Creating the ticket advances the account root's - sequence number so the same ticket (or sequence) value cannot be - used again. - - 2. When a "TicketCreate" transaction creates a batch of tickets it advances - the account root sequence to one past the largest created ticket. - - Therefore all tickets in a batch other than the first may never have - the same value as a sequence on that same account. And since a ticket - may only be used once there will never be any duplicates within this - account. -*/ +/** A type-tagged @c uint32_t that identifies a transaction by either a + * traditional account sequence number or a ticket sequence number. + * + * Before the Tickets feature, every XRPL transaction consumed exactly one + * account sequence number in order, so a plain @c uint32_t was sufficient. + * Tickets allow an account to pre-reserve sequence slots and use them + * out-of-order, which introduces a second namespace of transaction + * identifiers. @c SeqProxy encapsulates the choice in one place so callers + * never need to carry a separate @c bool isTicket flag. + * + * The raw @c value() is used as a ledger-object key for Offers, Checks, + * Payment Channels, and Escrows — the same role a bare sequence number + * played before tickets existed. This is safe because of two invariants + * maintained by the @c TicketCreate transactor: + * + * 1. Every ticket created has a numeric value that falls within the range + * the account root's sequence has already advanced past — so a ticket + * value can never equal any sequence number that will be consumed in the + * future by that account. + * 2. When a batch of tickets is created, the account root's sequence is + * advanced to one past the highest ticket number in the batch, permanently + * retiring all of those values from the sequence namespace. + * + * Together these guarantee that ticket values and sequence values for a + * given account never collide, even when stored without type metadata. + * + * @note The sort order imposed by @c operator< places all sequence-typed + * proxies strictly before all ticket-typed proxies, regardless of + * numeric value. @c CanonicalTXSet relies on this to ensure that + * @c TicketCreate transactions (which carry a sequence number) always + * precede the ticket-consuming transactions they enable during consensus + * replay. + * + * @see STTx::getSeqProxy() — primary production construction site + * @see CanonicalTXSet — uses SeqProxy as the per-account sort key + * @see Indexes::ticketIndex() — uses SeqProxy to derive the ledger-object key + */ class SeqProxy { public: - enum class Type : std::uint8_t { Seq = 0, Ticket }; + /** Discriminator indicating whether the proxy holds a sequence or ticket. */ + enum class Type : std::uint8_t { + Seq = 0, ///< Traditional account sequence number. + Ticket ///< Ticket sequence number (out-of-order slot). + }; private: std::uint32_t value_; Type type_; public: + /** Construct a SeqProxy with an explicit type and value. + * + * Prefer the @c sequence() factory for the common case. Ticket proxies + * are typically constructed directly: @c SeqProxy{SeqProxy::Type::Ticket, v}. + * + * @param t Whether this proxy represents a sequence or a ticket. + * @param v The numeric value of the sequence or ticket. + */ constexpr explicit SeqProxy(Type t, std::uint32_t v) : value_{v}, type_{t} { } @@ -51,35 +78,60 @@ public: SeqProxy& operator=(SeqProxy const& other) = default; - /** Factory function to return a sequence-based SeqProxy */ + /** Create a sequence-typed SeqProxy. + * + * Named factory for the common case. Ticket construction uses the + * explicit constructor directly, making it visibly intentional at each + * call site. + * + * @param v The account sequence number. + * @return A SeqProxy of type @c Type::Seq with value @c v. + */ static constexpr SeqProxy sequence(std::uint32_t v) { return SeqProxy{Type::Seq, v}; } + /** Return the raw numeric value of this proxy. + * + * Used as a ledger-object key for Offers, Checks, Payment Channels, and + * Escrows. Safe to use without the type tag because the TicketCreate + * invariants guarantee no numeric collision between sequence and ticket + * values for the same account (see class-level documentation). + * + * @return The @c uint32_t sequence or ticket number. + */ [[nodiscard]] constexpr std::uint32_t value() const { return value_; } + /** Return @c true if this proxy holds a traditional sequence number. */ [[nodiscard]] constexpr bool isSeq() const { return type_ == Type::Seq; } + /** Return @c true if this proxy holds a ticket sequence number. */ [[nodiscard]] constexpr bool isTicket() const { return type_ == Type::Ticket; } - // Occasionally it is convenient to be able to increase the value_ - // of a SeqProxy. But it's unusual. So, rather than putting in an - // addition operator, you must invoke the method by name. That makes - // if more difficult to invoke accidentally. + /** Increment the proxy's value in place and return @c *this. + * + * A named method rather than @c operator+= is deliberate: incrementing + * a @c SeqProxy is an unusual operation (currently used only in tests to + * step through a sequence of dummy transactions) and the explicit name + * prevents accidental arithmetic on what is normally a fixed identifier. + * + * @param amount Number of positions to advance the value. + * @return Reference to @c *this after the increment. + */ SeqProxy& advanceBy(std::uint32_t amount) { @@ -87,16 +139,11 @@ public: return *this; } - // Comparison - // - // The comparison is designed specifically so _all_ Sequence - // representations sort in front of Ticket representations. This - // is true even if the Ticket value() is less that the Sequence - // value(). - // - // This somewhat surprising sort order has benefits for transaction - // processing. It guarantees that transactions creating Tickets are - // sorted in from of transactions that consume Tickets. + /** Test equality — two proxies are equal only if both type and value match. + * + * A sequence proxy and a ticket proxy with the same numeric value are + * @b not equal. + */ friend constexpr bool operator==(SeqProxy lhs, SeqProxy rhs) { @@ -105,12 +152,24 @@ public: return (lhs.value() == rhs.value()); } + /** Test inequality. */ friend constexpr bool operator!=(SeqProxy lhs, SeqProxy rhs) { return !(lhs == rhs); } + /** Less-than comparison with type-first ordering. + * + * All sequence-typed proxies sort strictly before all ticket-typed + * proxies, regardless of numeric value. Within the same type, proxies + * are ordered numerically. This means even the largest possible sequence + * number (@c UINT32_MAX) sorts before the smallest ticket (@c 0). + * + * @note @c CanonicalTXSet depends on this invariant: it ensures that + * @c TicketCreate transactions (sequence-based) always precede the + * ticket-consuming transactions they enable in consensus ordering. + */ friend constexpr bool operator<(SeqProxy lhs, SeqProxy rhs) { @@ -119,24 +178,28 @@ public: return lhs.value() < rhs.value(); } + /** Greater-than comparison. */ friend constexpr bool operator>(SeqProxy lhs, SeqProxy rhs) { return rhs < lhs; } + /** Greater-than-or-equal comparison. */ friend constexpr bool operator>=(SeqProxy lhs, SeqProxy rhs) { return !(lhs < rhs); } + /** Less-than-or-equal comparison. */ friend constexpr bool operator<=(SeqProxy lhs, SeqProxy rhs) { return !(lhs > rhs); } + /** Stream a human-readable representation: @c "sequence N" or @c "ticket N". */ friend std::ostream& operator<<(std::ostream& os, SeqProxy seqProx) { diff --git a/include/xrpl/protocol/Serializer.h b/include/xrpl/protocol/Serializer.h index 81706e152a..8d2a1ef2b2 100644 --- a/include/xrpl/protocol/Serializer.h +++ b/include/xrpl/protocol/Serializer.h @@ -1,3 +1,12 @@ +/** @file + * Defines `Serializer` (write side) and `SerialIter` (read side) — the two + * classes that implement the XRPL canonical binary serialization format. + * + * Every transaction, ledger object, and signed message exchanged across the + * XRP Ledger network is encoded using this format. `Serializer` accumulates + * typed values in big-endian byte order; `SerialIter` consumes the resulting + * byte stream as a forward-only cursor. + */ #pragma once #include @@ -17,6 +26,17 @@ namespace xrpl { +/** Accumulates bytes for XRPL canonical binary serialization (write side). + * + * Every `add*` method appends data in big-endian byte order and returns the + * byte offset at which writing began, allowing callers to locate previously + * written slots for later inspection or patching. The default constructor + * pre-reserves 256 bytes to avoid reallocation on typical transaction sizes. + * + * @note The internal `Blob` (`std::vector`) storage is + * deprecated. New code should prefer zero-copy patterns built on + * `Slice` and `Buffer` where possible. + */ class Serializer { private: @@ -24,11 +44,21 @@ private: Blob data_; public: + /** Construct a serializer, pre-reserving capacity. + * + * @param n Initial byte capacity to reserve (default 256). + */ explicit Serializer(int n = 256) { data_.reserve(n); } + /** Construct a serializer pre-populated with a copy of an existing buffer. + * + * @param data Pointer to the source bytes. Must be non-null when + * `size != 0`. + * @param size Number of bytes to copy. + */ Serializer(void const* data, std::size_t size) { data_.resize(size); @@ -40,18 +70,21 @@ public: } } + /** Return a non-owning view of the accumulated bytes. */ [[nodiscard]] Slice slice() const noexcept { return Slice(data_.data(), data_.size()); } + /** Return the number of bytes accumulated so far. */ [[nodiscard]] std::size_t size() const noexcept { return data_.size(); } + /** Return a const pointer to the first accumulated byte. */ [[nodiscard]] void const* data() const noexcept { @@ -59,11 +92,33 @@ public: } // assemble functions + + /** Append a single byte in big-endian order. + * + * @param i Value to append. + * @return Byte offset at which the value was written. + */ int add8(unsigned char i); + + /** Append a 16-bit unsigned integer in big-endian byte order. + * + * @param i Value to append. + * @return Byte offset at which the value was written. + */ int add16(std::uint16_t i); + /** Append a 32-bit integer in big-endian byte order. + * + * Accepts any type whose unsigned form is exactly `uint32_t` (i.e. + * `int32_t` or `uint32_t`), preventing accidental narrowing from wider + * types at compile time. + * + * @tparam T An integer type whose unsigned counterpart is `uint32_t`. + * @param i Value to append. + * @return Byte offset at which the value was written. + */ template requires(std::is_same_v>, std::uint32_t>) int @@ -77,9 +132,30 @@ public: return ret; } + /** Append a `HashPrefix` domain-separator as a big-endian 32-bit value. + * + * Hash-domain prefixes (e.g. `TXN`, `STX`, `VAL`) are prepended to + * every signable or hashable payload to prevent cross-domain collisions. + * A `static_assert` in the implementation guards that `HashPrefix`'s + * underlying type remains `uint32_t`, which is an invariant of the wire + * format. + * + * @param p The domain-separation prefix to append. + * @return Byte offset at which the prefix was written. + */ int add32(HashPrefix p); + /** Append a 64-bit integer in big-endian byte order. + * + * Accepts any type whose unsigned form is exactly `uint64_t` (i.e. + * `int64_t` or `uint64_t`), preventing accidental narrowing at compile + * time. + * + * @tparam T An integer type whose unsigned counterpart is `uint64_t`. + * @param i Value to append. + * @return Byte offset at which the value was written. + */ template requires(std::is_same_v>, std::uint64_t>) int @@ -97,9 +173,29 @@ public: return ret; } + /** Append an integer of any supported width in big-endian byte order. + * + * Dispatches to `add8`, `add16`, `add32`, or `add64` based on `Integer`. + * Explicit specializations in the `.cpp` cover `unsigned char`, + * `uint16_t`, `uint32_t`, `int32_t`, and `uint64_t`. + * + * @tparam Integer One of the supported integer types listed above. + * @param i Value to append. + * @return Byte offset at which the value was written. + */ template int addInteger(Integer); + /** Append the raw bytes of a fixed-width integer type without any prefix. + * + * Covers `uint128`, `uint160`, `uint192`, `uint256`, and any other + * `BaseUInt` specialization. + * + * @tparam Bits Bit width of the `BaseUInt` type. + * @tparam Tag Distinguishing tag type of the `BaseUInt` specialization. + * @param v Value to append. + * @return Byte offset at which the value was written. + */ template int addBitString(BaseUInt const& v) @@ -107,29 +203,118 @@ public: return addRaw(v.data(), v.size()); } + /** Append a raw byte sequence without any length prefix. + * + * @param vector Bytes to append. + * @return Byte offset at which the data was written. + */ int addRaw(Blob const& vector); + + /** Append the bytes referenced by a `Slice` without any length prefix. + * + * @param slice Non-owning view of bytes to append. + * @return Byte offset at which the data was written. + */ int addRaw(Slice slice); + + /** Append a raw memory region without any length prefix. + * + * @param ptr Pointer to the first byte to append. + * @param len Number of bytes to copy from `ptr`. + * @return Byte offset at which the data was written. + */ int addRaw(void const* ptr, int len); + + /** Append all bytes accumulated in another `Serializer` without a length prefix. + * + * @param s Source serializer whose buffer is appended in full. + * @return Byte offset at which the data was written. + */ int addRaw(Serializer const& s); + /** Append a variable-length-prefixed blob using XRPL's three-tier VL encoding. + * + * Writes a compact 1–3 byte length header followed by the raw bytes: + * 0–192 bytes use a 1-byte header; 193–12,480 use 2 bytes; 12,481–918,744 + * use 3 bytes. + * + * @param vector Data to append. + * @return Byte offset at which the length header was written. + * @throws std::overflow_error if the data exceeds 918,744 bytes. + */ int addVL(Blob const& vector); + + /** Append a variable-length-prefixed blob from a `Slice`. + * + * Writes a compact length header then the referenced bytes. An empty + * slice writes the header only (length 0). + * + * @param slice Non-owning view of the data to append. + * @return Byte offset at which the length header was written. + * @throws std::overflow_error if the slice exceeds 918,744 bytes. + */ int addVL(Slice const& slice); + + /** Append a variable-length-prefixed blob from an iterator range. + * + * Writes the length header for a payload of `len` bytes, then iterates + * `[begin, end)` calling `addRaw` on each element's `.data()`/`.size()`. + * In debug builds an assertion verifies that the total bytes iterated + * equals `len`. + * + * @tparam Iter Forward iterator whose value type exposes `.data()` and + * `.size()`. + * @param begin Start of the range. + * @param end Past-the-end of the range. + * @param len Total byte count of all elements in the range. + * @return Byte offset at which the length header was written. + * @throws std::overflow_error if `len` exceeds 918,744. + */ template int addVL(Iter begin, Iter end, int len); + + /** Append a variable-length-prefixed blob from a raw pointer. + * + * Writes a compact length header then `len` bytes from `ptr`. When + * `len == 0` only the header is written; `ptr` is not dereferenced. + * + * @param ptr Pointer to the data to append. May be null when `len == 0`. + * @param len Number of bytes to copy. + * @return Byte offset at which the length header was written. + * @throws std::overflow_error if `len` exceeds 918,744. + */ int addVL(void const* ptr, int len); // disassemble functions - bool - get8(int&, int offset) const; + /** Read a single byte at a given offset without consuming it. + * + * @param[out] i Output parameter set to the byte value on success. + * @param offset Zero-based byte offset into the internal buffer. + * @return `true` if `offset` is within bounds; `false` otherwise. + */ + bool + get8(int& i, int offset) const; + + /** Read an integer of any supported width from the given byte offset. + * + * Assembles the value from big-endian bytes without consuming them. + * + * @tparam Integer Target integer type; must fit within the buffer from + * `offset`. + * @param[out] number Set to the decoded value on success. + * @param offset Zero-based byte offset at which to start reading. + * @return `true` if `[offset, offset + sizeof(Integer))` is within + * bounds; `false` otherwise (and `number` is unmodified). + */ template bool getInteger(Integer& number, int offset) @@ -149,6 +334,19 @@ public: return true; } + /** Copy a fixed-width integer type out of the buffer at the given offset. + * + * Uses `memcpy` directly into the `BaseUInt` storage; no byte-order + * conversion is performed, so the buffer must already contain the value + * in the expected byte order. + * + * @tparam Bits Bit width of the `BaseUInt` type. + * @tparam Tag Distinguishing tag type of the `BaseUInt` specialization. + * @param[out] data Destination for the extracted value. + * @param offset Zero-based byte offset at which to start reading. + * @return `true` if `[offset, offset + Bits/8)` is within bounds; + * `false` otherwise (and `data` is unmodified). + */ template bool getBitString(BaseUInt& data, int offset) const @@ -159,132 +357,268 @@ public: return success; } + /** Append a compact TLV field tag used by `STObject` serialization. + * + * Encodes the (type, name) pair into 1, 2, or 3 bytes: + * - Both < 16: one byte `(type << 4) | name`. + * - Type < 16, name ≥ 16: two bytes — `(type << 4)` then `name`. + * - Type ≥ 16, name < 16: two bytes — `name` then `type`. + * - Both ≥ 16: three bytes — `0x00` sentinel, then `type`, then `name`. + * + * @param type Serialized-type family code (1–255). + * @param name Per-type field index (1–255). + * @return Byte offset at which the tag was written. + * @note Both `type` and `name` must be in [1, 255]; an assertion fires in + * debug builds if either is out of range. + */ int addFieldID(int type, int name); + + /** Append a field tag using the `SerializedTypeID` enum as the type code. + * + * Convenience overload that casts `type` to `int` before delegating to + * `addFieldID(int, int)`. + * + * @param type Serialized-type family. + * @param name Per-type field index (1–255). + * @return Byte offset at which the tag was written. + */ int addFieldID(SerializedTypeID type, int name) { return addFieldID(safeCast(type), name); } + /** @deprecated Use `sha512Half(s.slice())` directly instead. + * + * Compute the XRPL "SHA-512 half" hash over the accumulated buffer. + * + * @return The first 256 bits of SHA-512 applied to the accumulated bytes. + */ // DEPRECATED [[nodiscard]] uint256 getSHA512Half() const; // totality functions + + /** Return a const reference to the underlying byte vector. + * + * @note The `Blob` type is deprecated; prefer `slice()` for new code. + */ [[nodiscard]] Blob const& peekData() const { return data_; } + + /** Return a copy of the accumulated byte vector. + * + * @note Allocates; prefer `slice()` to avoid the copy. + */ [[nodiscard]] Blob getData() const { return data_; } + + /** Return a mutable reference to the underlying byte vector. + * + * Intended for legacy callers that need to splice or overwrite bytes + * in place. New code should not use this. + */ Blob& modData() { return data_; } + /** Return the number of accumulated bytes. + * + * @note Prefer `size()` for new code. + */ [[nodiscard]] int getDataLength() const { return data_.size(); } + + /** Return a const pointer to the first accumulated byte. */ [[nodiscard]] void const* getDataPtr() const { return data_.data(); } + + /** Return a mutable pointer to the first accumulated byte. */ void* getDataPtr() { return data_.data(); } + + /** Return the number of accumulated bytes. + * + * @note Alias for `getDataLength()`; prefer `size()` for new code. + */ [[nodiscard]] int getLength() const { return data_.size(); } + + /** Return the accumulated bytes as a `std::string`. */ [[nodiscard]] std::string getString() const { return std::string(static_cast(getDataPtr()), size()); } + + /** Clear all accumulated bytes, leaving the buffer empty. */ void erase() { data_.clear(); } + + /** Remove bytes from the end of the buffer. + * + * @param num Number of bytes to remove. + * @return `true` on success; `false` if `num` exceeds the current size, + * leaving the buffer unchanged. + */ bool chop(int num); // vector-like functions - Blob ::iterator + + /** Return a mutable iterator to the first byte. */ + Blob::iterator begin() { return data_.begin(); } - Blob ::iterator + + /** Return a mutable past-the-end iterator. */ + Blob::iterator end() { return data_.end(); } - [[nodiscard]] Blob ::const_iterator + + /** Return a const iterator to the first byte. */ + [[nodiscard]] Blob::const_iterator begin() const { return data_.begin(); } - [[nodiscard]] Blob ::const_iterator + + /** Return a const past-the-end iterator. */ + [[nodiscard]] Blob::const_iterator end() const { return data_.end(); } + + /** Reserve capacity for at least `n` bytes without changing the size. + * + * @param n Minimum byte capacity to reserve. + */ void reserve(size_t n) { data_.reserve(n); } + + /** Resize the buffer to exactly `n` bytes. + * + * New bytes are zero-initialized; existing bytes beyond `n` are dropped. + * + * @param n Target size in bytes. + */ void resize(size_t n) { data_.resize(n); } + + /** Return the number of bytes that can be held without reallocation. */ [[nodiscard]] size_t capacity() const { return data_.capacity(); } + /** Compare the accumulated bytes against a raw `Blob` for equality. */ bool operator==(Blob const& v) const { return v == data_; } + + /** Compare the accumulated bytes against a raw `Blob` for inequality. */ bool operator!=(Blob const& v) const { return v != data_; } + + /** Compare two `Serializer` instances for byte-for-byte equality. */ bool operator==(Serializer const& v) const { return v.data_ == data_; } + + /** Compare two `Serializer` instances for byte-for-byte inequality. */ bool operator!=(Serializer const& v) const { return v.data_ != data_; } + /** Return the number of header bytes used to encode a VL prefix. + * + * Dispatches on the first header byte: ≤192 → 1 byte; 193–240 → 2 + * bytes; 241–254 → 3 bytes. + * + * @param b1 First byte of the VL header (0–254). + * @return 1, 2, or 3. + * @throws std::overflow_error if `b1` is negative or equals 255. + */ static int decodeLengthLength(int b1); + + /** Decode a one-byte VL length (0–192 range). + * + * @param b1 The sole header byte. + * @return The decoded payload length. + * @throws std::overflow_error if `b1` is negative or > 254. + */ static int decodeVLLength(int b1); + + /** Decode a two-byte VL length (193–12,480 range). + * + * Formula: `193 + (b1 - 193) * 256 + b2`. + * + * @param b1 First header byte (193–240). + * @param b2 Second header byte. + * @return The decoded payload length. + * @throws std::overflow_error if `b1` is outside [193, 240]. + */ static int decodeVLLength(int b1, int b2); + + /** Decode a three-byte VL length (12,481–918,744 range). + * + * Formula: `12481 + (b1 - 241) * 65536 + b2 * 256 + b3`. + * + * @param b1 First header byte (241–254). + * @param b2 Second header byte. + * @param b3 Third header byte. + * @return The decoded payload length. + * @throws std::overflow_error if `b1` is outside [241, 254]. + */ static int decodeVLLength(int b1, int b2, int b3); @@ -313,8 +647,22 @@ Serializer::addVL(Iter begin, Iter end, int len) //------------------------------------------------------------------------------ +/** Forward-only cursor over an external byte buffer for XRPL deserialization + * (read side). + * + * Stores a pointer into the caller-owned buffer together with `remain_` (bytes + * not yet consumed) and `used_` (bytes consumed). All `get*` methods advance + * the cursor and throw `std::runtime_error` on underflow — error codes are not + * returned; malformed input is treated as an exceptional condition. + * + * The buffer must outlive the iterator; no ownership is taken. `reset()` + * rewinds to the original position in O(1) using `used_` as the rewind delta. + * + * @note This class is deprecated as a direct dependency. New code should + * prefer zero-copy patterns built on `Slice` and `Buffer`. In + * particular, `getSlice()` is preferred over the copying `getRaw()`. + */ // DEPRECATED -// Transitional adapter to new serialization interfaces class SerialIter { private: @@ -323,28 +671,53 @@ private: std::size_t used_ = 0; public: + /** Construct a cursor over an existing byte buffer. + * + * The iterator does not take ownership; the caller must ensure that + * `data` remains valid for the iterator's lifetime. + * + * @param data Pointer to the first byte of the buffer. + * @param size Total number of bytes available. + */ SerialIter(void const* data, std::size_t size) noexcept; + /** Construct a cursor from a `Slice`. + * + * @param slice Non-owning view of the buffer to iterate. + */ SerialIter(Slice const& slice) : SerialIter(slice.data(), slice.size()) { } - // Infer the size of the data based on the size of the passed array. + /** Construct a cursor from a fixed-size byte array. + * + * The array size is inferred at compile time. + * + * @tparam N Size of the array (must be > 0). + * @param data Reference to the byte array. + */ template explicit SerialIter(std::uint8_t const (&data)[N]) : SerialIter(&data[0], N) { static_assert(N > 0, ""); } + /** Return `true` if all bytes have been consumed. */ [[nodiscard]] bool empty() const noexcept { return remain_ == 0; } + /** Rewind the cursor to the beginning of the buffer. + * + * O(1): uses `used_` as the rewind delta rather than storing a separate + * copy of the original pointer. + */ void reset() noexcept; + /** Return the number of bytes not yet consumed. */ [[nodiscard]] int getBytesLeft() const noexcept { @@ -352,76 +725,199 @@ public: } // get functions throw on error + + /** Consume and return the next byte. + * + * @return The byte at the current cursor position. + * @throws std::runtime_error if the buffer is exhausted. + */ unsigned char get8(); + /** Consume and decode the next 2 bytes as a big-endian unsigned 16-bit integer. + * + * @return Decoded value. + * @throws std::runtime_error if fewer than 2 bytes remain. + */ std::uint16_t get16(); + /** Consume and decode the next 4 bytes as a big-endian unsigned 32-bit integer. + * + * Use `geti32()` for signed values. + * + * @return Decoded value. + * @throws std::runtime_error if fewer than 4 bytes remain. + */ std::uint32_t get32(); + + /** Consume and decode the next 4 bytes as a big-endian signed 32-bit integer. + * + * Uses `boost::endian::load_big_s32` to ensure correct two's-complement + * sign extension. + * + * @return Decoded value. + * @throws std::runtime_error if fewer than 4 bytes remain. + */ std::int32_t geti32(); + /** Consume and decode the next 8 bytes as a big-endian unsigned 64-bit integer. + * + * Use `geti64()` for signed values. + * + * @return Decoded value. + * @throws std::runtime_error if fewer than 8 bytes remain. + */ std::uint64_t get64(); + + /** Consume and decode the next 8 bytes as a big-endian signed 64-bit integer. + * + * Uses `boost::endian::load_big_s64` to ensure correct two's-complement + * sign extension. + * + * @return Decoded value. + * @throws std::runtime_error if fewer than 8 bytes remain. + */ std::int64_t geti64(); + /** Consume and return the next `Bits/8` bytes as a `BaseUInt`. + * + * Constructs the result via `BaseUInt::fromVoid`, providing zero-copy + * extraction of fixed-width types such as `uint128`, `uint160`, `uint192`, + * and `uint256`. + * + * @tparam Bits Bit width of the target type (must be a multiple of 8). + * @tparam Tag Distinguishing tag type of the `BaseUInt` specialization. + * @return The decoded value. + * @throws std::runtime_error if fewer than `Bits/8` bytes remain. + */ template BaseUInt getBitString(); + /** Consume and return the next 16 bytes as a `uint128`. */ uint128 get128() { return getBitString<128>(); } + /** Consume and return the next 20 bytes as a `uint160`. */ uint160 get160() { return getBitString<160>(); } + /** Consume and return the next 24 bytes as a `uint192`. */ uint192 get192() { return getBitString<192>(); } + /** Consume and return the next 32 bytes as a `uint256`. */ uint256 get256() { return getBitString<256>(); } + /** Decode and consume the next field-ID tag, inverse of `Serializer::addFieldID`. + * + * Reads 1–3 bytes depending on the packing scheme. + * + * @param[out] type Decoded type family code (≥ 1). + * @param[out] name Decoded per-type field index (≥ 1). + * @throws std::runtime_error if the buffer is exhausted or a decoded + * uncommon code is < 16 (which would be ambiguous with the common + * single-byte encoding). + */ void getFieldID(int& type, int& name); - // Returns the size of the VL if the - // next object is a VL. Advances the iterator - // to the beginning of the VL. + /** Decode and consume the variable-length header, returning the payload size. + * + * Reads 1–3 header bytes and advances the cursor to the first byte of + * the payload. + * + * @return Decoded payload length in bytes. + * @throws std::runtime_error if the buffer is exhausted mid-header. + * @throws std::overflow_error if the first byte is outside the valid range. + */ int getVLDataLength(); + /** Return a zero-copy view of the next `bytes` bytes and advance the cursor. + * + * The returned `Slice` points directly into the underlying buffer and is + * valid only while that buffer is alive. Prefer this over `getRaw()` + * when an allocation can be avoided. + * + * @param bytes Number of bytes to expose. + * @return A `Slice` referencing the requested region. + * @throws std::runtime_error if `bytes` exceeds the remaining bytes. + */ Slice getSlice(std::size_t bytes); - // VFALCO DEPRECATED Returns a copy + /** @deprecated Prefer `getSlice()` to avoid allocation. + * + * Copy `size` bytes from the current position into a new `Blob` and + * advance the cursor. + * + * @param size Number of bytes to copy. + * @return A `Blob` containing the copied bytes. + * @throws std::runtime_error if `size` exceeds the remaining bytes. + */ Blob getRaw(int size); - // VFALCO DEPRECATED Returns a copy + /** @deprecated Prefer `getVLBuffer()` or `getVLDataLength()` + `getSlice()`. + * + * Decode the VL header and return a copy of the payload as a `Blob`. + * + * @return A `Blob` containing the VL payload. + * @throws std::runtime_error if the buffer is exhausted. + */ Blob getVL(); + /** Advance the cursor by `num` bytes without reading the data. + * + * @param num Number of bytes to skip. + * @throws std::runtime_error if `num` exceeds the remaining bytes. + */ void skip(int num); + /** Decode the VL header and return the payload as a move-only `Buffer`. + * + * Equivalent to `getVL()` but avoids the SSO overhead of `std::vector`. + * Prefer this over `getVL()` for new callers. + * + * @return A `Buffer` containing the VL payload. + * @throws std::runtime_error if the buffer is exhausted. + */ Buffer getVLBuffer(); + /** Copy `size` bytes from the current position into a new container of + * type `T` and advance the cursor. + * + * `T` must be either `Blob` or `Buffer`. The `size == 0` guard skips + * `memcpy` because passing a null pointer — which an empty `Buffer` may + * have — to `memcpy` with a zero count is undefined behavior in C++. + * + * @tparam T Either `Blob` or `Buffer`. + * @param size Number of bytes to copy. + * @return A freshly allocated container holding the copied bytes. + * @throws std::runtime_error if `size` exceeds the remaining bytes. + */ template T getRawHelper(int size); diff --git a/include/xrpl/protocol/Sign.h b/include/xrpl/protocol/Sign.h index 0b5b5d7239..1c2741951b 100644 --- a/include/xrpl/protocol/Sign.h +++ b/include/xrpl/protocol/Sign.h @@ -1,3 +1,19 @@ +/** @file + * Signing and verification API for XRPL serialized protocol objects. + * + * Every function here follows the same pipeline: prepend the 4-byte + * `HashPrefix` domain-separation constant, serialize the object via + * `STObject::addWithoutSigningFields()` (which omits signature-carrying + * fields to break the circular dependency), then delegate to the raw + * cryptographic primitives in `SecretKey.h` and `PublicKey.h`. + * + * The `HashPrefix` guarantees that a valid signature in one protocol + * context (e.g. a single-signed transaction via `HashPrefix::TxSign`) + * cannot be replayed as a valid signature in another (e.g. a ledger + * validation via `HashPrefix::Validation`), even if both objects happen + * to share identical serialized bytes. + */ + #pragma once #include @@ -7,17 +23,32 @@ namespace xrpl { -/** Sign an STObject - - @param st Object to sign - @param prefix Prefix to insert before serialized object when hashing - @param type Signing key type used to derive public key - @param sk Signing secret key - @param sigField Field in which to store the signature on the object. - If not specified the value defaults to `sfSignature`. - - @note If a signature already exists, it is overwritten. -*/ +/** Sign an STObject and store the resulting signature in the object. + * + * Serializes `st` via `addWithoutSigningFields()` (excluding all + * signing-related fields to avoid circularity), prepends `prefix` as a + * 4-byte domain-separation constant, then computes an asymmetric signature + * over the resulting bytes using `type` and `sk`. The produced signature is + * written into `st` at `sigField`, overwriting any pre-existing value. + * + * @param st The object to sign. Modified in place: `sigField` is set + * to the computed signature. + * @param prefix Domain-separation prefix prepended to the serialized + * payload before hashing. Must match the prefix used by callers of + * `verify()` for the same signing context (e.g. `HashPrefix::TxSign` + * for single-signed transactions, `HashPrefix::Manifest` for validator + * manifests). + * @param type Key algorithm (`secp256k1` or `ed25519`) used to sign. + * Must be consistent with the algorithm of `sk`. + * @param sk Secret key used to compute the signature. The key material + * is never copied or retained beyond the duration of this call. + * @param sigField Field in `st` that receives the signature blob. Defaults + * to `sfSignature` for standard single-signed transactions; pass an + * alternative field (e.g. `sfMasterSignature`) for other signing + * contexts such as validator manifests. + * + * @note Any existing value in `sigField` is unconditionally overwritten. + */ void sign( STObject& st, @@ -26,14 +57,23 @@ sign( SecretKey const& sk, SF_VL const& sigField = sfSignature); -/** Returns `true` if STObject contains valid signature - - @param st Signed object - @param prefix Prefix inserted before serialized object when hashing - @param pk Public key for verifying signature - @param sigField Object's field containing the signature. - If not specified the value defaults to `sfSignature`. -*/ +/** Verify that an STObject carries a valid signature. + * + * Reads the signature blob from `sigField`, regenerates the identical + * serialized payload used by `sign()` (prefix prepended to + * `addWithoutSigningFields()` output), and verifies the blob against `pk`. + * + * @param st The signed object to verify. + * @param prefix Domain-separation prefix that was prepended during signing. + * Must be the same value that was passed to `sign()`. + * @param pk Public key corresponding to the secret key used to sign. + * @param sigField Field in `st` from which to read the signature blob. + * Defaults to `sfSignature`; pass an alternative field (e.g. + * `sfMasterSignature`) to verify other signing contexts. + * @return `true` if the signature in `sigField` is cryptographically valid + * for the serialized payload and `pk`; `false` if `sigField` is absent + * or the signature does not verify. + */ bool verify( STObject const& st, @@ -41,25 +81,62 @@ verify( PublicKey const& pk, SF_VL const& sigField = sfSignature); -/** Return a Serializer suitable for computing a multisigning TxnSignature. */ +/** Build the complete multi-signing payload for a single signer. + * + * Prepends `HashPrefix::TxMultiSign`, serializes `obj` without signing + * fields, then appends `signingID` as a raw 160-bit account identifier. + * The result is equivalent to calling `startMultiSigningData` followed + * immediately by `finishMultiSigningData`. + * + * The `signingID` **must** be incorporated in the payload. Without it an + * attacker could substitute one signer slot for another account that shares + * the same `RegularKey` — a realistic threat when a custodial service + * provides a single signing key across many accounts. Binding the account + * identity into the signed data makes each authorization cryptographically + * specific to that signer slot. + * + * Use this function for single-signer contexts. For batch multi-sig + * verification, prefer `startMultiSigningData` + `finishMultiSigningData` + * to avoid redundant serialization of the shared transaction body. + * + * @param obj The transaction or object being authorized. + * @param signingID The `AccountID` of the signer authorizing `obj`. + * @return A `Serializer` containing the complete signing payload, ready + * for hashing and signing. + * @see startMultiSigningData, finishMultiSigningData + */ Serializer buildMultiSigningData(STObject const& obj, AccountID const& signingID); -/** Break the multi-signing hash computation into 2 parts for optimization. - - We can optimize verifying multiple multisignatures by splitting the - data building into two parts; - o A large part that is shared by all of the computations. - o A small part that is unique to each signer in the multisignature. - - The following methods support that optimization: - 1. startMultiSigningData provides the large part which can be shared. - 2. finishMultiSigningData caps the passed in serializer with each - signer's unique data. -*/ +/** Build the shared prefix of a multi-signing payload. + * + * Prepends `HashPrefix::TxMultiSign` and serializes `obj` without signing + * fields. The returned `Serializer` is identical for every signer of the + * same transaction; pass it to `finishMultiSigningData` once per signer to + * append only the small, signer-specific `AccountID` tail. This split avoids + * re-serializing the (potentially large) transaction body for each signer + * during batch verification. + * + * @param obj The transaction or object being authorized. + * @return A `Serializer` holding the shared signing prefix. The returned + * value must be completed with `finishMultiSigningData` before use. + * @see finishMultiSigningData, buildMultiSigningData + */ Serializer startMultiSigningData(STObject const& obj); +/** Append the per-signer suffix to a multi-signing payload in place. + * + * Writes `signingID` as a raw 160-bit bit-string onto the end of `s`, + * completing the payload started by `startMultiSigningData`. After this + * call, `s.slice()` is ready to be passed to the cryptographic sign or + * verify functions. + * + * @param signingID The `AccountID` of the signer being authorized. + * @param s The in-progress `Serializer` returned by + * `startMultiSigningData`. Modified in place. + * @see startMultiSigningData, buildMultiSigningData + */ inline void finishMultiSigningData(AccountID const& signingID, Serializer& s) { diff --git a/include/xrpl/protocol/SystemParameters.h b/include/xrpl/protocol/SystemParameters.h index 029c0418b5..cfa92dc76e 100644 --- a/include/xrpl/protocol/SystemParameters.h +++ b/include/xrpl/protocol/SystemParameters.h @@ -1,3 +1,13 @@ +/** @file + * Protocol-wide constants and validation helpers for the XRP Ledger. + * + * This header is intentionally lightweight: it is included across virtually + * the entire codebase, so it avoids heavy dependencies. Everything here is + * either a fixed property of the XRP Ledger network (total supply, earliest + * known ledger, governance thresholds) or a small convenience function built + * directly on those values. + */ + #pragma once #include @@ -8,9 +18,14 @@ namespace xrpl { -// Various protocol and system specific constant globals. - -/* The name of the system. */ +/** Return the canonical name of the XRP Ledger daemon. + * + * Uses a Meyers singleton (function-local `static`) to avoid the static + * initialization order fiasco. The `inline` specifier allows inclusion in + * multiple translation units without ODR violations. + * + * @return The string `"xrpld"`. + */ static inline std::string const& systemName() { @@ -18,29 +33,14 @@ systemName() return kNAME; } -/** Configure the native currency. */ - -/** Number of drops in the genesis account. */ -constexpr XRPAmount kINITIAL_XRP{100'000'000'000 * kDROPS_PER_XRP}; -static_assert(kINITIAL_XRP.drops() == 100'000'000'000'000'000); -static_assert(Number::kMAX_REP >= kINITIAL_XRP.drops()); - -/** Returns true if the amount does not exceed the initial XRP in existence. */ -inline bool -isLegalAmount(XRPAmount const& amount) -{ - return amount <= kINITIAL_XRP; -} - -/** Returns true if the absolute value of the amount does not exceed the initial - * XRP in existence. */ -inline bool -isLegalAmountSigned(XRPAmount const& amount) -{ - return amount >= -kINITIAL_XRP && amount <= kINITIAL_XRP; -} - -/* The currency code for the native currency. */ +/** Return the ISO currency code for the native asset. + * + * Uses a Meyers singleton (function-local `static`) for the same reasons as + * `systemName()`. Callers should prefer this over scattering `"XRP"` literals + * throughout the codebase. + * + * @return The string `"XRP"`. + */ static inline std::string const& systemCurrencyCode() { @@ -48,20 +48,97 @@ systemCurrencyCode() return kCODE; } -/** The XRP ledger network's earliest allowed sequence */ +/** Total XRP supply at ledger genesis: 100 billion XRP expressed in drops. + * + * Computed as `100'000'000'000 * kDROPS_PER_XRP` (= 10^17 drops). + * Two `static_assert`s immediately below guard that the raw bit value is + * correct and that `Number::kMAX_REP` can represent it — a compile-time + * tripwire if either the XRP total or `Number`'s internal representation + * is ever changed. + */ +constexpr XRPAmount kINITIAL_XRP{100'000'000'000 * kDROPS_PER_XRP}; +static_assert(kINITIAL_XRP.drops() == 100'000'000'000'000'000); +static_assert(Number::kMAX_REP >= kINITIAL_XRP.drops()); + +/** Return whether @p amount is within the legal unsigned XRP range. + * + * An amount is legal when it does not exceed the total XRP ever in existence. + * Called by `Transactor::preflight1` to reject fee fields that would exceed + * `kINITIAL_XRP`, and by `InvariantCheck` as a post-transaction guard. + * + * @param amount The drop amount to validate. + * @return `true` if `amount <= kINITIAL_XRP`. + */ +inline bool +isLegalAmount(XRPAmount const& amount) +{ + return amount <= kINITIAL_XRP; +} + +/** Return whether @p amount is within the legal signed XRP range. + * + * Extends `isLegalAmount` to accept negative values, which arise in delta + * and fee calculations. Used by `InvariantCheck` to ensure no ledger + * operation manufactures XRP out of thin air. + * + * @param amount The signed drop amount to validate. + * @return `true` if `amount` is in `[-kINITIAL_XRP, kINITIAL_XRP]`. + */ +inline bool +isLegalAmountSigned(XRPAmount const& amount) +{ + return amount >= -kINITIAL_XRP && amount <= kINITIAL_XRP; +} + +/** Earliest ledger sequence available on the XRP Ledger mainnet. + * + * Ledgers 1–32569 were lost in an early network incident and no longer exist + * anywhere. The `Database` class uses this value as the default lower bound + * for the `earliest_seq` configuration parameter, causing any node without + * a custom setting to refuse requests for pre-genesis sequences. + */ static constexpr std::uint32_t kXRP_LEDGER_EARLIEST_SEQ{32570u}; -/** The XRP Ledger mainnet's earliest ledger with a FeeSettings object. Only - * used in asserts and tests. */ +/** Earliest mainnet ledger sequence that contains a `FeeSettings` object. + * + * Used exclusively in `XRPL_ASSERT` calls and tests in the form: + * @code + * XRPL_ASSERT( + * ledger->header().seq < kXRP_LEDGER_EARLIEST_FEES || + * ledger->read(keylet::fees()), + * "..."); + * @endcode + * This allows the `FeeSettings` invariant to be checked on modern ledgers + * while skipping it for historical replay of early mainnet ledgers where + * the object did not yet exist. + */ static constexpr std::uint32_t kXRP_LEDGER_EARLIEST_FEES{562177u}; -/** The minimum amount of support an amendment should have. */ +/** Required validator support for an amendment to achieve majority. + * + * Represents 80% as a `std::ratio` rather than a floating-point constant + * so that `AmendmentTable` can compute the threshold count with pure integer + * arithmetic (`(trustedValidations * num) / den`), avoiding rounding error + * in this consensus-critical gate. + */ constexpr std::ratio<80, 100> kAMENDMENT_MAJORITY_CALC_THRESHOLD; -/** The minimum amount of time an amendment must hold a majority */ +/** Minimum continuous duration that an amendment must hold 80% validator + * support before it activates on mainnet. + * + * Seeds `Config::AMENDMENT_MAJORITY_TIME`, which operators may override. + * Uses the `weeks` alias from `xrpl/basics/chrono.h` (predates C++20 + * `std::chrono::weeks`). + */ constexpr std::chrono::seconds const kDEFAULT_AMENDMENT_MAJORITY_TIME = weeks{2}; } // namespace xrpl -/** Default peer port (IANA registered) */ +/** IANA-registered port for XRP Ledger peer-to-peer connections. + * + * Declared outside the `xrpl` namespace so that networking code constructing + * socket addresses can reference it without namespace qualification. + * Used by `OverlayImpl` peer discovery and the `peer_connect` RPC handler + * as the fallback when no explicit port is configured. + */ inline std::uint16_t constexpr kDEFAULT_PEER_PORT{2459}; diff --git a/include/xrpl/tx/paths/AMMOffer.h b/include/xrpl/tx/paths/AMMOffer.h index 21c45c36a3..210aabe8ef 100644 --- a/include/xrpl/tx/paths/AMMOffer.h +++ b/include/xrpl/tx/paths/AMMOffer.h @@ -1,3 +1,10 @@ +/** @file + * Declares `AMMOffer`, the synthetic offer adapter that presents an AMM pool + * as a `TOffer`-compatible object for `BookStep`'s generic payment-engine loop. + * + * @see AMMLiquidity, BookStep, QualityFunction + */ + #pragma once #include @@ -13,91 +20,264 @@ template class AMMLiquidity; class QualityFunction; -/** Represents synthetic AMM offer in BookStep. AMMOffer mirrors TOffer - * methods for use in generic BookStep methods. AMMOffer amounts - * are changed indirectly in BookStep limiting steps. +/** Synthetic offer representing AMM pool liquidity inside `BookStep`. + * + * `AMMOffer` exposes the same named interface as `TOffer` — + * `quality()`, `amount()`, `consume()`, `fullyConsumed()`, `limitIn()`, + * `limitOut()`, `send()`, `isFunded()`, `adjustRates()`, `checkInvariant()` + * — so that `BookStep` can treat CLOB and AMM liquidity polymorphically via + * structural duck-typing without virtual dispatch. + * + * An `AMMOffer` is not backed by any ledger entry; `key()` always returns + * `std::nullopt` and pool balance updates happen in `BookStep::consumeOffer()` + * via `accountSend`, not here. Each instance may be consumed at most once + * per payment-engine iteration; `AMMLiquidity::getOffer()` creates a fresh + * one for each iteration. + * + * Behavior diverges between single-path and multi-path modes: + * - **Single-path**: `limitOut`/`limitIn` apply the constant-product swap + * formula against `balances_`; `getQualityFunc()` returns an AMM quality + * function with a nonzero slope encoding the pool curve. + * - **Multi-path**: `limitOut`/`limitIn` scale proportionally to `quality_` + * (like a fixed-rate CLOB offer); `getQualityFunc()` returns a constant + * quality function. This preserves strand quality ordering across + * competing paths. + * + * @tparam TIn Amount type for the input asset (`IOUAmount`, `XRPAmount`, + * or `MPTAmount`). + * @tparam TOut Amount type for the output asset (`IOUAmount`, `XRPAmount`, + * or `MPTAmount`). + * + * @note Explicitly instantiated for all eight valid `(TIn, TOut)` pairings in + * `AMMOffer.cpp`; do not add implicit instantiations elsewhere. */ template class AMMOffer { private: AMMLiquidity const& ammLiquidity_; - // Initial offer amounts. It is fibonacci seq generated for multi-path. - // If the offer size is set based on the competing CLOB offer then - // the AMM offer size is such that if the offer is consumed then - // the updated AMM pool SP quality is going to be equal to competing - // CLOB offer quality. If there is no competing CLOB offer then - // the initial size is set to in=cMax[Native,Value],balances.out. - // While this is not a "real" offer it simulates the case of - // the swap out of the entire side of the pool, in which case - // the swap in amount is infinite. + + /** Synthetic offer size as presented to `BookStep`. + * + * In multi-path mode this is a Fibonacci-sequence-scaled amount so that + * successive iterations probe progressively larger AMM liquidity slices. + * In single-path mode it is either quality-matched to the competing CLOB + * offer (full consumption moves the pool's spot price to that quality) or + * a "max offer" representing 99% of the output-side pool balance when no + * CLOB offer exists. + */ TAmounts const amounts_; - // Current pool balances. + + /** Pool token balances at the moment this offer was generated. + * + * Snapshotted separately from `amounts_` because in single-path mode the + * spot-price quality used as `quality_` can diverge from the raw ratio of + * `amounts_` when the offer is sized relative to a competing CLOB. + * `limitOut` and `limitIn` use these in single-path mode to evaluate the + * constant-product swap formula. + */ TAmounts const balances_; - // The Spot Price quality if balances != amounts - // else the amounts quality + + /** Effective exchange-rate quality for this offer. + * + * Equals the spot-price quality derived from `balances_` when + * `balances_ != amounts_` (single-path quality-matched sizing); otherwise + * equals the quality implied directly by `amounts_`. + */ Quality const quality_; - // AMM offer can be consumed once at a given iteration + + /** True once `consume()` has been called; enforces at-most-once crossing. */ bool consumed_{false}; public: + /** Construct from sizing data provided by `AMMLiquidity::getOffer`. + * + * @param ammLiquidity Owning liquidity manager; provides pool metadata + * (account ID, assets, trading fee) and the `AMMContext` that tracks + * cross-iteration state. Must outlive this offer. + * @param amounts Synthetic offer size — Fibonacci-scaled in multi-path + * mode, quality-matched or pool-draining in single-path mode. + * @param balances Live pool balances at the moment of offer generation; + * used by `limitOut`/`limitIn` in single-path mode. + * @param quality Spot-price quality when `balances != amounts`; otherwise + * the quality implied by `amounts`. + */ AMMOffer( AMMLiquidity const& ammLiquidity, TAmounts const& amounts, TAmounts const& balances, Quality const& quality); + /** Return the effective exchange-rate quality for this offer. + * + * In single-path mode this is the pool's spot-price quality; in + * multi-path mode it is the fixed quality of the Fibonacci-sized offer. + * `BookStep` uses this to order competing AMM and CLOB offers. + * + * @return The offer's quality (input-to-output ratio, sorted ascending). + */ [[nodiscard]] Quality quality() const noexcept { return quality_; } + /** Return the input-side asset of the underlying AMM pool. + * + * @return Reference to the pool's input `Asset`; lifetime is that of + * the owning `AMMLiquidity`. + */ [[nodiscard]] Asset const& assetIn() const; + /** Return the output-side asset of the underlying AMM pool. + * + * @return Reference to the pool's output `Asset`; lifetime is that of + * the owning `AMMLiquidity`. + */ [[nodiscard]] Asset const& assetOut() const; + /** Return the AMM pool's on-ledger account ID. + * + * `BookStep` uses this as the logical "owner" of the synthetic offer for + * logging and metadata purposes. + * + * @return Reference to the AMM `AccountID`; lifetime is that of the + * owning `AMMLiquidity`. + */ [[nodiscard]] AccountID const& owner() const; + /** Return `std::nullopt` to indicate there is no backing ledger entry. + * + * `TOffer::key()` returns the offer's ledger-object key so `BookStep` can + * erase it after crossing. `AMMOffer` has no ledger object; returning + * `nullopt` signals to `BookStep` that no erase is needed. + * + * @return Always `std::nullopt`. + */ [[nodiscard]] std::optional key() const { return std::nullopt; } + /** Return the synthetic offer size (TakerPays / TakerGets equivalent). + * + * @return Reference to the `{in, out}` amounts set at construction; + * not modified by `limitOut`, `limitIn`, or `consume`. + */ [[nodiscard]] TAmounts const& amount() const; + /** 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 may only be consumed once per payment-engine iteration. + * + * @param view Mutable ledger view (unused; present for interface + * parity with `TOffer::consume`). + * @param consumed The `{in, out}` amounts actually transferred. Must not + * exceed `amounts_` in either dimension. + * @throws std::logic_error if `consumed.in > amounts_.in` or + * `consumed.out > amounts_.out`. + */ void consume(ApplyView& view, TAmounts const& consumed); + /** Return `true` once the offer has been consumed this iteration. + * + * Unlike `TOffer::fullyConsumed()`, which tests whether the remaining + * amount has reached zero, this simply reflects the `consumed_` flag set + * by `consume()`. AMM offers are always either fully consumed or not + * consumed at all within a single payment-engine iteration. + * + * @return `true` if `consume()` has been called; `false` otherwise. + */ [[nodiscard]] bool fullyConsumed() const { return consumed_; } - /** Limit out of the provided offer. If one-path then swapOut - * using current balances. If multi-path then ceil_out using - * current quality. + /** Resize the offer to deliver at most `limit` units of the output asset. + * + * **Single-path mode**: applies `swapAssetOut(balances_, limit, + * tradingFee())` — the constant-product formula — for an exact result + * along the AMM curve. + * + * **Multi-path mode**: resizes proportionally via + * `quality().ceilOutStrict(offerAmount, limit, roundUp)`, preserving + * strand quality ordering. The taker overpays slightly, ensuring the + * post-trade pool product does not decrease. + * + * @param offerAmount Current offer size (used for proportional scaling in + * multi-path mode; ignored in single-path mode). + * @param limit Maximum output amount that may be delivered. + * @param roundUp Whether to round the computed input side up (forwarded + * to `ceilOutStrict` in multi-path mode). + * @return Resized `{in, out}` pair where `out <= limit`. */ [[nodiscard]] TAmounts limitOut(TAmounts const& offerAmount, TOut const& limit, bool roundUp) const; - /** Limit in of the provided offer. If one-path then swapIn - * using current balances. If multi-path then ceil_in using - * current quality. + /** Resize the offer to consume at most `limit` units of the input asset. + * + * **Single-path mode**: applies `swapAssetIn(balances_, limit, + * tradingFee())` — the constant-product formula — for an exact result. + * + * **Multi-path mode**: resizes proportionally to `quality_`. When the + * `fixReducedOffersV2` amendment is active, uses `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. + * + * @param offerAmount Current offer size (used for proportional scaling in + * multi-path mode; ignored in single-path mode). + * @param limit Maximum input amount the taker will supply. + * @param roundUp Whether to round the computed output side up (forwarded + * to `ceilInStrict` when `fixReducedOffersV2` is active). + * @return Resized `{in, out}` pair where `in <= limit`. */ [[nodiscard]] TAmounts limitIn(TAmounts const& offerAmount, TIn const& limit, bool roundUp) const; + /** Return the quality function used by the single-path optimizer. + * + * **Single-path mode**: returns a `QualityFunction` with a nonzero slope + * derived from `balances_` and the trading fee, encoding the AMM curve + * `q(out) = -cfee/poolIn × out + poolOut × cfee/poolIn`. The optimizer + * uses this to solve in closed form for the output amount that satisfies + * a requested quality limit. + * + * **Multi-path mode**: returns a constant `QualityFunction` (slope = 0, + * intercept = `quality_`), identical to a CLOB offer, so that the AMM's + * varying spot price does not disturb relative quality ordering across + * competing strands. + * + * @return A `QualityFunction` encoding the effective exchange rate as a + * linear function of output amount. + */ [[nodiscard]] QualityFunction getQualityFunc() const; - /** Send funds without incurring the transfer fee + /** Transfer funds from the AMM pool, waiving the transfer fee. + * + * Delegates to `accountSend` with `WaiveTransferFee::Yes`. AMM swaps on + * Payment transactions are exempt from transfer fees; this is the + * send-side enforcement of that exemption (the rate-side enforcement is + * in `adjustRates()`). + * + * @param args Forwarded verbatim to `accountSend`. + * @return The `TER` result of `accountSend`. */ template static TER @@ -107,22 +287,53 @@ public: std::forward(args)..., WaiveTransferFee::Yes, AllowMPTOverflow::Yes); } + /** Return `true` unconditionally — the AMM pool is always its own issuer. + * + * Unlike CLOB offers, which can become unfunded if the owner's balance + * falls, an AMM offer is backed by the pool itself and is never + * underfunded at the time it is generated. + * + * @return Always `true`. + */ [[nodiscard]] bool isFunded() const { - // AMM offer is fully funded by the pool return true; } + /** Return adjusted transfer-fee rates, zeroing the output-side rate. + * + * AMM swaps on Payment transactions are exempt from transfer fees on the + * output side. Passing `QUALITY_ONE` for `ofrOutRate` suppresses the + * output-side fee that `BookStep` would otherwise apply. The input-side + * rate is passed through unchanged. + * + * @param ofrInRate Transfer fee rate on the input asset (passed through). + * @param ofrOutRate Transfer fee rate on the output asset (ignored; + * replaced with `QUALITY_ONE`). + * @return `{ofrInRate, QUALITY_ONE}`. + */ static std::pair adjustRates(std::uint32_t ofrInRate, std::uint32_t ofrOutRate) { - // AMM doesn't pay transfer fee on Payment tx return {ofrInRate, QUALITY_ONE}; } - /** Check the new pool product is greater or equal to the old pool - * product or if decreases then within some threshold. + /** Verify the constant-product invariant after offer execution. + * + * Recomputes `k = balances_.in × balances_.out` and the post-trade + * product `k' = (balances_.in + consumed.in) × (balances_.out - + * consumed.out)`. The check passes when `k' >= k` (exact conservation) + * or when the relative decrease is within `1e-7`, a tolerance that absorbs + * finite-precision rounding in the swap formulas without masking genuinely + * broken swaps. Violations are logged at error level; 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. */ [[nodiscard]] bool checkInvariant(TAmounts const& consumed, beast::Journal j) const; diff --git a/include/xrpl/tx/paths/BookTip.h b/include/xrpl/tx/paths/BookTip.h index e06a2da86c..3b05df6e20 100644 --- a/include/xrpl/tx/paths/BookTip.h +++ b/include/xrpl/tx/paths/BookTip.h @@ -8,10 +8,22 @@ namespace xrpl { class Logs; -/** Iterates and consumes raw offers in an order book. - Offers are presented from highest quality to lowest quality. This will - return all offers present including missing, invalid, unfunded, etc. -*/ +/** Consuming iterator over a DEX order book's quality-sorted offer directories. + * + * Traverses offer directories from best quality (highest exchange rate) to + * worst, calling `offerDelete` on each offer before advancing to the next. + * Every offer is exposed without filtering — missing SLEs, expired offers, + * and unfunded entries are all returned; filtering is the caller's + * responsibility (see `TOfferStreamBase`). + * + * Requires `ApplyView` rather than `ReadView` because `step()` physically + * removes the current offer from the ledger view on each advance. + * + * @note `BookTip` is a *consuming* iterator: each call to `step()` deletes + * the previously yielded offer. Callers that wish to keep an offer must + * act on it before calling `step()` again. + * @see TOfferStreamBase, OfferStream + */ class BookTip { private: @@ -25,37 +37,80 @@ private: Quality quality_{}; public: - /** Create the iterator. */ + /** Construct an iterator positioned before the first offer in a book. + * + * Derives the key-space range for `book` via `getBookBase` (inclusive + * lower bound, best quality) and `getQualityNext` (exclusive upper + * bound, worst quality). No ledger access occurs until the first call + * to `step()`. + * + * @param view The mutable ledger view used for both directory traversal + * and offer deletion during `step()`. + * @param book The currency/issuer pair identifying the order book to + * iterate. + */ BookTip(ApplyView& view, Book const& book); + /** Ledger index of the quality-directory that owns the current offer. + * + * The directory key encodes the exchange rate in the `uint256` index + * itself. Valid only after a successful call to `step()`. + */ [[nodiscard]] uint256 const& dir() const noexcept { return dir_; } + /** Ledger index of the current offer SLE. + * + * Valid only after a successful call to `step()`. + */ [[nodiscard]] uint256 const& index() const noexcept { return index_; } + /** Exchange rate of the current offer's quality directory. + * + * Decoded from the directory's `uint256` key via `getQuality()`. + * Valid only after a successful call to `step()`. + */ [[nodiscard]] Quality const& quality() const noexcept { return quality_; } + /** Shared pointer to the current offer's SLE, or `nullptr` if missing. + * + * May be `nullptr` when the offer index exists in a directory but + * the corresponding ledger entry has been removed. Callers must + * null-check before use. Valid only after a successful call to `step()`. + */ [[nodiscard]] SLE::pointer const& entry() const noexcept { return entry_; } - /** Erases the current offer and advance to the next offer. - Complexity: Constant - @return `true` if there is a next offer - */ + /** Delete the current offer from the ledger and advance to the next. + * + * On every invocation after the first, removes the previously yielded + * offer via `offerDelete` before searching for the next one. Uses + * `view_.succ(book_, end_)` to locate the next occupied quality + * directory in O(log n) without scanning empty key ranges. + * + * Empty directories (which should never occur in a well-formed ledger) + * are silently skipped rather than treated as fatal errors. + * + * @param j Journal used for diagnostic logging during offer deletion and + * directory traversal. + * @return `true` if a next offer was found and the accessor fields are + * valid; `false` if the book is exhausted. + * @note Complexity is O(log n) in the number of ledger entries per call. + */ bool step(beast::Journal j); }; diff --git a/include/xrpl/tx/paths/Flow.h b/include/xrpl/tx/paths/Flow.h index c746249866..870359d9b4 100644 --- a/include/xrpl/tx/paths/Flow.h +++ b/include/xrpl/tx/paths/Flow.h @@ -1,3 +1,12 @@ +/** @file + * Public entry point for the XRP Ledger payment flow engine. + * + * Declares `flow()`, the single function that every Payment transaction, + * offer crossing, and check-cash operation calls to move funds through + * the ledger. The implementation resolves source/destination asset types, + * builds execution strands from the supplied path hints, and dispatches + * to a type-parameterised inner loop in `StrandFlow.h`. + */ #pragma once #include @@ -7,29 +16,74 @@ namespace xrpl { namespace path::detail { +/** Diagnostic trace populated by `flow()` during testing. + * + * When a non-null pointer is passed to `flow()`, the inner strand-execution + * loop fills this structure with per-strand, per-step execution traces. + * In production the pointer is null and this path has zero overhead. + */ struct FlowDebugInfo; } // namespace path::detail -/** - Make a payment from the src account to the dst account - - @param view Trust lines and balances - @param deliver Amount to deliver to the dst account - @param src Account providing input funds for the payment - @param dst Account receiving the payment - @param paths Set of paths to explore for liquidity - @param defaultPaths Include defaultPaths in the path set - @param partialPayment If the payment cannot deliver the entire - requested amount, deliver as much as possible, given the constraints - @param ownerPaysTransferFee If true then owner, not sender, pays fee - @param offerCrossing If Yes or Sell then flow is executing offer crossing, not - payments - @param limitQuality Do not use liquidity below this quality threshold - @param sendMax Do not spend more than this amount - @param j Journal to write journal messages to - @param flowDebugInfo If non-null a pointer to FlowDebugInfo for debugging - @return Actual amount in and out, and the result code -*/ +/** Execute a payment through the path-finding and strand-execution engine. + * + * Routes funds from `src` to `dst` using the candidate paths in `paths` + * (and optionally the default path). Strand construction, offer-book + * traversal, AMM liquidity, and trust-line balance updates are all staged + * inside `view`. The sandbox is mutated only when the result is + * `tesSUCCESS`; on any failure the sandbox is left pristine. + * + * Source-asset inference: if `sendMax` is present its asset is used as the + * source asset. Otherwise, for IOU deliveries the source asset adopts `src` + * as its issuer (the "any issuer from src" semantic); for MPT and XRP the + * delivery asset is used directly. + * + * @param view Mutable speculative ledger view. All balance and trust-line + * mutations are staged here. The caller owns the sandbox and decides + * whether to commit the result to the underlying view. + * @param deliver Target amount to deliver to `dst`. Determines the + * destination asset and, when `sendMax` is absent, the source asset. + * @param src Account supplying the input funds. + * @param dst Account receiving the delivered funds. + * @param paths Candidate path hints from the transaction's `sfPaths` field. + * Translated into `Strand` objects via `toStrands()`; an empty set is + * valid when `defaultPaths` is true. + * @param defaultPaths When true, the direct src→dst path is added to the + * strand set even if it does not appear in `paths`. + * @param partialPayment When true, the engine delivers as much as possible + * up to `deliver` rather than failing if the full amount cannot be + * routed. Corresponds to the `tfPartialPayment` transaction flag. + * @param ownerPaysTransferFee When true, IOU transfer fees are charged to + * the offer owner rather than the payment sender. Set for offer + * crossing; clear for normal payments. + * @param offerCrossing Distinguishes operational mode: `No` for Payment + * transactions, `Yes` or `Sell` for offer crossing. Affects fee + * attribution, quality constraints, and offer eligibility within each + * strand step. + * @param limitQuality Optional minimum acceptable exchange rate + * (output/input). Book steps stop consuming liquidity once the best + * available offer quality falls below this threshold. Used to enforce + * the taker's price constraint during offer crossing. + * @param sendMax Optional upper bound on the sender's spend. Its asset + * also drives source-asset inference when present. + * @param domainID Optional domain identifier for domain-scoped order books. + * When set, book lookups are restricted to the specified domain and + * threaded down into every `StrandContext` and `BookStep`. + * @param j Journal for diagnostic logging during strand execution. + * @param flowDebugInfo If non-null, the inner flow template populates this + * structure with per-strand execution traces for testing or diagnostics. + * Null in production; the debug path has zero overhead when null. + * @return A `RippleCalc::Output` containing: `actualAmountIn` (source + * spend), `actualAmountOut` (amount delivered), `removableOffers` + * (unfunded/expired offers discovered during traversal — populated even + * on failure so callers can clean up ledger hygiene), and `result()` + * (the `TER` outcome). On failure, only `removableOffers` and + * `result()` are meaningful; `actualAmountIn`/`Out` may be zero. + * @note If strand construction via `toStrands()` fails, the error `TER` is + * returned immediately and the sandbox is not touched. + * @see path::RippleCalc::rippleCalculate for the older pre-Flow path engine + * that shares the same `Output` type. + */ path::RippleCalc::Output flow( PaymentSandbox& view, diff --git a/include/xrpl/tx/paths/Offer.h b/include/xrpl/tx/paths/Offer.h index 0a551f4c2d..48e4aac8aa 100644 --- a/include/xrpl/tx/paths/Offer.h +++ b/include/xrpl/tx/paths/Offer.h @@ -1,3 +1,10 @@ +/** @file + * Defines `TOffer`, the typed CLOB offer wrapper used by the payment-path + * engine to read, limit, and consume Central Limit Order Book entries. + * + * @see AMMOffer, BookStep, TOfferStreamBase + */ + #pragma once #include @@ -15,6 +22,32 @@ namespace xrpl { +/** Typed wrapper around a CLOB offer ledger entry for the payment-path engine. + * + * `TOffer` bridges a raw `SLE` (Shared Ledger Entry) and the generic + * `BookStep` template, providing a clean typed interface for reading offer + * amounts, applying partial fills, and routing funds. Template parameters + * `TIn` and `TOut` — constrained to `XRPAmount`, `IOUAmount`, or `MPTAmount` + * — let a single class body handle every asset-type combination while + * permitting compile-time dispatch where serialization paths differ. + * + * `TOffer` and `AMMOffer` expose the same named interface so that `BookStep` + * can treat CLOB and AMM liquidity polymorphically via structural duck-typing + * without virtual dispatch. + * + * @tparam TIn Amount type for the input (TakerPays) side of the offer. + * Must satisfy the `StepAmount` concept (`XRPAmount`, `IOUAmount`, or + * `MPTAmount`). + * @tparam TOut Amount type for the output (TakerGets) side of the offer. + * Must satisfy the `StepAmount` concept. + * + * @note After construction the object is self-contained — it holds copies of + * all relevant amounts and asset identities extracted from the `SLE`. + * The `SLE` itself is not read again until `consume()` writes back the + * updated amounts. + * + * @see AMMOffer, BookStep, TOfferStreamBase + */ template class TOffer { @@ -26,23 +59,47 @@ private: Asset assetOut_; TAmounts amounts_{}; + + /** Write the current `amounts_` back into the underlying `SLE` fields. + * + * Uses `if constexpr` to select between the XRP path (`toSTAmount(amount)` + * with no asset context) and the IOU/MPT path (`toSTAmount(amount, asset_)`) + * at compile time, avoiding runtime polymorphism while sharing the body. + * Called only from `consume()`. + */ void setFieldAmounts(); public: TOffer() = default; + /** Construct from a ledger entry and its pre-computed quality. + * + * Reads `sfTakerPays` and `sfTakerGets` from `entry` and converts them + * to the strongly-typed `TIn`/`TOut` amounts via `toAmount()`. Asset + * identities are captured from the `STAmount::asset()` accessors. + * + * @param entry Shared pointer to the offer's `SLE`. Must not be null. + * @param quality Pre-computed quality for this offer as stored in the + * order book page; not recalculated here. + */ TOffer(SLE::pointer entry, Quality quality); /** Returns the quality of the offer. - Conceptually, the quality is the ratio of output to input currency. - The implementation calculates it as the ratio of input to output - currency (so it sorts ascending). The quality is computed at the time - the offer is placed, and never changes for the lifetime of the offer. - This is an important business rule that maintains accuracy when an - offer is partially filled; Subsequent partial fills will use the - original quality. - */ + * + * Conceptually the quality is the ratio of output to input currency. + * Internally it is stored as input-to-output (ascending integer order + * maps to descending quality) so that the order book's sort order is + * stable. + * + * Quality is fixed at the moment the offer is placed and never + * recalculated, even after partial fills. This is a deliberate ledger + * invariant: partial fills reduce only the absolute amounts, leaving the + * exchange rate unchanged and preventing accumulated rounding drift from + * silently worsening the effective rate for later takers. + * + * @return The offer's immutable quality. + */ [[nodiscard]] Quality quality() const noexcept { @@ -56,16 +113,30 @@ public: return account_; } - /** Returns the in and out amounts. - Some or all of the out amount may be unfunded. - */ + /** Returns the remaining in/out amounts for this offer. + * + * The out amount reflects what is recorded in the ledger entry; some or + * all of it may be unfunded if the owner's balance has dropped since the + * offer was placed. `TOfferStreamBase` verifies actual owner funds via + * `ownerFunds_` before crossing. + * + * @return Reference to the `{in, out}` pair; valid for the lifetime of + * this `TOffer`. + */ [[nodiscard]] TAmounts const& amount() const { return amounts_; } - /** Returns `true` if no more funds can flow through this offer. */ + /** Returns `true` if no more funds can flow through this offer. + * + * The offer is considered fully consumed when either the input or output + * side has reached zero. `BookStep` uses this to decide whether to erase + * the offer from the ledger after crossing. + * + * @return `true` if `amounts_.in <= 0` or `amounts_.out <= 0`. + */ [[nodiscard]] bool fullyConsumed() const { @@ -76,7 +147,20 @@ public: return false; } - /** Adjusts the offer to indicate that we consumed some (or all) of it. */ + /** Applies a partial or full consumption to this offer and stages the + * update in the ledger view. + * + * Decrements `amounts_` by `consumed`, writes the updated values back + * into the `SLE` via `setFieldAmounts()`, and calls `view.update(entry_)` + * to stage the change in the `ApplyView`. + * + * @param view Mutable ledger view that will receive the updated SLE. + * @param consumed The `{in, out}` amounts actually transferred. Must + * not exceed the current `amounts_` in either dimension. + * @throws std::logic_error if `consumed.in > amounts_.in` or + * `consumed.out > amounts_.out`. The calling code in `BookStep` + * is expected to clamp consumption first via `limitOut`/`limitIn`. + */ void consume(ApplyView& view, TAmounts const& consumed) { @@ -91,33 +175,109 @@ public: view.update(entry_); } + /** Returns the ledger-object key as a hex string, for logging. + * + * @return String representation of the offer's 256-bit ledger key. + */ [[nodiscard]] std::string id() const { return to_string(entry_->key()); } + /** Returns the 256-bit ledger key of the underlying offer SLE. + * + * `BookStep` uses this key to erase fully-consumed offers from the ledger. + * Unlike `AMMOffer::key()`, which always returns `std::nullopt`, this + * always returns a value for a valid `TOffer`. + * + * @return The offer's ledger object key. + */ [[nodiscard]] std::optional key() const { return entry_->key(); } + /** Returns the input-side asset of this offer. + * + * @return Reference to the `Asset` captured from `sfTakerPays` at + * construction; valid for the lifetime of this `TOffer`. + */ [[nodiscard]] Asset const& assetIn() const; + + /** Returns the output-side asset of this offer. + * + * @return Reference to the `Asset` captured from `sfTakerGets` at + * construction; valid for the lifetime of this `TOffer`. + */ [[nodiscard]] Asset const& assetOut() const; + /** Clamps the offer to deliver at most `limit` units of the output asset. + * + * Always delegates to `Quality::ceilOutStrict()`, which uses a tighter + * rounding algorithm than the older `ceilOut()` to remove slop that + * could keep offers alive longer than they should be. Unlike `limitIn()`, + * the strict ceiling is unconditional — it was deployed before + * `fixReducedOffersV2` and does not require an amendment gate. + * + * @param offerAmount Current offer size used for proportional scaling. + * @param limit Maximum output amount this offer may deliver. + * @param roundUp Whether to round the computed input side up. + * @return Resized `{in, out}` pair where `out <= limit`. + */ [[nodiscard]] TAmounts limitOut(TAmounts const& offerAmount, TOut const& limit, bool roundUp) const; + /** Clamps the offer to consume at most `limit` units of the input asset. + * + * When the `fixReducedOffersV2` amendment is active, delegates to + * `Quality::ceilInStrict()`, which removes a small rounding slop present + * in the older `ceilIn()`. The stricter ceiling changes observable + * transaction outcomes (it can prevent tiny residual amounts from keeping + * an offer alive), so it is gated behind the amendment to preserve replay + * of historical ledgers. Without the amendment, falls back to + * `quality_.ceilIn()`. + * + * @note The asymmetry with `limitOut()` — which is always strict — reflects + * the order in which these fixes were deployed on the network. + * + * @param offerAmount Current offer size used for proportional scaling. + * @param limit Maximum input amount the taker will supply. + * @param roundUp Whether to round the computed output side up (only + * forwarded when `fixReducedOffersV2` is active). + * @return Resized `{in, out}` pair where `in <= limit`. + */ [[nodiscard]] TAmounts limitIn(TAmounts const& offerAmount, TIn const& limit, bool roundUp) const; + /** Transfers funds from the offer owner, charging the issuer's transfer fee. + * + * Delegates to `accountSend` with `WaiveTransferFee::No`, meaning CLOB + * offer owners pay the output asset issuer's transfer fee on each crossing. + * This is in contrast to `AMMOffer::send()`, which passes + * `WaiveTransferFee::Yes` because AMM pools are exempt from transfer fees + * under the protocol rules. + * + * @param args Arguments forwarded verbatim to `accountSend`. + * @return The `TER` result of `accountSend`. + */ template static TER send(Args&&... args); + /** Returns `true` when the offer owner is also the output-asset issuer. + * + * An IOU issuer can deliver their own currency without holding a balance, + * so the path engine can bypass the normal `ownerFunds_` balance check for + * such offers. Returns `false` for MPT and XRP output assets because + * issuers have no special delivery privilege for those types. + * + * @return `true` only when `account_ == assetOut_.getIssuer()` and the + * output asset is an `Issue` (IOU); `false` otherwise. + */ [[nodiscard]] bool isFunded() const { @@ -125,6 +285,18 @@ public: return account_ == assetOut_.getIssuer() && assetOut_.holds(); } + /** Returns the in/out transfer-fee rates unchanged. + * + * CLOB offer owners pay both the input-side and output-side transfer fees, + * so both rates are returned as-is. This is in contrast to + * `AMMOffer::adjustRates()`, which zeroes the output-side rate to + * `QUALITY_ONE` because AMM swaps on Payment transactions are exempt from + * output-side transfer fees. + * + * @param ofrInRate Transfer fee rate on the input asset. + * @param ofrOutRate Transfer fee rate on the output asset. + * @return `{ofrInRate, ofrOutRate}` — both rates unchanged. + */ static std::pair adjustRates(std::uint32_t ofrInRate, std::uint32_t ofrOutRate) { @@ -132,8 +304,22 @@ public: return {ofrInRate, ofrOutRate}; } - /** Check any required invariant. Limit order book offer - * always returns true. + /** Verifies that the consumed amounts do not exceed the available amounts. + * + * Gated on the `fixAMMv1_3` amendment. For well-behaved callers this is + * always a no-op because `consume()` already enforces the same constraint + * — the check exists so `BookStep::consumeOffer()` can invoke it + * uniformly across both `TOffer` and `AMMOffer` (the AMM version performs + * a far more expensive constant-product pool invariant check). + * + * @note The failure branch is marked `LCOV_EXCL_START`; it is considered + * unreachable under normal test coverage and exists purely as a + * defense-in-depth guard. + * + * @param consumed Amounts actually consumed in the trade. + * @param j Journal for error-level diagnostics on invariant failure. + * @return `true` if `consumed` does not exceed `amounts_` in either + * dimension (or if the amendment is inactive); `false` otherwise. */ [[nodiscard]] bool checkInvariant(TAmounts const& consumed, beast::Journal j) const @@ -241,6 +427,12 @@ TOffer::assetOut() const return assetOut_; } +/** Streams a `TOffer` to an output stream by its ledger-object key string. + * + * @param os The output stream. + * @param offer The offer to stream. + * @return `os` after writing the offer's key string. + */ template inline std::ostream& operator<<(std::ostream& os, TOffer const& offer) diff --git a/include/xrpl/tx/paths/OfferStream.h b/include/xrpl/tx/paths/OfferStream.h index 69409b9ef7..2dadb9b4b4 100644 --- a/include/xrpl/tx/paths/OfferStream.h +++ b/include/xrpl/tx/paths/OfferStream.h @@ -1,3 +1,12 @@ +/** @file + * Order-book iterator used by the Flow payment engine. + * + * Defines `TOfferStreamBase` and `FlowOfferStream`, which together provide + * validated, quality-ordered traversal of a single order-book leg during + * payment processing. All offer validation, expiry handling, and + * unfunded-offer detection are encapsulated here so that `BookStep` only + * needs to drive the `step()` loop. + */ #pragma once #include @@ -12,10 +21,46 @@ namespace xrpl { +/** Base order-book iterator for the Flow payment engine. + * + * Wraps a `BookTip` cursor and adds per-offer validation: expired offers, + * missing ledger entries, zero-amount offers, deep-frozen trust lines, + * permissioned-DEX domain mismatches, and unfunded-offer detection are all + * handled inside `step()`. + * + * The dual-view design (`view_` / `cancelView_`) is critical for correctness: + * when an owner's balance is zero in `view_` the implementation checks the + * same balance in the pristine `cancelView_`. A zero balance in both views + * means the offer was *already* unfunded before this transaction began and + * must be permanently removed. A zero balance only in `view_` means an + * earlier strand consumed the funds; the offer is skipped but not deleted + * (it may be valid if that strand is rolled back). + * + * @tparam TIn Amount type for the book's input asset (`XRPAmount`, + * `IOUAmount`, or `MPTAmount`). + * @tparam TOut Amount type for the book's output asset. + * + * @note The order and logic of operations inside `step()` are consensus- + * critical. Changing them constitutes a protocol-breaking change. + * + * @see FlowOfferStream, BookTip, TOffer + */ template class TOfferStreamBase { public: + /** DoS guard that enforces a maximum number of offers examined per payment. + * + * Each call to `step()` increments an internal counter. Once the counter + * reaches the configured limit the method returns `false`, terminating + * offer iteration. This prevents a pathological order book with many + * tiny or invalid offers from forcing unbounded validator work within a + * single transaction. + * + * In `BookStep`, the limit is `MaxOffersToConsume` and the final count is + * returned to the caller so the engine can track total resource usage + * across all strands. + */ class StepCounter { private: @@ -24,10 +69,20 @@ public: beast::Journal j_; public: + /** Construct a counter with the given step budget. + * + * @param limit Maximum number of offers that may be examined. + * @param j Journal for logging when the limit is exceeded. + */ StepCounter(std::uint32_t limit, beast::Journal j) : limit_(limit), j_(j) { } + /** Increment the step count and check the budget. + * + * @return `true` if the budget has not yet been exhausted; + * `false` once `limit_` offers have been examined. + */ bool step() { @@ -39,6 +94,8 @@ public: count_++; return true; } + + /** Return the number of offers examined so far. */ [[nodiscard]] std::uint32_t count() const { @@ -48,28 +105,83 @@ public: protected: beast::Journal const j_; - ApplyView& view_; - ApplyView& cancelView_; + ApplyView& view_; ///< Transactional view accumulating current-payment mutations. + ApplyView& cancelView_; ///< Pristine pre-transaction snapshot used for unfunded-offer detection. Book book_; bool validBook_; - NetClock::time_point const expire_; + NetClock::time_point const expire_; ///< Close time of the ledger being built; used for expiry checks. BookTip tip_; - TOffer offer_; - std::optional ownerFunds_; + TOffer offer_; ///< The validated offer at the current position; valid only after a successful `step()`. + std::optional ownerFunds_; ///< Cached owner funds for the current offer; set by `step()`, nullopt between iterations. StepCounter& counter_; + /** Remove a directory entry whose corresponding offer ledger object is missing. + * + * Surgically erases the orphaned index from the directory page's + * `sfIndexes` vector in @p view. This is a best-effort cleanup that + * deliberately avoids `ApplyView::dirRemove` to preserve protocol + * compatibility — using `dirRemove` would alter which ledger entries a + * payment touches, constituting a protocol-breaking change. As a + * consequence, an empty directory page may be left behind in edge cases. + * + * @param view The view to apply the directory patch to; called for both + * `view_` and `cancelView_` when an entry is missing. + */ void erase(ApplyView& view); + /** Schedule an offer for permanent removal from the ledger. + * + * Called by `step()` for offers that must be deleted regardless of whether + * the current strand is ultimately committed. The concrete subclass + * decides how to store the index (e.g., `FlowOfferStream` accumulates it + * in `permToRemove_`). + * + * @param offerIndex Ledger key of the offer to be permanently removed. + */ virtual void permRmOffer(uint256 const& offerIndex) = 0; + /** Determine whether a partially-funded offer should be removed due to + * quality degradation caused by integer rounding. + * + * Offer quality is frozen at creation time for fairness on partial fills. + * When an owner holds less than the offer's `TakerGets`, the effective + * amounts after rounding can produce a quality *worse* than the stored + * quality, which would misrepresent the offer to later consumers. This + * method detects that condition: if the effective quality is lower than + * the stored quality *and* the effective input amount is at or below + * `minPositiveAmount()`, the offer is considered stale and should be + * purged. + * + * The check is skipped when `TakerGets` is XRP because an XRP-output + * offer can only improve in effective quality (a minimum of 1 drop + * remains deliverable at arbitrarily high quality for any realistic IOU + * input amount). + * + * @tparam TTakerPays Compile-time amount type for TakerPays (input side). + * @tparam TTakerGets Compile-time amount type for TakerGets (output side). + * @return `true` if the offer should be removed; `false` if it is still + * usable. + */ template requires ValidTaker [[nodiscard]] bool shouldRmSmallIncreasedQOffer() const; public: + /** Construct an offer stream for the given order book. + * + * @param view Mutable ledger view accumulating transaction changes. + * @param cancelView Pristine ledger snapshot preceding this transaction, + * used to distinguish "found unfunded" from "became unfunded" offers. + * @param book The currency-pair order book to iterate. + * @param when Close time of the ledger under construction; offers + * whose `sfExpiration` is ≤ this value are removed. + * @param counter Step-budget guard shared with the caller; iteration + * stops when the budget is exhausted. + * @param journal Logging sink. + */ TOfferStreamBase( ApplyView& view, ApplyView& cancelView, @@ -80,26 +192,50 @@ public: virtual ~TOfferStreamBase() = default; - /** Returns the offer at the tip of the order book. - Offers are always presented in decreasing quality. - Only valid if step() returned `true`. - */ + /** Return the offer at the tip of the order book. + * + * Offers are always presented in decreasing quality order. + * + * @note Only valid when the most recent call to `step()` returned `true`. + */ [[nodiscard]] TOffer& tip() const { return const_cast(this)->offer_; } - /** Advance to the next valid offer. - This automatically removes: - - Offers with missing ledger entries - - Offers found unfunded - - expired offers - @return `true` if there is a valid offer. - */ + /** Advance to the next valid offer in the order book. + * + * Automatically skips or permanently removes offers that are: + * - Missing their ledger entry (corrupted directory state) + * - Expired (`sfExpiration` ≤ close time) + * - Zero-amount (corrupted offer) + * - Backed by a deep-frozen trust line + * - No longer matching their permissioned DEX domain + * - Found unfunded (owner balance was already zero before this transaction) + * - Stale due to quality degradation from integer rounding + * + * "Found unfunded" offers are permanently scheduled for removal via + * `permRmOffer()`. Offers that *became* unfunded because an earlier + * strand consumed the owner's balance are skipped but not permanently + * removed, since they may be valid if that strand is rolled back. + * + * @return `true` if a valid offer is now available via `tip()`; + * `false` when the book is exhausted or the step budget is exceeded. + * + * @note Modifying the order or logic of the checks in this method + * constitutes a protocol-breaking change. + */ bool step(); + /** Return the owner's available funds for the current offer's output asset. + * + * Reflects the balance in the in-progress transactional view, capped to + * zero if the trust line is frozen or the holder is unauthorized. + * + * @note Only valid when the most recent call to `step()` returned `true`. + */ [[nodiscard]] TOut ownerFunds() const { @@ -108,23 +244,24 @@ public: } }; -/** Presents and consumes the offers in an order book. - - The `view_' ` `ApplyView` accumulates changes to the ledger. - The `cancelView_` is used to determine if an offer is found - unfunded or became unfunded. - The `permToRemove` collection identifies offers that should be - removed even if the strand associated with this OfferStream - is not applied. - - Certain invalid offers are added to the `permToRemove` collection: - - Offers with missing ledger entries - - Offers that expired - - Offers found unfunded: - An offer is found unfunded when the corresponding balance is zero - and the caller has not modified the balance. This is accomplished - by also looking up the balance in the cancel view. -*/ +/** Concrete order-book iterator for the Flow payment engine. + * + * Extends `TOfferStreamBase` with a `permToRemove_` set that accumulates the + * ledger keys of offers that must be erased from the ledger regardless of + * whether the strand that found them is ultimately committed. `BookStep` + * reads this set after the strand completes and applies the removals via + * `ApplyView`. + * + * `boost::container::flat_set` is used because the set is typically small + * (a handful of entries per payment), making cache-friendly sorted-array + * storage faster than a node-based container for both insertion and + * iteration. + * + * @tparam TIn Amount type for the book's input asset. + * @tparam TOut Amount type for the book's output asset. + * + * @see TOfferStreamBase, BookStep + */ template class FlowOfferStream : public TOfferStreamBase { @@ -134,13 +271,25 @@ private: public: using TOfferStreamBase::TOfferStreamBase; - // The following interface allows offer crossing to permanently - // remove self crossed offers. The motivation is somewhat - // unintuitive. See the discussion in the comments for - // BookOfferCrossingStep::limitSelfCrossQuality(). + /** Schedule an offer for permanent ledger removal. + * + * Inserts @p offerIndex into `permToRemove_`. This override also + * supports permanent cleanup of self-crossed offers during + * offer-crossing transactions; see + * `BookOfferCrossingStep::limitSelfCrossQuality()` for the motivation. + * + * @param offerIndex Ledger key of the offer to be permanently removed. + */ void permRmOffer(uint256 const& offerIndex) override; + /** Return the set of offer indices scheduled for permanent ledger removal. + * + * `BookStep` applies these removals after the strand completes, even if + * the strand itself was discarded. The set is non-empty only when + * invalid offers (expired, missing, found-unfunded, or self-crossed) were + * encountered during iteration. + */ [[nodiscard]] boost::container::flat_set const& permToRemove() const { diff --git a/include/xrpl/tx/paths/RippleCalc.h b/include/xrpl/tx/paths/RippleCalc.h index c747787820..7354307ace 100644 --- a/include/xrpl/tx/paths/RippleCalc.h +++ b/include/xrpl/tx/paths/RippleCalc.h @@ -1,3 +1,12 @@ +/** @file + * Entry point for the XRPL payment path quality engine. + * + * Declares `RippleCalc`, the adapter that orchestrates a speculative + * payment computation through the trust-line and order-book network. + * The `Payment` transactor is the primary caller; offer-crossing logic + * calls `flow()` directly. + */ + #pragma once #include @@ -15,49 +24,108 @@ namespace detail { struct FlowDebugInfo; } // namespace detail -/** RippleCalc calculates the quality of a payment path. - - Quality is the amount of input required to produce a given output along a - specified path - another name for this is exchange rate. -*/ +/** Computes payment quality across the XRPL trust-line and order-book network. + * + * "Quality" is the effective exchange rate for a payment: the input amount + * required to deliver a given output along a set of candidate paths. + * + * The sole public interface is the static `rippleCalculate()` factory, which + * creates a nested `PaymentSandbox` over the caller's view, delegates to + * `flow()`, and commits the nested sandbox to the caller's view only on + * success. The caller retains full ownership of the outer sandbox and may + * discard it if the enclosing transaction fails. + * + * `RippleCalc` objects are lightweight: they hold only a sandbox reference + * and the `permanentlyUnfundedOffers` set. No heap allocation is incurred + * for the common case. + */ class RippleCalc { public: + /** Behavioral knobs for a single `rippleCalculate()` invocation. + * + * A null `Input` pointer passed to `rippleCalculate()` is valid and + * equivalent to the default-constructed values below. + */ struct Input { explicit Input() = default; + /** Allow delivery of less than the full requested amount. + * + * When true, the engine delivers as much as paths allow rather than + * failing with `tecPATH_PARTIAL` if the full amount cannot be routed. + * Corresponds to the `tfPartialPayment` transaction flag. + */ bool partialPaymentAllowed = false; + + /** Include the implicit direct sender→receiver path. + * + * When true (the default), a direct path is added to the strand set + * even if it is absent from `sfPaths`. Set to false only when the + * caller wants to restrict routing to explicitly provided paths. + */ bool defaultPathsAllowed = true; + + /** Enforce a minimum acceptable exchange rate. + * + * When true and `saMaxAmountReq` is positive, the engine derives a + * quality floor of `Amounts(saMaxAmountReq, saDstAmountReq)` and + * rejects liquidity below that rate. Prevents accepting a worse rate + * than the sender specified via `sfSendMax`. + */ bool limitQuality = false; + + /** Distinguish open-ledger from closed-ledger processing. + * + * Passed through to downstream rounding and fee logic. Set to + * `view().open()` by the `Payment` transactor. + */ bool isLedgerOpen = true; }; + + /** Results of a `rippleCalculate()` or `flow()` invocation. */ struct Output { explicit Output() = default; - // The computed input amount. + /** Actual source amount consumed from the sender's account. */ STAmount actualAmountIn; - // The computed output amount. + /** Actual amount delivered to the destination account. */ STAmount actualAmountOut; - // Collection of offers found expired or unfunded. When a payment - // succeeds, unfunded and expired offers are removed. When a payment - // fails, they are not removed. This vector contains the offers that - // could have been removed but were not because the payment fails. It is - // useful for offer crossing, which does remove the offers. + /** Offers found to be expired or unfunded during path traversal. + * + * On a successful payment these offers are deleted from the ledger as + * a side effect. On failure the ledger is not modified, but this set + * is still populated so that offer-crossing logic can clean up stale + * state independently of payment outcome. + * + * The flat_set provides compact, ordered storage for deterministic + * iteration — consensus requires every validator to process the same + * offers in the same sequence. + */ boost::container::flat_set removableOffers; private: TER calculationResult_ = temUNKNOWN; public: + /** Return the TER outcome of the calculation. */ [[nodiscard]] TER result() const { return calculationResult_; } + + /** Set the TER outcome of the calculation. + * + * Controlled access prevents callers from accidentally overwriting + * an internally-set error code. + * + * @param value The TER code to store. + */ void setResult(TER const value) { @@ -65,43 +133,70 @@ public: } }; + /** Compute the quality of a payment and stage ledger mutations speculatively. + * + * Creates a nested `PaymentSandbox` over `view`, invokes `flow()` to walk + * paths and cross order books, then — on success — commits the nested + * sandbox back into `view`. On any failure `view` is left unmodified. + * + * Any exception thrown by `flow()` is caught and converted to a + * `tecINTERNAL` result so the transaction is stored rather than silently + * dropped by the node. + * + * @param view Caller-owned speculative ledger view. Mutations are staged + * here only when the return value carries `tesSUCCESS`. The caller + * decides whether to commit `view` to the underlying ledger. + * @param saMaxAmountReq Maximum amount the sender is willing to spend + * (`sfSendMax`). Pass -1 for no limit. For XRP the issuer must be + * `xrpAccount()`; for non-XRP assets the issuer is `uSrcAccountID` + * or another account with a trust node. + * @param saDstAmountReq Exact amount to deliver to `uDstAccountID` + * (`sfAmount`). For XRP the issuer must be `xrpAccount()`; for + * non-XRP assets the issuer is `uDstAccountID` or another account + * with a trust node. + * @param uDstAccountID Account that must receive `saDstAmountReq`. + * @param uSrcAccountID Account supplying the input funds. + * @param spsPaths Candidate path hints from the transaction's `sfPaths` + * field. An empty set is valid when `pInputs->defaultPathsAllowed` + * is true. + * @param domainID Optional domain identifier for domain-scoped order + * books. When set, liquidity is restricted to the specified + * permissioned domain. + * @param registry Service registry supplying cross-cutting services, + * including the journal obtained via `registry.getJournal("Flow")`. + * @param pInputs Optional behavioral flags. Null is treated as the + * conservative default: no partial payment, default paths enabled, + * no quality limit. + * @return An `Output` holding `actualAmountIn`, `actualAmountOut`, + * `removableOffers`, and a `TER` result. On failure only + * `removableOffers` and `result()` are meaningful. + * @note `sendMax` is derived internally: it is set to `saMaxAmountReq` + * unless the sender and issuer are identical and the source/destination + * assets match, in which case `sendMax` is `nullopt` (no separate + * spending cap needed). + */ static Output rippleCalculate( PaymentSandbox& view, - - // Compute paths using this ledger entry set. Up to caller to actually - // apply to ledger. - - // Issuer: - // XRP: xrpAccount() - // non-XRP: uSrcAccountID (for any issuer) or another account with - // trust node. - STAmount const& saMaxAmountReq, // --> -1 = no limit. - - // Issuer: - // XRP: xrpAccount() - // non-XRP: uDstAccountID (for any issuer) or another account with - // trust node. + STAmount const& saMaxAmountReq, STAmount const& saDstAmountReq, - AccountID const& uDstAccountID, AccountID const& uSrcAccountID, - - // A set of paths that are included in the transaction that we'll - // explore for liquidity. STPathSet const& spsPaths, - std::optional const& domainID, ServiceRegistry& registry, Input const* const pInputs = nullptr); - // The view we are currently working on + /** Mutable speculative ledger view used during path computation. */ PaymentSandbox& view; - // If the transaction fails to meet some constraint, still need to delete - // unfunded offers in a deterministic order (hence the ordered container). - // - // Offers that were found unfunded. + /** Offers that must be removed from the ledger regardless of payment outcome. + * + * Tracks structurally unfunded offers (as opposed to temporarily + * underfunded ones). Removal is performed in a deterministic order using + * this ordered container so that every validator produces identical ledger + * mutations and agrees on the resulting hash. + */ boost::container::flat_set permanentlyUnfundedOffers; }; diff --git a/include/xrpl/tx/paths/detail/AmountSpec.h b/include/xrpl/tx/paths/detail/AmountSpec.h index e69de29bb2..94514761ff 100644 --- a/include/xrpl/tx/paths/detail/AmountSpec.h +++ b/include/xrpl/tx/paths/detail/AmountSpec.h @@ -0,0 +1,39 @@ +/** + * @file AmountSpec.h + * @brief Include-compatibility shim for the legacy `AmountSpec` / `EitherAmount` types. + * + * This file is intentionally empty. It once defined two structs central to the + * multi-currency path-payment engine: + * + * - **`AmountSpec`** — a tagged union pairing an `XRPAmount` or `IOUAmount` + * with optional issuer and currency metadata, distinguishing the two cases + * via a `bool native` flag. It acted as a richly-annotated amount description + * that carried both the numeric value and the asset denomination in a single + * object. + * + * - **`EitherAmount`** — a lighter companion union used at `Step` interface + * boundaries where asset-type metadata was already implicit in the step's + * template parameters. + * + * Both structs were overhauled when MPT (Multi-Purpose Token) support was + * introduced (commit `dfcad6915`): + * + * - `EitherAmount` was extracted to `EitherAmount.h` and reimplemented as + * `std::variant`, constrained by the + * `StepAmount` concept from `protocol/Concepts.h`. The raw `union` + `bool` + * pattern was replaced with a type-safe, three-way discriminated union. + * + * - `AmountSpec` was retired. Its role is now fulfilled by the type system + * directly: `Step` subclasses are templated on `TIn`/`TOut` (both constrained + * to `StepAmount`), and the `Asset` / `Issue` / `MPTIssue` hierarchy carries + * issuer and denomination identity without a wrapper struct. + * + * The file is kept as an empty stub rather than removed because `StrandFlow.h` + * and `FlowDebugInfo.h` both `#include` it. Removing it would be a breaking + * change for any out-of-tree code that transitively relied on the inclusion. + * + * @note Any code that `#include`s this header directly should instead include: + * - `xrpl/tx/paths/detail/EitherAmount.h` for the `EitherAmount` type, or + * - `xrpl/protocol/XRPAmount.h`, `xrpl/protocol/IOUAmount.h`, + * `xrpl/protocol/MPTAmount.h` for the concrete amount types. + */ diff --git a/include/xrpl/tx/paths/detail/EitherAmount.h b/include/xrpl/tx/paths/detail/EitherAmount.h index ffd90751b8..20b00e6841 100644 --- a/include/xrpl/tx/paths/detail/EitherAmount.h +++ b/include/xrpl/tx/paths/detail/EitherAmount.h @@ -7,17 +7,55 @@ namespace xrpl { +/** + * Type-erased amount wrapper for the payment path engine. + * + * The `Step` abstraction uses virtual dispatch (`rev`/`fwd`) yet each + * concrete step works with a single, statically-typed amount — + * `XRPAmount`, `IOUAmount`, or `MPTAmount`. `EitherAmount` bridges this + * gap by holding `std::variant` behind a + * uniform value type that flows freely through the virtual step interface. + * + * Value semantics (no heap allocation, copyable, storable in `std::vector`) + * make this suitable for use in `FlowDebugInfo` diagnostic vectors and as + * return values from `Step::cachedIn()`/`cachedOut()`. A pointer-based + * design would require ownership management for what is fundamentally a + * lightweight numeric wrapper. + * + * All templated members are constrained by the `StepAmount` concept, which + * explicitly enumerates the three valid amount types. This closes the + * variant: you cannot accidentally construct from `STAmount` or any other + * compatible numeric type. + * + * @see Step::rev, Step::fwd, Step::cachedIn, Step::cachedOut + */ struct EitherAmount { + /** The underlying variant holding the active amount. */ std::variant amount; + /** Constructs a default (value-initialized) `EitherAmount`. */ explicit EitherAmount() = default; + /** + * Constructs an `EitherAmount` holding the given typed amount. + * + * @tparam T One of `XRPAmount`, `IOUAmount`, or `MPTAmount`. + * @param a The amount value to store. + */ template explicit EitherAmount(T const& a) : amount(a) { } + /** + * Returns true if the variant currently holds type `T`. + * + * Call this before `get()` when the active type is not statically + * guaranteed, to avoid the `std::logic_error` thrown on mismatch. + * + * @tparam T One of `XRPAmount`, `IOUAmount`, or `MPTAmount`. + */ template [[nodiscard]] bool holds() const @@ -25,6 +63,18 @@ struct EitherAmount return std::holds_alternative(amount); } + /** + * Returns a const reference to the held amount as type `T`. + * + * Throws `std::logic_error` if the variant does not hold `T`. This + * fail-fast design signals a programming error in the flow engine + * rather than a runtime data condition; mismatched access means the + * step dispatch wired the wrong amount type. + * + * @tparam T One of `XRPAmount`, `IOUAmount`, or `MPTAmount`. + * @return Const reference to the held amount. + * @throws std::logic_error if `holds()` is false. + */ template [[nodiscard]] T const& get() const @@ -35,6 +85,13 @@ struct EitherAmount } #ifndef NDEBUG + /** + * Writes a human-readable representation of the held amount to `stream`. + * + * Only compiled in debug builds. Uses `std::visit` with a template lambda + * to dispatch `to_string` to whichever concrete amount type is active, + * avoiding dead formatting overhead on the production hot path. + */ friend std::ostream& operator<<(std::ostream& stream, EitherAmount const& amt) { @@ -44,6 +101,18 @@ struct EitherAmount #endif }; +/** + * Free-function accessor for `EitherAmount`; delegates to `amt.get()`. + * + * Provides an alternative calling convention used throughout the flow engine + * (e.g., `get(either)` instead of `either.get()`). + * Throws `std::logic_error` via the member `get` if `T` is not the active type. + * + * @tparam T One of `XRPAmount`, `IOUAmount`, or `MPTAmount`. + * @param amt The `EitherAmount` to extract from. + * @return Const reference to the held amount. + * @throws std::logic_error if `amt` does not hold type `T`. + */ template T const& get(EitherAmount const& amt) diff --git a/include/xrpl/tx/paths/detail/FlatSets.h b/include/xrpl/tx/paths/detail/FlatSets.h index c0fc8fa417..acd6ec409d 100644 --- a/include/xrpl/tx/paths/detail/FlatSets.h +++ b/include/xrpl/tx/paths/detail/FlatSets.h @@ -1,14 +1,44 @@ +/** @file + * In-place union helper for `boost::container::flat_set`. + * + * The payment-path engine accumulates sets of offer IDs that must be + * removed from the ledger (consumed, expired, or unfunded offers found + * during flow computation). Each strand and book step produces its own + * local removal set; `setUnion` merges those sets into a single + * accumulator so that cleanup can happen atomically after the flow + * completes, regardless of whether the overall payment succeeded. + */ + #pragma once #include namespace xrpl { -/** Given two flat sets dst and src, compute dst = dst union src - - @param dst set to store the resulting union, and also a source of elements - for the union - @param src second source of elements for the union +/** Merge @p src into @p dst in place, computing `dst = dst ∪ src`. + * + * Three optimizations make this efficient on the hot payment-flow path: + * + * 1. **Early exit** — returns immediately when @p src is empty, avoiding any + * allocation. This is the common case when a strand traverses a path + * that encounters no bad offers. + * + * 2. **Single pre-allocation** — `dst.reserve(dst.size() + src.size())` + * reserves enough capacity for the worst case (zero overlap) before any + * insertions, preventing repeated reallocation of the underlying + * contiguous array. + * + * 3. **Merge-style insert** — passing `ordered_unique_range_t{}` tells + * `flat_set` that @p src is already sorted and deduplicated (guaranteed + * because @p src is itself a `flat_set`). Boost performs an + * `inplace_merge`-style operation — O(n + m) — rather than inserting + * elements one at a time at O(n log n). + * + * In practice @p T is always `uint256` (offer ledger keys), but the + * function is generic over any element type that `flat_set` supports. + * + * @param dst Accumulator set; receives the union result in place. + * @param src Set of elements to fold into @p dst; left unchanged. */ template void diff --git a/include/xrpl/tx/paths/detail/FlowDebugInfo.h b/include/xrpl/tx/paths/detail/FlowDebugInfo.h index ec7df86e53..d6d294d075 100644 --- a/include/xrpl/tx/paths/detail/FlowDebugInfo.h +++ b/include/xrpl/tx/paths/detail/FlowDebugInfo.h @@ -12,29 +12,111 @@ #include namespace xrpl::path::detail { -// Track performance information of a single payment + +/** + * Per-payment telemetry accumulator for the flow engine. + * + * Constructed once per call to `flow()` and threaded down the call stack as + * a raw pointer. The `flow()` entry point accepts `FlowDebugInfo* = nullptr`, + * making the entire instrumentation path opt-in with zero overhead when the + * pointer is null. `RippleCalc::rippleCalculate` currently always passes + * `nullptr`, so no telemetry is gathered in production consensus paths — this + * struct is intended for testing, benchmarking, and developer tooling only. + * + * Two `boost::container::flat_map` members (reserved to 16 entries at + * construction) store named timing intervals (`timePoints`) and occurrence + * counters (`counts`). The flat-map layout is cache-friendly at the small + * sizes typical for a single payment execution. + * + * The single `PassInfo passInfo` member captures the sequence of incremental + * liquidity fills performed by the outer loop in `StrandFlow.h`. + * + * @note Callers must ensure the `FlowDebugInfo` object outlives any `Stopper` + * returned by `timeBlock()`, as `Stopper` holds a raw pointer back to + * this struct. + * @see FlowDebugInfo::timeBlock, FlowDebugInfo::PassInfo + */ struct FlowDebugInfo { + /** High-resolution clock used for all timing measurements. */ using clock = std::chrono::high_resolution_clock; + + /** A point in time as recorded by `clock`. */ using time_point = clock::time_point; + + /** Named timing intervals: tag → (start, end). Reserved to 16 entries. */ boost::container::flat_map> timePoints; + + /** Named occurrence counters: tag → count. Reserved to 16 entries. */ boost::container::flat_map counts; + /** + * Per-pass liquidity data for one complete `flow()` execution. + * + * The outer liquidity-selection loop in `StrandFlow.h` iterates, each + * iteration selecting the best-quality strand and routing an increment of + * the payment through it. `PassInfo` records one data point per iteration: + * - `in` / `out`: total amount consumed and delivered in that pass. + * - `numActive`: number of strands still active after that pass. + * - `liquiditySrcIn` / `liquiditySrcOut`: per-strand contributions within + * the pass, indexed as `[pass][strand]`. + * + * `nativeIn` and `nativeOut` are set at construction and drive amount + * serialization in `FlowDebugInfo::toString()` — XRP amounts are extracted + * via `get()`, IOU amounts via `get()`. + */ struct PassInfo { PassInfo() = delete; + + /** + * Constructs a `PassInfo` for a payment whose source and destination + * currency types are known up front. + * + * @param nativeIn True if the payment's input currency is XRP. + * @param nativeOut True if the payment's output currency is XRP. + */ PassInfo(bool nativeIn, bool nativeOut) : nativeIn(nativeIn), nativeOut(nativeOut) { } + + /** True if the payment's source currency is XRP. */ bool const nativeIn; + + /** True if the payment's destination currency is XRP. */ bool const nativeOut; + + /** Total amount consumed from senders, one entry per pass. */ std::vector in; + + /** Total amount delivered to receivers, one entry per pass. */ std::vector out; + + /** Number of active strands remaining after each pass. */ std::vector numActive; + /** + * Per-strand input amounts, indexed as `[pass][strand]`. + * + * Each inner vector is opened by `newLiquidityPass()` and populated + * by `pushLiquiditySrc()`. + */ std::vector> liquiditySrcIn; + + /** + * Per-strand output amounts, indexed as `[pass][strand]`. + * + * Each inner vector is opened by `newLiquidityPass()` and populated + * by `pushLiquiditySrc()`. + */ std::vector> liquiditySrcOut; + /** + * Reserves capacity in all parallel vectors to avoid repeated + * reallocations during the liquidity loop. + * + * @param s Expected number of passes. + */ void reserve(size_t s) { @@ -45,12 +127,26 @@ struct FlowDebugInfo numActive.reserve(s); } + /** + * Returns the number of passes recorded so far (equal to `in.size()`). + */ [[nodiscard]] size_t size() const { return in.size(); } + /** + * Records the aggregate result of one liquidity pass. + * + * Appends one entry to `in`, `out`, and `numActive`. Must be called + * once per outer-loop iteration after all per-strand contributions for + * that iteration have been pushed via `pushLiquiditySrc()`. + * + * @param inAmt Total amount consumed from senders in this pass. + * @param outAmt Total amount delivered to receivers in this pass. + * @param active Number of strands still active after this pass. + */ void pushBack(EitherAmount const& inAmt, EitherAmount const& outAmt, std::size_t active) { @@ -59,6 +155,16 @@ struct FlowDebugInfo numActive.push_back(active); } + /** + * Records the contribution of a single strand within the current pass. + * + * Appends to the back of `liquiditySrcIn` and `liquiditySrcOut`. + * `newLiquidityPass()` must be called before the first `pushLiquiditySrc()` + * call for each pass; an assertion fires if the inner vectors are empty. + * + * @param eIn Amount consumed from this strand's source in this pass. + * @param eOut Amount delivered by this strand in this pass. + */ void pushLiquiditySrc(EitherAmount const& eIn, EitherAmount const& eOut) { @@ -70,6 +176,14 @@ struct FlowDebugInfo liquiditySrcOut.back().push_back(eOut); } + /** + * Opens a new inner vector in `liquiditySrcIn` and `liquiditySrcOut` + * before the start of each liquidity pass. + * + * The capacity of the new inner vectors is hinted from the active-strand + * count of the previous pass (or 16 for the first pass), minimizing + * reallocations within the pass. + */ void newLiquidityPass() { @@ -82,9 +196,21 @@ struct FlowDebugInfo } }; + /** Accumulated per-pass data for this payment execution. */ PassInfo passInfo; FlowDebugInfo() = delete; + + /** + * Constructs a `FlowDebugInfo` for a payment whose source and destination + * currency types are known up front. + * + * Pre-reserves the flat maps to 16 entries and `passInfo` to 64 passes to + * avoid repeated reallocations during typical payment executions. + * + * @param nativeIn True if the payment's input currency is XRP. + * @param nativeOut True if the payment's output currency is XRP. + */ FlowDebugInfo(bool nativeIn, bool nativeOut) : passInfo(nativeIn, nativeOut) { timePoints.reserve(16); @@ -92,6 +218,13 @@ struct FlowDebugInfo passInfo.reserve(64); } + /** + * Returns the wall-clock duration of the named timing block. + * + * @param tag The name used when the block was opened with `timeBlock()`. + * @return Elapsed time as a `std::chrono::duration` (seconds). + * Returns zero and triggers `UNREACHABLE` if `tag` was never recorded. + */ [[nodiscard]] auto duration(std::string const& tag) const { @@ -109,6 +242,13 @@ struct FlowDebugInfo return std::chrono::duration_cast>(t.second - t.first); } + /** + * Returns the current occurrence count for a named counter. + * + * @param tag Counter name previously incremented via `inc()` or set via + * `setCount()`. + * @return The stored count, or 0 if the tag has never been recorded. + */ [[nodiscard]] std::size_t count(std::string const& tag) const { @@ -118,7 +258,28 @@ struct FlowDebugInfo return i->second; } - // Time the duration of the existence of the result + /** + * Times the duration of a lexical scope via RAII. + * + * Returns a `Stopper` whose constructor records `clock::now()` as both the + * start and the initial end of `tag`'s entry in `timePoints`. The + * destructor overwrites the end with a fresh `clock::now()`, capturing the + * elapsed time when the `Stopper` goes out of scope: + * + * ```cpp + * auto _ = flowDebugInfo->timeBlock("main"); + * // ... timed work ... + * // duration captured automatically on scope exit + * ``` + * + * The returned `Stopper` is move-constructible (required for RVO) but not + * copy-constructible, and it holds a raw pointer to this `FlowDebugInfo`. + * The parent must outlive the `Stopper`. + * + * @param name Unique tag for this timing interval. + * @return A `Stopper` RAII guard; keep it in scope for the duration to + * be measured. + */ auto timeBlock(std::string name) { @@ -141,6 +302,20 @@ struct FlowDebugInfo return Stopper(std::move(name), *this); } + /** + * Increments the occurrence count for a named counter. + * + * @param tag Counter name to increment. + * + * @note Contains a latent bug: when `tag` is absent from `counts`, the + * map inserts `counts[tag] = 1` and then attempts `++i->second` using + * the pre-insertion iterator `i`. For `flat_map`'s vector-backed + * storage, insertion can relocate elements, invalidating `i` and + * producing undefined behavior on the first use of any new tag. Because + * `FlowDebugInfo` is only used in diagnostic (non-production) code + * paths, this has not caused observable failures, but callers should + * prefer `setCount()` for new tags. + */ void inc(std::string const& tag) { @@ -152,36 +327,97 @@ struct FlowDebugInfo ++i->second; } + /** + * Sets the occurrence count for a named counter to an explicit value. + * + * Overwrites any existing count for `tag`. + * + * @param tag Counter name. + * @param c Value to assign. + */ void setCount(std::string const& tag, std::size_t c) { counts[tag] = c; } + /** + * Returns the total number of liquidity passes recorded. + * + * Equal to `passInfo.size()` (the number of entries in `passInfo.in`). + */ [[nodiscard]] std::size_t passCount() const { return passInfo.size(); } + /** + * Records the aggregate result of one liquidity pass. + * + * Delegates to `passInfo.pushBack()`. Called once per outer-loop iteration + * in `StrandFlow.h` after all per-strand contributions have been pushed. + * + * @param in Total amount consumed from senders in this pass. + * @param out Total amount delivered to receivers in this pass. + * @param activeStrands Number of strands still active after this pass. + */ void pushPass(EitherAmount const& in, EitherAmount const& out, std::size_t activeStrands) { passInfo.pushBack(in, out, activeStrands); } + /** + * Records the contribution of a single strand within the current pass. + * + * Delegates to `passInfo.pushLiquiditySrc()`. Called once per strand + * selection inside the outer loop of `StrandFlow.h`. + * + * @param in Amount consumed from this strand's source. + * @param out Amount delivered by this strand. + */ void pushLiquiditySrc(EitherAmount const& in, EitherAmount const& out) { passInfo.pushLiquiditySrc(in, out); } + /** + * Opens a new per-strand contribution slot for the upcoming pass. + * + * Must be called once at the start of each outer-loop iteration in + * `StrandFlow.h`, before any `pushLiquiditySrc()` calls for that pass. + * Delegates to `passInfo.newLiquidityPass()`. + */ void newLiquidityPass() { passInfo.newLiquidityPass(); } + /** + * Serializes the accumulated telemetry to a human-readable string. + * + * Always emits the total wall-clock duration of the `"main"` timed block + * and the number of passes. When `writePassInfo` is true, also emits the + * full sequence of per-pass in/out amounts, active strand counts, and + * per-strand liquidity arrays. + * + * Format (pass info omitted when `writePassInfo` is false): + * ``` + * duration: , pass_count: [, in_pass: [...], out_pass: [...], + * num_active: [...][, l_src_in: [[...|...]], l_src_out: [[...|...]]]] + * ``` + * Outer delimiter between passes is `;`; inner delimiter between strands + * within a pass is `|`. + * + * Amount serialization branches on `passInfo.nativeIn`/`nativeOut`: XRP + * amounts use `get()`, IOU amounts use `get()`. + * + * @param writePassInfo If true, include the full per-pass breakdown. + * @return Formatted diagnostic string suitable for logging. + */ [[nodiscard]] std::string toString(bool writePassInfo) const { @@ -298,6 +534,16 @@ struct FlowDebugInfo } }; +/** + * Formats a single trust-line balance change entry into an output stream. + * + * Writes the element as `[sender|receiver|currency|amount]`, where the key + * is a `(AccountID, AccountID, Currency)` tuple and the value is the net + * `STAmount` change observed in a `PaymentSandbox`. + * + * @param ostr Destination stream. + * @param elem A map entry from `PaymentSandbox::balanceChanges()`. + */ inline void writeDiffElement( std::ostringstream& ostr, @@ -309,6 +555,19 @@ writeDiffElement( ostr << '[' << get<0>(k) << '|' << get<1>(k) << '|' << get<2>(k) << '|' << v << ']'; }; +/** + * Serializes a range of trust-line balance change entries to an output stream. + * + * Writes the full range as `[elem0;elem1;...]` using `writeDiffElement()` for + * each entry. Used by `balanceDiffsToString()` to produce a compact, machine- + * readable audit trail of every trust-line mutation caused by a payment. + * + * @tparam Iter Forward iterator over `(key, STAmount)` pairs as produced by + * `PaymentSandbox::balanceChanges()`. + * @param ostr Destination stream. + * @param begin Start of the range. + * @param end One past the end of the range. + */ template void writeDiffs(std::ostringstream& ostr, Iter begin, Iter end) diff --git a/include/xrpl/tx/paths/detail/StepChecks.h b/include/xrpl/tx/paths/detail/StepChecks.h index a1e6490781..9f03792b22 100644 --- a/include/xrpl/tx/paths/detail/StepChecks.h +++ b/include/xrpl/tx/paths/detail/StepChecks.h @@ -1,3 +1,14 @@ +/** @file + * Freeze and NoRipple compliance guards for payment path steps. + * + * Every candidate step through the XRPL payment engine must pass both + * `checkFreeze` and `checkNoRipple` before the engine may use it. Both + * functions are read-only probes against the ledger state; neither modifies + * any entry. They are inlined here so each step-type translation unit + * embeds its own copy, avoiding call overhead on the hot path of payment + * execution. + */ + #pragma once #include @@ -9,6 +20,41 @@ namespace xrpl { +/** Determine whether a trust-line hop is blocked by any freeze mechanism. + * + * Tests three freeze layers in order, returning `terNO_LINE` at the first + * violation: + * + * 1. **Global freeze** — if `dst` has `lsfGlobalFreeze` set, all IOUs it + * issues are inaccessible regardless of per-line flags. + * 2. **Directional per-line freeze** — the `dst`-side freeze flag + * (`lsfHighFreeze` or `lsfLowFreeze`, selected by canonical high/low + * ordering) blocks the step. This is asymmetric: an issuer may freeze a + * customer's line without affecting its own ability to redeem. + * 3. **Deep freeze** — either side's `lsfHighDeepFreeze`/`lsfLowDeepFreeze` + * bit blocks movement in both directions unconditionally. + * + * When `fixFrozenLPTokenTransfer` is active a fourth check applies: if + * `dst` is an AMM account (`sfAMMID` present), the corresponding AMM object + * is read and `isLPTokenFrozen()` tests whether the underlying pool assets + * are frozen. A missing AMM entry returns `tecINTERNAL` (indicates ledger + * corruption; marked `LCOV_EXCL_LINE`). + * + * @note In `DirectStep`, this check is intentionally skipped when the step + * is simultaneously first and last (`ctx.isFirst && ctx.isLast`), + * because a pure issue/redeem between the transaction's ultimate source + * and destination is inherently authorized and cannot be frozen. + * + * @param view The read-only ledger view. + * @param src The source account of this hop. + * @param dst The destination account of this hop (typically the IOU issuer + * whose freeze flags are authoritative for the line). + * @param currency The currency flowing across the trust line. + * @return `tesSUCCESS` if the hop is unblocked; `terNO_LINE` if any freeze + * layer blocks it; `tecINTERNAL` on AMM ledger corruption. + * @pre `src != dst` — self-loops must be excluded by the caller before + * invoking this function. + */ inline TER checkFreeze( ReadView const& view, @@ -60,6 +106,35 @@ checkFreeze( return tesSUCCESS; } +/** Determine whether the NoRipple setting on an intermediate account blocks + * a payment path through it. + * + * Evaluates the triple (`prev` → `cur` → `next`): transit through `cur` is + * rejected only when **both** of `cur`'s trust lines — the incoming line from + * `prev` and the outgoing line to `next` — carry `cur`'s NoRipple flag. The + * correct flag bit (`lsfHighNoRipple` or `lsfLowNoRipple`) is selected for + * each line using the canonical high/low account ordering. + * + * The AND semantics are deliberate: an intermediate account may have NoRipple + * enabled on some lines (to isolate certain counterparties) while leaving + * others open to routing. Transit is permitted as long as at least one side + * of the path through `cur` is "open." + * + * Returns `terNO_LINE` immediately if either trust line is absent — there is + * nothing to route through. A diagnostic message at `info` level is logged + * when `terNO_RIPPLE` is returned, naming all three accounts involved, to aid + * in tracing pathfinding failures. + * + * @param view The read-only ledger view. + * @param prev The account on the incoming side of `cur`. + * @param cur The intermediate account whose NoRipple constraints are checked. + * @param next The account on the outgoing side of `cur`. + * @param currency The currency flowing through both trust lines. + * @param j Journal used to log a diagnostic when the path is rejected. + * @return `tesSUCCESS` if transit through `cur` is permitted; `terNO_LINE` + * if either trust line does not exist; `terNO_RIPPLE` if both lines have + * NoRipple set from `cur`'s perspective. + */ inline TER checkNoRipple( ReadView const& view, diff --git a/include/xrpl/tx/paths/detail/Steps.h b/include/xrpl/tx/paths/detail/Steps.h index db9d3a6f3a..23e58f4a5a 100644 --- a/include/xrpl/tx/paths/detail/Steps.h +++ b/include/xrpl/tx/paths/detail/Steps.h @@ -1,3 +1,16 @@ +/** @file + * Architectural backbone of the XRPL payment flow engine. + * + * Defines the `Step` polymorphic interface, the `Strand` type alias, + * factory functions for every concrete step variant, and the context + * and exception types that tie the system together. Every transaction + * that moves value through more than one trust line or offer book — + * payments, offer crossings, AMM swaps — goes through these abstractions. + * + * @see StrandFlow.h for the multi-strand execution engine that drives these steps. + * @see PaySteps.cpp for the implementations of `toStrand` and `normalizePath`. + */ + #pragma once #include @@ -18,175 +31,232 @@ class ReadView; class ApplyView; class AMMContext; -enum class DebtDirection { Issues, Redeems }; -enum class QualityDirection { In, Out }; -enum class StrandDirection { Forward, Reverse }; -enum class OfferCrossing { No = 0, Yes = 1, Sell = 2 }; +/** Whether an account is creating new IOU obligations or cancelling existing ones. + * + * This is not merely metadata — it drives transfer fee logic. When an account + * redeems (receives back its own issued IOU), no transfer fee is assessed on + * that side of the hop. When an account issues new credit, a fee may apply. + * `qualityUpperBound()` queries this per step so quality estimates reflect real + * fee impacts. + */ +enum class DebtDirection { + Issues, /**< Account is creating new credit obligations; transfer fee may apply. */ + Redeems, /**< Account is cancelling its own IOU; no transfer fee on this side. */ +}; +/** Direction along which quality is being evaluated for a trust-line hop. */ +enum class QualityDirection { + In, /**< Quality-in: receiving side discount applied by the trust line. */ + Out, /**< Quality-out: sending side premium applied by the trust line. */ +}; + +/** Which execution pass is running over the strand. */ +enum class StrandDirection { + Forward, /**< Forward pass: given input, compute produced output. */ + Reverse, /**< Reverse pass: given desired output, compute required input. */ +}; + +/** Whether the step is part of a payment or an offer-crossing operation. */ +enum class OfferCrossing { + No = 0, /**< Ordinary payment; sender pays transfer fees. */ + Yes = 1, /**< Offer crossing; owner may pay transfer fees. */ + Sell = 2, /**< Offer crossing with tfSell; sell exactly the input amount. */ +}; + +/** Return true if @p dir is `DebtDirection::Redeems`. */ inline bool redeems(DebtDirection dir) { return dir == DebtDirection::Redeems; } +/** Return true if @p dir is `DebtDirection::Issues`. */ inline bool issues(DebtDirection dir) { return dir == DebtDirection::Issues; } -/** - A step in a payment path - - There are five concrete step classes: - DirectStepI is an IOU step between accounts - BookStepII is an IOU/IOU offer book - BookStepIX is an IOU/XRP offer book - BookStepXI is an XRP/IOU offer book - XRPEndpointStep is the source or destination account for XRP - MPTEndpointStep is the source or destination account for MPT - - Amounts may be transformed through a step in either the forward or the - reverse direction. In the forward direction, the function `fwd` is used to - find the amount the step would output given an input amount. In the reverse - direction, the function `rev` is used to find the amount of input needed to - produce the desired output. - - Amounts are always transformed using liquidity with the same quality (quality - is the amount out/amount in). For example, a BookStep may use multiple offers - when executing `fwd` or `rev`, but all those offers will be from the same - quality directory. - - A step may not have enough liquidity to transform the entire requested - amount. Both `fwd` and `rev` return a pair of amounts (one for input amount, - one for output amount) that show how much of the requested amount the step - was actually able to use. +/** One hop in a payment path. + * + * Concrete step classes: + * - `DirectStepI` — IOU-to-IOU ripple between two accounts. + * - `BookStepII` — IOU/IOU offer book hop. + * - `BookStepIX` — IOU/XRP offer book hop. + * - `BookStepXI` — XRP/IOU offer book hop. + * - `XRPEndpointStep` — source or destination account for XRP. + * - `MPTEndpointStep` — source or destination account for an MPT. + * + * Steps are evaluated in two passes. The *reverse* pass (`rev`) asks: + * "what input do I need to produce a given output?" The *forward* pass + * (`fwd`) then confirms: "given this input, what output do I actually get?" + * This two-pass protocol lets `StrandFlow` identify the *limiting step* + * (where liquidity first runs out) and anchor the forward pass from there. + * + * All amounts are transformed using liquidity of the same quality (quality = + * out/in). A `BookStep` may consume multiple offers in a single call, but + * all of them come from the same quality tier. If liquidity is insufficient, + * both `fwd` and `rev` return the achievable (in, out) pair — they never + * throw on a dry path. + * + * Concrete classes implement the CRTP mixin `StepImp`, + * which type-erases the strongly-typed `revImp`/`fwdImp` methods into the + * `EitherAmount`-based virtual interface declared here. */ class Step { public: virtual ~Step() = default; - /** - Find the amount we need to put into the step to get the requested out - subject to liquidity limits - - @param sb view with the strand's state of balances and offers - @param afView view the state of balances before the strand runs - this determines if an offer becomes unfunded or is found unfunded - @param ofrsToRm offers found unfunded or in an error state are added to - this collection - @param out requested step output - @return actual step input and output - */ + /** Compute the input required to produce a desired output (reverse pass). + * + * Given a desired output amount, returns the (actual_in, actual_out) pair + * achievable given current liquidity. The returned pair may be less than + * requested when liquidity is exhausted; it is never more. Results are + * cached and accessible via `cachedIn()` / `cachedOut()` so the forward + * pass can seed itself without re-executing the reverse pass. + * + * @param sb Sandbox carrying the running ledger state for the strand. + * @param afView Ledger state before the strand started; used to classify + * offers as pre-existing unfunded vs. drained by this payment. + * @param ofrsToRm Offer IDs found unfunded or in error are appended here + * for later deletion, regardless of whether the payment succeeds. + * @param out The desired output amount. + * @return Pair of (actual input consumed, actual output produced). + */ virtual std::pair rev(PaymentSandbox& sb, ApplyView& afView, boost::container::flat_set& ofrsToRm, EitherAmount const& out) = 0; - /** - Find the amount we get out of the step given the input - subject to liquidity limits - - @param sb view with the strand's state of balances and offers - @param afView view the state of balances before the strand runs - this determines if an offer becomes unfunded or is found unfunded - @param ofrsToRm offers found unfunded or in an error state are added to - this collection - @param in requested step input - @return actual step input and output - */ + /** Compute the output produced by a given input (forward pass). + * + * Given an available input amount, returns the (actual_in, actual_out) + * pair the step can produce. The returned pair may be less than + * requested when liquidity is exhausted. + * + * @param sb Sandbox carrying the running ledger state for the strand. + * @param afView Ledger state before the strand started; used to classify + * offers as pre-existing unfunded vs. drained by this payment. + * @param ofrsToRm Offer IDs found unfunded or in error are appended here + * for later deletion, regardless of whether the payment succeeds. + * @param in The available input amount. + * @return Pair of (actual input consumed, actual output produced). + */ virtual std::pair fwd(PaymentSandbox& sb, ApplyView& afView, boost::container::flat_set& ofrsToRm, EitherAmount const& in) = 0; - /** - Amount of currency computed coming into the Step the last time the - step ran in reverse. - */ + /** Input amount cached by the most recent `rev()` call. + * + * The forward pass in `StrandFlow` seeds itself from this value for the + * limiting step, avoiding a redundant reverse-pass invocation. + * Returns `std::nullopt` if `rev()` has not yet been called on this step. + */ [[nodiscard]] virtual std::optional cachedIn() const = 0; - /** - Amount of currency computed coming out of the Step the last time the - step ran in reverse. - */ + /** Output amount cached by the most recent `rev()` call. + * + * Returns `std::nullopt` if `rev()` has not yet been called on this step. + * @see cachedIn() + */ [[nodiscard]] virtual std::optional cachedOut() const = 0; - /** - If this step is DirectStepI (IOU->IOU direct step), return the src - account. This is needed for checkNoRipple. - */ + /** Return the source account if this step is a `DirectStepI`, else nullopt. + * + * Used by `checkNoRipple` to determine whether consecutive direct steps + * pass through an account whose trust lines are both marked `noRipple`. + */ [[nodiscard]] virtual std::optional directStepSrcAcct() const { return std::nullopt; } - // for debugging. Return the src and dst accounts for a direct step - // For XRP endpoints, one of src or dst will be the root account + /** Return the (src, dst) account pair for a direct step, else nullopt. + * + * For `XRPEndpointStep`, one of the returned accounts is the XRP root + * account. Intended for debugging and diagnostic purposes only. + */ [[nodiscard]] virtual std::optional> directStepAccts() const { return std::nullopt; } - /** - If this step is a DirectStepI and the src redeems to the dst, return - true, otherwise return false. If this step is a BookStep, return false if - the owner pays the transfer fee, otherwise return true. - - @param sb view with the strand's state of balances and offers - @param dir reverse -> called from rev(); forward -> called from fwd(). - */ + /** Determine whether this step issues or redeems on the given pass. + * + * For a `DirectStepI`: returns `Redeems` when the source account is + * returning its own IOU to the issuer, `Issues` otherwise. For a + * `BookStep`: returns `Issues` when the offer owner pays the transfer fee + * (i.e., owner-pays mode), `Redeems` otherwise. The result propagates + * through the chain to drive transfer-fee logic in `qualityUpperBound`. + * + * @param sb View used to read trust-line balances. + * @param dir `Reverse` when called from `rev()`, `Forward` from `fwd()`. + * @return The debt direction for this step on this pass. + */ [[nodiscard]] virtual DebtDirection debtDirection(ReadView const& sb, StrandDirection dir) const = 0; - /** - If this step is a DirectStepI, return the quality in of the dst account. - */ + /** Return the `QualityIn` ratio for the destination trust line, or + * `QUALITY_ONE` (1.0) for non-`DirectStepI` steps. + * + * A value less than `QUALITY_ONE` represents a discount the destination + * account has negotiated with its counterparty. + */ [[nodiscard]] virtual std::uint32_t lineQualityIn(ReadView const&) const { return QUALITY_ONE; } - /** - Find an upper bound of quality for the step - - @param v view to query the ledger state from - @param prevStepDir Set to DebtDirection::redeems if the previous step redeems. - @return A pair. The first element is the upper bound of quality for the step, or std::nullopt - if the step is dry. The second element will be set to DebtDirection::redeems if this - steps redeems, DebtDirection:issues if this step issues. - @note It is an upper bound because offers on the books may be unfunded. If there is always a - funded offer at the tip of the book, then we could rename this `theoreticalQuality` - rather than `qualityUpperBound`. It could still differ from the actual quality, but - except for "dust" amounts, it should be a good estimate for the actual quality. - */ + /** Compute a theoretical upper bound on the quality (out/in ratio) for this step. + * + * Used by `StrandFlow` to rank competing strands from best to worst before + * committing to any execution. The estimate is an upper bound because book + * offers visible at strand-construction time may be unfunded by the time + * the step actually runs. + * + * @param v View to query the current ledger state from. + * @param prevStepDir Debt direction of the immediately preceding step; + * influences whether this step's transfer fee is included in the bound. + * @return A pair whose first element is the quality upper bound, or + * `std::nullopt` if the step is dry (no funded offers / zero balance). + * The second element is this step's own `DebtDirection`, propagated to + * the next call in the chain. + */ [[nodiscard]] virtual std::pair, DebtDirection> qualityUpperBound(ReadView const& v, DebtDirection prevStepDir) const = 0; - /** Get QualityFunction. Used in one path optimization where - * the quality function is non-constant (has AMM) and there is - * limitQuality. QualityFunction allows calculation of - * required path output given requested limitQuality. - * All steps, except for BookStep have the default - * implementation. + /** Return the quality function for this step. + * + * For most steps (non-AMM), this wraps `qualityUpperBound` into a + * constant `QualityFunction{quality, CLOBLikeTag{}}`. AMM `BookStep` + * overrides this to return the full non-linear quality function, which + * encodes the constant-product pricing curve. `StrandFlow` uses the + * non-linear form to back-calculate the precise output required to exactly + * hit a `limitQuality` constraint, rather than executing to dryness. + * + * @param v View to query the current ledger state from. + * @param prevStepDir Debt direction of the immediately preceding step. + * @return Pair of (optional QualityFunction, this step's DebtDirection). + * `std::nullopt` in the first element means the step is dry. */ [[nodiscard]] virtual std::pair, DebtDirection> getQualityFunc(ReadView const& v, DebtDirection prevStepDir) const; - /** Return the number of offers consumed or partially consumed the last time - the step ran, including expired and unfunded offers. - - N.B. This this not the total number offers consumed by this step for the - entire payment, it is only the number the last time it ran. Offers may - be partially consumed multiple times during a payment. + /** Return the number of offers consumed or partially consumed on the last run. + * + * Includes expired and unfunded offers encountered during the pass. This + * is a per-invocation count, not a cumulative total: an offer partially + * consumed in multiple passes is counted once per pass. Non-book steps + * return 0. */ [[nodiscard]] virtual std::uint32_t offersUsed() const @@ -194,25 +264,23 @@ public: return 0; } - /** - If this step is a BookStep, return the book. - */ + /** Return the order book if this step is a `BookStep`, else nullopt. */ [[nodiscard]] virtual std::optional bookStepBook() const { return std::nullopt; } - /** - Check if amount is zero - */ + /** Return true if @p out is zero for this step's output amount type. */ [[nodiscard]] virtual bool isZero(EitherAmount const& out) const = 0; - /** - Return true if the step should be considered inactive. - A strand that has additional liquidity may be marked inactive if a step - has consumed too many offers. + /** Return true if this step has consumed too many offers and should be + * treated as exhausted even though liquidity may remain on the book. + * + * `StrandFlow` treats a strand containing an inactive step as dry for + * the current iteration. The limit is `MaxOffersToConsume` (1000) per + * book step. */ [[nodiscard]] virtual bool inactive() const @@ -220,28 +288,26 @@ public: return false; } - /** - Return true if Out of lhs == Out of rhs. - */ + /** Return true if the output amounts of @p lhs and @p rhs are equal. */ [[nodiscard]] virtual bool equalOut(EitherAmount const& lhs, EitherAmount const& rhs) const = 0; - /** - Return true if In of lhs == In of rhs. - */ + /** Return true if the input amounts of @p lhs and @p rhs are equal. */ [[nodiscard]] virtual bool equalIn(EitherAmount const& lhs, EitherAmount const& rhs) const = 0; - /** - Check that the step can correctly execute in the forward direction - - @param sb view with the strands state of balances and offers - @param afView view the state of balances before the strand runs - this determines if an offer becomes unfunded or is found unfunded - @param in requested step input - @return first element is true if step is valid, second element is out - amount - */ + /** Re-execute the step in the forward direction and validate the result. + * + * After the reverse pass identifies the limiting step, `StrandFlow` calls + * `validFwd` on each step to confirm that the forward pass produces an + * output consistent (within tolerance via `checkNear`) with what the + * reverse pass computed. Returns `{false, zero}` on any `FlowException`. + * + * @param sb Sandbox carrying the running ledger state for the strand. + * @param afView Ledger state before the strand started. + * @param in The input amount to validate against. + * @return Pair of (is-valid flag, actual output amount). + */ virtual std::pair validFwd(PaymentSandbox& sb, ApplyView& afView, EitherAmount const& in) = 0; @@ -294,9 +360,21 @@ Step::getQualityFunc(ReadView const& v, DebtDirection prevStepDir) const return {std::nullopt, res.second}; } -/// @cond INTERNAL +/** An ordered sequence of steps representing one candidate payment path. + * + * The first step is always a source-account endpoint; the last is always + * a destination-account endpoint. Inner steps are either direct (IOU + * ripple) or book (offer crossing) hops. `toStrand` constructs a Strand + * from a normalised `STPath`. + */ using Strand = std::vector>; +/** Return the total number of offers consumed across all steps in @p strand + * on their most recent execution. + * + * Non-book steps contribute 0. This is a per-invocation snapshot, not a + * cumulative total across the full payment. + */ inline std::uint32_t offersUsed(Strand const& strand) { @@ -308,9 +386,14 @@ offersUsed(Strand const& strand) } return r; } -/// @endcond -/// @cond INTERNAL +/** Return true if @p lhs and @p rhs represent identical payment paths. + * + * Two strands are equal iff they have the same length and every + * corresponding step compares equal via `Step::operator==`. Used by + * `toStrands` to deduplicate strands constructed from the default path + * and the user-supplied path set. + */ inline bool operator==(Strand const& lhs, Strand const& rhs) { @@ -323,21 +406,26 @@ operator==(Strand const& lhs, Strand const& rhs) } return true; } -/// @endcond -/* - Normalize a path by inserting implied accounts and offers - - @param src Account that is sending assets - @param dst Account that is receiving assets - @param deliver Asset the dst account will receive - (if issuer of deliver == dst, then accept any issuer) - @param sendMax Optional asset to send. - @param path Liquidity sources to use for this strand of the payment. The path - contains an ordered collection of the offer books to use and - accounts to ripple through. - @return error code and normalized path -*/ +/** Expand a user-supplied path to its canonical form by inserting implied nodes. + * + * XRPL allows callers to omit obvious intermediate accounts (e.g. the issuer + * of a currency) from `STPath` entries. This function inserts them so that + * `toStrand` sees an unambiguous sequence: source account → (optional SendMax + * issuer) → user-supplied hops → (optional Deliver issuer) → destination + * account. + * + * @param src Account sending assets. + * @param dst Account receiving assets. + * @param deliver Asset the destination account will receive. If + * `deliver.account() == dst`, any issuer is accepted. + * @param sendMaxAsset Optional asset the source account is spending. When + * absent, the deliver asset is used as the send asset. + * @param path User-supplied liquidity sources: offer books and + * intermediate rippling accounts, in traversal order. + * @return Pair of (TER error code, normalised `STPath`). On success the TER + * is `tesSUCCESS`; on failure the path is unspecified. + */ std::pair normalizePath( AccountID const& src, @@ -346,30 +434,35 @@ normalizePath( std::optional const& sendMaxAsset, STPath const& path); -/** - Create a Strand for the specified path - - @param sb view for trust lines, balances, and attributes like auth and freeze - @param src Account that is sending assets - @param dst Account that is receiving assets - @param deliver Asset the dst account will receive - (if issuer of deliver == dst, then accept any issuer) - @param limitQuality Offer crossing BookSteps use this value in an - optimization. If, during direct offer crossing, the - quality of the tip of the book drops below this value, - then evaluating the strand can stop. - @param sendMaxAsset Optional asset to send. - @param path Liquidity sources to use for this strand of the payment. The path - contains an ordered collection of the offer books to use and - accounts to ripple through. - @param ownerPaysTransferFee false -> charge sender; true -> charge offer - owner - @param offerCrossing false -> payment; true -> offer crossing - @param ammContext counts iterations with AMM offers - @param domainID the domain that order books will use - @param j Journal for logging messages - @return Error code and constructed Strand -*/ +/** Build a single Strand from one normalised path. + * + * Calls `normalizePath` internally, then iterates the resulting nodes and + * invokes the appropriate `make_*` factory for each hop, threading + * `StrandContext` (with shared `seenDirectAssets` / `seenBookOuts` sets) + * through each call. Returns on the first factory error. + * + * @param sb View for trust lines, balances, and auth/freeze + * attributes used by step factories during construction. + * @param src Account sending assets. + * @param dst Account receiving assets. + * @param deliver Asset the destination will receive. If the + * issuer equals @p dst, any issuer is accepted. + * @param limitQuality Optional quality floor. During offer crossing, + * a `BookStep` stops consuming offers once the book-tip quality falls + * below this value. + * @param sendMaxAsset Optional asset the source is spending. + * @param path User-supplied liquidity sources in traversal + * order (offer books and rippling accounts). + * @param ownerPaysTransferFee When true, the offer owner pays transfer fees; + * when false, the sender pays. + * @param offerCrossing `No` for payments; `Yes` or `Sell` for offer + * crossing. + * @param ammContext Shared AMM iteration counter. + * @param domainID Optional permissioned-domain ID for order books. + * @param j Journal for logging. + * @return Pair of (TER error code, constructed Strand). On failure the + * Strand is empty. + */ std::pair toStrand( ReadView const& sb, @@ -385,32 +478,31 @@ toStrand( std::optional const& domainID, beast::Journal j); -/** - Create a Strand for each specified path (including the default path, if - indicated) - - @param sb View for trust lines, balances, and attributes like auth and freeze - @param src Account that is sending assets - @param dst Account that is receiving assets - @param deliver Asset the dst account will receive - (if issuer of deliver == dst, then accept any issuer) - @param limitQuality Offer crossing BookSteps use this value in an - optimization. If, during direct offer crossing, the - quality of the tip of the book drops below this value, - then evaluating the strand can stop. - @param sendMax Optional asset to send. - @param paths Paths to use to fulfill the payment. Each path in the pathset - contains an ordered collection of the offer books to use and - accounts to ripple through. - @param addDefaultPath Determines if the default path should be included - @param ownerPaysTransferFee false -> charge sender; true -> charge offer - owner - @param offerCrossing false -> payment; true -> offer crossing - @param ammContext counts iterations with AMM offers - @param domainID the domain that order books will use - @param j Journal for logging messages - @return error code and collection of strands -*/ +/** Build one Strand per path in @p paths, plus an optional default path. + * + * Calls `toStrand` for each path. Duplicate strands (same steps in the same + * order) are silently removed. Returns an error if no valid strand can be + * constructed. + * + * @param sb View for trust lines, balances, and auth/freeze + * attributes. + * @param src Account sending assets. + * @param dst Account receiving assets. + * @param deliver Asset the destination will receive. + * @param limitQuality Optional quality floor for offer-crossing paths. + * @param sendMax Optional asset the source is spending. + * @param paths Set of user-supplied paths to convert. + * @param addDefaultPath When true, also construct the implicit + * direct-path strand (src → dst with no intermediate hops). + * @param ownerPaysTransferFee When true, offer owners pay transfer fees. + * @param offerCrossing `No` for payments; `Yes` or `Sell` for offer + * crossing. + * @param ammContext Shared AMM iteration counter. + * @param domainID Optional permissioned-domain ID for order books. + * @param j Journal for logging. + * @return Pair of (TER error code, vector of distinct Strands). Returns an + * error if the vector would be empty. + */ std::pair> toStrands( ReadView const& sb, @@ -427,7 +519,19 @@ toStrands( std::optional const& domainID, beast::Journal j); -/// @cond INTERNAL +/** CRTP mixin that bridges typed step implementations into the type-erased `Step` interface. + * + * Concrete step classes (e.g., `DirectStepI`, `BookStepII`) are strongly typed + * in their input and output amount types. `StepImp` unwraps the `EitherAmount` + * variant at each call boundary (using `get()`) and forwards to the derived + * class's `revImp` / `fwdImp` methods which receive the concrete `TIn` / `TOut` + * amounts. A type mismatch produces a clear `std::logic_error` rather than + * undefined behaviour. + * + * @tparam TIn Input amount type (`XRPAmount`, `IOUAmount`, or `MPTAmount`). + * @tparam TOut Output amount type. + * @tparam TDerived The concrete step class (CRTP parameter). + */ template struct StepImp : public Step { @@ -445,8 +549,6 @@ public: return {EitherAmount(r.first), EitherAmount(r.second)}; } - // Given the requested amount to consume, compute the amount produced. - // Return the consumed/produced std::pair fwd(PaymentSandbox& sb, ApplyView& afView, @@ -476,81 +578,154 @@ public: } friend TDerived; }; -/// @endcond -/// @cond INTERNAL -// Thrown when unexpected errors occur +/** Exception signalling an unrecoverable failure inside a step. + * + * Thrown when a step encounters a ledger state that cannot be handled by + * returning a zero amount — for example, an internal consistency check fails + * or `toStrand` constructs an empty strand despite a `tesSUCCESS` return. + * `StrandFlow` catches this exception at the strand boundary and treats the + * strand as dry rather than propagating the error further. + * + * The embedded `ter` code is used by the calling flow to classify the failure + * (e.g., `tecINTERNAL`) and, in debug builds, to produce a diagnostic message. + */ class FlowException : public std::runtime_error { public: - TER ter; + TER ter; /**< TER code identifying the failure category. */ + /** Construct with an explicit message. + * @param t TER code. + * @param msg Human-readable description of the failure. + */ FlowException(TER t, std::string const& msg) : std::runtime_error(msg), ter(t) { } + /** Construct using the standard human-readable string for @p t. */ explicit FlowException(TER t) : std::runtime_error(transHuman(t)), ter(t) { } }; -/// @endcond -/// @cond INTERNAL -// Check equal with tolerance +/** Return true if @p expected and @p actual are equal within floating-point tolerance. + * + * IOU arithmetic uses a mantissa/exponent representation where accumulated + * rounding across a multi-step path can produce values that are near but not + * exactly equal. This overload allows a relative tolerance of ~0.1% and an + * exponent difference of at most 1. Values with exponent below -20 are + * treated as equal unconditionally (precision floor). + * + * @note Called by concrete step implementations inside `validFwd` to confirm + * the forward-pass result matches the cached reverse-pass result. + */ bool checkNear(IOUAmount const& expected, IOUAmount const& actual); + +/** Return true if @p expected equals @p actual (exact integer comparison). + * + * MPT amounts are 64-bit integers; no floating-point tolerance is needed. + */ inline bool checkNear(MPTAmount const& expected, MPTAmount const& actual) { return expected == actual; } + +/** Return true if @p expected equals @p actual (exact integer comparison). + * + * XRP amounts are 64-bit integers; no floating-point tolerance is needed. + */ inline bool checkNear(XRPAmount const& expected, XRPAmount const& actual) { return expected == actual; } -/// @endcond -/** - Context needed to build Strand Steps and for error checking +/** Immutable context threaded through every step factory during strand construction. + * + * Each `make_*` factory receives a `StrandContext` and uses it to validate the + * new step in isolation and as part of the growing strand. The two loop-detection + * sets (`seenDirectAssets` and `seenBookOuts`) are mutated by each factory to + * register the new step's assets, so the next factory in the chain can detect + * duplicates. Construction fails with `temBAD_PATH_LOOP` if a cycle is found. + * + * `prevStep` allows each factory to query the preceding step's `debtDirection` + * when enforcing the `noRipple` constraint: a path that enters and exits an + * intermediate account through two trust lines both marked `noRipple` is rejected. */ struct StrandContext { - ReadView const& view; ///< Current ReadView - AccountID const strandSrc; ///< Strand source account - AccountID const strandDst; ///< Strand destination account - Asset const strandDeliver; ///< Asset strand delivers - std::optional const limitQuality; ///< Worst accepted quality - bool const isFirst; ///< true if Step is first in Strand - bool const isLast = false; ///< true if Step is last in Strand - bool const ownerPaysTransferFee; ///< true if owner, not sender, pays fee - OfferCrossing const offerCrossing; ///< Yes/Sell if offer crossing, not payment - bool const isDefaultPath; ///< true if Strand is default path - size_t const strandSize; ///< Length of Strand - /** The previous step in the strand. Needed to check the no ripple - constraint + ReadView const& view; ///< Ledger state used to read balances and trust-line attributes. + AccountID const strandSrc; ///< Source account for the whole strand. + AccountID const strandDst; ///< Destination account for the whole strand. + Asset const strandDeliver; ///< Asset the destination will receive. + std::optional const limitQuality; ///< Quality floor; book steps stop when the book tip falls below this. + bool const isFirst; ///< True if the step being constructed is the first in the strand. + bool const isLast = false; ///< True if the step being constructed is the last in the strand. + bool const ownerPaysTransferFee; ///< True if the offer owner pays transfer fees rather than the sender. + OfferCrossing const offerCrossing; ///< `Yes` or `Sell` for offer-crossing operations; `No` for payments. + bool const isDefaultPath; ///< True if this strand was constructed from the implicit default path. + size_t const strandSize; ///< Number of steps already in the strand (before the new step is added). + + /** The step immediately preceding the one being constructed. + * + * `nullptr` when `isFirst` is true. Used by direct-step factories to + * query `debtDirection` and enforce the `noRipple` constraint across + * consecutive trust-line hops through the same account. */ Step const* const prevStep = nullptr; - /** A strand may not include the same account node more than once - in the same currency. In a direct step, an account will show up - at most twice: once as a src and once as a dst (hence the two element - array). The strandSrc and strandDst will only show up once each. - */ - std::array, 2>& seenDirectAssets; - /** A strand may not include an offer that output the same issue more - than once - */ - boost::container::flat_set& seenBookOuts; - AMMContext& ammContext; - std::optional domainID; // the domain the order book will use - beast::Journal const j; - /** StrandContext constructor. */ + /** Per-position asset tracking for direct-step cycle detection. + * + * `seenDirectAssets[0]` records assets appearing as the *source* of a + * direct step; `seenDirectAssets[1]` records assets appearing as the + * *destination*. An account node may appear at most twice in a strand + * (once as src, once as dst), but never in the same position twice. + * Insertions return `false` on collision, which causes the factory to + * return `temBAD_PATH_LOOP`. + */ + std::array, 2>& seenDirectAssets; + + /** Asset tracking for book-step cycle detection. + * + * Records every asset that a book step has already output in this strand. + * Two book steps outputting the same asset would allow the first book's + * offers to unfund the second book's offers, so this is prohibited. + * Direct steps also reject a source asset that appears here (unless it is + * the output of the immediately preceding book step). + */ + boost::container::flat_set& seenBookOuts; + + AMMContext& ammContext; ///< Shared AMM iteration counter; limits total AMM offer evaluations per payment. + std::optional domainID; ///< If set, restricts book steps to offers in this permissioned domain. + beast::Journal const j; ///< Journal for diagnostic logging during strand construction. + + /** Construct a context for the next step to be added to @p strand. + * + * @param view Ledger read view. + * @param strand Steps constructed so far; used to derive `isFirst`, + * `strandSize`, and `prevStep`. + * @param strandSrc Strand source account. + * @param strandDst Strand destination account. + * @param strandDeliver Asset to be delivered to the destination. + * @param limitQuality Optional quality floor for offer-crossing paths. + * @param isLast True if this is the final step of the strand. + * @param ownerPaysTransferFee True when the offer owner bears transfer fees. + * @param offerCrossing Mode of the enclosing operation. + * @param isDefaultPath True when constructing from the default path. + * @param seenDirectAssets Mutable loop-detection set for direct steps; shared + * across all steps in this strand construction call. + * @param seenBookOuts Mutable loop-detection set for book steps; shared + * across all steps in this strand construction call. + * @param ammContext Shared AMM iteration counter. + * @param domainID Optional permissioned domain ID. + * @param j Journal for logging. + */ StrandContext( ReadView const& view, std::vector> const& strand, - // A strand may not include an inner node that - // replicates the source or destination. AccountID const& strandSrc, AccountID const& strandDst, Asset const& strandDeliver, @@ -559,17 +734,23 @@ struct StrandContext bool ownerPaysTransferFee, OfferCrossing offerCrossing, bool isDefaultPath, - std::array, 2>& - seenDirectAssets, ///< For detecting currency loops - boost::container::flat_set& seenBookOuts, ///< For detecting book loops + std::array, 2>& seenDirectAssets, + boost::container::flat_set& seenBookOuts, AMMContext& ammContext, std::optional const& domainID, - beast::Journal j); ///< Journal for logging + beast::Journal j); }; -/// @cond INTERNAL +/** White-box inspection helpers for unit tests. + * + * These functions allow tests to inspect step identity without exposing + * internal state through the production `Step` API. They are declared in + * `PaySteps.cpp` and linked only into test binaries. + */ namespace test { -// Needed for testing + +/** Return true if @p step is a `DirectStepI` between @p src and @p dst + * for @p currency. */ bool directStepEqual( Step const& step, @@ -577,6 +758,8 @@ directStepEqual( AccountID const& dst, Currency const& currency); +/** Return true if @p step is an `MPTEndpointStep` between @p src and @p dst + * for the given MPT ID @p mptid. */ bool mptEndpointStepEqual( Step const& step, @@ -584,13 +767,27 @@ mptEndpointStepEqual( AccountID const& dst, MPTID const& mptid); +/** Return true if @p step is an `XRPEndpointStep` for account @p acc. */ bool xrpEndpointStepEqual(Step const& step, AccountID const& acc); +/** Return true if @p step is a `BookStep` for the given @p book. */ bool bookStepEqual(Step const& step, xrpl::Book const& book); + } // namespace test +/** Construct an IOU-to-IOU direct (ripple) step between @p src and @p dst. + * + * Validates the trust line, freeze status, and `noRipple` constraint. + * Registers the source and destination assets in `ctx.seenDirectAssets`. + * + * @param ctx Construction context carrying loop-detection state. + * @param src Account issuing (or redeeming) the IOU. + * @param dst Counterparty on the trust line. + * @param c Currency of the trust line. + * @return Pair of (TER, step). TER is `tesSUCCESS` on success. + */ std::pair> makeDirectStepI( StrandContext const& ctx, @@ -598,6 +795,16 @@ makeDirectStepI( AccountID const& dst, Currency const& c); +/** Construct an MPT endpoint step (source or destination account for an MPT). + * + * Validates MPT authorization, freeze/lock status, and loop-detection. + * + * @param ctx Construction context. + * @param src Source account. + * @param dst Destination account. + * @param a MPT issuance ID. + * @return Pair of (TER, step). + */ std::pair> makeMptEndpointStep( StrandContext const& ctx, @@ -605,33 +812,105 @@ makeMptEndpointStep( AccountID const& dst, MPTID const& a); +/** Construct an IOU/IOU offer-book step. + * + * @param ctx Construction context. + * @param in Input IOU issue. + * @param out Output IOU issue. + * @return Pair of (TER, step). + */ std::pair> makeBookStepIi(StrandContext const& ctx, Issue const& in, Issue const& out); +/** Construct an IOU/XRP offer-book step. + * + * @param ctx Construction context. + * @param in Input IOU issue. + * @return Pair of (TER, step). Output is always XRP. + */ std::pair> makeBookStepIx(StrandContext const& ctx, Issue const& in); +/** Construct an XRP/IOU offer-book step. + * + * @param ctx Construction context. + * @param out Output IOU issue. + * @return Pair of (TER, step). Input is always XRP. + */ std::pair> makeBookStepXi(StrandContext const& ctx, Issue const& out); +/** Construct an XRP endpoint step (source or destination account for XRP). + * + * @param ctx Construction context. + * @param acc The XRP-holding account. + * @return Pair of (TER, step). + */ std::pair> makeXrpEndpointStep(StrandContext const& ctx, AccountID const& acc); +/** Construct an MPT/MPT offer-book step. + * + * @param ctx Construction context. + * @param in Input MPT issue. + * @param out Output MPT issue. + * @return Pair of (TER, step). + */ std::pair> makeBookStepMm(StrandContext const& ctx, MPTIssue const& in, MPTIssue const& out); +/** Construct an MPT/XRP offer-book step. + * + * @param ctx Construction context. + * @param in Input MPT issue. + * @return Pair of (TER, step). Output is always XRP. + */ std::pair> makeBookStepMx(StrandContext const& ctx, MPTIssue const& in); +/** Construct an XRP/MPT offer-book step. + * + * @param ctx Construction context. + * @param out Output MPT issue. + * @return Pair of (TER, step). Input is always XRP. + */ std::pair> makeBookStepXm(StrandContext const& ctx, MPTIssue const& out); +/** Construct an MPT/IOU cross-asset offer-book step. + * + * @param ctx Construction context. + * @param in Input MPT issue. + * @param out Output IOU issue. + * @return Pair of (TER, step). + */ std::pair> makeBookStepMi(StrandContext const& ctx, MPTIssue const& in, Issue const& out); +/** Construct an IOU/MPT cross-asset offer-book step. + * + * @param ctx Construction context. + * @param in Input IOU issue. + * @param out Output MPT issue. + * @return Pair of (TER, step). + */ std::pair> makeBookStepIm(StrandContext const& ctx, Issue const& in, MPTIssue const& out); +/** Compile-time predicate: true iff @p strand is a direct XRP-to-XRP transfer. + * + * A direct XRP-to-XRP path consists of exactly two endpoint steps (source and + * destination) with no intermediate hops. The flow engine uses this to skip + * execution entirely for such strands, since they cannot change value. The + * check short-circuits at compile time via `if constexpr` when either template + * parameter is not `XRPAmount`, avoiding any runtime evaluation. + * + * @tparam InAmt Amount type flowing into the strand. + * @tparam OutAmt Amount type flowing out of the strand. + * @param strand The strand to test. + * @return True only when both template parameters are `XRPAmount` and the strand + * contains exactly 2 steps. + */ template bool isDirectXrpToXrp(Strand const& strand) @@ -645,6 +924,5 @@ isDirectXrpToXrp(Strand const& strand) return false; } } -/// @endcond } // namespace xrpl diff --git a/include/xrpl/tx/paths/detail/StrandFlow.h b/include/xrpl/tx/paths/detail/StrandFlow.h index f69e10e99a..b59a0eee30 100644 --- a/include/xrpl/tx/paths/detail/StrandFlow.h +++ b/include/xrpl/tx/paths/detail/StrandFlow.h @@ -1,3 +1,19 @@ +/** + * @file StrandFlow.h + * @brief Core payment flow execution engine: two-pass strand algorithm and + * multi-strand outer search loop. + * + * This header is the heart of the XRPL payment engine. It provides: + * - `flow(PaymentSandbox, Strand, ...)` — executes one + * strand via a reverse-then-forward two-pass algorithm. + * - `flow(PaymentSandbox, vector, ...)` — the + * outer multi-strand search loop that consumes strands in quality order + * until the requested output is satisfied or all strands are dry. + * + * Every XRP payment — direct transfer, cross-currency, or offer cross — is + * ultimately executed through these functions. Callers are in `Flow.cpp`, + * which dispatches to the correct template instantiation via `std::visit`. + */ #pragma once #include @@ -23,26 +39,46 @@ namespace xrpl { -/** Result of flow() execution of a single Strand. */ +/** + * Result of executing a single Strand through the two-pass flow algorithm. + * + * Bundles the actual amounts consumed/produced, the proposed ledger state + * (inside a `PaymentSandbox` that can be merged or discarded), the set of + * offers that must be removed regardless of payment outcome, and flags + * indicating whether the strand is now exhausted. + * + * @tparam TInAmt Input amount type (`XRPAmount`, `IOUAmount`, or `MPTAmount`). + * @tparam TOutAmt Output amount type. + */ template struct StrandResult { - bool success = false; ///< Strand succeeded - TInAmt in = beast::kZERO; ///< Currency amount in - TOutAmt out = beast::kZERO; ///< Currency amount out - std::optional sandbox; ///< Resulting Sandbox state - boost::container::flat_set ofrsToRm; ///< Offers to remove - // Num offers consumed or partially consumed (includes expired and unfunded - // offers) + bool success = false; ///< True if the strand produced non-zero output. + TInAmt in = beast::kZERO; ///< Actual input consumed by the strand. + TOutAmt out = beast::kZERO; ///< Actual output produced by the strand. + std::optional sandbox; ///< Proposed ledger mutations; empty on failure. + boost::container::flat_set ofrsToRm; ///< Offers to remove from the ledger (bad/expired), even on failure. + /// Number of offers consumed or partially consumed (includes expired and + /// unfunded offers). Counts toward `maxOffersToConsider` in the outer loop. std::uint32_t ofrsUsed = 0; - // strand can be inactive if there is no more liquidity or too many offers - // have been consumed - bool inactive = false; ///< Strand should not considered as a further - ///< source of liquidity (dry) + /// True when the strand has no remaining liquidity or has consumed too many + /// offers. An inactive strand is not returned to the `next_` candidate set + /// for future rounds. + bool inactive = false; - /** Strand result constructor */ + /** Constructs a default (failed/dry) result with no sandbox. */ StrandResult() = default; + /** + * Constructs a successful strand result. + * + * @param strand The strand that was executed (used to count offers). + * @param in Actual input consumed. + * @param out Actual output produced. + * @param sandbox Sandbox containing the resulting ledger mutations. + * @param ofrsToRemoveMember Offers to remove regardless of payment success. + * @param inactive True if the strand is now exhausted. + */ StrandResult( Strand const& strand, TInAmt const& in, @@ -60,6 +96,14 @@ struct StrandResult { } + /** + * Constructs a failed (dry) strand result that still carries offers to + * remove. Used when the strand produced no usable liquidity but did + * encounter unfunded or expired offers that must be cleaned up. + * + * @param strand The strand that was executed. + * @param ofrsToRemoveMember Offers to remove even though the strand failed. + */ StrandResult(Strand const& strand, boost::container::flat_set ofrsToRemoveMember) : ofrsToRm(std::move(ofrsToRemoveMember)), ofrsUsed(offersUsed(strand)) { @@ -67,15 +111,40 @@ struct StrandResult }; /** - Request `out` amount from a strand - - @param baseView Trust lines and balances - @param strand Steps of Accounts to ripple through and offer books to use - @param maxIn Max amount of input allowed - @param out Amount of output requested from the strand - @param j Journal to write log messages to - @return Actual amount in and out from the strand, errors, offers to remove, - and payment sandbox + * Execute a single payment strand and request `out` units of output. + * + * Implements the classical reverse-then-forward two-pass algorithm: + * + * **Reverse pass (right-to-left):** Each step is called via `rev()` in + * reverse order, threading the requested output backwards to determine how + * much input each step needs. When a step cannot fully satisfy its request + * (the *limiting step*), the sandbox is discarded and that step is + * re-executed with the capped amount. If the limiting step is index 0 and + * would exceed `maxIn`, the step is re-executed forward with exactly `maxIn`. + * + * **Forward pass (left-to-right from limiting step):** After the reverse + * pass, every step to the right of the limiting step is called via `fwd()` + * to propagate the actual amounts and complete the sandbox state. + * + * A debug-only re-validation (`#ifndef NDEBUG`) re-executes the whole strand + * forward via `validFwd()` to catch inconsistencies in step implementations. + * + * Two early exits apply: an empty strand returns immediately, and a direct + * XRP-to-XRP strand (no exchange needed) returns a dry result without any + * execution. Any `FlowException` is caught and converted to a dry result so + * a bad offer in one strand does not abort the entire payment. + * + * @tparam TInAmt Input amount type (`XRPAmount`, `IOUAmount`, `MPTAmount`). + * @tparam TOutAmt Output amount type. + * @param baseView Read-only view of trust lines and balances before this + * strand executes (the "all funds" baseline). + * @param strand Ordered list of `Step` objects describing the route. + * @param maxIn If present, caps the total input this strand may consume. + * @param out Amount of output requested from the strand. + * @param j Journal for diagnostic log messages. + * @return A `StrandResult` containing the actual amounts, the proposed + * ledger mutations in a `PaymentSandbox`, offers to remove, and the + * `inactive` flag indicating whether the strand is now exhausted. */ template StrandResult @@ -106,9 +175,9 @@ flow( std::size_t limitingStep = strand.size(); std::optional sb(&baseView); - // The "all funds" view determines if an offer becomes unfunded or is - // found unfunded - // These are the account balances before the strand executes + // afView is the "all funds" snapshot: account balances as they were + // before any step in this strand executed. Steps use it to determine + // whether an offer is currently funded (vs. the evolving `sb` view). std::optional afView(&baseView); EitherAmount limitStepOut; { @@ -124,12 +193,12 @@ flow( if (i == 0 && maxIn && *maxIn < get(r.first)) { - // limiting - exceeded maxIn - // Throw out previous results + // Step 0 would exceed maxIn: re-execute forward with + // exactly maxIn rather than in reverse, because step 0 + // has no predecessor to supply a revised input. sb.emplace(&baseView); limitingStep = i; - // re-execute the limiting step r = strand[i]->fwd(*sb, *afView, ofrsToRm, EitherAmount(*maxIn)); limitStepOut = r.second; @@ -140,9 +209,9 @@ flow( } if (get(r.first) != *maxIn) { - // Something is very wrong - // throwing out the sandbox can only increase liquidity - // yet the limiting is still limiting + // Invariant violation: discarding the old sandbox can + // only increase available liquidity, so re-executing + // with maxIn should always consume exactly maxIn. // LCOV_EXCL_START JLOG(j.fatal()) << "Re-executed limiting step failed. r.first: " @@ -156,13 +225,14 @@ flow( } else if (!strand[i]->equalOut(r.second, stepOut)) { - // limiting - // Throw out previous results + // This step is the limiting step: it produced less than + // requested. Discard the partial sandbox (fresh view gives + // the step full liquidity again) and re-execute in reverse + // with the capped amount to record the definitive state. sb.emplace(&baseView); afView.emplace(&baseView); limitingStep = i; - // re-execute the limiting step stepOut = r.second; r = strand[i]->rev(*sb, *afView, ofrsToRm, stepOut); limitStepOut = r.second; @@ -176,9 +246,9 @@ flow( } if (!strand[i]->equalOut(r.second, stepOut)) { - // Something is very wrong - // throwing out the sandbox can only increase liquidity - // yet the limiting is still limiting + // Invariant violation: a fresh sandbox can only + // increase liquidity, so the re-executed limiting step + // must be able to deliver at least as much as before. // LCOV_EXCL_START #ifndef NDEBUG JLOG(j.fatal()) @@ -195,8 +265,7 @@ flow( } } - // prev node needs to produce what this node wants to consume - stepOut = r.first; + stepOut = r.first; // propagate: predecessor must deliver what this step will consume } } @@ -214,9 +283,9 @@ flow( } if (!strand[i]->equalIn(r.first, stepIn)) { - // The limits should already have been found, so executing a - // strand forward from the limiting step should not find a - // new limit + // Invariant violation: the reverse pass already found the + // global limiting step, so the forward pass must never + // encounter a new limit. // LCOV_EXCL_START #ifndef NDEBUG JLOG(j.fatal()) << "Re-executed forward pass failed. r.first: " << r.first @@ -281,17 +350,37 @@ flow( } /// @cond INTERNAL +/** + * Aggregate result of the multi-strand payment flow loop. + * + * Accumulates the total input consumed and output produced across all + * successful strand rounds, the merged `PaymentSandbox` representing all + * proposed ledger changes, offers that must be cleaned up regardless of + * payment success, and a `TER` result code. + * + * @tparam TInAmt Input amount type. + * @tparam TOutAmt Output amount type. + */ template struct FlowResult { - TInAmt in = beast::kZERO; - TOutAmt out = beast::kZERO; - std::optional sandbox; - boost::container::flat_set removableOffers; - TER ter = temUNKNOWN; + TInAmt in = beast::kZERO; ///< Total input consumed across all rounds. + TOutAmt out = beast::kZERO; ///< Total output delivered across all rounds. + std::optional sandbox; ///< Merged sandbox; present only on success. + boost::container::flat_set removableOffers; ///< Offers to remove whether or not payment succeeds. + TER ter = temUNKNOWN; ///< `tesSUCCESS` or a TEC/TEF error code. + /** Constructs a default (unknown-error) result. */ FlowResult() = default; + /** + * Constructs a successful result with all amounts and ledger state. + * + * @param in Total input consumed. + * @param out Total output delivered. + * @param sandbox Merged sandbox holding all ledger mutations. + * @param ofrsToRm Offers to remove from the ledger. + */ FlowResult( TInAmt const& in, TOutAmt const& out, @@ -305,11 +394,27 @@ struct FlowResult { } + /** + * Constructs a failure result carrying only the offers to remove. + * Used when the flow loop exits with no usable liquidity at all. + * + * @param ter Error code describing the failure. + * @param ofrsToRm Offers to remove even though the payment failed. + */ FlowResult(TER ter, boost::container::flat_set ofrsToRm) : removableOffers(std::move(ofrsToRm)), ter(ter) { } + /** + * Constructs a partial-failure result that includes the amounts achieved + * before the flow loop stopped (e.g., `tecPATH_PARTIAL`). + * + * @param ter Error code (typically `tecPATH_PARTIAL`). + * @param in Input consumed up to the point of failure. + * @param out Output delivered up to the point of failure. + * @param ofrsToRm Offers to remove regardless of payment outcome. + */ FlowResult( TER ter, TInAmt const& in, @@ -322,6 +427,22 @@ struct FlowResult /// @endcond /// @cond INTERNAL +/** + * Compute an upper bound on the exchange rate (quality) achievable by a strand. + * + * Calls `qualityUpperBound()` on each step in sequence, composing the + * per-step bounds via `composedQuality()` while propagating the `DebtDirection` + * (distinguishing redemption from issuance, which affects transfer fees). + * Returns `std::nullopt` if any step is provably dry. + * + * This estimate may be optimistic — unfunded offers at the tip of a book can + * make the actual quality lower — but it is a sound heuristic for ranking + * candidate strands in `ActiveStrands::activateNext()`. + * + * @param v Read-only ledger view. + * @param strand The strand to evaluate. + * @return An upper-bound `Quality`, or `std::nullopt` if the strand is dry. + */ inline std::optional qualityUpperBound(ReadView const& v, Strand const& strand) { @@ -344,13 +465,29 @@ qualityUpperBound(ReadView const& v, Strand const& strand) /// @endcond /// @cond INTERNAL -/** Limit remaining out only if one strand and limitQuality is included. - * Targets one path payment with AMM where the average quality is linear - * and instant quality is quadratic function of output. Calculating quality - * function for the whole strand enables figuring out required output - * to produce requested strand's limitQuality. Reducing the output, - * increases quality of AMM steps, increasing the strand's composite - * quality as the result. +/** + * Reduce the requested output to the amount that exactly satisfies + * `limitQuality` when there is exactly one active strand. + * + * This optimization applies only to AMM-backed strands where quality is not + * constant: for an AMM, the average exchange rate is a quadratic function of + * output, so requesting less output yields a better average quality. The + * function collects per-step `QualityFunction` objects and combines them into + * a strand-level quality function, then solves for the output that achieves + * `limitQuality` via `QualityFunction::outFromAvgQ()`. + * + * A relative-distance guard (`withinRelativeDistance(..., 1e-9)`) absorbs + * floating-point rounding and avoids spurious adjustments. If the quality + * function is constant (no AMM steps), or if any step does not provide a + * quality function, the function returns `remainingOut` unchanged. + * + * @tparam TOutAmt Output amount type. + * @param v Ledger view used by step quality-function queries. + * @param strand The single active strand. + * @param remainingOut Current remaining output request; returned unchanged + * if no adjustment is possible. + * @param limitQuality Minimum acceptable average quality. + * @return The adjusted output cap, which is ≤ `remainingOut`. */ template inline TOutAmt @@ -382,7 +519,8 @@ limitOut( } } - // QualityFunction is constant + // If every step is a fixed-rate hop the quality function is constant; + // no adjustment is possible or necessary. if (!qf || qf->isConst()) return remainingOut; @@ -407,7 +545,9 @@ limitOut( return STAmount{remainingOut.asset(), out->mantissa(), out->exponent()}; } }(); - // A tiny difference could be due to the round off + // A computed `out` that is negligibly close to `remainingOut` (within 1e-9 + // relative) is treated as equal to avoid spurious reductions caused by + // floating-point rounding in `outFromAvgQ`. if (withinRelativeDistance(out, remainingOut, Number(1, -9))) return remainingOut; return std::min(out, remainingOut); @@ -415,22 +555,40 @@ limitOut( /// @endcond /// @cond INTERNAL -/* Track the non-dry strands - - flow will search the non-dry strands (stored in `cur_`) for the best - available liquidity If flow doesn't use all the liquidity of a strand, that - strand is added to `next_`. The strands in `next_` are searched after the - current best liquidity is used. +/** + * Lazy candidate set for the multi-strand liquidity search loop. + * + * Tracks which strands are still eligible to provide liquidity across + * outer iterations. Maintains two sets: + * - `cur_` — strands being evaluated in the *current* round. + * - `next_` — strands to evaluate in the *next* round (strands that still + * had remaining liquidity after the current round, plus any + * strand not yet reached in `cur_`). + * + * The probe-and-push pattern guarantees that only one strand is consumed per + * outer iteration: the loop picks the first strand from `cur_` that yields + * usable liquidity (`best`), pushes all remaining unchecked `cur_` strands to + * `next_`, and returns `best` to `next_` if the strand is not exhausted. + * This ensures high-quality strands that only partially satisfy the remaining + * output remain in contention for subsequent rounds. + * + * @note `activateNext()` uses `std::ranges::stable_sort` intentionally: the + * stability is required for deterministic tie-breaking across different + * C++ standard library implementations, which is critical for consensus. */ class ActiveStrands { private: - // Strands to be explored for liquidity - std::vector cur_; - // Strands that may be explored for liquidity on the next iteration - std::vector next_; + std::vector cur_; ///< Strands under evaluation this round. + std::vector next_; ///< Candidates for the next round. public: + /** + * Initialise from all candidate strands; all are placed in `next_` so + * that the first call to `activateNext()` populates `cur_`. + * + * @param strands The full set of payment path strands. + */ ActiveStrands(std::vector const& strands) { cur_.reserve(strands.size()); @@ -439,13 +597,21 @@ public: next_.push_back(&strand); } - // Start a new iteration in the search for liquidity - // Set the current strands to the strands in `next_` + /** + * Advance to the next round: sort `next_` by quality upper-bound + * (best first) and swap it into `cur_`. + * + * Strands whose `qualityUpperBound` falls below `limitQuality` are pruned + * and will never appear in `cur_` again. The sort is a `stable_sort` to + * guarantee deterministic ordering when two strands have equal quality + * upper bounds — required for consensus across nodes. + * + * @param v Read-only ledger view for quality estimation. + * @param limitQuality If present, strands below this threshold are pruned. + */ void activateNext(ReadView const& v, std::optional const& limitQuality) { - // add the strands in `next_` to `cur_`, sorted by theoretical quality. - // Best quality first. cur_.clear(); if (!next_.empty()) { @@ -464,19 +630,17 @@ public: { if (limitQuality && *qual < *limitQuality) { - // If a strand's quality is ever over limitQuality - // it is no longer part of the candidate set. Note - // that when transfer fees are charged, and an - // account goes from redeeming to issuing then - // strand quality _can_ increase; However, this is - // an unusual corner case. + // Prune this strand permanently. Transfer fees can + // cause quality to increase as an account moves from + // redeeming to issuing, but this is rare enough that + // we accept the occasional premature prune. continue; } strandQualities.emplace_back(*qual, strand); } } - // must stable sort for deterministic order across different c++ - // standard library implementations + // stable_sort is mandatory: deterministic tie-breaking is a + // consensus requirement across different stdlib implementations. std::ranges::stable_sort( strandQualities, @@ -495,6 +659,13 @@ public: std::swap(cur_, next_); } + /** + * Return the strand at position `i` in the current round's candidate set. + * + * @param i Zero-based index into `cur_`. + * @return Pointer to the strand, or `nullptr` if `i` is out of range + * (which should never happen in correct usage). + */ [[nodiscard]] Strand const* get(size_t i) const { @@ -508,13 +679,28 @@ public: return cur_[i]; } + /** + * Return a strand to the `next_` candidate set for consideration in the + * following round. Called for the winning `best` strand when it still + * has remaining liquidity (i.e., `!f.inactive`). + * + * @param s Strand to preserve for the next round. + */ void push(Strand const* s) { next_.push_back(s); } - // Push the strands from index i to the end of cur_ to next_ + /** + * Move all strands from `cur_[i..end)` to `next_`. + * + * Called after the probe-and-push loop selects a winning strand: all + * strands after the winner in `cur_` (which were not yet evaluated this + * round) are deferred to `next_` so they remain in contention. + * + * @param i Index of the first strand to defer (inclusive). + */ void pushRemainingCurToNext(size_t i) { @@ -523,6 +709,7 @@ public: next_.insert(next_.end(), std::next(cur_.begin(), i), cur_.end()); } + /** Number of strands in the current round's candidate set. */ [[nodiscard]] auto size() const { @@ -532,25 +719,58 @@ public: /// @endcond /** - Request `out` amount from a collection of strands - - Attempt to fulfill the payment by using liquidity from the strands in order - from least expensive to most expensive - - @param baseView Trust lines and balances - @param strands Each strand contains the steps of accounts to ripple through - and offer books to use - @param outReq Amount of output requested from the strand - @param partialPayment If true allow less than the full payment - @param offerCrossing If true offer crossing, not handling a standard payment - @param limitQuality If present, the minimum quality for any strand taken - @param sendMaxST If present, the maximum STAmount to send - @param j Journal to write journal messages to - @param ammContext counts iterations with AMM offers - @param flowDebugInfo If pointer is non-null, write flow debug info here - @return Actual amount in and out from the strands, errors, and payment - sandbox -*/ + * Execute a multi-strand payment and request `outReq` units of output. + * + * This is the top-level payment loop. It iterates over candidate strands in + * quality order (best first), consuming one strand per outer iteration until + * `outReq` is satisfied, all strands are exhausted, or a safety limit is hit. + * + * **Safety limits:** + * - `maxTries = 1000` — maximum outer iterations; returns + * `telFAILED_PROCESSING` if exceeded. + * - `maxOffersToConsider = 1500` — maximum cumulative offers consumed across + * all strands; triggers early exit with the best result so far. + * + * **Precision:** Rather than accumulating a running total (lossy for IOU + * arithmetic), each round's amounts are collected into `flat_multiset` + * containers (`savedIns`, `savedOuts`) and summed smallest-to-largest via + * `std::accumulate` to minimise floating-point drift. + * + * **Offer cleanup:** Bad offers (`ofrsToRm`) are deleted from the sandbox + * immediately after each strand attempt — even failed strands — so they + * cannot interfere with subsequent strands. A superset (`ofrsToRmOnFail`) + * is returned to callers for cleanup if the payment ultimately fails. + * + * **FillOrKill semantics (offer crossing):** The final section branches on the + * `fixFillOrKill` amendment and the `offerCrossing` mode: + * 1. Without `tfSell` (or pre-amendment): if `actualOut < outReq`, kill. + * 2. With `tfSell` (or pre-amendment): if `remainingIn != 0`, kill (all of + * `TakerGets` must be spent). + * + * **AMM integration:** `ammContext.setMultiPath(n > 1)` informs the AMM + * whether it is competing with other active strands (affects virtual offer + * sizing). `ammContext.clear()` resets the per-strand used flag before each + * strand attempt. `ammContext.update()` increments the AMM iteration counter + * after each successful round. + * + * @tparam TInAmt Input amount type (`XRPAmount`, `IOUAmount`, `MPTAmount`). + * @tparam TOutAmt Output amount type. + * @param baseView Read-only view of trust lines and balances. + * @param strands All candidate payment path strands. + * @param outReq Requested output amount. + * @param partialPayment If true, a partial delivery is acceptable. + * @param offerCrossing Indicates whether this is offer crossing and which + * mode (`No`, `Sell`, or full `FillOrKill`). + * @param limitQuality If present, only strands meeting this quality + * threshold are used. + * @param sendMaxST If present, caps the total input consumed. + * @param j Journal for diagnostic log messages. + * @param ammContext Tracks AMM iteration state across rounds. + * @param flowDebugInfo If non-null, receives per-round debug telemetry. + * @return A `FlowResult` with the aggregate amounts, merged sandbox, offers to + * clean up, and a `TER` result code (`tesSUCCESS`, + * `tecPATH_PARTIAL`, `tecPATH_DRY`, or `telFAILED_PROCESSING`). + */ template FlowResult flow( @@ -565,8 +785,7 @@ flow( AMMContext& ammContext, path::detail::FlowDebugInfo* flowDebugInfo = nullptr) { - // Used to track the strand that offers the best quality (output/input - // ratio) + /** Holds the winning strand's result for the current outer iteration. */ struct BestStrand { TInAmt in; @@ -591,26 +810,22 @@ flow( std::uint32_t const maxOffersToConsider = 1500; std::uint32_t offersConsidered = 0; - // There is a bug in gcc that incorrectly warns about using uninitialized - // values if `remainingIn` is initialized through a copy constructor. We can - // get similar warnings for `sendMax` if it is initialized in the most - // natural way. Using `make_optional`, allows us to work around this bug. + // GCC incorrectly warns about uninitialized values when initialising + // std::optional via a copy constructor. Using make_optional + // avoids this false positive without semantic change. TInAmt const sendMaxInit = sendMaxST ? toAmount(*sendMaxST) : TInAmt{beast::kZERO}; std::optional const sendMax = (sendMaxST && sendMaxInit >= beast::kZERO) ? std::make_optional(sendMaxInit) : std::nullopt; std::optional remainingIn = !!sendMax ? std::make_optional(sendMaxInit) : std::nullopt; - // std::optional remainingIn{sendMax}; TOutAmt remainingOut(outReq); PaymentSandbox sb(&baseView); - // non-dry strands ActiveStrands activeStrands(strands); - // Keeping a running sum of the amount in the order they are processed - // will not give the best precision. Keep a collection so they may be summed - // from smallest to largest + // Amounts are accumulated in sorted flat_multiset containers and summed + // smallest-to-largest to minimise floating-point precision loss. boost::container::flat_multiset savedIns; savedIns.reserve(maxTries); boost::container::flat_multiset savedOuts; @@ -623,8 +838,8 @@ flow( return std::accumulate(col.begin() + 1, col.end(), *col.begin()); }; - // These offers only need to be removed if the payment is not - // successful + // Accumulates all bad offers seen during the flow loop. Returned to the + // caller for removal even if the payment ultimately fails. boost::container::flat_set ofrsToRmOnFail; while (remainingOut > beast::kZERO && (!remainingIn || *remainingIn > beast::kZERO)) @@ -639,7 +854,8 @@ flow( ammContext.setMultiPath(activeStrands.size() > 1); - // Limit only if one strand and limitQuality + // Apply AMM quality-function optimisation only when there is a single + // active strand and a limitQuality threshold is set. auto const limitRemainingOut = [&]() { if (activeStrands.size() == 1 && limitQuality) { @@ -662,9 +878,9 @@ flow( // should not happen continue; } - // Clear AMM liquidity used flag. The flag might still be set if - // the previous strand execution failed. It has to be reset - // since this strand might not have AMM liquidity. + // Reset AMM used-flag before each strand: a failed prior strand + // may have left the flag set, which would incorrectly mark the + // next (unrelated) strand as having consumed AMM liquidity. ammContext.clear(); if (offerCrossing != OfferCrossing::No && limitQuality) { @@ -674,8 +890,7 @@ flow( } auto f = flow(sb, *strand, remainingIn, limitRemainingOut, j); - // rm bad offers even if the strand fails - setUnion(ofrsToRm, f.ofrsToRm); + setUnion(ofrsToRm, f.ofrsToRm); // collect bad offers even on strand failure offersConsidered += f.ofrsUsed; @@ -694,9 +909,8 @@ flow( JLOG(j.trace()) << "New flow iter (iter, in, out): " << curTry - 1 << " " << to_string(f.in) << " " << to_string(f.out); - // limitOut() finds output to generate exact requested - // limitQuality. But the actual limit quality might be slightly - // off due to the round off. + // `limitOut()` targets exact limitQuality but actual quality may + // differ by ~1e-7 due to rounding; accept values within that band. if (limitQuality && q < *limitQuality && (!adjustedRemOut || !withinRelativeDistance(q, *limitQuality, Number(1, -7)))) { diff --git a/include/xrpl/tx/transactors/account/AccountDelete.h b/include/xrpl/tx/transactors/account/AccountDelete.h index 0e0ad1c33a..e19ff74ca7 100644 --- a/include/xrpl/tx/transactors/account/AccountDelete.h +++ b/include/xrpl/tx/transactors/account/AccountDelete.h @@ -4,6 +4,27 @@ namespace xrpl { +/** Executes the AccountDelete transaction on the XRP Ledger. + * + * AccountDelete is the most structurally complex transactor in the system: + * it tears down an entire account by erasing every owned ledger object, + * transferring the remaining XRP balance to a destination, and finally + * removing the account root SLE. + * + * `kCONSEQUENCES_FACTORY = Blocker` prevents the transaction queue from + * scheduling any transaction from the same account behind a pending delete, + * because deletion clears sequence numbers and owned objects, making any + * queued follower semantically undefined. + * + * @note An account can only be deleted if it satisfies all of: + * its sequence number is at least 256 below the current ledger index, + * `FirstNFTokenSequence + MintedNFTokens + 255` does not exceed the + * current ledger index, it has no live NFTs and no outstanding obligations + * (trust lines, escrows, etc.), and the owner directory contains at most + * `maxDeletableDirEntries` entries. + * + * @see Transactor + */ class AccountDelete : public Transactor { public: @@ -13,27 +34,132 @@ public: { } + /** Gate the transaction on the `featureCredentials` amendment. + * + * If `sfCredentialIDs` is present in the transaction, this method + * returns `false` unless the `featureCredentials` amendment is enabled, + * causing `invokePreflight` to return `temDISABLED`. Called by the + * preflight framework before any field-level validation. + * + * @param ctx The preflight context carrying the transaction and rules. + * @return `true` if the transaction may proceed to `preflight`; `false` + * if the required amendment is not yet active. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Perform stateless early rejection of the transaction. + * + * Rejects self-sends (`sfAccount == sfDestination` → `temDST_IS_SRC`) + * and validates the format of any `sfCredentialIDs` field via + * `credentials::checkFields`. No ledger state is consulted. + * + * @param ctx The preflight context carrying the transaction and rules. + * @return `tesSUCCESS` if the transaction passes static checks, or a + * `tem*` code describing the first validation failure. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Compute the fee as one owner-reserve unit. + * + * Overrides the standard reference-fee calculation to call + * `calculateOwnerReserveFee()`, pricing deletion at one reserve + * increment. This makes the operation economically meaningful and + * discourages spam. + * + * @param view Read-only view used to look up current reserve settings. + * @param tx The AccountDelete transaction. + * @return The required fee in drops (one owner-reserve unit). + */ static XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx); + /** Validate ledger state against all deletion preconditions. + * + * Performs every stateful check required before account deletion may + * proceed. Checks are applied in this order: + * + * 1. Destination account must exist (`tecNO_DST`). + * 2. Destination tag must be supplied if required by the destination + * (`tecDST_TAG_NEEDED`). + * 3. If `sfCredentialIDs` are absent, the destination's `lsfDepositAuth` + * flag must be satisfied by a pre-authorized entry; if credentials are + * present this check is deferred to `doApply` so that expiry is caught + * at apply-time rather than claim-time. + * 4. The account must have no live NFTs (minted ≠ burned → `tecHAS_OBLIGATIONS`). + * 5. The account's sequence must be ≥ 256 below the current ledger index + * to prevent transaction replay after account resurrection (`tecTOO_SOON`). + * 6. `FirstNFTokenSequence + MintedNFTokens + 255` must not exceed the + * current ledger index, preventing duplicate NFTokenIDs via authorized + * minters after resurrection (`tecTOO_SOON`). + * 7. Every entry in the owner directory must have a registered deleter + * (i.e. be a non-obligation type such as an offer, ticket, or signer + * list); any unrecognized type returns `tecHAS_OBLIGATIONS`. + * 8. The owner directory must not exceed `maxDeletableDirEntries` + * entries (`tefTOO_BIG`). + * + * @param ctx The preclaim context carrying a read-only ledger view. + * @return `tesSUCCESS` if all conditions are met, or the appropriate + * `tec`/`tef` code describing the first unmet condition. + */ static TER preclaim(PreclaimContext const& ctx); + /** Execute the account teardown and XRP transfer. + * + * Assumes `preclaim` has already validated ledger state and proceeds to: + * + * 1. If `sfCredentialIDs` are present, call `verifyDepositPreauth` to + * confirm authorization and detect expired credentials (deferred from + * `preclaim`). + * 2. Walk the owner directory via `cleanupOnAccountDelete`, invoking the + * appropriate deleter for each entry. + * 3. Transfer the remaining XRP balance to the destination and call + * `ctx_.deliver()` to record the delivered amount. + * 4. Erase the (now empty) owner directory root node; a non-empty + * directory at this point is treated as a ledger integrity error. + * 5. Clear `lsfPasswordSpent` on the destination if XRP was received + * and the flag was set. + * 6. Erase the source account root SLE. + * + * @return `tesSUCCESS` on successful deletion, or a `tec`/`tef` code + * if an unexpected error is encountered during cleanup. + */ TER doApply() override; + /** Accumulate per-entry state for transaction-specific invariant checks. + * + * Currently a no-op placeholder; AccountDelete relies on the protocol- + * level invariants (`AccountRootsNotDeleted`, `AccountRootsDeletedClean`, + * `XRPNotCreated`, etc.) rather than defining additional per-entry checks. + * Reserved for future transaction-specific invariants. + * + * @param isDelete `true` if the entry was erased. + * @param before SLE state before the transaction (nullptr if new). + * @param after SLE state after the transaction (nullptr for deletions + * is not guaranteed; use `isDelete` to detect deletions). + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Verify transaction-specific post-conditions after all entries are visited. + * + * Currently a no-op placeholder that always returns `true`. + * AccountDelete's correctness is enforced by the protocol-level invariant + * checkers. Reserved for future transaction-specific invariants. + * + * @param tx The transaction being applied. + * @param result The tentative TER result so far. + * @param fee The fee consumed by the transaction. + * @param view Read-only view of the ledger after the transaction. + * @param j Journal for logging invariant failures. + * @return Always `true`; no transaction-specific invariants are defined yet. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/account/AccountSet.h b/include/xrpl/tx/transactors/account/AccountSet.h index 69eff6d005..19de9fe536 100644 --- a/include/xrpl/tx/transactors/account/AccountSet.h +++ b/include/xrpl/tx/transactors/account/AccountSet.h @@ -5,6 +5,25 @@ namespace xrpl { +/** Processes AccountSet transactions, the primary mechanism for configuring an + * account root entry on the XRP Ledger. + * + * Covers behavioral flags (RequireAuth, DisableMaster, NoFreeze, GlobalFreeze, + * DepositAuth, disallow-incoming variants), metadata fields (Domain, EmailHash, + * MessageKey, WalletLocator), economic parameters (TransferRate, TickSize), and + * NFT settings (authorized minter). + * + * The `ConsequencesFactory` is `Custom` because most AccountSet transactions are + * normal but those that touch `asfRequireAuth`, `asfDisableMaster`, + * `asfAccountTxnID`, or the legacy `tfRequireAuth`/`tfOptionalAuth` flags are + * classified as blockers, which prevent subsequent same-account transactions from + * being queued until the blocker confirms. + * + * @note Flag changes accept two parallel encodings for historical compatibility: + * the legacy transaction-flags bitfield (`tfRequireAuth`, etc.) and the + * modern `sfSetFlag`/`sfClearFlag` fields (`asfRequireAuth`, etc.). Both + * paths are validated and applied in all three pipeline phases. + */ class AccountSet : public Transactor { public: @@ -14,30 +33,160 @@ public: { } + /** Determine whether this AccountSet is a normal or blocker transaction. + * + * Returns `Blocker` when the transaction sets or clears `asfRequireAuth`, + * `asfDisableMaster`, or `asfAccountTxnID`, or when the legacy + * `tfRequireAuth`/`tfOptionalAuth` transaction flags are used. Blocker + * classification prevents later same-account transactions from being queued + * until this one confirms, guarding state-dependent sequences (e.g., + * establishing trust lines before enabling RequireAuth). + * + * @param ctx Preflight context containing the raw transaction. + * @return A `TxConsequences` with `Category::Blocker` or `Category::Normal`. + */ static TxConsequences makeTxConsequences(PreflightContext const& ctx); + /** Return the set of transaction flags valid for AccountSet. + * + * @param ctx Preflight context (unused; present for interface uniformity). + * @return `tfAccountSetMask`, causing any unknown flag bits to be rejected + * by the framework's `preflight1` call. + */ static std::uint32_t getFlagsMask(PreflightContext const& ctx); + /** Perform stateless validation of AccountSet transaction fields. + * + * Runs without ledger access. Validates: + * - No flag is simultaneously set and cleared via the dual-path flag system + * (legacy `Flags` bitfield and `sfSetFlag`/`sfClearFlag` are both accepted + * but must not contradict each other for RequireAuth, RequireDestTag, and + * DisallowXRP). + * - `sfTransferRate`, if present, is zero (unset) or within + * [`QUALITY_ONE`, `2 × QUALITY_ONE`]. Values below `QUALITY_ONE` are + * rejected to prevent discount-rate value creation. + * - `sfTickSize`, if present, is zero (unset) or within + * [`Quality::kMIN_TICK_SIZE`, `Quality::kMAX_TICK_SIZE`]. + * - `sfMessageKey`, if present and non-empty, is a recognized public key type. + * - `sfDomain`, if present, does not exceed `kMAX_DOMAIN_LENGTH`. + * - Setting `asfAuthorizedNFTokenMinter` requires `sfNFTokenMinter` present; + * clearing it requires `sfNFTokenMinter` absent. + * + * @param ctx Preflight context. + * @return `tesSUCCESS` on valid input, or a `tem*`/`tel*` code describing + * the first validation failure encountered. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Enforce the granular delegation model for delegate-signed AccountSet + * transactions. + * + * If no `sfDelegate` field is present the transaction passes immediately. + * Otherwise the method looks up the `DelegateObject` for `(account, delegate)` + * and rejects the transaction unless every modified field has been explicitly + * granted: + * - Any use of `sfSetFlag`, `sfClearFlag`, or the legacy `Flags` bitfield + * is categorically rejected — behavioral flag changes cannot be delegated. + * - `sfWalletLocator` and `sfNFTokenMinter` are unconditionally blocked. + * - `sfEmailHash`, `sfMessageKey`, `sfDomain`, `sfTransferRate`, and + * `sfTickSize` are permitted only when the corresponding granular permission + * constant (`AccountEmailHashSet`, `AccountMessageKeySet`, etc.) is present + * in the delegate's permission list. + * + * @param view Read-only ledger view used to locate the `DelegateObject`. + * @param tx The AccountSet transaction being evaluated. + * @return `tesSUCCESS` if delegation is absent or fully authorized; + * `terNO_DELEGATE_PERMISSION` if the delegate object is missing or any + * required granular permission has not been granted. + */ static NotTEC checkPermission(ReadView const& view, STTx const& tx); + /** Perform read-only ledger-state checks that may still reject the transaction. + * + * Two state-dependent constraints are enforced here: + * - **RequireAuth**: enabling `asfRequireAuth` when the account's owner + * directory is non-empty is rejected (`tecOWNERS` or `terOWNERS` when + * `tapRetry` is set), preventing retroactive breakage of existing trust + * relationships. + * - **Clawback / NoFreeze mutual exclusion** (when `featureClawback` is + * active): `asfAllowTrustLineClawback` cannot be set if `lsfNoFreeze` is + * already set, and vice versa. Enabling clawback also requires an empty + * owner directory for the same retroactive-breakage reason. + * + * @param ctx Preclaim context providing a read-only ledger view and the + * transaction. + * @return `tesSUCCESS` on success; `tecOWNERS`, `terOWNERS`, or + * `tecNO_PERMISSION` when the relevant constraint is violated; + * `terNO_ACCOUNT` if the account root does not exist. + */ static TER preclaim(PreclaimContext const& ctx); + /** Apply the AccountSet transaction, mutating the account root SLE. + * + * Reads the current `sfFlags` bitmask, computes the new value by applying + * each flag change in sequence (both legacy-bitfield and `sfSetFlag`/ + * `sfClearFlag` paths), updates scalar metadata fields, and calls + * `ctx_.view().update(sle)` once at the end. + * + * Security-sensitive flag handling: + * - **DisableMaster**: requires the transaction to be signed with the master + * key itself and the account to already have an alternative signing path + * (`sfRegularKey` or a signer list); returns `tecNEED_MASTER_KEY` or + * `tecNO_ALTERNATIVE_KEY` otherwise. + * - **NoFreeze**: likewise requires a master-key signature when master is + * still enabled. `asfNoFreeze` is permanent — the flag can never be + * cleared. + * - **GlobalFreeze interlock**: once `lsfNoFreeze` is active, clearing + * `lsfGlobalFreeze` is prohibited to prevent selective market manipulation. + * + * Scalar fields (`sfDomain`, `sfEmailHash`, `sfMessageKey`, `sfWalletLocator`, + * `sfTransferRate`, `sfTickSize`) use an empty or zero value as a deletion + * signal (`makeFieldAbsent`) rather than a separate clear flag. + * + * Amendment-gated flags (`asfAllowTrustLineLocking` via `featureTokenEscrow`, + * `asfAllowTrustLineClawback` via `featureClawback`) are only applied when + * the corresponding amendment is active. + * + * @return `tesSUCCESS` on success; `tecNEED_MASTER_KEY` if a master-key + * operation was attempted without a master-key signature; + * `tecNO_ALTERNATIVE_KEY` if DisableMaster was requested with no fallback + * signing path; `tefINTERNAL` if the account SLE cannot be located + * (indicates ledger corruption, expected unreachable). + */ TER doApply() override; + /** Per-entry hook for AccountSet-specific invariant checking. + * + * Currently a no-op placeholder; AccountSet has no transaction-specific + * invariants beyond the global set. + * + * @param isDelete Whether the entry is being deleted. + * @param before SLE state before the transaction. + * @param after SLE state after the transaction. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Finalize AccountSet-specific invariant checks after all entries are visited. + * + * Currently a no-op placeholder; always returns `true`. + * + * @param tx The AccountSet transaction. + * @param result The TER result from `doApply`. + * @param fee The fee charged for this transaction. + * @param view Read-only view of the ledger after application. + * @param j Journal for diagnostic logging. + * @return `true` unconditionally (no AccountSet-specific invariants yet). + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/account/SetRegularKey.h b/include/xrpl/tx/transactors/account/SetRegularKey.h index 6ea6375b1d..99c5c245df 100644 --- a/include/xrpl/tx/transactors/account/SetRegularKey.h +++ b/include/xrpl/tx/transactors/account/SetRegularKey.h @@ -4,30 +4,105 @@ namespace xrpl { +/** Transactor for the SetRegularKey transaction type. + * + * Assigns or revokes an account's regular key — a secondary keypair that may + * sign transactions in place of the master key. Separating signing authority + * from account ownership lets accounts keep their master key in cold storage + * while using the regular key for day-to-day operations. + * + * Declared as `Blocker` so that no other transaction from the same account + * may be queued ahead of or alongside it: changing the signing key would + * invalidate the signatures of any concurrently queued transactions. + * + * @see AccountSet, SignerListSet + */ class SetRegularKey : public Transactor { public: + /** Marks this transactor as a queue blocker. + * + * A Blocker transaction prevents later transactions from the same account + * from being queued until this one settles, because a key change can + * invalidate the signatures of any pending transactions. + */ static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Blocker; explicit SetRegularKey(ApplyContext& ctx) : Transactor(ctx) { } + /** Stateless validity check run before any ledger access. + * + * Rejects the transaction with `temBAD_REGKEY` if `sfRegularKey` equals + * `sfAccount` — assigning the account's own ID as its regular key is a + * degenerate no-op and almost certainly a mistake. + * + * @param ctx The preflight context carrying the raw transaction. + * @return `temBAD_REGKEY` if `sfRegularKey == sfAccount`; otherwise + * `tesSUCCESS`. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Computes the base fee, waiving it for a first-time key rotation. + * + * If the transaction is signed with the account's own master public key + * and the account's `lsfPasswordSpent` flag is not yet set, returns + * `XRPAmount{0}` — making the transaction free. This bootstrapping + * mechanic lets a newly funded account replace an operator-assigned + * "password" key with its own key at no cost, lowering the barrier to + * entry. The waiver is consumed exactly once: `doApply` sets + * `lsfPasswordSpent` on the same code path. + * + * @param view Read-only ledger view used to inspect the account SLE. + * @param tx The transaction being evaluated. + * @return `XRPAmount{0}` when the one-time waiver applies; otherwise + * delegates to `Transactor::calculateBaseFee`. + */ static XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx); + /** Applies the key change to the account SLE. + * + * **Setting a key**: writes `sfRegularKey` to the account SLE. If the + * fee-waiver path was used (i.e., `minimumFee` returns zero), also sets + * `lsfPasswordSpent` to prevent a second free rotation. + * + * **Removing a key** (`sfRegularKey` absent in the transaction): enforces + * the lockout-prevention invariant — if `lsfDisableMaster` is set and no + * multisig signer list exists, removing the regular key would leave the + * account with no valid signing path. Returns `tecNO_ALTERNATIVE_KEY` in + * that case. Otherwise clears `sfRegularKey` via `makeFieldAbsent`. + * + * @return `tesSUCCESS` on success; `tecNO_ALTERNATIVE_KEY` when removal + * would permanently lock the account; `tefINTERNAL` (unreachable in + * practice) if the account SLE cannot be found. + */ TER doApply() override; + /** Invariant visitor hook — no per-entry checks are defined yet. + * + * @param isDelete Whether the entry is being deleted. + * @param before SLE state before the transaction. + * @param after SLE state after the transaction. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Invariant finalization hook — no transaction-specific invariants yet. + * + * @param tx The applied transaction. + * @param result The TER result of `doApply`. + * @param fee The fee charged. + * @param view Read-only view of the post-apply ledger. + * @param j Journal for diagnostic logging. + * @return Always `true`; reserved for future invariant checks. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/account/SignerListSet.h b/include/xrpl/tx/transactors/account/SignerListSet.h index a2c75a27d8..13dc639052 100644 --- a/include/xrpl/tx/transactors/account/SignerListSet.h +++ b/include/xrpl/tx/transactors/account/SignerListSet.h @@ -10,17 +10,40 @@ namespace xrpl { -/** -See the README.md for an overview of the SignerListSet transaction that -this class implements. -*/ +/** Manages an account's multi-signature signer list on the XRP Ledger. + * + * Handles three operations determined entirely by the transaction fields: + * creating or replacing a signer list (`sfSignerQuorum` non-zero + + * `sfSignerEntries` present), or destroying the existing list (`sfSignerQuorum` + * zero + `sfSignerEntries` absent). Any other combination is `temMALFORMED`. + * + * `kCONSEQUENCES_FACTORY` is `Blocker`: a queued `SignerListSet` prevents + * later transactions from the same account from being applied until this one + * resolves, because changing signing authority has security implications that + * require serialized processing. + * + * @note Signer accounts are not required to exist in the ledger; XRPL + * permits "phantom accounts" as signers. + * @see AccountDelete, which calls `removeFromLedger()` during account cleanup. + */ class SignerListSet : public Transactor { private: - // Values determined during preCompute for use later. - enum class Operation { Unknown, Set, Destroy }; + /** Identifies which ledger mutation `doApply()` will perform. */ + enum class Operation { + Unknown, /**< Malformed transaction; `doApply()` will not be reached. */ + Set, /**< Create or replace the signer list. */ + Destroy /**< Remove the signer list entirely. */ + }; + + /** Cached operation type, populated by `preCompute()`. */ Operation do_{Operation::Unknown}; + + /** Cached quorum value from the transaction, populated by `preCompute()`. */ std::uint32_t quorum_{0}; + + /** Cached deserialized signer entries, sorted for duplicate detection, + * populated by `preCompute()`. */ std::vector signers_; public: @@ -30,23 +53,66 @@ public: { } + /** Returns the valid transaction flags mask. + * + * Returns `tfUniversalMask` when `fixInvalidTxFlags` is active so that + * unknown flag bits are rejected. Returns `0` (allow any flags) on older + * rule sets to preserve backward compatibility. + * + * @param ctx Preflight context carrying the current rule set. + * @return The flags mask to enforce, or `0` to allow all flags. + */ static std::uint32_t getFlagsMask(PreflightContext const& ctx); + /** Stateless validation of the transaction fields. + * + * Determines the intended operation via `determineOperation()` and, for + * `Set` operations, delegates to `validateQuorumAndSignerEntries()` to + * enforce signer count bounds, uniqueness, self-reference, positive + * weights, and quorum reachability. No ledger access is performed. + * + * @param ctx Preflight context. + * @return `tesSUCCESS` if the transaction is well-formed; `temMALFORMED` + * for an invalid quorum/entries combination; `temBAD_SIGNER`, + * `temBAD_WEIGHT`, or `temBAD_QUORUM` for specific signer list + * violations. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Executes the signer list operation against the ledger. + * + * Dispatches to `replaceSignerList()` or `destroySignerList()` based on + * the `do_` value set by `preCompute()`. The `Unknown` branch is + * unreachable in practice because `preflight()` already rejected it. + * + * @return `tesSUCCESS` on success, or a `tec`/`tef` code on failure. + */ TER doApply() override; + + /** Parses and caches operation parameters before `doApply()` runs. + * + * Calls `determineOperation()` and stores the resulting `quorum_`, + * `signers_`, and `do_` for use in `doApply()`, avoiding a second parse + * of the transaction fields. Asserts that the operation is well-formed + * (preflight must have already validated it). + */ void preCompute() override; + /** No-op: no transaction-specific invariant entries to visit yet. */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** No-op: no transaction-specific invariants to finalize yet. + * + * @return Always `true`. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, @@ -55,7 +121,22 @@ public: ReadView const& view, beast::Journal const& j) override; - // Interface used by AccountDelete + /** Removes the account's signer list from the ledger without constructing + * a `SignerListSet` instance. + * + * Called by `AccountDelete` during account deletion to clean up the + * owned `ltSIGNER_LIST` entry. Adjusts the owner count to match the + * pre- or post-`MultiSignReserve` accounting based on the `lsfOneOwnerCount` + * flag on the existing SLE. If no signer list exists, returns `tesSUCCESS` + * immediately. + * + * @param registry Service registry used to obtain a journal. + * @param view Mutable ledger view. + * @param account The account whose signer list is to be removed. + * @param j Journal for diagnostic logging. + * @return `tesSUCCESS` on success; `tefBAD_LEDGER` if the owner directory + * entry cannot be removed (indicates ledger corruption). + */ static TER removeFromLedger( ServiceRegistry& registry, @@ -64,9 +145,40 @@ public: beast::Journal j); private: + /** Parses the transaction to determine the intended operation. + * + * A non-zero `sfSignerQuorum` combined with `sfSignerEntries` present + * maps to `Operation::Set`; zero quorum with no entries maps to + * `Operation::Destroy`; any other combination leaves the operation as + * `Operation::Unknown`. The returned signer list is sorted to enable + * O(n) duplicate detection via `std::adjacent_find`. + * + * @param tx The transaction to inspect. + * @param flags Apply-phase flags. + * @param j Journal for diagnostic logging. + * @return A tuple of `(NotTEC, quorum, signers, Operation)`. The `NotTEC` + * is non-success only if `SignerEntries::deserialize` fails. + */ static std::tuple, Operation> determineOperation(STTx const& tx, ApplyFlags flags, beast::Journal j); + /** Validates signer list semantics for a `Set` operation. + * + * Enforces: signer count in `[kMIN_MULTI_SIGNERS, kMAX_MULTI_SIGNERS]` + * (currently 1–32); no duplicates (requires `signers` to be sorted); + * no self-referencing signer; every weight positive; and total weight + * reachable by at least one combination (sum ≥ quorum). + * + * @param quorum The quorum threshold from `sfSignerQuorum`. + * @param signers The sorted, deserialized signer list. + * @param account The submitting account; no signer may reference it. + * @param j Journal for diagnostic logging. + * @param rules Current ledger rules. + * @return `tesSUCCESS`, `temMALFORMED`, `temBAD_SIGNER`, `temBAD_WEIGHT`, + * or `temBAD_QUORUM`. + * @note Signer accounts are not checked for ledger existence; phantom + * accounts are intentionally permitted. + */ static NotTEC validateQuorumAndSignerEntries( std::uint32_t quorum, @@ -75,11 +187,41 @@ private: beast::Journal j, Rules const&); + /** Creates or replaces the signer list in the ledger. + * + * Removes any existing `ltSIGNER_LIST` first (which may lower the owner + * count and ease the subsequent reserve check), then inserts the new + * entry with `lsfOneOwnerCount` set, consuming exactly 1 owner-count + * unit under the `MultiSignReserve` amendment. Reserve is checked against + * `preFeeBalance_` to allow fee payment from reserve. + * + * @return `tesSUCCESS`, `tecINSUFFICIENT_RESERVE`, or `tecDIR_FULL`. + */ TER replaceSignerList(); + + /** Removes the signer list from the ledger. + * + * Rejects with `tecNO_ALTERNATIVE_KEY` if the master key is disabled + * (`lsfDisableMaster`) and no `sfRegularKey` is set, which would leave + * the account with no signing mechanism. + * + * @return `tesSUCCESS`, `tecNO_ALTERNATIVE_KEY`, or `tefBAD_LEDGER`. + */ TER destroySignerList(); + /** Serializes the cached signer list into a ledger SLE. + * + * Writes `sfSignerQuorum`, `sfSignerListID` (always 0), and `sfFlags`. + * Conditionally writes `sfOwner` only when `fixIncludeKeyletFields` is + * active. Each signer entry's `sfWalletLocator` (tag) is written only + * when a tag is present, ensuring no spurious tag field is ever committed + * to the ledger. + * + * @param ledgerEntry The SLE being populated. + * @param flags `sfFlags` value to write; skipped if zero. + */ void writeSignersToSLE(SLE::pointer const& ledgerEntry, std::uint32_t flags) const; }; diff --git a/include/xrpl/tx/transactors/bridge/XChainBridge.h b/include/xrpl/tx/transactors/bridge/XChainBridge.h index a98ef58238..6a62500675 100644 --- a/include/xrpl/tx/transactors/bridge/XChainBridge.h +++ b/include/xrpl/tx/transactors/bridge/XChainBridge.h @@ -1,3 +1,16 @@ +/** @file + * Declares all eight transactors implementing the XRPL cross-chain bridge + * protocol (XLS-38d). + * + * The bridge protocol enables value transfers between two independent XRPL + * ledgers via a three-phase pipeline common to all XRPL transactors: + * stateless `preflight` validation, read-only `preclaim` ledger checks, and + * state-mutating `doApply` execution. + * + * @see XChainAttestations.h for the cryptographic witness structures consumed + * by `XChainAddClaimAttestation` and `XChainAddAccountCreateAttestation`. + */ + #pragma once #include @@ -5,10 +18,34 @@ namespace xrpl { +/** Maximum number of pending account-creation claims in the destination-chain + * queue. + * + * Account-create commits are processed strictly in source-chain sequence order. + * This cap bounds the queue depth — and therefore the maximum processing lag — + * for pending account-creation attestations. If the queue is full, new + * `XChainCreateAccountCommit` transactions are rejected until earlier entries + * are finalized. + */ constexpr size_t kXBRIDGE_MAX_ACCOUNT_CREATE_CLAIMS = 128; -// Attach a new bridge to a door account. Once this is done, the cross-chain -// transfer transactions may be used to transfer funds from this account. +/** Attaches a new bridge definition to a "door account" on one side of a + * cross-chain bridge, establishing it as the custody point for locked assets. + * + * This is a one-time setup transaction. After it succeeds, the door account + * anchors all subsequent cross-chain activity on its chain: `XChainCommit` + * locks funds into it, witness servers attest to events on its behalf, and + * `XChainClaim` releases funds from it. Both a locking-chain door and an + * issuing-chain door must be configured (on their respective chains) before + * transfers can flow. + * + * @note `preflight` enforces that the transaction sender must be one of the + * two door accounts specified in the bridge definition, that both sides + * carry the same asset type (XRP or a specific IOU), and that the reward + * amount is a non-negative XRP value. `preclaim` rejects if a bridge + * already exists on this account or, for IOU bridges, if the issuer + * account does not exist or has clawback enabled. + */ class XChainCreateBridge : public Transactor { public: @@ -18,21 +55,52 @@ public: { } + /** Validates the bridge specification before any ledger access. + * + * Checks that: the transaction sender is one of the two declared door + * accounts; the two door accounts are distinct; both sides of the bridge + * carry the same asset type; and the reward amount is a non-negative XRP + * value. + * + * @param ctx Preflight context providing the transaction and rules. + * @return `tesSUCCESS` on valid input; a `tem*` code otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Checks ledger state before committing the bridge creation. + * + * Verifies that no bridge object already exists for this account, that + * the submitting account has sufficient reserve for the new ledger entry, + * and — for IOU bridges — that the issuer account exists and has not + * enabled clawback. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if the ledger state permits creation; a `tec*` + * code otherwise. + */ static TER preclaim(PreclaimContext const& ctx); + /** Creates the bridge ledger object and adds it to the owner directory. + * + * Initialises claim ID and account-creation counters to zero, inserts the + * new `ltBRIDGE` SLE, and increments the door account's owner count. + * + * @return `tesSUCCESS` on success; a `tec*` code if state has changed + * since preclaim (e.g., reserve no longer satisfied). + */ TER doApply() override; + /** @copydoc Transactor::visitInvariantEntry */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** @copydoc Transactor::finalizeInvariants */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, @@ -42,6 +110,18 @@ public: beast::Journal const& j) override; }; +/** Modifies parameters of an existing bridge object owned by a door account. + * + * At least one of the following must be specified: a new witness-reward + * amount, a new `MinAccountCreateAmount`, or the `tfClearAccountCreateAmount` + * flag (which removes the field and disables `XChainCreateAccountCommit` on + * that bridge). Setting and clearing `MinAccountCreateAmount` in the same + * transaction is rejected. + * + * Unlike most transactors, `BridgeModify` overrides `getFlagsMask()` because + * bridge modification supports flag-driven parameter toggles that require a + * custom mask (`tfXChainModifyBridgeMask`). + */ class BridgeModify : public Transactor { public: @@ -51,24 +131,60 @@ public: { } + /** Returns the set of transaction flags valid for bridge modification. + * + * Overrides the base-class default to return `tfXChainModifyBridgeMask`, + * which includes the `tfClearAccountCreateAmount` toggle flag not present + * in the common flag set. + * + * @param ctx Preflight context (unused; present for interface conformance). + * @return Bitmask of permitted transaction flags. + */ static std::uint32_t getFlagsMask(PreflightContext const& ctx); + /** Validates modification parameters before any ledger access. + * + * Rejects if: no modifiable field is present; both `sfMinAccountCreateAmount` + * and `tfClearAccountCreateAmount` are specified simultaneously; the + * transaction sender is not one of the bridge's door accounts; or any + * supplied amount is invalid. + * + * @param ctx Preflight context providing the transaction and rules. + * @return `tesSUCCESS` on valid input; a `tem*` code otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Checks that the target bridge exists before applying modifications. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if the bridge ledger entry exists; `tecNO_ENTRY` + * otherwise. + */ static TER preclaim(PreclaimContext const& ctx); + /** Applies the requested parameter changes to the bridge ledger object. + * + * Updates `sfSignatureReward` and/or `sfMinAccountCreateAmount` in-place, + * or removes `sfMinAccountCreateAmount` when `tfClearAccountCreateAmount` + * is set. + * + * @return `tesSUCCESS` on success; a `tec*` code if the bridge no longer + * exists when doApply runs. + */ TER doApply() override; + /** @copydoc Transactor::visitInvariantEntry */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** @copydoc Transactor::finalizeInvariants */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, @@ -78,44 +194,86 @@ public: beast::Journal const& j) override; }; +/** Alias for `BridgeModify` using the canonical XRPL transaction-type name. */ using XChainModifyBridge = BridgeModify; //------------------------------------------------------------------------------ -// Claim funds from a `XChainCommit` transaction. This is normally not needed, -// but may be used to handle transaction failures or if the destination account -// was not specified in the `XChainCommit` transaction. It may only be used -// after a quorum of signatures have been sent from the witness servers. -// -// If the transaction succeeds in moving funds, the referenced `XChainClaimID` -// ledger object will be destroyed. This prevents transaction replay. If the -// transaction fails, the `XChainClaimID` will not be destroyed and the -// transaction may be re-run with different parameters. +/** Releases funds on the destination chain that were locked by `XChainCommit`. + * + * This is step 4 of the normal cross-chain transfer sequence. It is required + * when no destination account was embedded in the original `XChainCommit`, or + * when the embedded destination failed (e.g., deposit auth rejected). It can + * only execute after a quorum of witness-server attestations has been + * accumulated against the referenced `XChainClaimID`. + * + * On success the `XChainClaimID` ledger object is destroyed, preventing + * replay. On failure the claim ID survives, allowing the transaction to be + * resubmitted with corrected parameters (e.g., a different destination or + * amount). This recovery path distinguishes `XChainClaim` from the + * account-creation flow, which has no undo mechanism. + * + * `kCONSEQUENCES_FACTORY` is `Blocker` because whether attestations have + * already reached quorum — and therefore whether this transaction will move + * funds — cannot be determined at fee-calculation time. + */ class XChainClaim : public Transactor { public: - // Blocker since we cannot accurately calculate the consequences static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Blocker; explicit XChainClaim(ApplyContext& ctx) : Transactor(ctx) { } + /** Validates the claim request before any ledger access. + * + * Checks that the claimed amount is positive and matches one of the + * bridge's configured asset types. + * + * @param ctx Preflight context providing the transaction and rules. + * @return `tesSUCCESS` on valid input; a `tem*` code otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Validates ledger state required for a successful claim. + * + * Verifies that: the referenced bridge exists; the destination account + * exists; the claimed amount matches the bridge's expected issue for this + * chain; and the `XChainClaimID` exists and is owned by the transaction + * sender. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if all preconditions are met; a `tec*` code + * otherwise. + */ static TER preclaim(PreclaimContext const& ctx); + /** Executes the fund release on the destination chain. + * + * Retrieves the door account's signer list and quorum, checks that + * attestations on the claim ID meet quorum, transfers funds to the + * destination (bypassing deposit auth for the claim owner), distributes + * witness rewards, and destroys the `XChainClaimID` on success. + * + * @return `tesSUCCESS` on successful fund transfer; a `tec*` code if + * quorum is not yet reached, the destination rejects the payment, or + * the claim ID is no longer valid. On failure the claim ID is + * preserved for retry. + */ TER doApply() override; + /** @copydoc Transactor::visitInvariantEntry */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** @copydoc Transactor::finalizeInvariants */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, @@ -127,14 +285,38 @@ public: //------------------------------------------------------------------------------ -// Put assets into trust on the locking-chain so they may be wrapped on the -// issuing-chain, or return wrapped assets on the issuing-chain so they can be -// unlocked on the locking-chain. The second step in a cross-chain transfer. +/** Locks assets on the source chain to initiate a cross-chain transfer. + * + * This is step 2 of the normal cross-chain transfer sequence. On the locking + * chain it places XRP or IOU assets into the door account's custody. On the + * issuing chain it returns wrapped assets to the door account so they can be + * released on the locking chain. The transaction must reference a claim ID + * previously created on the *destination* chain via `XChainCreateClaimID`. + * + * `kCONSEQUENCES_FACTORY` is `Custom` because the committed amount must be + * precisely reflected in consequence calculations: committing XRP effectively + * removes it from the sender's spendable balance, which the `Normal` factory + * cannot model. + * + * @note The transaction sender must not be the door account; a door account + * cannot commit funds to itself. The committed amount must match the + * bridge's configured asset for the source chain. + */ class XChainCommit : public Transactor { public: static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Custom; + /** Computes transaction consequences reflecting the committed amount. + * + * Returns the XRP value of the committed funds as the maximum spend so + * that the transaction queue can accurately model the sender's balance + * impact. Returns zero for non-XRP asset commits. + * + * @param ctx Preflight context providing the transaction fields. + * @return A `TxConsequences` object encoding the XRP spend (or zero for + * IOU commits). + */ static TxConsequences makeTxConsequences(PreflightContext const& ctx); @@ -142,21 +324,50 @@ public: { } + /** Validates the commit parameters before any ledger access. + * + * Checks that the committed amount is positive, legal, and matches one of + * the bridge's configured asset types. + * + * @param ctx Preflight context providing the transaction and rules. + * @return `tesSUCCESS` on valid input; a `tem*` code otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Validates ledger state required for a successful commit. + * + * Verifies that: the referenced bridge exists; the sender is not the door + * account; and the committed amount matches the bridge's expected asset + * for this chain direction. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if all preconditions are met; a `tec*` code + * otherwise. + */ static TER preclaim(PreclaimContext const& ctx); + /** Transfers the committed assets to the door account. + * + * Moves funds from the sender to the door account via `transferHelper`, + * which enforces deposit-auth and destination-tag checks. The sender is + * permitted to dip into reserve to cover the transaction fee. + * + * @return `tesSUCCESS` on successful transfer; a `tec*` code (e.g., + * `tecUNFUNDED_PAYMENT`) if the sender has insufficient balance. + */ TER doApply() override; + /** @copydoc Transactor::visitInvariantEntry */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** @copydoc Transactor::finalizeInvariants */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, @@ -168,14 +379,25 @@ public: //------------------------------------------------------------------------------ -// Create a new claim id owned by the account. This is the first step in a -// cross-chain transfer. The claim id must be created on the destination chain -// before the `XChainCommit` transaction (which must reference this number) can -// be sent on the source chain. The account that will send the `XChainCommit` on -// the source chain must be specified in this transaction (see note on the -// `SourceAccount` field in the `XChainClaimID` ledger object for -// justification). The actual sequence number must be retrieved from a validated -// ledger. +/** Reserves a claim ID on the destination chain — the first step in a normal + * cross-chain transfer. + * + * The claim ID must be created on the *destination* chain before + * `XChainCommit` can be submitted on the source chain, because the commit + * transaction must reference this monotonically-increasing sequence number. + * The account that will later send `XChainCommit` on the source chain must be + * bound here via `sfOtherChainSource`; this binding is the primary anti-replay + * mechanism and prevents an attacker from substituting a different sender + * after funds are locked. + * + * The actual sequence number assigned to the new claim ID must be retrieved + * from a validated ledger after this transaction closes. + * + * @note `preflight` validates only that `sfSignatureReward` is a non-negative + * XRP amount. `preclaim` additionally checks that the reward matches the + * bridge's configured reward and that the creator has sufficient reserve + * for the new `XChainClaimID` ledger object. + */ class XChainCreateClaimID : public Transactor { public: @@ -185,21 +407,51 @@ public: { } + /** Validates the claim ID creation request before any ledger access. + * + * Checks that `sfSignatureReward` is a non-negative XRP amount. + * + * @param ctx Preflight context providing the transaction and rules. + * @return `tesSUCCESS` on valid input; a `tem*` code otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Validates ledger state required to create a claim ID. + * + * Verifies that the referenced bridge exists, that the specified signature + * reward matches the bridge's configured reward, and that the creating + * account has sufficient reserve for the new ledger entry. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if all preconditions are met; `tecNO_ENTRY` if the + * bridge does not exist; `tecXCHAIN_REWARD_MISMATCH` if the reward + * does not match; `tecINSUFFICIENT_RESERVE` if reserve is too low. + */ static TER preclaim(PreclaimContext const& ctx); + /** Creates the `XChainClaimID` ledger object and adds it to the owner + * directory. + * + * Atomically increments the bridge's claim ID counter, initialises the + * new SLE with an empty attestations array and the bound source account, + * and increments the creator's owner count. + * + * @return `tesSUCCESS` on success; a `tec*` code if the bridge no longer + * exists or reserve has changed since preclaim. + */ TER doApply() override; + /** @copydoc Transactor::visitInvariantEntry */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** @copydoc Transactor::finalizeInvariants */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, @@ -211,38 +463,82 @@ public: //------------------------------------------------------------------------------ -// Provide attestations from a witness server attesting to events on -// the other chain. The signatures must be from one of the keys on the door's -// signer's list at the time the signature was provided. However, if the -// signature list changes between the time the signature was submitted and the -// quorum is reached, the new signature set is used and some of the currently -// collected signatures may be removed. Also note the reward is only sent to -// accounts that have keys on the current list. +/** Submits a witness-server attestation for a regular cross-chain claim. + * + * This is step 3 of the normal cross-chain transfer sequence. Each off-chain + * witness server calls this transaction once to add its cryptographic + * signature to the `XChainClaimID` on the destination chain. Attestations + * accumulate until the door account's signer-list quorum is reached, at which + * point funds are automatically transferred and rewards are distributed. + * + * Signatures must originate from a key on the door account's signer list at + * the time the attestation is submitted. If the signer list is updated between + * submission and quorum, the new list governs: stale attestations may be + * removed and only attesters whose keys are on the *current* list receive + * rewards. + * + * `kCONSEQUENCES_FACTORY` is `Blocker` because it is not determinable at + * fee-calculation time whether this attestation will push the running count + * past quorum and trigger an immediate fund transfer. + */ class XChainAddClaimAttestation : public Transactor { public: - // Blocker since we cannot accurately calculate the consequences static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Blocker; explicit XChainAddClaimAttestation(ApplyContext& ctx) : Transactor(ctx) { } + /** Validates the attestation before any ledger access. + * + * Verifies that the embedded `AttestationClaim` is well-formed: the + * public key format is valid, the signature over the claim data is + * cryptographically correct, and the attested amounts are legal. + * + * @param ctx Preflight context providing the transaction and rules. + * @return `tesSUCCESS` on valid attestation; a `tem*` code otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Validates that the attesting key is authorised by the bridge. + * + * Confirms the bridge exists and that the attesting public key and its + * corresponding account appear in the door account's signer list. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if the key is authorised; `tecXCHAIN_NO_SIGNERS_LIST` + * if the door account has no multi-sign list; `tecNO_ENTRY` if the + * bridge does not exist. + */ static TER preclaim(PreclaimContext const& ctx); + /** Adds the attestation to the claim ID and triggers transfer if quorum + * is reached. + * + * Loads the `XChainClaimID` SLE, appends the new `AttestationClaim`, + * reconciles against the current signer list (removing any stale entries), + * and — if the updated set meets quorum — calls `finalizeClaimHelper` to + * transfer funds and distribute rewards. Destroys the claim ID on + * successful transfer. + * + * @return `tesSUCCESS` on success; a `tec*` code if the claim ID does + * not exist, the amount mismatches, or the destination account cannot + * receive funds. + */ TER doApply() override; + /** @copydoc Transactor::visitInvariantEntry */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** @copydoc Transactor::finalizeInvariants */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, @@ -252,31 +548,82 @@ public: beast::Journal const& j) override; }; +/** Submits a witness-server attestation for a cross-chain account-creation + * claim. + * + * Mirrors `XChainAddClaimAttestation` but operates on the account-creation + * flow governed by `XChainCreateAccountCommit`. Attestations for account + * creation are processed in strict source-chain sequence order (by + * `createCount`); an attestation is only eligible to trigger execution once + * its sequence position is the next expected one. + * + * When quorum is reached and the sequence position is current, the attested + * XRP is credited to the destination account (creating it if it does not yet + * exist). The claim entry is then removed via `OnTransferFail::RemoveClaim` + * to prevent a failed or stale attestation from permanently blocking all + * subsequent account-creation claims. + * + * `kCONSEQUENCES_FACTORY` is `Blocker` for the same reason as + * `XChainAddClaimAttestation`: the impact of crossing quorum cannot be + * predicted at fee-calculation time. + */ class XChainAddAccountCreateAttestation : public Transactor { public: - // Blocker since we cannot accurately calculate the consequences static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Blocker; explicit XChainAddAccountCreateAttestation(ApplyContext& ctx) : Transactor(ctx) { } + /** Validates the account-creation attestation before any ledger access. + * + * Verifies that the embedded `AttestationCreateAccount` is well-formed: + * the public key format is valid and the signature over the account- + * creation event data is cryptographically correct. + * + * @param ctx Preflight context providing the transaction and rules. + * @return `tesSUCCESS` on valid attestation; a `tem*` code otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Validates that the attesting key is authorised by the bridge. + * + * Same checks as `XChainAddClaimAttestation::preclaim`: bridge must exist + * and the attesting key/account pair must be in the door account's signer + * list. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if authorised; a `tec*` code otherwise. + */ static TER preclaim(PreclaimContext const& ctx); + /** Adds the attestation and executes account creation if quorum is reached + * at the expected sequence position. + * + * Loads or creates the `XChainOwnedCreateAccountClaimID` SLE, appends the + * new `AttestationCreateAccount`, and — if quorum is met and this entry's + * `createCount` matches the bridge's current counter — transfers the XRP + * amount to the destination account (creating it if necessary) and + * distributes rewards. The claim entry is always removed after quorum, + * regardless of transfer outcome, to unblock subsequent sequence entries. + * + * @return `tesSUCCESS` on success; a `tec*` code if the attestation is + * malformed, the bridge does not exist, or the transfer itself fails. + */ TER doApply() override; + /** @copydoc Transactor::visitInvariantEntry */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** @copydoc Transactor::finalizeInvariants */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, @@ -288,29 +635,36 @@ public: //------------------------------------------------------------------------------ -// This is a special transaction used for creating accounts through a -// cross-chain transfer. A normal cross-chain transfer requires a "chain claim -// id" (which requires an existing account on the destination chain). One -// purpose of the "chain claim id" is to prevent transaction replay. For this -// transaction, we use a different mechanism: the accounts must be claimed on -// the destination chain in the same order that the `XChainCreateAccountCommit` -// transactions occurred on the source chain. -// -// This transaction can only be used for XRP to XRP bridges. -// -// IMPORTANT: This transaction should only be enabled if the witness -// attestations will be reliably delivered to the destination chain. If the -// signatures are not delivered (for example, the chain relies on user wallets -// to collect signatures) then account creation would be blocked for all -// transactions that happened after the one waiting on attestations. This could -// be used maliciously. To disable this transaction on XRP to XRP bridges, the -// bridge's `MinAccountCreateAmount` should not be present. -// -// Note: If this account already exists, the XRP is transferred to the existing -// account. However, note that unlike the `XChainCommit` transaction, there is -// no error handling mechanism. If the claim transaction fails, there is no -// mechanism for refunds. The funds are permanently lost. This transaction -// should still only be used for account creation. +/** Commits XRP on the source chain to bootstrap account creation on the + * destination chain. + * + * Addresses the bootstrapping problem: a user who has no destination-chain + * account cannot call `XChainCreateClaimID`, because that requires an + * existing account. This transaction substitutes ordering-based replay + * prevention: commits are processed on the destination chain in strict + * source-chain sequence (enforced by the `createCount` counter), rather than + * via single-use claim ID objects. + * + * **Restricted to XRP-to-XRP bridges only.** The bridge's + * `sfMinAccountCreateAmount` field both sets a minimum commit size and acts + * as a feature gate: if the field is absent, this transaction type is + * disabled on that bridge. + * + * **⚠ Operational hazard — no error recovery.** If any attestation in the + * ordered sequence is not delivered to the destination chain, all subsequent + * account-creation claims are permanently blocked. There is no retry + * mechanism. The global cap `kXBRIDGE_MAX_ACCOUNT_CREATE_CLAIMS` limits + * queue depth but does not prevent this blockage. + * + * **⚠ Permanent loss on failure.** Unlike `XChainCommit` (whose claim ID + * survives a failed claim for retry), if the destination-chain finalisation + * fails the committed XRP is irrecoverably lost. This transaction should be + * used solely as an account-creation primitive, even if the destination + * account already exists. + * + * If the destination account already exists, the XRP is transferred to it + * rather than creating a new account. + */ class XChainCreateAccountCommit : public Transactor { public: @@ -320,21 +674,57 @@ public: { } + /** Validates the account-creation commit before any ledger access. + * + * Checks that both the committed amount and the witness reward are + * positive native XRP values and use the same asset type. + * + * @param ctx Preflight context providing the transaction and rules. + * @return `tesSUCCESS` on valid input; a `tem*` code otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Validates ledger state required for the account-creation commit. + * + * Verifies that: the bridge exists; the specified reward matches the + * bridge's configured reward; `sfMinAccountCreateAmount` is set on the + * bridge (otherwise this transaction type is disabled); the committed + * amount is at least `sfMinAccountCreateAmount`; the bridge is XRP-to-XRP; + * and the sender is not the door account. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if all preconditions are met; `tecXCHAIN_SELF_COMMIT` + * if the sender is the door account; `tecXCHAIN_INSUFF_CREATE_AMOUNT` + * if the amount is below the minimum; `tecNO_ENTRY` if the bridge does + * not exist or account creation is not enabled on it. + */ static TER preclaim(PreclaimContext const& ctx); + /** Transfers the committed XRP to the door account and increments the + * bridge's account-creation counter. + * + * Moves the committed amount plus the witness reward to the door account + * via `transferHelper`. The sender is permitted to dip into reserve to + * cover the transaction fee. Atomically increments `XChainAccountCreateCount` + * on the bridge SLE, establishing this commit's position in the + * destination-chain processing sequence. + * + * @return `tesSUCCESS` on success; `tecUNFUNDED_PAYMENT` if the sender + * has insufficient balance. + */ TER doApply() override; + /** @copydoc Transactor::visitInvariantEntry */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** @copydoc Transactor::finalizeInvariants */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, @@ -344,6 +734,9 @@ public: beast::Journal const& j) override; }; +/** Alias for `XChainCreateAccountCommit` using the canonical XRPL + * transaction-type name. + */ using XChainAccountCreateCommit = XChainCreateAccountCommit; //------------------------------------------------------------------------------ diff --git a/include/xrpl/tx/transactors/check/CheckCancel.h b/include/xrpl/tx/transactors/check/CheckCancel.h index 787ce516e3..460cc91cee 100644 --- a/include/xrpl/tx/transactors/check/CheckCancel.h +++ b/include/xrpl/tx/transactors/check/CheckCancel.h @@ -1,33 +1,123 @@ +/** + * @file + * @brief Transactor for the CheckCancel transaction type. + */ + #pragma once #include namespace xrpl { +/** + * Removes a Check ledger object without transferring value. + * + * `CheckCancel` completes the Check lifecycle alongside `CheckCreate` (which + * writes the on-ledger object and reserves owner funds) and `CheckCash` (which + * redeems it). It is the only path for reclaiming the source account's owner + * reserve when a check goes unused. + * + * Permission model: if the check has **not** yet expired (tested against the + * parent ledger's close time), only the source account or the destination + * account may cancel it. An expired check may be removed by any account, + * allowing third-party cleanup of stale objects. + * + * `ConsequencesFactory` is `Normal` — no special blocking semantics. Unlike + * `CheckCreate` and `CheckCash`, this transactor does not override + * `checkExtraFeatures` because cancellation is always permitted regardless of + * active amendments. + */ class CheckCancel : public Transactor { public: static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Normal; + /** Constructs a `CheckCancel` transactor bound to the given apply context. */ explicit CheckCancel(ApplyContext& ctx) : Transactor(ctx) { } + /** + * Stateless validation phase — always succeeds. + * + * All meaningful validation (Check existence, canceller permission) is + * deferred to `preclaim`, which has read access to ledger state. + * + * @param ctx The preflight context (no ledger access). + * @return `tesSUCCESS` unconditionally. + */ static NotTEC preflight(PreflightContext const& ctx); + /** + * Ledger-state validation phase. + * + * Resolves the Check object via `sfCheckID`. Enforces the permission rule: + * if the check has not expired, only the source or destination account may + * cancel it. Expiry is evaluated against the **parent** ledger's close time + * (the only definitively known close time at apply time). + * + * @param ctx The preclaim context providing a read-only ledger view. + * @return `tesSUCCESS` if the caller is permitted to cancel. + * @return `tecNO_ENTRY` if the Check object does not exist. + * @return `tecNO_PERMISSION` if the check is unexpired and the submitter + * is neither the source nor the destination account. + */ static TER preclaim(PreclaimContext const& ctx); + /** + * Commits the cancellation to the ledger. + * + * Performs four coordinated writes in order: + * 1. Removes the Check from the destination account's owner directory + * using the stored `sfDestinationNode` page index (skipped when source + * and destination are the same account). + * 2. Removes the Check from the source account's owner directory using + * the stored `sfOwnerNode` page index. + * 3. Decrements the source account's owner count by 1 via + * `adjustOwnerCount`, releasing the reserve that `CheckCreate` claimed. + * 4. Erases the Check SLE from the ledger. + * + * The two `dirRemove` calls are guarded by `tefBAD_LEDGER` paths + * (marked `LCOV_EXCL`) — the stored page indices are immutable after + * creation and `preclaim` has already confirmed the Check exists, so + * those branches represent ledger corruption rather than user error. + * + * @return `tesSUCCESS` on success. + * @return `tecNO_ENTRY` if the Check cannot be peeked (should not occur + * after a passing `preclaim`). + * @return `tefBAD_LEDGER` if a directory removal fails due to ledger + * corruption (unreachable in a correctly functioning ledger). + */ TER doApply() override; + /** + * Per-entry invariant visitor hook — no transaction-specific invariants + * are currently registered for `CheckCancel`. + * + * @param isDelete Whether the entry is being deleted. + * @param before SLE state before the transaction. + * @param after SLE state after the transaction. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** + * Post-transaction invariant finalizer — no transaction-specific + * invariants are currently registered for `CheckCancel`. + * + * @param tx The applied transaction. + * @param result The TER result from `doApply`. + * @param fee The fee deducted. + * @param view A read-only view of the resulting ledger state. + * @param j Journal for diagnostic logging. + * @return `true` unconditionally (no invariants to check yet). + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/check/CheckCash.h b/include/xrpl/tx/transactors/check/CheckCash.h index 6141c93bc0..32372daa3f 100644 --- a/include/xrpl/tx/transactors/check/CheckCash.h +++ b/include/xrpl/tx/transactors/check/CheckCash.h @@ -1,36 +1,180 @@ +/** @file + * Declares the CheckCash transactor for the XRP Ledger. + * + * `CheckCash` is the only check transactor that transfers value. The other + * two (`CheckCreate`, `CheckCancel`) manage the ledger object lifecycle + * without moving funds. + */ + #pragma once #include namespace xrpl { +/** Executes a `ttCHECK_CASH` transaction, redeeming a Check for its value. + * + * A Check is an asynchronous, pull-based payment: the sender creates it via + * `CheckCreate`, authorizing the named destination to withdraw up to + * `sfSendMax` at any future time. `CheckCash` is how the destination collects + * those funds. After a successful cash, the Check ledger object is deleted + * and the source's owner reserve is released. + * + * The transaction accepts either `sfAmount` (exact delivery) or + * `sfDeliverMin` (deliver as much as possible, at least this minimum) — + * exactly one must be present. XRP transfers are handled directly; IOU and + * MPT transfers route through the `flow()` payment engine, which may + * auto-create a trust line or MPT holding on the destination if needed. + * + * `ConsequencesFactory` is `Normal` — the transaction claims its fee on + * failure and does not block other transactions in the sender's queue. + * + * @note MPT-denominated checks require the `featureMPTokensV2` amendment, + * enforced in `checkExtraFeatures` rather than `preflight`. + * @see CheckCreate, CheckCancel + */ class CheckCash : public Transactor { public: static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Normal; + /** Constructs a `CheckCash` transactor bound to the given apply context. */ explicit CheckCash(ApplyContext& ctx) : Transactor(ctx) { } + /** Amendment guard for MPT-denominated checks. + * + * Returns `false` (blocking the transaction) when either `sfAmount` or + * `sfDeliverMin` names an MPT asset and `featureMPTokensV2` is not yet + * active. IOU and XRP checks pass unconditionally. + * + * @param ctx The preflight context carrying the transaction and active + * rule set. + * @return `true` if the transaction may proceed to `preflight`; `false` + * if it should be rejected because the required amendment is not + * enabled. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Stateless structural validation. + * + * Enforces two invariants without accessing ledger state: + * 1. Exactly one of `sfAmount` or `sfDeliverMin` is present; both or + * neither is `temMALFORMED`. + * 2. The chosen amount passes `isLegalNet()`, is strictly positive, and + * does not name `badAsset()`. + * + * @param ctx The preflight context (no ledger access). + * @return `tesSUCCESS` if the transaction is structurally valid. + * @return `temMALFORMED` if both or neither amount field is present, or + * if the amount is zero, negative, or an illegal asset. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Read-only ledger-state validation. + * + * Resolves the Check via `sfCheckID` and verifies all preconditions + * without modifying state: + * - Check exists (`tecNO_ENTRY` otherwise). + * - Submitter is the check's destination (`tecNO_PERMISSION` otherwise). + * - Source and destination are different accounts (`tecINTERNAL` if not — + * this is a "should never happen" path guarded by `LCOV_EXCL`). + * - Destination tag is present if the destination account requires it. + * - Check has not expired (`tecEXPIRED` otherwise). + * - Requested asset and issuer match the check's `sfSendMax`; requested + * amount does not exceed `sfSendMax`. + * - Source has sufficient available funds. For XRP checks, one reserve + * increment is added to the available balance to account for the reserve + * that will be released when the Check is deleted. + * - For IOU assets not self-issued by the destination: issuer account + * exists, trust line authorization is satisfied, and the destination's + * trust line is not frozen. + * - For MPT assets: destination's holding passes `requireAuth` (weak), + * is not frozen (`isFrozen`), and the asset permits DEX trading + * (`canTrade`). + * + * @param ctx The preclaim context providing a read-only ledger view. + * @return `tesSUCCESS` if all conditions are met. + * @return `tecNO_ENTRY` if the Check object does not exist. + * @return `tecNO_PERMISSION` if the submitter is not the check's + * destination. + * @return `tecINTERNAL` if source and destination are the same account + * (ledger-corruption sentinel, should be unreachable). + * @return `tecEXPIRED` if the check has passed its expiration time. + * @return `tecINSUFFICIENT_FUNDS` if the source cannot cover the + * requested amount. + * @return `tecFROZEN` / `tecLOCKED` if the relevant trust line or MPT + * holding is frozen or locked. + * @return `tecNO_AUTH` if authorization is required but not granted. + */ static TER preclaim(PreclaimContext const& ctx); + /** Commits the check redemption to the ledger. + * + * All mutations occur inside a `PaymentSandbox`; `psb.apply()` is called + * only after every step succeeds, ensuring atomicity. + * + * **XRP path** — handled directly (not via `flow()`): `xrpLiquid()` is + * called with a `-1` reserve adjustment to account for the reserve + * released when the Check is deleted. For `sfDeliverMin`, delivery is + * `max(DeliverMin, min(sendMax, srcLiquid))`. + * + * **IOU/MPT path** — routed through `flow()`. If no trust line exists + * between the destination and the issuer, one is created automatically + * (the destination is signing, so their intent is unambiguous); its limit + * is temporarily raised to `cMaxValue` for the duration of the `flow()` + * call, then restored via `scope_exit`. For MPT assets without an + * existing holding, `checkCreateMPT()` initializes the slot. For + * `sfDeliverMin`, the ask amount passed to `flow()` is `cMaxValue / 2` + * to tolerate up to 200% gateway transfer rates without overflow. + * + * On success: the Check is removed from both the owner and destination + * account directories, the source's owner count is decremented, and the + * Check SLE is erased. + * + * @return `tesSUCCESS` on successful fund transfer and Check deletion. + * @return `tecNO_ENTRY` if the Check SLE cannot be peeked (should not + * occur after passing `preclaim`). + * @return `tecPATH_PARTIAL` if `sfDeliverMin` is specified and `flow()` + * cannot deliver the minimum amount. + * @return `tecPATH_DRY` if the payment path is exhausted. + * @return `tefBAD_LEDGER` if a directory removal fails due to ledger + * corruption (unreachable in a correctly functioning ledger). + */ TER doApply() override; + /** Per-entry invariant visitor hook. + * + * No transaction-specific invariants are currently registered for + * `CheckCash`; this is a no-op stub reserved for future use. + * + * @param isDelete Whether the entry is being deleted. + * @param before SLE state before the transaction. + * @param after SLE state after the transaction. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Post-transaction invariant finalizer. + * + * No transaction-specific invariants are currently registered for + * `CheckCash`; always returns `true`. Reserved for future use. + * + * @param tx The applied transaction. + * @param result The TER result from `doApply`. + * @param fee The fee deducted. + * @param view A read-only view of the resulting ledger state. + * @param j Journal for diagnostic logging. + * @return `true` unconditionally. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/check/CheckCreate.h b/include/xrpl/tx/transactors/check/CheckCreate.h index dc3ece3446..c232e3b3e3 100644 --- a/include/xrpl/tx/transactors/check/CheckCreate.h +++ b/include/xrpl/tx/transactors/check/CheckCreate.h @@ -1,36 +1,175 @@ +/** @file + * Declares the CheckCreate transactor for the XRP Ledger. + * + * A Check is a deferred payment authorization: the sender commits a + * `sfSendMax` amount to a named destination, which may later redeem it + * via `CheckCash` or cancel it via `CheckCancel`. This header defines the + * interface; the validation and ledger-mutation logic lives in + * `CheckCreate.cpp`. + */ + #pragma once #include namespace xrpl { +/** Executes a `ttCHECK_CREATE` transaction, writing a Check object to the + * ledger. + * + * A Check is a pull-based payment authorization: the drawer (source account) + * specifies a destination and a maximum spend (`sfSendMax`), but no funds + * move at creation time. The destination may later redeem the Check via + * `CheckCash`, or either party may remove it via `CheckCancel`. + * + * Creating a Check adds one object to the sender's owner directory and + * increments their owner count, increasing their reserve requirement by one + * increment. The reserve is checked against `preFeeBalance_` so that the + * transaction fee may be paid from reserve funds while still requiring full + * reserve coverage for the new object. + * + * `kCONSEQUENCES_FACTORY` is `Normal` — the transaction claims its fee on + * failure and does not block other transactions in the sender's queue. + * + * @note MPT-denominated checks require the `featureMPTokensV2` amendment, + * enforced in `checkExtraFeatures` rather than in `preflight`. + * @see CheckCash, CheckCancel + */ class CheckCreate : public Transactor { public: static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Normal; + /** Constructs a `CheckCreate` transactor bound to the given apply context. */ explicit CheckCreate(ApplyContext& ctx) : Transactor(ctx) { } + /** Amendment guard for MPT-denominated checks. + * + * Returns `false` (blocking the transaction) when `sfSendMax` names an + * MPT asset and `featureMPTokensV2` is not yet active. IOU and XRP checks + * pass unconditionally. This is the correct place for amendment gating, + * keeping feature checks separate from the core field validation in + * `preflight`. + * + * @param ctx The preflight context carrying the transaction and active + * rule set. + * @return `true` if the transaction may proceed to `preflight`; `false` + * if it should be rejected because the required amendment is not + * enabled. + */ static bool checkExtraFeatures(xrpl::PreflightContext const& ctx); + /** Stateless structural validation. + * + * Validates the transaction fields without accessing ledger state: + * - Rejects self-addressed checks (`sfAccount == sfDestination`) with + * `temREDUNDANT`. + * - Rejects `sfSendMax` that fails `isLegalNet()`, is non-positive, or + * names `badAsset()`. + * - Rejects a zero `sfExpiration` value; a non-zero value means "expires + * at that time", while absence means "never expires". + * + * @param ctx The preflight context (no ledger access). + * @return `tesSUCCESS` if the transaction is structurally valid. + * @return `temREDUNDANT` if the source and destination accounts are + * identical. + * @return `temBAD_AMOUNT` if `sfSendMax` is zero, negative, or otherwise + * malformed. + * @return `temBAD_CURRENCY` if `sfSendMax` names `badAsset()`. + * @return `temBAD_EXPIRATION` if `sfExpiration` is present and zero. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Read-only ledger-state validation. + * + * Checks all preconditions that require ledger access: + * - Destination account exists. + * - Destination has not set `lsfDisallowIncomingCheck`. + * - Destination is not a pseudo-account (not amendment-gated; the + * pseudo-account discriminator fields are themselves amendment-gated). + * - Destination tag is present when `lsfRequireDestTag` is set on the + * destination. + * - For non-XRP `sfSendMax`: the asset is not globally frozen; IOU + * trustlines from sender to issuer and from issuer to destination are + * not individually frozen; MPT holdings for sender and destination (when + * neither is the issuer) are not locked. A missing trustline on the + * sender side is permitted — the check is speculative and the trustline + * need not exist at creation time. + * - `sfExpiration`, if present, has not already passed. + * - The asset is tradeable via `canTrade`. + * + * @param ctx The preclaim context providing a read-only ledger view. + * @return `tesSUCCESS` if all conditions are met. + * @return `tecNO_DST` if the destination account does not exist. + * @return `tecNO_PERMISSION` if the destination has set + * `lsfDisallowIncomingCheck` or is a pseudo-account. + * @return `tecDST_TAG_NEEDED` if the destination requires a destination + * tag and none was supplied. + * @return `tecFROZEN` if an IOU trustline (global or per-line) is frozen. + * @return `tecLOCKED` if the MPT asset or an MPT holding is locked. + * @return `tecEXPIRED` if the specified expiration has already passed. + */ static TER preclaim(PreclaimContext const& ctx); + /** Commits the Check creation to the ledger. + * + * Enforces the reserve requirement against `preFeeBalance_` (the + * pre-fee balance captured by the base class), deliberately allowing the + * transaction fee to be paid from reserve funds while still requiring full + * reserve coverage for the new Check object. + * + * Constructs the Check SLE keyed by `keylet::check(account_, seq)`, where + * `seq` is the transaction's sequence or ticket value. Populates all + * optional fields (`sfSourceTag`, `sfDestinationTag`, `sfInvoiceID`, + * `sfExpiration`) only when present in the transaction. The Check is + * inserted into both the sender's and destination's owner directories; + * `sfOwnerNode` and `sfDestinationNode` record the respective directory + * page indices for O(1) removal. On success, `adjustOwnerCount` + * increments the sender's owner count by 1. + * + * @return `tesSUCCESS` on successful Check creation. + * @return `tecINSUFFICIENT_RESERVE` if the sender's pre-fee balance + * cannot cover the incremented reserve requirement. + * @return `tecDIR_FULL` if either owner directory has no room for a new + * entry (ledger-corruption sentinel, marked `LCOV_EXCL`). + * @return `tefINTERNAL` if the sender's `AccountRoot` SLE cannot be + * peeked (ledger-corruption sentinel, marked `LCOV_EXCL`). + */ TER doApply() override; + /** Per-entry invariant visitor hook. + * + * No transaction-specific invariants are currently registered for + * `CheckCreate`; this is a no-op stub reserved for future use. + * + * @param isDelete Whether the entry is being deleted. + * @param before SLE state before the transaction. + * @param after SLE state after the transaction. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Post-transaction invariant finalizer. + * + * No transaction-specific invariants are currently registered for + * `CheckCreate`; always returns `true`. Reserved for future use. + * + * @param tx The applied transaction. + * @param result The TER result from `doApply`. + * @param fee The fee deducted. + * @param view A read-only view of the resulting ledger state. + * @param j Journal for diagnostic logging. + * @return `true` unconditionally. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/credentials/CredentialAccept.h b/include/xrpl/tx/transactors/credentials/CredentialAccept.h index ac76384142..b488003ac2 100644 --- a/include/xrpl/tx/transactors/credentials/CredentialAccept.h +++ b/include/xrpl/tx/transactors/credentials/CredentialAccept.h @@ -4,33 +4,107 @@ namespace xrpl { +/** Transactor for the CredentialAccept transaction type. + * + * Implements the subject-side acceptance step of the two-phase credential + * issuance lifecycle. After an issuer creates a credential directed at a + * subject via `CredentialCreate`, the subject must submit a `CredentialAccept` + * transaction to activate it. Only accepted credentials are usable in + * permission-gated operations. + * + * On acceptance the reserve burden shifts atomically from issuer to subject: + * the issuer's owner count decrements by 1 and the subject's increments by 1. + * If the credential has already expired at apply time it is deleted and + * `tecEXPIRED` is returned, even though the accepting transaction itself + * fails. + */ class CredentialAccept : public Transactor { public: + /** Standard consequence semantics: consumes a sequence number, no blocking + * or custom consequence logic. + */ static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Normal; + /** Construct a CredentialAccept transactor for the given apply context. */ explicit CredentialAccept(ApplyContext& ctx) : Transactor(ctx) { } + /** Return the set of flags permitted on this transaction type. + * + * When `fixInvalidTxFlags` is active, restricts accepted flags to + * `tfUniversalMask`, causing any unknown flag bits to be rejected as + * malformed. Returns `0` (allow any flags) on pre-fix ledgers to preserve + * backward compatibility. + * + * @param ctx Preflight context carrying the current rule set. + * @return Bitmask of valid non-universal flags, or `0` if unconstrained. + */ static std::uint32_t getFlagsMask(PreflightContext const& ctx); + /** Validate transaction fields without accessing ledger state. + * + * Enforces two structural invariants: `sfIssuer` must not be the zero + * account ID, and `sfCredentialType` must be non-empty and within + * `kMAX_CREDENTIAL_TYPE_LENGTH` bytes. Both violations produce `tem` + * codes, meaning the transaction is permanently invalid and will not be + * retried. + * + * @param ctx Preflight context carrying the transaction and rule set. + * @return `tesSUCCESS` on valid input; `temINVALID_ACCOUNT_ID` if + * `sfIssuer` is zero; `temMALFORMED` if `sfCredentialType` is empty + * or too long. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Perform read-only ledger checks before applying the transaction. + * + * Verifies that the issuer account exists, that the credential object + * keyed by `(subject, issuer, credentialType)` is present in the ledger, + * and that `lsfAccepted` has not already been set on it. + * + * @param ctx Preclaim context providing a read-only ledger view. + * @return `tesSUCCESS` if all checks pass; `tecNO_ISSUER` if the issuer + * account does not exist; `tecNO_ENTRY` if the credential object is + * absent; `tecDUPLICATE` if the credential is already accepted. + */ static TER preclaim(PreclaimContext const& ctx); + /** Execute the acceptance state transition against the mutable ledger view. + * + * Checks that the subject has sufficient reserve to take ownership of the + * credential (comparing `preFeeBalance_` against the post-increment owner + * reserve). If the credential has expired by `parentCloseTime`, deletes it + * via `credentials::deleteSLE` and returns `tecEXPIRED`. On the happy + * path, sets `lsfAccepted` on the credential SLE and transfers the owner + * count: issuer decremented by 1, subject incremented by 1. + * + * @return `tesSUCCESS` on successful acceptance; `tecINSUFFICIENT_RESERVE` + * if the subject cannot afford the incremented reserve; `tecEXPIRED` + * if the credential has passed its expiration (credential is deleted); + * `tefINTERNAL` if account SLEs or the credential SLE cannot be loaded + * (unreachable under correct ledger invariants). + * @note The reserve is checked against `preFeeBalance_` (balance before + * fee deduction), ensuring the fee has already been accounted for. + */ TER doApply() override; + /** Invariant visitor hook; no credential-specific invariants are checked. */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Invariant finalizer hook; no credential-specific invariants are checked. + * + * @return Always `true` (no invariants to enforce yet). + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/credentials/CredentialCreate.h b/include/xrpl/tx/transactors/credentials/CredentialCreate.h index b6476ec460..0f82703c19 100644 --- a/include/xrpl/tx/transactors/credentials/CredentialCreate.h +++ b/include/xrpl/tx/transactors/credentials/CredentialCreate.h @@ -4,33 +4,117 @@ namespace xrpl { +/** Transactor for the CredentialCreate transaction type. + * + * Implements the issuance step of the W3C Verifiable Credentials (VC) + * lifecycle on the XRP Ledger. A trusted issuer attests facts about a subject + * account by submitting this transaction; the resulting credential SLE can be + * inspected by third parties without contacting the issuer again. + * + * The issuer bears the reserve cost for the credential until the subject + * accepts it via `CredentialAccept`. For self-issued credentials (subject == + * issuer) the credential is immediately marked `lsfAccepted` and inserted into + * only the single shared owner directory. + * + * @see CredentialAccept, CredentialDelete + */ class CredentialCreate : public Transactor { public: + /** Standard consequence semantics: consumes a sequence number, no blocking + * or custom consequence logic. + */ static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Normal; + /** Construct a CredentialCreate transactor for the given apply context. */ explicit CredentialCreate(ApplyContext& ctx) : Transactor(ctx) { } + /** Return the set of flags permitted on this transaction type. + * + * When `fixInvalidTxFlags` is active, restricts accepted flags to + * `tfUniversalMask`, causing any unknown flag bits to be rejected as + * malformed. Returns `0` (allow any flags) on pre-fix ledgers to preserve + * backward compatibility. + * + * @param ctx Preflight context carrying the current rule set. + * @return Bitmask of valid non-universal flags, or `0` if unconstrained. + */ static std::uint32_t getFlagsMask(PreflightContext const& ctx); + /** Validate transaction fields without accessing ledger state. + * + * Checks three structural invariants: `sfSubject` must be present and + * non-zero; the optional `sfURI`, if provided, must be non-empty and + * within `kMAX_CREDENTIAL_URI_LENGTH` bytes; and `sfCredentialType` must + * be non-empty and within `kMAX_CREDENTIAL_TYPE_LENGTH` bytes. All + * violations produce `temMALFORMED`, permanently rejecting the + * transaction without charging a fee. + * + * @param ctx Preflight context carrying the transaction and rule set. + * @return `tesSUCCESS` on valid input; `temMALFORMED` if `sfSubject` is + * absent, `sfURI` is empty or too long, or `sfCredentialType` is empty + * or too long. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Perform read-only ledger checks before applying the transaction. + * + * Verifies that the target subject account exists in the ledger and that + * no credential keyed by `(subject, issuer, credentialType)` already + * exists. These checks require ledger lookups and are therefore separated + * from `preflight`, which is cacheable across ledger closes. + * + * @param ctx Preclaim context providing a read-only ledger view. + * @return `tesSUCCESS` if all checks pass; `tecNO_TARGET` if the subject + * account does not exist; `tecDUPLICATE` if the credential triple is + * already present in the ledger. + */ static TER preclaim(PreclaimContext const& ctx); + /** Create the credential SLE and insert it into the ledger. + * + * Performs three sequential checks before mutating ledger state: + * (1) if `sfExpiration` is present and already behind the ledger's + * `parentCloseTime`, returns `tecEXPIRED` without creating the object; + * (2) confirms the issuer holds sufficient reserve for one additional owned + * object, comparing against `preFeeBalance_`; (3) inserts the credential + * into the issuer's owner directory and increments the issuer's owner count. + * + * For third-party credentials (subject != issuer) the object is also + * inserted into the subject's owner directory (without incrementing the + * subject's owner count), enabling `CredentialAccept` to later transfer + * reserve ownership. For self-issued credentials the `lsfAccepted` flag is + * set immediately and only the single shared directory is used. + * + * @return `tesSUCCESS` on successful creation; `tecEXPIRED` if the + * supplied expiration is already in the past; `tecINSUFFICIENT_RESERVE` + * if the issuer cannot afford the incremented reserve; `tecDIR_FULL` + * if either owner directory has no space for a new entry; + * `tefINTERNAL` if the issuer's `AccountRoot` SLE cannot be loaded + * (unreachable under correct ledger invariants). + * @note Expiration is compared against `parentCloseTime`, not the current + * ledger's own close time, consistent with XRPL's convention for + * time-sensitive operations. + */ TER doApply() override; + /** Invariant visitor hook; no credential-specific invariants are checked. */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Invariant finalizer hook; no credential-specific invariants are checked. + * + * @return Always `true` (no invariants to enforce yet). + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/credentials/CredentialDelete.h b/include/xrpl/tx/transactors/credentials/CredentialDelete.h index bcae90cefa..90e334386e 100644 --- a/include/xrpl/tx/transactors/credentials/CredentialDelete.h +++ b/include/xrpl/tx/transactors/credentials/CredentialDelete.h @@ -4,33 +4,105 @@ namespace xrpl { +/** Transactor for the CredentialDelete transaction type. + * + * Removes a `Credential` ledger object — the on-chain record that an issuer + * has asserted a verifiable claim about a subject — from the ledger state. + * Either the subject or the issuer may delete a credential unconditionally. + * A third party may only delete a credential that has already expired, which + * allows orphaned objects to be pruned by anyone and their reserve reclaimed. + * + * @see CredentialCreate, CredentialAccept + */ class CredentialDelete : public Transactor { public: + /** Standard consequence semantics: consumes a sequence number, no blocking + * or custom consequence logic. + */ static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Normal; + /** Construct a CredentialDelete transactor for the given apply context. */ explicit CredentialDelete(ApplyContext& ctx) : Transactor(ctx) { } + /** Return the set of flags permitted on this transaction type. + * + * When `fixInvalidTxFlags` is active, restricts accepted flags to + * `tfUniversalMask`, causing any unknown flag bits to be rejected as + * malformed. Returns `0` (allow any flags) on pre-fix ledgers to preserve + * backward compatibility. + * + * @param ctx Preflight context carrying the current rule set. + * @return Bitmask of valid non-universal flags, or `0` if unconstrained. + */ static std::uint32_t getFlagsMask(PreflightContext const& ctx); + /** Validate transaction fields without accessing ledger state. + * + * Enforces three structural invariants: at least one of `sfSubject` or + * `sfIssuer` must be present (both absent → `temMALFORMED`, as the + * credential cannot be identified); any present `sfSubject` or `sfIssuer` + * must be a non-zero `AccountID`; and `sfCredentialType` must be non-empty + * and within `kMAX_CREDENTIAL_TYPE_LENGTH` bytes. + * + * @param ctx Preflight context carrying the transaction and rule set. + * @return `tesSUCCESS` on valid input; `temMALFORMED` if both identity + * fields are absent or `sfCredentialType` is empty or too long; + * `temINVALID_ACCOUNT_ID` if `sfSubject` or `sfIssuer` is the zero + * account. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Perform read-only ledger checks before applying the transaction. + * + * Derives the credential keylet from `(subject, issuer, credentialType)`, + * substituting `sfAccount` for any absent `sfSubject` or `sfIssuer`. This + * default-to-sender substitution is how self-deletion works: a subject can + * omit `sfIssuer` when they are also the sender, and vice-versa for the + * issuer. Returns `tecNO_ENTRY` if no matching credential exists. + * + * @param ctx Preclaim context providing a read-only ledger view. + * @return `tesSUCCESS` if the credential exists; `tecNO_ENTRY` otherwise. + */ static TER preclaim(PreclaimContext const& ctx); + /** Remove the credential SLE from the ledger. + * + * Applies the same sender-as-default substitution for absent `sfSubject` + * or `sfIssuer` as `preclaim`. Authorization is then checked: if the + * sender is neither the subject nor the issuer, deletion is only permitted + * when the credential has expired (as determined by comparing `sfExpiration` + * against `parentCloseTime`). A valid, unexpired credential may not be + * deleted by a third party. Authorized deletions are delegated to + * `credentials::deleteSLE()`, which unlinks the object from the owner + * directory and decrements the owner's reserve count. + * + * @return `tesSUCCESS` on successful deletion; `tecNO_PERMISSION` if the + * sender is a third party and the credential has not yet expired; + * `tefINTERNAL` if the SLE cannot be loaded after passing `preclaim` + * (indicates ledger corruption, unreachable in practice). + * @note Expiration is evaluated against `parentCloseTime`, consistent with + * XRPL's convention for time-sensitive ledger operations. + */ TER doApply() override; + /** Invariant visitor hook; no credential-specific invariants are checked. */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Invariant finalizer hook; no credential-specific invariants are checked. + * + * @return Always `true` (no invariants to enforce yet). + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/delegate/DelegateSet.h b/include/xrpl/tx/transactors/delegate/DelegateSet.h index b0fcbfea22..decd58e2c8 100644 --- a/include/xrpl/tx/transactors/delegate/DelegateSet.h +++ b/include/xrpl/tx/transactors/delegate/DelegateSet.h @@ -4,30 +4,95 @@ namespace xrpl { +/** Transactor for the `DelegateSet` transaction type. + * + * Creates, updates, or deletes a `Delegate` ledger entry that grants a + * delegate account a named set of transaction permissions on behalf of the + * grantor. A single transaction covers all three operations: the action is + * determined by whether `sfPermissions` is non-empty and whether an existing + * `Delegate` SLE is present in the ledger. + * + * @note Self-delegation (grantor == delegate) is rejected in `preflight`. + * An empty `sfPermissions` array with no existing delegate object is + * rejected in `preclaim` to prevent a fee-charging no-op. + */ class DelegateSet : public Transactor { public: static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Normal; + /** Construct a `DelegateSet` transactor bound to the given apply context. + * + * @param ctx The apply context for this transaction. + */ explicit DelegateSet(ApplyContext& ctx) : Transactor(ctx) { } + /** Validate the transaction structure without ledger access. + * + * Rejects if `sfPermissions` exceeds `kPERMISSION_MAX_SIZE`, if the + * grantor and delegate are the same account, if any permission value + * appears more than once, or if any permission value is not delegable + * under the current rules. + * + * @param ctx The preflight context carrying the transaction and rules. + * @return `tesSUCCESS` on success; a `tem*` code on structural failure. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Validate ledger preconditions against a read-only view. + * + * Confirms that both the grantor account and the `sfAuthorize` target + * account exist in the ledger. Rejects with `tecNO_ENTRY` if an empty + * permissions array is submitted but no existing `Delegate` SLE is found + * (delete-intent with no object to delete). + * + * @param ctx The preclaim context carrying the transaction and a + * read-only ledger view. + * @return `tesSUCCESS`, `terNO_ACCOUNT`, `tecNO_TARGET`, or `tecNO_ENTRY`. + */ static TER preclaim(PreclaimContext const& ctx); + /** Apply the transaction to the mutable ledger view. + * + * Dispatches across three paths based on current ledger state: + * - **Update**: existing `Delegate` SLE + non-empty permissions → + * replaces `sfPermissions` in place, no reserve change. + * - **Delete**: existing `Delegate` SLE + empty permissions → + * delegates to `deleteDelegate()`. + * - **Create**: no existing SLE + non-empty permissions → checks + * reserve, allocates a new `Delegate` SLE, inserts it into both the + * grantor's and the delegate's owner directories, and increments the + * grantor's owner count. + * + * @return `tesSUCCESS` on success; `tecINSUFFICIENT_RESERVE` if the + * grantor cannot cover the reserve for a new object; `tecDIR_FULL` + * if an owner directory is full; `tecINTERNAL` if the no-SLE / + * empty-permissions branch is reached (defensive guard — `preclaim` + * should have blocked this case). + */ TER doApply() override; + /** @copydoc Transactor::visitInvariantEntry + * + * No transaction-specific invariants are enforced; this is a no-op + * reserved for future use. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** @copydoc Transactor::finalizeInvariants + * + * No transaction-specific invariants are enforced; always returns + * `true`. Reserved for future use. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, @@ -36,7 +101,23 @@ public: ReadView const& view, beast::Journal const& j) override; - // Interface used by AccountDelete + /** Remove a `Delegate` SLE from the ledger and clean up its directory + * entries. + * + * Called by `AccountDelete` to clean up delegate objects owned by an + * account being deleted, and by `doApply()` when the submitter passes + * an empty `sfPermissions` array. Removes the SLE from both the + * grantor's owner directory (via `sfOwnerNode`) and, if present, the + * delegate's owner directory (via `sfDestinationNode`), decrements the + * grantor's owner count, then erases the SLE. + * + * @param view The mutable ledger view to apply the deletion to. + * @param sle The `Delegate` SLE to remove; must not be null. + * @param j Journal for diagnostic logging. + * @return `tesSUCCESS` on success; `tefBAD_LEDGER` if a directory + * removal fails (indicates ledger state corruption, logged at + * `fatal` severity and marked unreachable in coverage). + */ static TER deleteDelegate(ApplyView& view, std::shared_ptr const& sle, beast::Journal j); }; diff --git a/include/xrpl/tx/transactors/dex/AMMBid.h b/include/xrpl/tx/transactors/dex/AMMBid.h index 38fb1a57fe..ea07dcea01 100644 --- a/include/xrpl/tx/transactors/dex/AMMBid.h +++ b/include/xrpl/tx/transactors/dex/AMMBid.h @@ -4,71 +4,121 @@ namespace xrpl { -/** AMMBid implements AMM bid Transactor. - * This is a mechanism for an AMM instance to auction-off - * the trading advantages to users (arbitrageurs) at a discounted - * TradingFee for a 24 hour slot. Any account that owns corresponding - * LPTokens can bid for the auction slot of that AMM instance. - * Part of the proceeds from the auction, i.e. LPTokens are refunded - * to the current slot-holder computed on a pro rata basis. - * Remaining part of the proceeds - in the units of LPTokens- is burnt, - * thus effectively increasing the LPs shares. - * Total slot time of 24 hours is divided into 20 equal intervals. - * The auction slot can be in any of the following states at any time: - * - Empty - no account currently holds the slot. - * - Occupied - an account owns the slot with at least 5% of the remaining - * slot time (in one of 1-19 intervals). - * - Tailing - an account owns the slot with less than 5% of the remaining time. - * The slot-holder owns the slot privileges when in state Occupied or Tailing. - * If x is the fraction of used slot time for the current slot holder - * and X is the price at which the slot can be bought specified in LPTokens - * then: The minimum bid price for the slot in first interval is - * f(x) = X * 1.05 + min_slot_price - * The bid price of slot any time is - * f(x) = X * 1.05 * (1 - x^60) + min_slot_price, where min_slot_price - * is a constant fraction of the total LPTokens. - * The revenue from a successful bid is split between the current slot-holder - * and the pool. The current slot holder is always refunded the remaining slot - * value f(x) = (1 - x) * X. - * The remaining LPTokens are burnt. - * The auction information is maintained in AuctionSlot of ltAMM object. - * AuctionSlot contains: - * Account - account id, which owns the slot. - * Expiration - slot expiration time - * DiscountedFee - trading fee charged to the account, default is 0. - * Price - price paid for the slot in LPTokens. - * AuthAccounts - up to four accounts authorized to trade at - * the discounted fee. - * @see [XLS30d:Continuous Auction - * Mechanism](https://github.com/XRPLF/XRPL-Standards/discussions/78) +/** Transactor for the AMM continuous auction slot bid (XLS-30d). + * + * Any LP holding tokens for an AMM pool may bid LP tokens for a 24-hour + * trading-fee discount slot, subdivided into 20 equal intervals (~72 min + * each). Auction slot states: + * - **Empty** — no current holder; minimum price applies. + * - **Occupied** — holder is in intervals 0–18; outbid formula applies. + * - **Tailing** — holder is in the final interval; holder pays minimum price + * only and receives no refund, discouraging last-minute camping. + * + * Pricing curve (X = price paid by current holder, x = fraction of time used): + * @code + * computedPrice = X * 1.05 * (1 - x^60) + minSlotPrice + * @endcode + * The `x^60` term decays rapidly early in the slot and flattens near expiry. + * The 5 % multiplier prevents token-free squatting. + * + * Bid revenue is split: `(1 - x) * X` LP tokens are refunded to the + * outgoing holder; the remainder is burned, increasing the proportional + * share of all remaining LPs. + * + * All ledger mutations are applied in a `Sandbox` and committed atomically + * only on success. + * + * @see https://github.com/XRPLF/XRPL-Standards/discussions/78 */ class AMMBid : public Transactor { public: + /** Normal consequences: bid consumes a bounded LP token amount and does not + * block other transactions from the same account while queued. */ static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Normal; explicit AMMBid(ApplyContext& ctx) : Transactor(ctx) { } + /** Gate the transaction on the AMM and MPT amendments. + * + * Returns false (yielding `temDISABLED`) if the base AMM feature is not + * active, or if either pool asset is an MPT and `featureMPTokensV2` is not + * yet enabled. + * + * @param ctx Preflight context providing the active ledger rules. + * @return true if the transaction type is enabled; false otherwise. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Stateless validation of the bid transaction fields. + * + * Rejects malformed asset pairs, validates that `sfBidMin` and `sfBidMax` + * are well-formed LP token amounts when present, and enforces that + * `sfAuthAccounts` contains at most `kAUCTION_SLOT_MAX_AUTH_ACCOUNTS` + * entries. Under `fixAMMv1_3`, additionally rejects duplicate accounts and + * self-authorization in `sfAuthAccounts`. + * + * @param ctx Preflight context (no ledger access). + * @return `tesSUCCESS` or a `tem*` code. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Read-only ledger validation for the bid transaction. + * + * Verifies the AMM object exists for the specified asset pair, the pool is + * not empty (`sfLPTokenBalance != 0`), all `sfAuthAccounts` entries + * reference existing on-ledger accounts, the submitter holds LP tokens, and + * any `sfBidMin`/`sfBidMax` amounts use the correct LP token asset and do + * not exceed the submitter's balance or the total pool balance. + * + * @param ctx Preclaim context with read-only ledger view. + * @return `tesSUCCESS`, a `ter*` retryable code, or a fee-claiming `tec*` + * code. + */ static TER preclaim(PreclaimContext const& ctx); + /** Execute the auction slot bid against a `Sandbox` view. + * + * Delegates to the file-local `applyBid` helper, which computes the bid + * price, refunds the outgoing slot holder (if any), burns the remainder of + * the payment as LP tokens, and writes the updated `sfAuctionSlot` object + * inside `ltAMM`. The sandbox is committed to the real ledger view only on + * full success; any failure leaves the ledger untouched. + * + * @return `tesSUCCESS` on success, or `tecAMM_FAILED` / + * `tecAMM_INVALID_TOKENS` / `tecINTERNAL` on failure. + */ TER doApply() override; + /** No-op stub; no transaction-specific invariant entries for `AMMBid` yet. + * + * @param isDelete True if the entry was erased. + * @param before Entry state before the transaction. + * @param after Entry state after the transaction. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** No-op stub; no transaction-specific post-conditions for `AMMBid` yet. + * + * Always returns true. Reserved for future transaction-specific invariants. + * + * @param tx The transaction being applied. + * @param result The tentative TER result. + * @param fee Fee consumed by the transaction. + * @param view Read-only ledger view after the transaction. + * @param j Journal for logging. + * @return true unconditionally. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/dex/AMMClawback.h b/include/xrpl/tx/transactors/dex/AMMClawback.h index 9a6dd36791..029c9191a8 100644 --- a/include/xrpl/tx/transactors/dex/AMMClawback.h +++ b/include/xrpl/tx/transactors/dex/AMMClawback.h @@ -1,9 +1,35 @@ +/** @file + * Declares the `AMMClawback` transactor (XLS-73), which allows a regulated + * token issuer to recover assets held inside an AMM liquidity position. + */ + #pragma once #include namespace xrpl { class Sandbox; + +/** Transactor for the `AMMClawback` transaction type (XLS-73). + * + * Allows a regulated-token issuer to reclaim assets from a specific holder's + * AMM liquidity position. This prevents holders from circumventing issuer + * clawback authority by depositing regulated tokens into an AMM pool. + * + * The clawback is implemented as a forced proportional withdrawal of the + * holder's LP tokens via `AMMWithdraw`'s helpers, with the proceeds sent to + * the issuer. Two modes are supported: full clawback (all LP tokens) when no + * `sfAmount` is specified, and partial clawback (up to a requested asset1 + * quantity) when `sfAmount` is present. Both modes pass a zero trading fee + * because the withdrawal is a proportional equal-ratio removal — charging a + * fee would penalise the issuer for exercising a regulatory right. + * + * All ledger mutations are accumulated in a `Sandbox` inside `doApply` and + * committed only on success, consistent with the standard transactor pattern. + * + * @see AMMWithdraw::equalWithdrawTokens + * @see AMMWithdraw::withdraw + */ class AMMClawback : public Transactor { public: @@ -13,27 +39,100 @@ public: { } + /** Gate the transaction on the `featureAMMClawback` amendment. + * + * Also requires `featureMPTokensV2` when any of `sfAsset`, `sfAsset2`, + * or `sfAmount` refers to an MPT issuance, ensuring MPT support is gated + * behind its own separate amendment rollout. + * + * @param ctx Preflight context providing access to the transaction and + * active amendment rules. + * @return `true` if the required amendments are enabled; `false` otherwise + * (transaction is rejected before preflight runs). + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Return the set of valid transaction flags for `AMMClawback`. + * + * @param ctx Preflight context (unused; present for interface uniformity). + * @return Bitmask of flags accepted by this transaction type + * (`tfAMMClawbackMask`). + */ static std::uint32_t getFlagsMask(PreflightContext const& ctx); + /** Stateless validation of the `AMMClawback` transaction fields. + * + * Enforces the following invariants without accessing ledger state: + * - `sfAsset` must not be XRP (only issued assets support clawback). + * - `sfAccount` (issuer) must equal `sfAsset`'s issuer field. + * - `sfHolder` must differ from `sfAccount`. + * - If `tfClawTwoAssets` is set, both `sfAsset` and `sfAsset2` must share + * the same issuer. + * - If `sfAmount` is present, its asset subfield must match `sfAsset` and + * the quantity must be positive. + * + * @param ctx Preflight context providing the transaction and active rules. + * @return `tesSUCCESS` on valid input; `temMALFORMED`, `temINVALID_FLAG`, + * or `temBAD_AMOUNT` on constraint violations. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Ledger-state permission checks for `AMMClawback`. + * + * Verifies that both the issuer account and the target AMM pool exist, and + * that the issuer has clawback authority over the relevant asset(s): + * - For IOU assets: the issuer's `AccountRoot` must have + * `lsfAllowTrustLineClawback` set and must not have `lsfNoFreeze` + * set (permanent freeze-waiver revokes clawback ability). + * - For MPT assets: the specific MPT issuance must carry + * `lsfMPTCanClawback` and must be owned by the transaction's account. + * + * When `tfClawTwoAssets` is set, permission is checked for both assets. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if all checks pass; `terNO_ACCOUNT` if issuer or + * holder account is absent; `terNO_AMM` if the pool does not exist; + * `tecNO_PERMISSION` if clawback authority is not established. + */ static TER preclaim(PreclaimContext const& ctx); + /** Execute the `AMMClawback` transaction against the ledger. + * + * Wraps `applyGuts` in a `Sandbox`. All ledger mutations are accumulated + * in the sandbox and flushed to the real `ApplyView` only if `applyGuts` + * returns `tesSUCCESS`. Failed executions leave no trace beyond fee + * deduction. + * + * @return `tesSUCCESS` on success; a `tec*` code on failure. + */ TER doApply() override; + /** Per-SLE invariant visitor — no transaction-specific invariants yet. + * + * @param isDelete `true` if the entry is being deleted. + * @param before SLE state before the transaction. + * @param after SLE state after the transaction. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Post-transaction invariant finalizer — no transaction-specific checks yet. + * + * @param tx The applied transaction. + * @param result TER result of the transaction. + * @param fee XRP fee charged. + * @param view Read-only view of the ledger after the transaction. + * @param j Journal for diagnostic logging. + * @return Always `true`; reserved for future invariant checks. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, @@ -43,19 +142,52 @@ public: beast::Journal const& j) override; private: + /** Core clawback logic executed inside a `Sandbox`. + * + * Reads pool balances, selects full or partial withdrawal based on + * whether `sfAmount` is present, drives `AMMWithdraw` helpers to burn + * LP tokens, and calls `directSendNoFee` to transfer recovered assets + * from the holder to the issuer. Both code paths pass + * `FreezeHandling::IgnoreFreeze` and `AuthHandling::IgnoreAuth` so the + * issuer is not blocked by trustline restrictions they may have set. + * + * @param view Sandbox accumulating all mutations for this transaction. + * @return `tesSUCCESS` on success; a `tec*` error code on failure. + */ TER applyGuts(Sandbox& view); - /** Withdraw both assets by providing maximum amount of asset1, - * asset2's amount will be calculated according to the current proportion. - * Since it is two-asset withdrawal, tfee is omitted. - * @param view - * @param ammAccount current AMM account - * @param amountBalance current AMM asset1 balance - * @param amount2Balance current AMM asset2 balance - * @param lptAMMBalance current AMM LPT balance - * @param amount asset1 withdraw amount - * @return + /** Proportional two-asset withdrawal bounded by a maximum asset1 amount. + * + * Computes the pool fraction corresponding to `amount` of asset1, derives + * the matching asset2 and LP-token quantities, then calls + * `AMMWithdraw::withdraw`. No trading fee is charged because both assets + * are withdrawn in the current pool ratio. + * + * If the derived LP-token quantity exceeds `holdLPtokens` (the holder + * does not have enough LP tokens to satisfy the requested asset1 amount), + * the method degrades gracefully to a full clawback of all LP tokens the + * holder owns via `AMMWithdraw::equalWithdrawTokens` — the issuer cannot + * over-claw. + * + * When `fixAMMClawbackRounding` is active, `getRoundedLPTokens` and + * `getRoundedAsset` are applied before calling `withdraw` to snap values + * to representable amounts and prevent dust or rounding-driven invariant + * violations. + * + * @param view Sandbox accumulating ledger mutations. + * @param ammSle The AMM ledger entry. + * @param holder Account whose LP tokens are being redeemed. + * @param ammAccount AMM pseudo-account holding the pooled assets. + * @param amountBalance Current pool balance of asset1. + * @param amount2Balance Current pool balance of asset2. + * @param lptAMMBalance Current total LP-token supply for the pool. + * @param holdLPtokens LP tokens currently held by `holder`. + * @param amount Maximum asset1 quantity the issuer wishes to recover. + * @return Tuple of `(TER, newLPTokenBalance, asset1Withdrawn, + * asset2Withdrawn)`. `asset2Withdrawn` is always populated for + * equal-ratio withdrawals; it is `std::nullopt` only on internal + * error paths. */ std::tuple> equalWithdrawMatchingOneAmount( diff --git a/include/xrpl/tx/transactors/dex/AMMContext.h b/include/xrpl/tx/transactors/dex/AMMContext.h index 3d13547b52..15951fbf79 100644 --- a/include/xrpl/tx/transactors/dex/AMMContext.h +++ b/include/xrpl/tx/transactors/dex/AMMContext.h @@ -1,3 +1,11 @@ +/** @file + * State governor for AMM participation in a single `flow()` call. + * + * `AMMContext` is threaded by reference through `toStrands()`, `flowOne()`, + * and every `AMMLiquidity` instance so that all parts of the payment engine + * share one authoritative counter for AMM iteration bookkeeping. + */ + #pragma once #include @@ -6,31 +14,56 @@ namespace xrpl { -/** Maintains AMM info per overall payment engine execution and - * individual iteration. - * Only one instance of this class is created in Flow.cpp::flow(). - * The reference is percolated through calls to AMMLiquidity class, - * which handles AMM offer generation. +/** Tracks AMM offer consumption state across one payment engine execution. + * + * A single instance is created at the top of `Flow.cpp::flow()` and passed + * by reference into every `AMMLiquidity` object for the life of that call. + * It governs two things: + * + * 1. **Iteration budget** — AMM pools are continuous and can produce a new + * offer at the current spot price after every consumption, unlike CLOB + * offers which are removed after use. `AMMContext` enforces + * `kMAX_ITERATIONS` to bound execution time. + * + * 2. **Offer-sizing strategy** — `multiPath_` tells `AMMLiquidity` whether + * to use quality-matched single-path sizing or Fibonacci-scaled multi-path + * sizing. It is updated dynamically as the set of active strands changes. + * + * @note Non-copyable by design: all `AMMLiquidity` objects hold a reference + * to the shared instance. Copying would create divergent counters that + * the engine could not reconcile. + * + * @see AMMLiquidity, AMMOffer, StrandFlow.h */ class AMMContext { public: - // Restrict number of AMM offers. If this restriction is removed - // then need to restrict in some other way because AMM offers are - // not counted in the BookStep offer counter. + /** Maximum number of payment engine iterations that may consume an AMM + * offer in a single `flow()` call. + * + * AMM pools are continuous liquidity sources that never become exhausted + * the way CLOB offers do, so `BookStep`'s built-in offer counter does not + * bound them. This constant caps the AMM-specific iteration count to + * prevent unbounded execution. `AMMLiquidity::generateFibSeqOffer` uses + * a Fibonacci table with exactly this many entries. + */ constexpr static std::uint8_t kMAX_ITERATIONS = 30; private: - // Tx account owner is required to get the AMM trading fee in BookStep AccountID account_; - // true if payment has multiple paths bool multiPath_{false}; - // Is true if AMM offer is consumed during a payment engine iteration. bool ammUsed_{false}; - // Counter of payment engine iterations with consumed AMM std::uint16_t ammIters_{0}; public: + /** Construct with the initiating account and initial path multiplicity. + * + * @param account `AccountID` of the transaction sender. `BookStep` + * uses this to look up the sender's per-account AMM trading fee. + * @param multiPath `true` if the payment already has more than one + * strand at construction time; `false` otherwise. May be updated + * later via `setMultiPath()`. + */ AMMContext(AccountID const& account, bool multiPath) : account_(account), multiPath_(multiPath) { } @@ -39,24 +72,55 @@ public: AMMContext& operator=(AMMContext const&) = delete; + /** Return whether the payment currently has more than one active strand. + * + * When `true`, `AMMLiquidity` sizes synthetic AMM offers using the + * Fibonacci sequence keyed to `curIters()`. When `false`, it sizes them + * so the post-swap spot price matches the best competing CLOB quality. + * + * @return `true` if multi-path mode is active. + */ [[nodiscard]] bool multiPath() const { return multiPath_; } + /** Update the path-multiplicity flag. + * + * Called by `StrandFlow.h` after each `activateNext()` because the + * number of active strands can change mid-payment. + * + * @param fs `true` if more than one strand is currently active. + */ void setMultiPath(bool fs) { multiPath_ = fs; } + /** Mark that an AMM offer was consumed during the current iteration. + * + * Called from `AMMOffer::consume()` the moment an AMM offer is accepted. + * The flag is read by `update()` to decide whether to increment the + * iteration counter, then cleared unconditionally. + */ void setAMMUsed() { ammUsed_ = true; } + /** Commit the current iteration's AMM usage to the running counter. + * + * Increments `ammIters_` if an AMM offer was consumed this iteration + * (i.e., `setAMMUsed()` was called), then resets the per-iteration flag. + * Called once per outer loop iteration in `StrandFlow.h` after the + * winning strand's sandbox has been applied to the main ledger view. + * + * @note Iterations that route entirely through CLOB offers leave + * `ammIters_` unchanged. + */ void update() { @@ -65,26 +129,49 @@ public: ammUsed_ = false; } + /** Return whether the AMM iteration budget has been exhausted. + * + * Checked by `AMMLiquidity::getOffer()` before generating each new AMM + * offer. Once `true`, no further AMM liquidity is offered for this + * payment. + * + * @return `true` if `ammIters_` has reached `kMAX_ITERATIONS`. + */ [[nodiscard]] bool maxItersReached() const { return ammIters_ >= kMAX_ITERATIONS; } + /** Return the number of iterations that have consumed AMM liquidity. + * + * Used by `AMMLiquidity::generateFibSeqOffer()` as the index into the + * Fibonacci scaling table. Zero on the first AMM-consuming iteration. + * + * @return Current AMM iteration count, in `[0, kMAX_ITERATIONS)`. + */ [[nodiscard]] std::uint16_t curIters() const { return ammIters_; } + /** Return the `AccountID` of the transaction sender. + * + * @return The account used to look up the per-sender AMM trading fee. + */ [[nodiscard]] AccountID account() const { return account_; } - /** Strand execution may fail. Reset the flag at the start - * of each payment engine iteration. + /** Reset the per-iteration AMM-used flag before a new strand attempt. + * + * Strand execution can fail and its sandbox is discarded. Resetting + * `ammUsed_` here prevents a failed strand's AMM consumption from being + * double-counted when `update()` is called for the strand that succeeds. + * Called by `StrandFlow.h` at the start of each strand attempt. */ void clear() diff --git a/include/xrpl/tx/transactors/dex/AMMCreate.h b/include/xrpl/tx/transactors/dex/AMMCreate.h index 35e3a951b4..784436aab5 100644 --- a/include/xrpl/tx/transactors/dex/AMMCreate.h +++ b/include/xrpl/tx/transactors/dex/AMMCreate.h @@ -4,35 +4,27 @@ namespace xrpl { -/** AMMCreate implements Automatic Market Maker(AMM) creation Transactor. - * It creates a new AMM instance with two tokens. Any trader, or Liquidity - * Provider (LP), can create the AMM instance and receive in return shares - * of the AMM pool in the form of LPTokens. The number of tokens that LP gets - * are determined by LPTokens = sqrt(A * B), where A and B is the current - * composition of the AMM pool. LP can add (AMMDeposit) or withdraw - * (AMMWithdraw) tokens from AMM and - * AMM can be used transparently in the payment or offer crossing transactions. - * Trading fee is charged to the traders for the trades executed against - * AMM instance. The fee is added to the AMM pool and distributed to the LPs - * in proportion to the LPTokens upon liquidity removal. The fee can be voted - * on by LP's (AMMVote). LP's can continuously bid (AMMBid) for the 24 hour - * auction slot, which enables LP's to trade at zero trading fee. - * AMM instance creates AccountRoot object with disabled master key - * for book-keeping of XRP balance if one of the tokens - * is XRP, a trustline for each IOU token, a trustline to keep track - * of LPTokens, and ltAMM ledger object. AccountRoot ID is generated - * internally from the parent's hash. ltAMM's object ID is - * hash{token1.currency, token1.issuer, token2.currency, token2.issuer}, where - * issue1 < issue2. ltAMM object provides mapping from the hash to AccountRoot - * ID and contains: AMMAccount - AMM AccountRoot ID. TradingFee - AMM voted - * TradingFee. VoteSlots - Array of VoteEntry, contains fee vote information. - * AuctionSlot - Auction slot, contains discounted fee bid information. - * LPTokenBalance - LPTokens outstanding balance. - * AMMToken - currency/issuer information for AMM tokens. - * AMMDeposit, AMMWithdraw, AMMVote, and AMMBid transactions use the hash - * to access AMM instance. - * @see [XLS30d:Creating AMM instance on - * XRPL](https://github.com/XRPLF/XRPL-Standards/discussions/78) +/** Bootstraps a new Automatic Market Maker pool on the XRP Ledger. + * + * `AMMCreate` is the entry point for the AMM DEX subsystem. It creates the + * four categories of ledger objects that constitute an AMM pool: a + * pseudo-account `AccountRoot` (keyed from the AMM keylet, carrying a + * disabled master key and tagged with `sfAMMID`), an `ltAMM` object keyed + * by `hash{asset1.currency, asset1.issuer, asset2.currency, asset2.issuer}` + * in canonical (`std::minmax`) order, the initial LP tokens computed as + * `sqrt(sfAmount * sfAmount2)` and sent to the creator, and the asset + * trustlines/MPToken entries required to hold the seeded liquidity. + * + * No other AMM transaction (`AMMDeposit`, `AMMWithdraw`, `AMMVote`, + * `AMMBid`, `AMMDelete`) can operate until this transaction succeeds. + * + * @note The fee charged is one owner reserve (not the standard base fee) + * because the transaction permanently allocates a scarce ledger object. + * @note LP-token trustlines are created with a zero credit limit. A holder + * can only receive LP tokens through affirmative action (deposit, + * `TrustSet`, offer crossing) — the AMM cannot push tokens to an + * unwilling account. + * @see [XLS-30d: AMM on XRPL](https://github.com/XRPLF/XRPL-Standards/discussions/78) */ class AMMCreate : public Transactor { @@ -43,28 +35,96 @@ public: { } + /** Gate the transaction on required feature flags. + * + * Returns false (and thereby rejects the transaction before any field + * validation) when the AMM subsystem is not yet active on the current + * rule set, or when either pool asset is an MPT issue and + * `featureMPTokensV2` has not been enabled. + * + * @param ctx The preflight context providing the current rules and tx. + * @return true if all required amendments are active; false otherwise. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Validate the transaction fields without ledger access. + * + * Rejects the transaction if the two pool assets are identical + * (`temBAD_AMM_TOKENS`), if either amount is structurally invalid + * (`invalidAMMAmount`), or if `sfTradingFee` exceeds + * `kTRADING_FEE_THRESHOLD` (`temBAD_FEE`). + * + * @param ctx The preflight context containing the transaction. + * @return `tesSUCCESS` on success, or a `tem*` error code on failure. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Return the fee required to submit this transaction. + * + * Overrides the default base-fee logic and returns + * `calculateOwnerReserveFee` instead — one owner reserve increment — + * because `AMMCreate` permanently allocates a ledger object. + * + * @param view Read-only ledger view providing the current fee schedule. + * @param tx The transaction being evaluated. + * @return Fee in drops equal to one owner reserve increment. + */ static XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx); + /** Check ledger state before applying the transaction. + * + * Verifies that no AMM already exists for the asset pair + * (`tecDUPLICATE`), that the creator is authorized for both assets, + * that neither asset is frozen (`tecFROZEN`), that IOU issuers have + * `lsfDefaultRipple` set (`terNO_RIPPLE`), that the creator holds + * sufficient XRP for the LP-token trustline reserve plus seed funds + * (`tecINSUF_RESERVE_LINE`, `tecUNFUNDED_AMM`), and that neither + * seed asset is itself an existing LP token (`tecAMM_INVALID_TOKENS`). + * Also validates MPT permissions via `checkMPTTxAllowed`. + * + * When `featureAMMClawback` is not yet active, assets whose issuers + * have clawback enabled (`lsfAllowTrustLineClawback` or + * `lsfMPTCanClawback`) are rejected with `tecNO_PERMISSION`. + * + * @param ctx The preclaim context providing read-only ledger access. + * @return `tesSUCCESS` on success, or an appropriate error `TER`. + */ static TER preclaim(PreclaimContext const& ctx); - /** Attempt to create the AMM instance. */ + /** Create all AMM ledger objects and seed initial liquidity. + * + * Executes inside a `Sandbox` overlay: the pseudo-account, `ltAMM` + * object, LP tokens, and asset trustlines/MPToken entries are created + * inside `applyCreate()`. The sandbox is flushed to the live view only + * when `applyCreate()` returns `tesSUCCESS`; any earlier failure leaves + * the ledger unchanged. On success, both directions of the trading pair + * are registered in the `OrderBookDB`. + * + * @return `tesSUCCESS` on success, or a `tec*` error code on failure. + */ TER doApply() override; + /** @copydoc Transactor::visitInvariantEntry + * + * Currently a no-op for `AMMCreate`; reserved for future + * transaction-specific invariants. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** @copydoc Transactor::finalizeInvariants + * + * Currently returns true unconditionally for `AMMCreate`; reserved for + * future transaction-specific post-conditions. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/dex/AMMDelete.h b/include/xrpl/tx/transactors/dex/AMMDelete.h index 57fdfebf94..a0b546ec61 100644 --- a/include/xrpl/tx/transactors/dex/AMMDelete.h +++ b/include/xrpl/tx/transactors/dex/AMMDelete.h @@ -4,11 +4,24 @@ namespace xrpl { -/** AMMDelete implements AMM delete transactor. This is a mechanism to - * delete AMM in an empty state when the number of LP tokens is 0. - * AMMDelete deletes the trustlines up to configured maximum. If all - * trustlines are deleted then AMM ltAMM and root account are deleted. - * Otherwise AMMDelete should be called again. +/** Removes a fully-drained AMM pool and its associated ledger objects. + * + * When all liquidity has been withdrawn via `AMMWithdraw` and + * `sfLPTokenBalance` on the `ltAMM` entry reaches zero, the pool's + * supporting objects (trustlines, MPToken entries, the `ltAMM` record, and + * the AMM pseudo-account `AccountRoot`) are not cleaned up automatically. + * `AMMDelete` handles that deferred cleanup. + * + * Because a long-lived pool may accumulate hundreds of LP-token trustlines, + * deletion is chunked: each invocation removes at most + * `maxDeletableAMMTrustLines` (512) trustlines and commits that partial + * progress to the ledger. If trustlines remain, `doApply` returns + * `tecINCOMPLETE` and the submitter must re-submit until the pool is fully + * removed. + * + * @note Deletion is only permitted when `sfLPTokenBalance` is exactly zero. + * Use `AMMWithdraw` to drain a pool that still holds liquidity. + * @see AMMCreate, AMMWithdraw */ class AMMDelete : public Transactor { @@ -19,24 +32,79 @@ public: { } + /** Gate the transaction on required feature flags. + * + * Returns false when the base AMM feature is not yet active, or when + * either pool asset is an MPT issue and `featureMPTokensV2` has not + * been enabled. The MPT gate prevents deletion of MPT-based pools on + * network versions that do not fully support MPT cleanup. + * + * @param ctx The preflight context providing the current rules and tx. + * @return true if all required amendments are active; false otherwise. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Validate the transaction fields without ledger access. + * + * All amendment checks are handled by `checkExtraFeatures`; there are + * no field-level constraints that can be verified without consulting + * ledger state. Always returns `tesSUCCESS`. + * + * @param ctx The preflight context containing the transaction. + * @return `tesSUCCESS` unconditionally. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Verify the AMM pool exists and has been fully drained. + * + * Reads the `ltAMM` object for the submitted asset pair and checks that + * `sfLPTokenBalance` is exactly zero. Returns `terNO_AMM` if no such + * pool exists, or `tecAMM_NOT_EMPTY` if LP tokens are still outstanding. + * Both failure codes prevent fee collection; use `AMMWithdraw` to drain + * a pool before deleting it. + * + * @param ctx The preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if the pool exists and is empty; `terNO_AMM` if + * the asset pair has no AMM; `tecAMM_NOT_EMPTY` if LP tokens remain. + */ static TER preclaim(PreclaimContext const& ctx); + /** Delete AMM trustlines and, when all are gone, the pool itself. + * + * Delegates to `deleteAMMAccount()` inside an isolated `Sandbox` view. + * That helper deletes up to `maxDeletableAMMTrustLines` (512) trustlines + * per call. The sandbox is applied to the live ledger on both + * `tesSUCCESS` and `tecINCOMPLETE`, committing partial progress even + * when the deletion is not yet complete. When `tesSUCCESS` is returned, + * any remaining MPToken entries, the `ltAMM` object, and the AMM + * pseudo-account `AccountRoot` are also erased. + * + * @return `tesSUCCESS` when the pool and all its objects are fully + * removed; `tecINCOMPLETE` when trustlines remain and the caller + * must re-submit. + */ TER doApply() override; + /** @copydoc Transactor::visitInvariantEntry + * + * Currently a no-op for `AMMDelete`; reserved for future + * transaction-specific invariants. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** @copydoc Transactor::finalizeInvariants + * + * Currently returns true unconditionally for `AMMDelete`; reserved for + * future transaction-specific post-conditions. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/dex/AMMDeposit.h b/include/xrpl/tx/transactors/dex/AMMDeposit.h index 8afe957e60..f579b1d0b2 100644 --- a/include/xrpl/tx/transactors/dex/AMMDeposit.h +++ b/include/xrpl/tx/transactors/dex/AMMDeposit.h @@ -6,69 +6,143 @@ namespace xrpl { class Sandbox; -/** AMMDeposit implements AMM deposit Transactor. - * The deposit transaction is used to add liquidity to the AMM instance pool, - * thus obtaining some share of the instance's pools in the form of LPTokens. - * If the trader deposits proportional values of both assets without changing - * their relative price, then no trading fee is charged on the transaction. - * The trader can specify different combination of the fields in the deposit. - * LPTokens - transaction assumes proportional deposit of pools assets in - * exchange for the specified amount of LPTokens of the AMM instance. - * Asset1In - transaction assumes single asset deposit of the amount of asset - * specified by Asset1In. This is essentially a swap and an equal asset - * deposit. - * Asset1In and Asset2In - transaction assumes proportional deposit of pool - * assets with the constraints on the maximum amount of each asset that - * the trader is willing to deposit. - * Asset1In and LPTokens - transaction assumes that a single asset asset1 - * is deposited to obtain some share of the AMM instance's pools - * represented by amount of LPTokens. - * Asset1In and EPrice - transaction assumes single asset deposit with - * the following two constraints: - * a. amount of asset1 if specified (not 0) in Asset1In specifies the - * maximum amount of asset1 that the trader is willing to deposit b. The - * effective-price of the LPTokens traded out does not exceed the specified - * EPrice. Following updates after a successful AMMDeposit transaction: The - * deposited asset, if XRP, is transferred from the account that initiated the - * transaction to the AMM instance account, thus changing the Balance field of - * each account. The deposited asset, if tokens, are balanced between the AMM - * account and the issuer account trustline. The LPTokens are issued by the AMM - * instance account to the account that initiated the transaction and a new - * trustline is created, if there does not exist one. The pool composition is - * updated. - * @see [XLS30d:AMMDeposit - * transaction](https://github.com/XRPLF/XRPL-Standards/discussions/78) +/** Transactor for `AMMDeposit` transactions (XLS-30d). + * + * Allows liquidity providers to add assets to an AMM pool in exchange for + * LP tokens that represent their fractional share of the pool's reserves. + * Six mutually exclusive deposit modes are supported, selected by a single + * flag bit from `tfDepositSubTx`: + * + * | Flag | Method | Fee charged? | Description | + * |---------------------|---------------------------|:------------:|-------------| + * | `tfLPToken` | `equalDepositTokens` | No | Proportional deposit targeting a specific LP token amount | + * | `tfTwoAsset` | `equalDepositLimit` | No | Proportional deposit with per-asset maximum constraints | + * | `tfSingleAsset` | `singleDeposit` | Yes | Single-asset deposit by amount | + * | `tfOneAssetLPToken` | `singleDepositTokens` | Yes | Single-asset deposit targeting an LP token quantity | + * | `tfLimitLPToken` | `singleDepositEPrice` | Yes | Single-asset deposit with effective-price ceiling | + * | `tfTwoAssetIfEmpty` | `equalDepositInEmptyState`| N/A | Bootstraps an AMM pool whose LP token balance is zero | + * + * Proportional modes (the first two) do not charge a trading fee because they + * preserve the pool's price ratio. Single-asset modes are mathematically + * equivalent to an implicit swap followed by a proportional deposit, so the + * pool's trading fee applies to the swap component. + * + * On success, XRP deposits adjust account `sfBalance` fields directly; IOU + * and MPT deposits move balances through trustlines between the depositor and + * the AMM account. LP tokens are issued to the depositor, creating a new + * trustline if none exists. + * + * Unlike `AMMWithdraw`, which exposes `withdraw` and `equalWithdrawTokens` as + * `public static` methods for reuse by `AMMDelete`, all deposit-mode helpers + * here are `private` — the deposit path is only reachable through the + * validated transactor lifecycle. + * + * @see [XLS-30d AMMDeposit](https://github.com/XRPLF/XRPL-Standards/discussions/78) */ class AMMDeposit : public Transactor { public: + /** Standard fee/sequence consequences; no special queue-blocking behavior. */ static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Normal; explicit AMMDeposit(ApplyContext& ctx) : Transactor(ctx) { } + /** Gate on the `featureAMM` amendment and, for MPT-backed pools, on + * `featureMPTokensV2`. + * + * Returns `false` (disabling this transaction type entirely) if `featureAMM` + * is not active. Additionally returns `false` if the pool asset pair or any + * deposit amount uses an `MPTIssue` but `featureMPTokensV2` has not yet + * activated, keeping MPT pool deposits gated behind the second amendment. + * + * @param ctx The preflight context providing rules and the transaction. + * @return `true` if the transaction type is permitted under current rules. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Return the valid transaction flag mask (`tfAMMDepositMask`). + * + * The framework uses this to reject any transaction whose flags fall + * outside the set of bits defined for `AMMDeposit`. + * + * @param ctx Unused; present for the static dispatch interface. + * @return Bitmask of all valid `AMMDeposit` flags. + */ static std::uint32_t getFlagsMask(PreflightContext const& ctx); + /** Validate the transaction structure without accessing ledger state. + * + * Enforces: + * - Exactly one flag bit from `tfDepositSubTx` is set + * (`std::popcount == 1`); otherwise `temMALFORMED`. + * - Per-mode field constraints (required and forbidden optional fields). + * - Asset-pair validity via `invalidAMMAssetPair`. + * - `sfAmount` and `sfAmount2` denominate different assets. + * - `sfLPTokenOut`, if present, is positive. + * - `sfTradingFee`, if present, does not exceed `kTRADING_FEE_THRESHOLD`. + * + * @param ctx The preflight context providing the transaction and rules. + * @return `tesSUCCESS` on success, or a `tem*` error code. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Validate against current ledger state (read-only). + * + * Runs after signature verification. Key checks: + * - The AMM ledger entry for the declared asset pair must exist; + * absent → `terNO_AMM`. + * - For `tfTwoAssetIfEmpty`: LP token balance must be zero + * (`tecAMM_NOT_EMPTY` if already populated). + * - For all other modes: LP token balance must be positive + * (`tecAMM_EMPTY` if the pool is drained). + * - Account has sufficient funds for the requested deposit amounts. + * Re-checked inside `deposit()` for modes where the final amount is + * derived from pool math rather than specified directly. + * - When `featureAMMClawback` is active: neither pool asset may be + * individually frozen on the depositor's account (`tecFROZEN`). + * - MPT-specific authorization via `checkMPTTxAllowed`. + * + * @param ctx The preclaim context providing the read-only ledger view. + * @return `tesSUCCESS` on success, or the appropriate `tec*`/`ter*` code. + */ static TER preclaim(PreclaimContext const& ctx); + /** Execute the deposit against a copy-on-write sandbox and commit on success. + * + * Creates a `Sandbox` over `ctx_.view()`, delegates to `applyGuts`, and + * calls `sb.apply(ctx_.rawView())` only if `applyGuts` returns success. + * Any failure leaves the consensus view unmodified. + * + * @return `tesSUCCESS` or the `tec*` error from the selected deposit mode. + */ TER doApply() override; + /** No-op placeholder for future transaction-specific invariant state. + * + * `AMMDeposit` currently delegates all invariant checking to the global + * `ValidAMM` invariant checker. This override exists to satisfy the + * `Transactor` interface and is reserved for future per-transaction + * invariants. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** No-op placeholder for future transaction-specific invariant finalization. + * + * Always returns `true`. See `visitInvariantEntry` for rationale. + * + * @return `true` unconditionally. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, @@ -78,23 +152,49 @@ public: beast::Journal const& j) override; private: + /** Dispatch to the mode-specific deposit helper and commit AMM state. + * + * Reads the current pool balances, determines the effective trading fee + * (pool fee for non-empty pools; `sfTradingFee` from the transaction for + * the empty-pool bootstrap path), then routes to the appropriate private + * method based on `tfDepositSubTx`. On `tesSUCCESS`, updates + * `sfLPTokenBalance` on the AMM ledger entry and, for the empty-pool case, + * calls `initializeFeeAuctionVote` to grant the bootstrapping LP their + * initial fee-governance position. + * + * @param view The sandbox to mutate; committed by the caller only on success. + * @return `{TER, true}` on success; `{tec*, false}` on failure. + */ std::pair applyGuts(Sandbox& view); - /** Deposit requested assets and token amount into LP account. - * Return new total LPToken balance. - * @param view - * @param ammAccount - * @param amountBalance current AMM asset1 balance - * @param amountDeposit - * @param amount2Deposit - * @param lptAMMBalance current AMM LPT balance - * @param lpTokensDeposit amount of tokens to deposit - * @param depositMin minimum accepted amount deposit - * @param deposit2Min minimum accepted amount2 deposit - * @param lpTokensDepositMin minimum accepted LPTokens deposit - * @param tfee trading fee in basis points - * @return + /** Shared execution kernel: transfer assets into the AMM and issue LP tokens. + * + * Adjusts the requested amounts via `adjustAmountsByLPTokens` (rounding + * and token-cap corrections), then verifies minimum constraints and account + * balance. On success, performs three `accountSend` transfers in order: + * depositor → AMM for asset1, depositor → AMM for asset2 (if present), + * and AMM → depositor for the LP tokens (creating a trustline if needed). + * + * This function re-checks account balance even for modes where `preclaim` + * already validated it, because the actual amounts may differ from the + * values specified in the transaction (they are derived from pool math). + * + * @param view Sandbox to mutate. + * @param ammAccount AccountID of the AMM pseudo-account. + * @param amountBalance Current pool balance of asset1. + * @param amountDeposit Computed asset1 amount to transfer. + * @param amount2Deposit Computed asset2 amount to transfer, or absent for + * single-asset modes. + * @param lptAMMBalance Current total LP token supply. + * @param lpTokensDeposit LP tokens to issue to the depositor. + * @param depositMin Minimum acceptable asset1 deposit; `tecAMM_FAILED` + * if the adjusted amount falls below this. + * @param deposit2Min Minimum acceptable asset2 deposit; same semantics. + * @param lpTokensDepositMin Minimum acceptable LP token issuance; same semantics. + * @param tfee Trading fee in basis points. + * @return `{tesSUCCESS, newLPTokenBalance}` on success, or + * `{tec*, STAmount{}}` on failure. */ std::pair deposit( @@ -110,18 +210,25 @@ private: std::optional const& lpTokensDepositMin, std::uint16_t tfee); - /** Equal asset deposit (LPTokens) for the specified share of - * the AMM instance pools. The trading fee is not charged. - * @param view - * @param ammAccount - * @param amountBalance current AMM asset1 balance - * @param amount2Balance current AMM asset2 balance - * @param lptAMMBalance current AMM LPT balance - * @param lpTokensDeposit amount of tokens to deposit - * @param depositMin minimum accepted amount deposit - * @param deposit2Min minimum accepted amount2 deposit - * @param tfee trading fee in basis points - * @return + /** Proportional two-asset deposit for a targeted LP token amount (`tfLPToken`). + * + * Computes asset1 and asset2 deposit amounts proportional to `lpTokensDeposit` + * relative to the current LP supply (`lptAMMBalance`), then forwards to + * `deposit`. Under `fixAMMv1_3`, if token adjustment rounds to zero the + * transaction fails with `tecAMM_INVALID_TOKENS`. No trading fee is charged + * because the deposit preserves the pool's price ratio. + * + * @param view Sandbox to mutate. + * @param ammAccount AMM pseudo-account ID. + * @param amountBalance Current pool balance of asset1. + * @param amount2Balance Current pool balance of asset2. + * @param lptAMMBalance Current total LP token supply. + * @param lpTokensDeposit Target LP token amount to receive. + * @param depositMin Optional minimum asset1 deposit; `tecAMM_FAILED` if not met. + * @param deposit2Min Optional minimum asset2 deposit; same semantics. + * @param tfee Trading fee in basis points (unused for fee charging; passed + * to `deposit` for amount-adjustment rounding). + * @return `{tesSUCCESS, newLPTokenBalance}` or `{tec*, STAmount{}}`. */ std::pair equalDepositTokens( @@ -135,19 +242,28 @@ private: std::optional const& deposit2Min, std::uint16_t tfee); - /** Equal asset deposit (Asset1In, Asset2In) with the constraint on - * the maximum amount of both assets that the trader is willing to deposit. - * The trading fee is not charged. - * @param view - * @param ammAccount - * @param amountBalance current AMM asset1 balance - * @param amount2Balance current AMM asset2 balance - * @param lptAMMBalance current AMM LPT balance - * @param amount maximum asset1 deposit amount - * @param amount2 maximum asset2 deposit amount - * @param lpTokensDepositMin minimum accepted LPTokens deposit - * @param tfee trading fee in basis points - * @return + /** Proportional two-asset deposit bounded by per-asset maximums (`tfTwoAsset`). + * + * Computes the LP token fraction implied by `amount` (asset1 max), then + * derives the required asset2. If asset2 exceeds `amount2`, re-derives from + * `amount2` and checks that asset1 stays within `amount`. If neither + * binding is satisfiable the transaction fails with `tecAMM_FAILED`. + * No trading fee is charged; the deposit preserves the pool price ratio. + * + * Equations used (A = pool asset1, B = pool asset2, T = LP supply): + * `a = (t/T) * A`, `b = (t/T) * B`, solved for whichever asset is the + * binding constraint. + * + * @param view Sandbox to mutate. + * @param ammAccount AMM pseudo-account ID. + * @param amountBalance Current pool balance of asset1. + * @param amount2Balance Current pool balance of asset2. + * @param lptAMMBalance Current total LP token supply. + * @param amount Maximum asset1 the depositor will provide. + * @param amount2 Maximum asset2 the depositor will provide. + * @param lpTokensDepositMin Optional minimum LP tokens to receive; `tecAMM_FAILED` if not met. + * @param tfee Trading fee in basis points (passed through for rounding). + * @return `{tesSUCCESS, newLPTokenBalance}` or `{tec*, STAmount{}}`. */ std::pair equalDepositLimit( @@ -161,16 +277,22 @@ private: std::optional const& lpTokensDepositMin, std::uint16_t tfee); - /** Single asset deposit (Asset1In) by the amount. - * The trading fee is charged. - * @param view - * @param ammAccount - * @param amountBalance current AMM asset1 balance - * @param lptAMMBalance current AMM LPT balance - * @param amount requested asset1 deposit amount - * @param lpTokensDepositMin minimum accepted LPTokens deposit - * @param tfee trading fee in basis points - * @return + /** Single-asset deposit by amount (`tfSingleAsset`). + * + * Computes LP tokens issued via the single-deposit formula + * `t = T * (b/B - x) / (1 + x)` where `x = sqrt(f1² + b/(B*(1-fee))) - f1` + * and `f1 = (1 - 0.5*fee) / (1 - fee)`. The trading fee is charged because + * depositing one asset is equivalent to swapping half for the other asset, + * then making a proportional deposit. + * + * @param view Sandbox to mutate. + * @param ammAccount AMM pseudo-account ID. + * @param amountBalance Current pool balance of asset1. + * @param lptAMMBalance Current total LP token supply. + * @param amount Asset1 amount to deposit. + * @param lpTokensDepositMin Optional minimum LP tokens to receive; `tecAMM_FAILED` if not met. + * @param tfee Pool trading fee in basis points. + * @return `{tesSUCCESS, newLPTokenBalance}` or `{tec*, STAmount{}}`. */ std::pair singleDeposit( @@ -182,16 +304,22 @@ private: std::optional const& lpTokensDepositMin, std::uint16_t tfee); - /** Single asset deposit (Asset1In, LPTokens) by the tokens. - * The trading fee is charged. - * @param view - * @param ammAccount - * @param amountBalance current AMM asset1 balance - * @param amount max asset1 to deposit - * @param lptAMMBalance current AMM LPT balance - * @param lpTokensDeposit amount of tokens to deposit - * @param tfee trading fee in basis points - * @return + /** Single-asset deposit targeting a specific LP token quantity (`tfOneAssetLPToken`). + * + * Solves the single-deposit formula for the required asset1 input given a + * desired LP token output (`lpTokensDeposit`). Fails with `tecAMM_FAILED` + * if the computed asset1 input would exceed the caller's stated maximum + * (`amount`). The trading fee is charged for the same reason as + * `singleDeposit`. + * + * @param view Sandbox to mutate. + * @param ammAccount AMM pseudo-account ID. + * @param amountBalance Current pool balance of asset1. + * @param amount Maximum asset1 the depositor will provide. + * @param lptAMMBalance Current total LP token supply. + * @param lpTokensDeposit Exact LP token amount the depositor targets. + * @param tfee Pool trading fee in basis points. + * @return `{tesSUCCESS, newLPTokenBalance}` or `{tec*, STAmount{}}`. */ std::pair singleDepositTokens( @@ -203,16 +331,24 @@ private: STAmount const& lpTokensDeposit, std::uint16_t tfee); - /** Single asset deposit (Asset1In, EPrice) with two constraints. - * The trading fee is charged. - * @param view - * @param ammAccount - * @param amountBalance current AMM asset1 balance - * @param amount requested asset1 deposit amount - * @param lptAMMBalance current AMM LPT balance - * @param ePrice maximum effective price - * @param tfee - * @return + /** Single-asset deposit with an effective-price ceiling (`tfLimitLPToken`). + * + * Accepts at most `amount` of asset1, subject to the constraint that the + * effective price EP = asset1_in / LP_out must not exceed `ePrice`. Two-pass + * algorithm: if a non-zero `amount` is given and its EP ≤ `ePrice`, that + * deposit is used directly. Otherwise, the exact asset1 and LP token amounts + * that satisfy EP = `ePrice` are derived by solving the quadratic form of + * the single-deposit equation (the derivation is in the `.cpp` inline + * comments). The trading fee is charged as for `singleDeposit`. + * + * @param view Sandbox to mutate. + * @param ammAccount AMM pseudo-account ID. + * @param amountBalance Current pool balance of asset1. + * @param amount Optional maximum asset1 amount (zero means unconstrained). + * @param lptAMMBalance Current total LP token supply. + * @param ePrice Maximum acceptable effective price (asset1/LP token). + * @param tfee Pool trading fee in basis points. + * @return `{tesSUCCESS, newLPTokenBalance}` or `{tec*, STAmount{}}`. */ std::pair singleDepositEPrice( @@ -224,13 +360,22 @@ private: STAmount const& ePrice, std::uint16_t tfee); - /** Equal deposit in empty AMM state (LP tokens balance is 0) - * @param view - * @param ammAccount - * @param amount requested asset1 deposit amount - * @param amount2 requested asset2 deposit amount - * @param tfee - * @return + /** Bootstrap a zero-balance AMM pool (`tfTwoAssetIfEmpty`). + * + * Only valid when `lptAMMBalance == 0`. Computes the initial LP token + * supply as `sqrt(amount * amount2)` via `ammLPTokens`, seeds the pool with + * both assets, and issues the resulting tokens to the depositor. After + * `applyGuts` commits this, `initializeFeeAuctionVote` grants the + * bootstrapping LP their initial auction-slot and voting position. + * + * @param view Sandbox to mutate. + * @param ammAccount AMM pseudo-account ID. + * @param amount Asset1 amount to seed the pool. + * @param amount2 Asset2 amount to seed the pool. + * @param lptIssue Asset descriptor for the LP token to be issued. + * @param tfee Initial trading fee (from `sfTradingFee` in the transaction, + * or 0 if absent); stored by `initializeFeeAuctionVote`. + * @return `{tesSUCCESS, newLPTokenBalance}` or `{tec*, STAmount{}}`. */ std::pair equalDepositInEmptyState( diff --git a/include/xrpl/tx/transactors/dex/AMMVote.h b/include/xrpl/tx/transactors/dex/AMMVote.h index 70e75144c4..317bdb49f3 100644 --- a/include/xrpl/tx/transactors/dex/AMMVote.h +++ b/include/xrpl/tx/transactors/dex/AMMVote.h @@ -4,56 +4,118 @@ namespace xrpl { -/** AMMVote implements AMM vote Transactor. - * This transactor allows for the TradingFee of the AMM instance be a votable - * parameter. Any account (LP) that holds the corresponding LPTokens can cast - * a vote using the new AMMVote transaction. VoteSlots array in ltAMM object - * keeps track of upto eight active votes (VoteEntry) for the instance. - * VoteEntry contains: - * Account - account id that cast the vote. - * FeeVal - proposed fee in basis points. - * VoteWeight - LPTokens owned by the account in basis points. - * TradingFee is calculated as sum(VoteWeight_i * fee_i)/sum(VoteWeight_i). - * Every time AMMVote transaction is submitted, the transactor - * - Fails the transaction if the account doesn't hold LPTokens - * - Removes VoteEntry for accounts that don't hold LPTokens - * - If there are fewer than eight VoteEntry objects then add new VoteEntry - * object for the account. - * - If all eight VoteEntry slots are full, then remove VoteEntry that - * holds less LPTokens than the account. If all accounts hold more - * LPTokens then fail transaction. - * - If the account already holds a vote, then update VoteEntry. - * - Calculate and update TradingFee. - * @see [XLS30d:Governance: Trading Fee Voting - * Mechanism](https://github.com/XRPLF/XRPL-Standards/discussions/78) +/** Governance transactor that lets liquidity providers vote on an AMM's trading fee. + * + * Each LP submits a preferred `sfTradingFee` weighted by their share of the + * pool's total `LPToken` supply. The AMM object stores up to + * `kVOTE_MAX_SLOTS` (8) `VoteEntry` records; the resulting fee is recomputed + * as a capital-weighted average `sum(fee_i * tokens_i) / sum(tokens_i)` over + * all active slots after every vote. + * + * **Slot management:** stale entries (LPs that no longer hold tokens) are + * pruned on every vote. When all eight slots are occupied and the voter is + * not already present, the entry with the fewest tokens is a candidate for + * eviction — but only if the incoming LP holds strictly more tokens, or equal + * tokens and proposes a higher fee. Ties are broken deterministically by + * account ID. If no slot can be displaced, the transaction succeeds but does + * not alter the slot array. + * + * **Auction slot coupling:** after updating `sfTradingFee`, the transactor + * also propagates a new `sfDiscountedFee` (`tradingFee / + * kAUCTION_SLOT_DISCOUNTED_FEE_FRACTION`) into `sfAuctionSlot` if one is + * present, keeping the auction winner's price advantage in sync with + * governance decisions. When either fee rounds to zero the field is removed + * rather than stored as zero. + * + * @see [XLS-30d: AMM fee-vote governance](https://github.com/XRPLF/XRPL-Standards/discussions/78) */ class AMMVote : public Transactor { public: + /** Uses standard fee and sequence-number consequences; no custom scaling. */ static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Normal; explicit AMMVote(ApplyContext& ctx) : Transactor(ctx) { } + /** Amendment gate for AMM vote transactions. + * + * Returns `false` (causing `invokePreflight` to emit `temDISABLED`) when + * the core AMM amendment (`featureAMM` + `fixUniversalNumber`) is not + * enabled. Additionally requires `featureMPTokensV2` when either pool + * asset is an `MPTIssue`. + * + * @param ctx Preflight context providing the active rule set. + * @return `true` if the transaction may proceed; `false` to disable it. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Stateless validation of the vote transaction fields. + * + * Verifies that the asset pair is structurally coherent via + * `invalidAMMAssetPair` and that `sfTradingFee` does not exceed + * `kTRADING_FEE_THRESHOLD` (1000 = 1%). No ledger access is performed. + * + * @param ctx Preflight context carrying the transaction and rule set. + * @return `tesSUCCESS`, `temBAD_FEE` if the fee is out of range, or a + * `tem*` code from `invalidAMMAssetPair` for a malformed asset pair. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Read-only ledger checks before the vote is applied. + * + * Confirms that the AMM object exists for the specified asset pair + * (`terNO_AMM`), that the pool is not empty — a zero `sfLPTokenBalance` + * means there are no active LPs (`tecAMM_EMPTY`) — and that the + * submitting account holds a non-zero LPToken balance (`tecAMM_INVALID_TOKENS`). + * An account with no stake in the pool has no standing to influence its fee. + * + * @param ctx Preclaim context providing the read-only ledger view. + * @return `tesSUCCESS` if all checks pass; `terNO_AMM`, + * `tecAMM_EMPTY`, or `tecAMM_INVALID_TOKENS` otherwise. + */ static TER preclaim(PreclaimContext const& ctx); + /** Apply the vote and update the AMM object's fee governance state. + * + * Wraps execution in a `Sandbox` view so all ledger mutations are + * committed atomically only on success. The vote logic (slot pruning, + * eviction, weighted-average fee recalculation, and auction slot + * discount propagation) is delegated to the file-scoped `applyVote` + * helper in the implementation. + * + * @return `tesSUCCESS` on success; `tecINTERNAL` if the AMM SLE + * cannot be peeked (indicates ledger corruption, unreachable under + * normal conditions). + */ TER doApply() override; + /** No transaction-specific invariant entries to visit (future work). + * + * @param isDelete Whether the SLE is being deleted. + * @param before SLE state before the transaction. + * @param after SLE state after the transaction. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** No transaction-specific invariants to finalize (future work). + * + * @param tx The applied transaction. + * @param result The TER code returned by `doApply`. + * @param fee The XRP fee charged. + * @param view Read-only view of the post-apply ledger state. + * @param j Journal for diagnostic logging. + * @return Always `true`. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/dex/AMMWithdraw.h b/include/xrpl/tx/transactors/dex/AMMWithdraw.h index 17ebe4e2be..c1dec7d0fb 100644 --- a/include/xrpl/tx/transactors/dex/AMMWithdraw.h +++ b/include/xrpl/tx/transactors/dex/AMMWithdraw.h @@ -8,75 +8,151 @@ namespace xrpl { class Sandbox; -/** AMMWithdraw implements AMM withdraw Transactor. - * The withdraw transaction is used to remove liquidity from the AMM instance - * pool, thus redeeming some share of the pools that one owns in the form - * of LPTokens. If the trader withdraws proportional values of both assets - * without changing their relative pricing, no trading fee is charged on - * the transaction. The trader can specify different combination of - * the fields in the withdrawal. - * LPTokens - transaction assumes proportional withdrawal of pool assets - * for the amount of LPTokens. - * Asset1Out - transaction assumes withdrawal of single asset equivalent - * to the amount specified in Asset1Out. - * Asset1Out and Asset2Out - transaction assumes all assets withdrawal - * with the constraints on the maximum amount of each asset that - * the trader is willing to withdraw. - * Asset1Out and LPTokens - transaction assumes withdrawal of single - * asset specified in Asset1Out proportional to the share represented - * by the amount of LPTokens. - * Asset1Out and EPrice - transaction assumes withdrawal of single - * asset with the following constraints: - * a. Amount of asset1 if specified (not 0) in Asset1Out specifies - * the minimum amount of asset1 that the trader is willing - * to withdraw. - * b. The effective price of asset traded out does not exceed - * the amount specified in EPrice. - * Following updates after a successful transaction: - * The withdrawn asset, if XRP, is transferred from AMM instance account - * to the account that initiated the transaction, thus changing - * the Balance field of each account. - * The withdrawn asset, if token, is balanced between the AMM instance - * account and the issuer account. - * The LPTokens ~ are balanced between the AMM instance account and - * the account that initiated the transaction. - * The pool composition is updated. - * @see [XLS30d:AMMWithdraw - * transaction](https://github.com/XRPLF/XRPL-Standards/discussions/78) +/** Sentinel flag used to enable exact-zero semantics in final-LP-token withdrawals. + * + * When a liquidity provider redeems their entire LP position, rounding in the + * constant-product math can leave dust amounts. `WithdrawAll::Yes` tells + * `equalWithdrawTokens` and `withdraw` to apply exact-zero arithmetic instead + * of ratio arithmetic, ensuring the pool is properly drained to zero. + * `isWithdrawAll` decodes this from the transaction's `tfWithdrawAll` or + * `tfOneAssetWithdrawAll` flag bits. */ - enum class WithdrawAll : bool { No = false, Yes }; +/** Transactor for `AMMWithdraw` transactions (XLS-30d). + * + * Burns LP tokens to return underlying pool assets to the liquidity provider. + * Five mutually exclusive withdrawal modes are supported, selected by a single + * flag bit from `tfWithdrawSubTx`: + * + * | Flag | Fields required | Fee? | Description | + * |------------------------|------------------------------|:----:|-------------| + * | `tfLPToken` | `sfLPTokenIn` | No | Proportional dual-asset withdrawal for a token amount | + * | `tfTwoAsset` | `sfAmount` + `sfAmount2` | No | Proportional dual-asset withdrawal with per-asset caps | + * | `tfSingleAsset` | `sfAmount` | Yes | Single-asset withdrawal by amount | + * | `tfOneAssetLPToken` | `sfAmount` + `sfLPTokenIn` | Yes | Single-asset withdrawal proportional to a token amount | + * | `tfLimitLPToken` | `sfAmount` + `sfEPrice` | Yes | Single-asset withdrawal with effective-price ceiling | + * + * Fee-free modes (`tfLPToken`, `tfTwoAsset`) remove liquidity proportionally + * without disturbing the pool's price ratio. Fee-bearing modes are equivalent + * to an implicit swap followed by a proportional withdrawal; the pool's + * trading fee applies to that swap component. + * + * The `tfTwoAsset` mode computes the largest proportional withdrawal fitting + * within both asset caps, so actual amounts may be less than the stated maxima. + * + * On success: XRP transfers adjust `sfBalance` on both accounts directly; IOU + * and MPT withdrawals move balances through trustlines between the AMM account + * and the LP. LP tokens are burned from the LP's holding. + * + * Two methods — `equalWithdrawTokens` and `withdraw` — are `public static` so + * that `AMMDelete` and `AMMClawback` can invoke the withdrawal machinery without + * constructing a full `AMMWithdraw` transactor instance. All five mode-specific + * helpers are `private`. + * + * @see [XLS-30d AMMWithdraw](https://github.com/XRPLF/XRPL-Standards/discussions/78) + */ class AMMWithdraw : public Transactor { public: + /** Standard fee/sequence consequences; no special queue-blocking behavior. */ static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Normal; explicit AMMWithdraw(ApplyContext& ctx) : Transactor(ctx) { } + /** Gate on the `featureAMM` amendment and, for MPT-backed pools, on + * `featureMPTokensV2`. + * + * Returns `false` if `featureAMM` is not active. Also returns `false` if + * the pool asset pair or any withdrawal amount uses an `MPTIssue` but + * `featureMPTokensV2` has not yet activated. + * + * @param ctx The preflight context providing rules and the transaction. + * @return `true` if the transaction type is permitted under current rules. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Return the valid transaction flag mask (`tfAMMWithdrawMask`). + * + * The framework uses this to reject any transaction whose flags fall + * outside the set of bits defined for `AMMWithdraw`. + * + * @param ctx Unused; present for the static dispatch interface. + * @return Bitmask of all valid `AMMWithdraw` flags. + */ static std::uint32_t getFlagsMask(PreflightContext const& ctx); + /** Validate the transaction structure without accessing ledger state. + * + * Enforces: + * - Exactly one flag bit from `tfWithdrawSubTx` is set + * (`std::popcount == 1`); otherwise `temMALFORMED`. + * - Per-mode field constraints (required and forbidden optional fields). + * - Asset-pair validity via `invalidAMMAssetPair`. + * - `sfAmount` and `sfAmount2` denominate different assets. + * - `sfLPTokenIn`, if present, is positive. + * - `sfEPrice`, `sfAmount`, and `sfAmount2` amounts are valid via + * `invalidAMMAmount`. + * + * @param ctx The preflight context providing the transaction and rules. + * @return `tesSUCCESS` on success, or a `tem*` error code. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Validate against current ledger state (read-only). + * + * Runs after signature verification. Key checks: + * - The AMM ledger entry for the declared asset pair must exist; + * absent → `terNO_AMM`. + * - LP token balance must be positive (`tecAMM_EMPTY` if the pool is + * already drained). + * - The caller holds a sufficient LP token balance for the requested + * withdrawal. + * - When `featureAMMClawback` is active: neither pool asset may be + * individually frozen on the LP's account (`tecFROZEN`). + * - MPT-specific authorization via `checkMPTTxAllowed`. + * + * @param ctx The preclaim context providing the read-only ledger view. + * @return `tesSUCCESS` on success, or the appropriate `tec*`/`ter*` code. + */ static TER preclaim(PreclaimContext const& ctx); + /** Execute the withdrawal against a copy-on-write sandbox and commit on success. + * + * Creates a `Sandbox` over `ctx_.view()`, delegates to `applyGuts`, and + * calls `sb.apply(ctx_.rawView())` only if `applyGuts` returns success. + * Any failure leaves the consensus view unmodified. + * + * @return `tesSUCCESS` or the `tec*` error from the selected withdrawal mode. + */ TER doApply() override; + /** No-op placeholder for future transaction-specific invariant state. + * + * `AMMWithdraw` currently delegates all invariant checking to the global + * `ValidAMM` invariant checker. This override exists to satisfy the + * `Transactor` interface and is reserved for future per-transaction + * invariants. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** No-op placeholder for future transaction-specific invariant finalization. + * + * Always returns `true`. See `visitInvariantEntry` for rationale. + * + * @return `true` unconditionally. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, @@ -85,20 +161,38 @@ public: ReadView const& view, beast::Journal const& j) override; - /** Equal-asset withdrawal (LPTokens) of some AMM instance pools - * shares represented by the number of LPTokens . - * The trading fee is not charged. - * @param view - * @param ammAccount - * @param amountBalance current LP asset1 balance - * @param amount2Balance current LP asset2 balance - * @param lptAMMBalance current AMM LPT balance - * @param lpTokens current LPT balance - * @param lpTokensWithdraw amount of tokens to withdraw - * @param tfee trading fee in basis points - * @param withdrawAll if withdrawing all lptokens - * @param priorBalance balance before fees - * @return + /** Proportional dual-asset withdrawal for a given LP token amount. + * + * Burns `lpTokensWithdraw` LP tokens and returns both pool assets to + * `account` in the ratio `lpTokensWithdraw / lptAMMBalance`. No trading + * fee is charged because the withdrawal preserves the pool's price ratio. + * When `withdrawAll` is `WithdrawAll::Yes`, exact-zero arithmetic is used + * to prevent dust from rounding errors on full-position redemptions. + * + * This overload is `public static` so `AMMDelete` and `AMMClawback` can + * invoke it without constructing an `AMMWithdraw` transactor. The caller + * supplies `freezeHandling`, `authHandling`, `priorBalance`, and a journal + * that the instance methods obtain from `ctx_` automatically. + * + * @param view Sandbox to mutate; caller commits only on success. + * @param ammSle AMM ledger entry. + * @param account LP account receiving the withdrawn assets. + * @param ammAccount AMM pseudo-account ID. + * @param amountBalance Current pool balance of asset1. + * @param amount2Balance Current pool balance of asset2. + * @param lptAMMBalance Current total LP token supply. + * @param lpTokens LP token balance held by `account`. + * @param lpTokensWithdraw LP tokens to burn. + * @param tfee Pool trading fee in basis points (unused for fee + * charging; passed to amount-adjustment helpers). + * @param freezeHandling Whether to zero out frozen trustline transfers. + * @param authHandling Whether to zero out unauthorized trustline transfers. + * @param withdrawAll `Yes` if the LP is redeeming their entire position. + * @param priorBalance Caller's XRP balance before fee deduction + * (used for reserve checks during asset transfer). + * @param journal For error logging. + * @return A tuple of `{TER, newLPTokenBalance, asset1Withdrawn, asset2Withdrawn}`. + * Returns `{tecINTERNAL, ...}` if an unexpected exception is caught. */ static std::tuple> equalWithdrawTokens( @@ -118,21 +212,38 @@ public: XRPAmount const& priorBalance, beast::Journal const& journal); - /** Withdraw requested assets and token from AMM into LP account. - * Return new total LPToken balance and the withdrawn amounts for both - * assets. - * @param view - * @param ammSle AMM ledger entry - * @param ammAccount AMM account - * @param amountBalance current LP asset1 balance - * @param amountWithdraw asset1 withdraw amount - * @param amount2Withdraw asset2 withdraw amount - * @param lpTokensAMMBalance current AMM LPT balance - * @param lpTokensWithdraw amount of lptokens to withdraw - * @param tfee trading fee in basis points - * @param withdrawAll if withdraw all lptokens - * @param priorBalance balance before fees - * @return + /** Transfer withdrawn assets from the AMM account to the LP and burn LP tokens. + * + * Moves `amountWithdraw` of asset1 (and optionally `amount2Withdraw` of + * asset2) from `ammAccount` to `account`, then burns `lpTokensWithdraw` + * tokens. The trading fee is charged on single-asset modes (the caller is + * responsible for passing the correct `tfee`; fee-free callers pass 0). + * When `withdrawAll` is `WithdrawAll::Yes`, exact-zero arithmetic prevents + * dust from rounding errors on full-position redemptions. + * + * This overload is `public static` so `AMMDelete` and `AMMClawback` can + * invoke it without constructing an `AMMWithdraw` transactor. The caller + * supplies `freezeHandling`, `authHandling`, `priorBalance`, and a journal + * that the instance methods obtain from `ctx_` automatically. + * + * @param view Sandbox to mutate; caller commits only on success. + * @param ammSle AMM ledger entry. + * @param ammAccount AMM pseudo-account ID. + * @param account LP account receiving the withdrawn assets. + * @param amountBalance Current pool balance of asset1. + * @param amountWithdraw Asset1 amount to transfer to the LP. + * @param amount2Withdraw Asset2 amount to transfer, or absent for + * single-asset withdrawals. + * @param lpTokensAMMBalance Current total LP token supply. + * @param lpTokensWithdraw LP tokens to burn. + * @param tfee Pool trading fee in basis points. + * @param freezeHandling Whether to zero out frozen trustline transfers. + * @param authHandling Whether to zero out unauthorized trustline transfers. + * @param withdrawAll `Yes` if the LP is redeeming their entire position. + * @param priorBalance Caller's XRP balance before fee deduction + * (used for reserve checks during asset transfer). + * @param journal For error logging. + * @return A tuple of `{TER, newLPTokenBalance, asset1Withdrawn, asset2Withdrawn}`. */ static std::tuple> withdraw( @@ -152,6 +263,32 @@ public: XRPAmount const& priorBalance, beast::Journal const& journal); + /** Delete the AMM instance account and its `ltAMM` entry if no LP tokens remain. + * + * After burning LP tokens, if `lpTokenBalance` has reached zero the AMM + * pseudo-account and its associated ledger entry are orphaned objects. + * This method detects that condition and calls `deleteAMMAccount`, which + * removes those objects before the sandbox is committed. + * + * If `deleteAMMAccount` returns `tecINCOMPLETE` (partial deletion — more + * objects remain to clean up), the LP token balance is still updated on + * `ammSle` so the next transaction can continue the teardown. If the + * balance is non-zero, only the balance field is updated and no deletion + * is attempted. + * + * Called by `applyGuts`, `AMMDelete`, and `AMMClawback` after every + * successful token burn. + * + * @param sb Sandbox staging all mutations; caller commits on success. + * @param ammSle AMM ledger entry (mutable); its `sfLPTokenBalance` + * is updated unless full deletion occurs. + * @param lpTokenBalance Post-burn LP token balance. + * @param asset1 First pool asset (identifies the AMM for deletion). + * @param asset2 Second pool asset (identifies the AMM for deletion). + * @param journal For error logging. + * @return `{TER, true}` on success or partial cleanup (`tecINCOMPLETE`); + * `{tec*, false}` if an unexpected deletion error occurs. + */ static std::pair deleteAMMAccountIfEmpty( Sandbox& sb, @@ -162,20 +299,37 @@ public: beast::Journal const& journal); private: + /** Dispatch to the mode-specific withdrawal helper and commit AMM state. + * + * Reads the current pool balances, selects the appropriate private method + * based on `tfWithdrawSubTx` flag bits, executes it, then calls + * `deleteAMMAccountIfEmpty` to clean up orphaned objects if the pool is + * fully drained. On `tesSUCCESS`, updates `sfLPTokenBalance` on the AMM + * ledger entry. + * + * @param view The sandbox to mutate; committed by the caller only on success. + * @return `{TER, true}` on success; `{tec*, false}` on failure. + */ std::pair applyGuts(Sandbox& view); - /** Withdraw requested assets and token from AMM into LP account. - * Return new total LPToken balance. - * @param view - * @param ammSle AMM ledger entry - * @param ammAccount AMM account - * @param amountBalance current LP asset1 balance - * @param amountWithdraw asset1 withdraw amount - * @param amount2Withdraw asset2 withdraw amount - * @param lpTokensAMMBalance current AMM LPT balance - * @param lpTokensWithdraw amount of lptokens to withdraw - * @return + /** Instance-method wrapper around the public static `withdraw` overload. + * + * Forwards to the static overload with `FreezeHandling::ZeroIfFrozen`, + * `AuthHandling::ZeroIfUnauthorized`, `isWithdrawAll(ctx_.tx)`, + * `preFeeBalance_`, and `j_` sourced from the instance's `ApplyContext`. + * + * @param view Sandbox to mutate. + * @param ammSle AMM ledger entry. + * @param ammAccount AMM pseudo-account ID. + * @param amountBalance Current pool balance of asset1. + * @param amountWithdraw Asset1 amount to transfer to the LP. + * @param amount2Withdraw Asset2 amount to transfer, or absent for + * single-asset withdrawals. + * @param lpTokensAMMBalance Current total LP token supply. + * @param lpTokensWithdraw LP tokens to burn. + * @param tfee Pool trading fee in basis points. + * @return `{tesSUCCESS, newLPTokenBalance}` or `{tec*, STAmount{}}`. */ std::pair withdraw( @@ -189,18 +343,24 @@ private: STAmount const& lpTokensWithdraw, std::uint16_t tfee); - /** Equal-asset withdrawal (LPTokens) of some AMM instance pools - * shares represented by the number of LPTokens . - * The trading fee is not charged. - * @param view - * @param ammAccount - * @param amountBalance current LP asset1 balance - * @param amount2Balance current LP asset2 balance - * @param lptAMMBalance current AMM LPT balance - * @param lpTokens current LPT balance - * @param lpTokensWithdraw amount of tokens to withdraw - * @param tfee trading fee in basis points - * @return + /** Instance-method wrapper around the public static `equalWithdrawTokens` overload. + * + * Forwards to the static overload with `FreezeHandling::ZeroIfFrozen`, + * `AuthHandling::ZeroIfUnauthorized`, `isWithdrawAll(ctx_.tx)`, + * `preFeeBalance_`, and `ctx_.journal` sourced from the instance's + * `ApplyContext`. No trading fee is charged. + * + * @param view Sandbox to mutate. + * @param ammSle AMM ledger entry. + * @param ammAccount AMM pseudo-account ID. + * @param amountBalance Current pool balance of asset1. + * @param amount2Balance Current pool balance of asset2. + * @param lptAMMBalance Current total LP token supply. + * @param lpTokens LP token balance held by the caller's account. + * @param lpTokensWithdraw LP tokens to burn. + * @param tfee Pool trading fee in basis points (passed to + * amount-adjustment helpers; not charged). + * @return `{tesSUCCESS, newLPTokenBalance}` or `{tec*, STAmount{}}`. */ std::pair equalWithdrawTokens( @@ -214,18 +374,27 @@ private: STAmount const& lpTokensWithdraw, std::uint16_t tfee); - /** Withdraw both assets (Asset1Out, Asset2Out) with the constraints - * on the maximum amount of each asset that the trader is willing - * to withdraw. The trading fee is not charged. - * @param view - * @param ammAccount - * @param amountBalance current AMM asset1 balance - * @param amount2Balance current AMM asset2 balance - * @param lptAMMBalance current AMM LPT balance - * @param amount asset1 withdraw amount - * @param amount2 max asset2 withdraw amount - * @param tfee trading fee in basis points - * @return + /** Proportional dual-asset withdrawal with per-asset maximum caps (`tfTwoAsset`). + * + * Computes the largest proportional withdrawal (as a fraction of + * `lptAMMBalance`) that fits within both `amount` (asset1 cap) and `amount2` + * (asset2 cap). Because the withdrawal is proportional, the pool's price + * ratio is preserved and no trading fee is charged. The actual amounts + * withdrawn may be less than the stated maximums. + * + * Fails with `tecAMM_FAILED` if neither asset cap yields a feasible solution. + * + * @param view Sandbox to mutate. + * @param ammSle AMM ledger entry. + * @param ammAccount AMM pseudo-account ID. + * @param amountBalance Current pool balance of asset1. + * @param amount2Balance Current pool balance of asset2. + * @param lptAMMBalance Current total LP token supply. + * @param amount Maximum asset1 the LP is willing to withdraw. + * @param amount2 Maximum asset2 the LP is willing to withdraw. + * @param tfee Pool trading fee in basis points (passed through + * for amount-adjustment rounding; not charged). + * @return `{tesSUCCESS, newLPTokenBalance}` or `{tec*, STAmount{}}`. */ std::pair equalWithdrawLimit( @@ -239,15 +408,22 @@ private: STAmount const& amount2, std::uint16_t tfee); - /** Single asset withdrawal (Asset1Out) equivalent to the amount specified - * in Asset1Out. The trading fee is charged. - * @param view - * @param ammAccount - * @param amountBalance current AMM asset1 balance - * @param lptAMMBalance current AMM LPT balance - * @param amount asset1 withdraw amount - * @param tfee trading fee in basis points - * @return + /** Single-asset withdrawal by specified amount (`tfSingleAsset`). + * + * Computes the LP tokens to burn via `lpTokensIn(amountBalance, amount, + * lptAMMBalance, tfee)`. Single-asset withdrawal is mathematically + * equivalent to a swap followed by a proportional withdrawal, so the pool's + * trading fee applies. Fails with `tecAMM_INVALID_TOKENS` (under + * `fixAMMv1_3`) if the computed token amount rounds to zero. + * + * @param view Sandbox to mutate. + * @param ammSle AMM ledger entry. + * @param ammAccount AMM pseudo-account ID. + * @param amountBalance Current pool balance of asset1. + * @param lptAMMBalance Current total LP token supply. + * @param amount Asset1 amount to withdraw. + * @param tfee Pool trading fee in basis points. + * @return `{tesSUCCESS, newLPTokenBalance}` or `{tec*, STAmount{}}`. */ std::pair singleWithdraw( @@ -259,16 +435,23 @@ private: STAmount const& amount, std::uint16_t tfee); - /** Single asset withdrawal (Asset1Out, LPTokens) proportional - * to the share specified by tokens. The trading fee is charged. - * @param view - * @param ammAccount - * @param amountBalance current AMM asset1 balance - * @param lptAMMBalance current AMM LPT balance - * @param amount asset1 withdraw amount - * @param lpTokensWithdraw amount of tokens to withdraw - * @param tfee trading fee in basis points - * @return + /** Single-asset withdrawal for a specified LP token amount (`tfOneAssetLPToken`). + * + * Adjusts `lpTokensWithdraw` via `adjustLPTokensIn`, then solves for the + * asset1 output implied by burning exactly that many tokens, subject to a + * minimum of `amount` (the caller's floor). The trading fee is charged. + * Fails with `tecAMM_INVALID_TOKENS` (under `fixAMMv1_3`) if the adjusted + * token amount rounds to zero. + * + * @param view Sandbox to mutate. + * @param ammSle AMM ledger entry. + * @param ammAccount AMM pseudo-account ID. + * @param amountBalance Current pool balance of asset1. + * @param lptAMMBalance Current total LP token supply. + * @param amount Minimum asset1 the LP is willing to receive. + * @param lpTokensWithdraw LP tokens to burn. + * @param tfee Pool trading fee in basis points. + * @return `{tesSUCCESS, newLPTokenBalance}` or `{tec*, STAmount{}}`. */ std::pair singleWithdrawTokens( @@ -281,16 +464,27 @@ private: STAmount const& lpTokensWithdraw, std::uint16_t tfee); - /** Withdraw single asset (Asset1Out, EPrice) with two constraints. - * The trading fee is charged. - * @param view - * @param ammAccount - * @param amountBalance current AMM asset1 balance - * @param lptAMMBalance current AMM LPT balance - * @param amount asset1 withdraw amount - * @param ePrice maximum asset1 effective price - * @param tfee trading fee in basis points - * @return + /** Single-asset withdrawal with an effective-price ceiling (`tfLimitLPToken`). + * + * Enforces two simultaneous constraints: a minimum output amount (`amount`, + * or zero meaning unconstrained) and a maximum effective price per LP token + * burned (`ePrice`). This mode is the closest analogue to a limit order at + * exit time — the transaction fails with `tecAMM_FAILED` if the current + * pool price exceeds `ePrice`. The trading fee is charged. + * + * Two-pass algorithm: if a non-zero `amount` satisfies the price constraint + * it is used directly; otherwise the exact asset1 and token amounts that + * satisfy `EP = ePrice` are derived analytically. + * + * @param view Sandbox to mutate. + * @param ammSle AMM ledger entry. + * @param ammAccount AMM pseudo-account ID. + * @param amountBalance Current pool balance of asset1. + * @param lptAMMBalance Current total LP token supply. + * @param amount Minimum asset1 to withdraw (zero = unconstrained). + * @param ePrice Maximum effective price (asset1 per LP token burned). + * @param tfee Pool trading fee in basis points. + * @return `{tesSUCCESS, newLPTokenBalance}` or `{tec*, STAmount{}}`. */ std::pair singleWithdrawEPrice( @@ -303,7 +497,12 @@ private: STAmount const& ePrice, std::uint16_t tfee); - /** Check from the flags if it's withdraw all */ + /** Decode the `tfWithdrawAll` / `tfOneAssetWithdrawAll` flag bits. + * + * @param tx The transaction to inspect. + * @return `WithdrawAll::Yes` if either full-redemption flag is set; + * `WithdrawAll::No` otherwise. + */ static WithdrawAll isWithdrawAll(STTx const& tx); }; diff --git a/include/xrpl/tx/transactors/dex/OfferCancel.h b/include/xrpl/tx/transactors/dex/OfferCancel.h index c16550f87d..a733b7f779 100644 --- a/include/xrpl/tx/transactors/dex/OfferCancel.h +++ b/include/xrpl/tx/transactors/dex/OfferCancel.h @@ -5,6 +5,23 @@ namespace xrpl { +/** Transactor for the OfferCancel transaction type. + * + * Removes a standing DEX offer identified by its original sequence number. + * The implementation is intentionally thin: field validation is minimal, + * and the actual ledger teardown — unlinking from the owner directory, + * removing the order-book directory entry, updating owner counts — is + * delegated entirely to the shared `offerDelete` helper. + * + * Cancellation is idempotent: if the target offer no longer exists + * (consumed by a trade, previously cancelled, or expired), `doApply` + * returns `tesSUCCESS` without modifying any ledger state. The fee is + * still charged — the transaction was valid and processed. + * + * `kCONSEQUENCES_FACTORY` is `Normal` because cancellation never reserves + * owner reserves or locks funds beyond the transaction fee itself, so the + * framework can model its consequences without per-transaction computation. + */ class OfferCancel : public Transactor { public: @@ -14,21 +31,67 @@ public: { } + /** Validate static transaction fields before ledger access. + * + * Rejects the transaction with `temBAD_SEQUENCE` if `sfOfferSequence` + * is zero. No valid offer can carry sequence number zero, so a zero + * value is always a client error. All other field checks (account, + * fee, flags, signature) are handled by `preflight1`/`preflight2` in + * the base-class pipeline. + * + * @param ctx Preflight context carrying the transaction and rules. + * @return `tesSUCCESS` if the sequence field is non-zero; + * `temBAD_SEQUENCE` otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Check ledger state before applying the cancellation. + * + * Reads the submitter's account entry and enforces a temporal ordering + * invariant: `sfOfferSequence` must be strictly less than the account's + * current sequence number. If the offer sequence is greater than or + * equal to the account sequence, the offer could not yet exist on the + * ledger, and the transaction is rejected. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if the account exists and the sequence ordering + * is valid; `terNO_ACCOUNT` if the submitter's account is not found + * (highly unusual at this stage); `temBAD_SEQUENCE` if + * `sfOfferSequence` is not strictly less than the account's current + * sequence number. + */ static TER preclaim(PreclaimContext const& ctx); + /** Remove the target offer from the ledger. + * + * Resolves the offer via `keylet::offer(account_, offerSequence)`. If + * the offer exists, delegates removal to `offerDelete`, which unlinks + * it from the owner and order-book directories and adjusts the owner + * count. If the offer is not found, returns `tesSUCCESS` without any + * state changes — the offer may have already been consumed by a + * crossing trade, cancelled, or expired. + * + * @return Result of `offerDelete` if the offer was found; + * `tesSUCCESS` if the offer was already absent from the ledger; + * `tefINTERNAL` (unreachable in practice) if the account SLE is + * missing. + */ TER doApply() override; + /** Per-entry invariant visitor (no-op; reserved for future work). */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Finalize invariant checks (no-op; reserved for future work). + * + * @return Always `true`. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/dex/OfferCreate.h b/include/xrpl/tx/transactors/dex/OfferCreate.h index 4ee69313e0..1646f5059a 100644 --- a/include/xrpl/tx/transactors/dex/OfferCreate.h +++ b/include/xrpl/tx/transactors/dex/OfferCreate.h @@ -1,3 +1,13 @@ +/** @file + * Declares the OfferCreate transactor for the XRPL decentralized exchange. + * + * OfferCreate processes `ttOFFER_CREATE` transactions: it validates offer + * fields, optionally cancels a pre-existing offer, attempts immediate crossing + * against resting orders via the payment engine, and — if any unfilled amount + * remains — writes a new offer ledger entry into the appropriate order-book + * directory. + */ + #pragma once #include @@ -8,44 +18,135 @@ namespace xrpl { class PaymentSandbox; class Sandbox; -/** Transactor specialized for creating offers in the ledger. */ +/** Transactor for `ttOFFER_CREATE` transactions on the XRPL DEX. + * + * Handles the full lifecycle of offer creation: structural validation in + * `preflight`, ledger-state checks in `preclaim`, and the three-stage + * `doApply` sequence of (optional) offer cancellation, crossing via the + * payment engine (`flowCross`), and conditional placement of any residual + * offer on the order book. + * + * Uses `ConsequencesFactoryType::Custom` so the transaction queue can + * accurately bound the maximum XRP a queued offer could spend (see + * `makeTxConsequences`). + * + * @see OfferCancel for the simpler remove-only counterpart. + */ class OfferCreate : public Transactor { public: static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Custom; - /** Construct a Transactor subclass that creates an offer in the ledger. */ + /** Construct the transactor for the given apply context. */ explicit OfferCreate(ApplyContext& ctx) : Transactor(ctx) { } + /** Compute the maximum XRP this offer could spend, for queue accounting. + * + * If `sfTakerGets` is native (XRP), returns that amount as the upper + * bound on XRP consumed. For IOU-only offers, the XRP spend is zero. + * This prevents over-reserving queue capacity for non-XRP offers. + * + * @param ctx Preflight context supplying the transaction fields. + * @return `TxConsequences` carrying the calculated maximum XRP spend. + */ static TxConsequences makeTxConsequences(PreflightContext const& ctx); + /** Gate the transaction on the protocol amendments it requires. + * + * Rejects the transaction (`temDISABLED`) if `sfDomainID` is present but + * `featurePermissionedDEX` is not enabled, or if either `sfTakerPays` or + * `sfTakerGets` carries an `MPTIssue` without `featureMPTokensV2`. + * + * @param ctx Preflight context supplying transaction fields and rules. + * @return `true` if all required amendments are active; `false` otherwise. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Return the bitmask of flags accepted by this transaction type. + * + * The base mask is `tfOfferCreateMask`. When `featurePermissionedDEX` is + * *not* active, `tfHybrid` is OR-ed in so that `preflight0` rejects any + * transaction that sets it. + * + * @param ctx Preflight context supplying the active amendment rules. + * @return 32-bit mask of permitted flag bits. + */ static std::uint32_t getFlagsMask(PreflightContext const& ctx); - /** Enforce constraints beyond those of the Transactor base class. */ + /** Validate the structural integrity of the transaction fields. + * + * Checks (in order): `tfHybrid` requires `sfDomainID`; mutual exclusivity + * of `tfImmediateOrCancel` and `tfFillOrKill`; non-zero expiration if + * present; non-zero cancel sequence if present; legal amounts for both + * sides; no XRP-for-XRP or same-asset offers; no bad currency codes; and + * native/issuer field consistency. All rejections are `tem*` codes — no + * fee is charged. + * + * @param ctx Preflight context supplying transaction fields and rules. + * @return `tesSUCCESS` on success, or a `tem*` code on any field error. + */ static NotTEC preflight(PreflightContext const& ctx); - /** Enforce constraints beyond those of the Transactor base class. */ + /** Check ledger-state preconditions against a read-only view. + * + * Verifies: the submitting account exists; neither asset is globally + * frozen; the account holds sufficient funds to back the offer + * (`tecUNFUNDED_OFFER`); the optional cancel-sequence is valid; the offer + * has not already expired; the account is authorized to receive + * `sfTakerPays` (via `checkAcceptAsset`); and — for domain offers — the + * account is a member of the referenced `PermissionedDEX` domain. + * + * @param ctx Preclaim context supplying the read-only ledger view. + * @return `tesSUCCESS` on success, or an appropriate `ter*`/`tec*` code. + */ static TER preclaim(PreclaimContext const& ctx); - /** Precondition: fee collection is likely. Attempt to create the offer. */ + /** Execute the offer: cancel, cross, and optionally place on the book. + * + * Allocates two sandboxes and delegates to `applyGuts`. Commits the + * primary sandbox when the offer is placed or fully crossed; commits only + * the cancel sandbox when a Fill-or-Kill offer cannot be filled (so that + * stale-offer housekeeping from crossing is still recorded). + * + * @return `tesSUCCESS` or a `tec*` code; never `tem*`/`tef*`. + */ TER doApply() override; + /** Accumulate per-SLE state for offer-specific invariant checks. + * + * Called once per modified ledger entry after `doApply` completes. No + * transaction-specific invariants are currently enforced here. + * + * @param isDelete `true` if the entry is being removed from the ledger. + * @param before SLE state before the transaction; `nullptr` for new entries. + * @param after SLE state after the transaction; `nullptr` for deletions. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Finalize offer-specific invariant checks after all entries are visited. + * + * No transaction-specific invariants are currently enforced. Always + * returns `true`. + * + * @param tx The transaction being applied. + * @param result The TER produced by `doApply`. + * @param fee The fee charged for this transaction. + * @param view The read-only ledger view after application. + * @param j Journal for diagnostic logging. + * @return `true` — no invariant violations detected. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, @@ -55,10 +156,43 @@ public: beast::Journal const& j) override; private: + /** Execute the core offer logic against a pair of sandboxes. + * + * Handles cancellation of any existing offer nominated by + * `sfOfferSequence`, tick-size rounding, crossing via `flowCross`, + * Fill-or-Kill / Immediate-or-Cancel evaluation, reserve checks, and + * placement of any residual offer into the order-book directory. If the + * transaction should be committed with full state changes, returns + * `{TER, true}`; if only `viewCancel` should be committed (e.g., a + * killed Fill-or-Kill), returns `{TER, false}`. + * + * @param view Primary sandbox for the complete transaction result. + * @param viewCancel Secondary sandbox committed on Fill-or-Kill failure, + * preserving stale-offer removals made during crossing. + * @return Pair of `{TER, bool}` where the bool selects which sandbox to + * commit. + */ std::pair applyGuts(Sandbox& view, Sandbox& viewCancel); - // Determine if we are authorized to hold the asset we want to get. + /** Verify that the account is permitted to receive the specified asset. + * + * Only meaningful for custom currencies (asserts XRP is never passed). + * For IOU assets: checks issuer existence and, when `lsfRequireAuth` is + * set on the issuer, verifies that a trust line exists and carries the + * appropriate authorization flag (`lsfLowAuth` or `lsfHighAuth` per + * canonical account ordering). Also rejects deep-frozen trust lines + * (`lsfLowDeepFreeze`/`lsfHighDeepFreeze`). For MPT assets: delegates to + * `requireAuth` with `WeakAuth` semantics (an `MPToken` holder entry need + * not pre-exist). + * + * @param view Read-only ledger view. + * @param flags Apply flags (controls `ter*` vs `tec*` error selection). + * @param id Account that would receive the asset. + * @param j Journal for diagnostic logging. + * @param asset The IOU or MPT asset to be received. + * @return `tesSUCCESS`, or `ter*`/`tec*` if the account lacks authority. + */ static TER checkAcceptAsset( ReadView const& view, @@ -67,7 +201,24 @@ private: beast::Journal const j, Asset const& asset); - // Use the payment flow code to perform offer crossing. + /** Cross the offer against resting orders using the payment flow engine. + * + * Delegates to the same `flow()` function used by `Payment` transactions, + * inverting `TakerPays`/`TakerGets` so the offer creator acts as a taker. + * Constructs a quality threshold to enforce the passive flag; for + * IOU-to-IOU offers injects an XRP intermediate path to enable two-book + * crossing. For `tfSell` passes maximum delivery limits to accept any + * amount of the `Gets` asset. After crossing, computes the residual offer + * amounts at the original quality, factoring out gateway transfer rates. + * Stale offers encountered during crossing are removed in both sandboxes. + * + * @param psb Primary `PaymentSandbox` (wraps the main `Sandbox`). + * @param psbCancel Cancel-only `PaymentSandbox` (wraps `sbCancel`). + * @param takerAmount Inverted offer amounts from the taker's perspective. + * @param domainID Optional permissioned-DEX domain to restrict crossing. + * @return Pair of `{TER, Amounts}` where `Amounts` is the residual offer + * after crossing (zero if fully filled). + */ std::pair flowCross( PaymentSandbox& psb, @@ -75,9 +226,28 @@ private: Amounts const& takerAmount, std::optional const& domainID); + /** Format an `STAmount` as a human-readable string for logging. */ static std::string formatAmount(STAmount const& amount); + /** Index a hybrid domain offer into the open (non-domain) order book. + * + * Sets `lsfHybrid` on the offer SLE, creates a second book-directory + * entry using `std::nullopt` as the domain, and records that entry's + * key and page node in `sfAdditionalBooks` on the offer. This dual + * indexing lets open-market order-book walks find and consume domain + * offers. Only called when `tfHybrid` is set. + * + * @param sb Main sandbox holding the offer SLE. + * @param sleOffer The offer ledger entry to be dually indexed. + * @param offerIndex Keylet of the offer entry. + * @param saTakerPays Amount the taker pays (book sort key). + * @param saTakerGets Amount the taker gets (book sort key). + * @param setDir Callback that writes the book-directory key and + * domain into the offer SLE. + * @return `tesSUCCESS`, or `tecDIR_FULL` if the open book directory is + * at capacity. + */ TER applyHybrid( Sandbox& sb, diff --git a/include/xrpl/tx/transactors/did/DIDDelete.h b/include/xrpl/tx/transactors/did/DIDDelete.h index 4e19a6664f..c65b9386f7 100644 --- a/include/xrpl/tx/transactors/did/DIDDelete.h +++ b/include/xrpl/tx/transactors/did/DIDDelete.h @@ -4,6 +4,18 @@ namespace xrpl { +/** Transactor for `ttDID_DELETE` (type 50) transactions. + * + * Removes a Decentralized Identifier (DID) ledger object that was previously + * created by `DIDSet`. Gated behind the `featureDID` amendment. Each account + * may hold at most one DID object, so the target is derived entirely from the + * submitting account's `AccountID` — no additional fields are required on the + * transaction. + * + * The `deleteSLE` overload pair is designed for reuse: `AccountDelete` calls + * the `ApplyView`-level overload directly to clean up an owned DID without + * constructing a full transactor context. + */ class DIDDelete : public Transactor { public: @@ -13,24 +25,85 @@ public: { } + /** Validates the transaction before any ledger state is touched. + * + * Returns `tesSUCCESS` unconditionally. A DID deletion carries no + * transaction-specific fields beyond the universal base fields; all + * meaningful validation (account existence, fee, sequence, signature, + * amendment gate) is handled by the `invokePreflight` machinery. + * + * @param ctx The preflight context (unused beyond base validation). + * @return `tesSUCCESS` always. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Convenience overload: peek the DID object by keylet and delegate deletion. + * + * Resolves `sleKeylet` through `ctx.view()`. If the object does not exist, + * returns `tecNO_ENTRY` (fee-claiming failure — sequence consumed, no + * mutation applied). Otherwise delegates to the `ApplyView` overload. + * + * @param ctx The apply context providing the mutable ledger view and journal. + * @param sleKeylet Keylet identifying the DID SLE to remove. + * @param owner `AccountID` of the account that owns the DID. + * @return `tesSUCCESS` on successful removal; `tecNO_ENTRY` if the DID + * object does not exist; propagates any error from the `ApplyView` + * overload. + */ static TER deleteSLE(ApplyContext& ctx, Keylet sleKeylet, AccountID const owner); + /** Remove a DID SLE from the ledger and update the owner's accounting. + * + * Performs the three-step deletion sequence required for any owned ledger + * object: + * 1. `dirRemove` — removes the entry from the owner's directory using the + * cached `sfOwnerNode` page index. Returns `tefBAD_LEDGER` on failure + * (ledger corruption; `LCOV_EXCL` branch, should never occur). + * 2. `adjustOwnerCount(..., -1)` — decrements the reserve-adjusted owner + * count on the account root. Returns `tecINTERNAL` if the account SLE + * cannot be found (`LCOV_EXCL` branch). + * 3. `view.erase(sle)` — removes the DID SLE from the ledger. + * + * Accepting `ApplyView&` rather than `ApplyContext&` makes this overload + * callable from any transactor with a mutable view — notably `AccountDelete`, + * which invokes it directly to clean up owned DIDs during account removal. + * + * @param view Mutable ledger view to apply changes to. + * @param sle Pre-resolved shared pointer to the DID SLE to delete. + * @param owner `AccountID` of the account that owns the DID. + * @param j Journal for diagnostic logging. + * @return `tesSUCCESS` on successful removal; `tefBAD_LEDGER` if + * `dirRemove` fails (ledger inconsistency); `tecINTERNAL` if the + * owner account SLE cannot be found. + */ static TER deleteSLE(ApplyView& view, std::shared_ptr sle, AccountID const owner, beast::Journal j); + /** Apply the DID deletion to the ledger. + * + * Derives the DID keylet from `account_` via `keylet::did` and delegates + * to `deleteSLE(ctx_, keylet, account_)`. Since each account holds at most + * one DID, no field lookup is needed. + * + * @return `tesSUCCESS` on success; `tecNO_ENTRY` if the account has no DID; + * or a `tef`/`tec` error from `deleteSLE`. + */ TER doApply() override; + /** Invariant visitor — no DID-specific invariants are currently enforced. */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Invariant finalizer — no DID-specific invariants are currently enforced. + * + * @return `true` always. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/did/DIDSet.h b/include/xrpl/tx/transactors/did/DIDSet.h index 972f3a9579..6aca3ccce9 100644 --- a/include/xrpl/tx/transactors/did/DIDSet.h +++ b/include/xrpl/tx/transactors/did/DIDSet.h @@ -4,6 +4,17 @@ namespace xrpl { +/** Transactor for `ttDID_SET` transactions. + * + * Creates or updates a Decentralized Identifier (DID) ledger object owned + * by the submitting account, conforming to the W3C DID v1.0 specification + * (https://www.w3.org/TR/did-core/). Each account may hold at most one DID + * object; submitting a `DIDSet` transaction when a DID already exists + * performs a field-level upsert rather than replacement. + * + * @note Gated behind the `featureDID` amendment. The sibling transactor + * `DIDDelete` handles removal. + */ class DIDSet : public Transactor { public: @@ -13,18 +24,57 @@ public: { } + /** Validate the transaction fields before any ledger state is touched. + * + * Enforces three rules without accessing the ledger: + * - At least one of `sfURI`, `sfDIDDocument`, or `sfData` must be present. + * - Not all three fields may be simultaneously present but empty (which + * would create or leave a semantically vacuous DID object). + * - No individual field may exceed its maximum byte length + * (`kMAX_DIDURI_LENGTH`, `kMAX_DID_DOCUMENT_LENGTH`, `kMAX_DID_DATA_LENGTH`). + * + * @param ctx Preflight context carrying the raw transaction fields. + * @return `temEMPTY_DID` if no non-empty field is provided; `temMALFORMED` + * if any field exceeds its length limit; `tesSUCCESS` otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Apply the DID create-or-update to the ledger. + * + * Follows an upsert pattern keyed on `keylet::did(account_)`: + * + * - **Update path** (DID SLE already exists): for each of `sfURI`, + * `sfDIDDocument`, and `sfData`, an absent transaction field is a + * no-op, an empty field removes the attribute from the existing object, + * and a non-empty field replaces the stored value. Returns + * `tecEMPTY_DID` if all three fields would be absent after the update. + * + * - **Create path** (no DID SLE exists): builds a fresh SLE, copies + * non-empty transaction fields into it, then calls the file-local + * `addSLE()` helper, which verifies owner reserve availability, inserts + * the object into the ledger, links it into the account's owner + * directory, and increments the owner count. + * + * @return `tesSUCCESS` on success; `tecEMPTY_DID` if the update would + * leave the DID with no attributes; `tecINSUFFICIENT_RESERVE` if the + * account cannot cover the new owner reserve (create path only); + * `tecDIR_FULL` if the owner directory has no room (create path only). + */ TER doApply() override; + /** Invariant visitor — no DID-specific invariants are currently enforced. */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Invariant finalizer — no DID-specific invariants are currently enforced. + * + * @return `true` always. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/escrow/EscrowCancel.h b/include/xrpl/tx/transactors/escrow/EscrowCancel.h index 628fd2e61f..ce6839446d 100644 --- a/include/xrpl/tx/transactors/escrow/EscrowCancel.h +++ b/include/xrpl/tx/transactors/escrow/EscrowCancel.h @@ -4,30 +4,127 @@ namespace xrpl { +/** Transactor for the EscrowCancel transaction type. + * + * Returns locked funds to the escrow creator once the escrow's + * `sfCancelAfter` time has elapsed. This is the "reclaim" counterpart to + * `EscrowFinish`: where `EscrowFinish` releases funds to the intended + * recipient when conditions are met, `EscrowCancel` unwinds the escrow + * entirely and restores the held amount to the originating account. + * + * The `kCONSEQUENCES_FACTORY` is `Normal` because cancellation unlocks + * funds rather than locking them; no bespoke `makeTxConsequences` is + * needed. Contrast with `EscrowCreate`, which uses `Custom` to account for + * the arbitrary amount of XRP or tokens it locks up. + * + * The three-phase interface (`preflight`, `preclaim`, `doApply`) is invoked + * by the framework through compile-time template dispatch — not virtual + * dispatch. Derived classes must match these exact signatures. + */ class EscrowCancel : public Transactor { public: + /** Uses the standard fee-consequence model; no custom factory needed. */ static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Normal; explicit EscrowCancel(ApplyContext& ctx) : Transactor(ctx) { } + /** Stateless preflight validation for EscrowCancel. + * + * There are no field-level constraints on `EscrowCancel` beyond what the + * framework's `preflight1`/`preflight2` wrappers already enforce (fee + * validity, flags, signature format). All meaningful constraints — + * escrow existence, cancel-time expiry, asset authorization — require + * ledger state and are therefore deferred to `preclaim` and `doApply`. + * + * @param ctx stateless preflight context carrying the raw transaction. + * @return always `tesSUCCESS`. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Read-only ledger checks for EscrowCancel. + * + * When `featureTokenEscrow` is active, verifies that the escrow object + * identified by `{sfOwner, sfOfferSequence}` exists (returning + * `tecNO_TARGET` if not), and for non-XRP amounts dispatches to + * `escrowCancelPreclaimHelper` (IOU) or + * `escrowCancelPreclaimHelper` (MPT) to guard against + * `requireAuth` violations: if the issuer enabled authorization + * requirements after the escrow was created and the original escrow + * account is no longer authorized, cancellation is blocked. + * + * Without `featureTokenEscrow`, this method returns `tesSUCCESS` + * unconditionally. + * + * @param ctx read-only preclaim context with ledger access. + * @return `tesSUCCESS` on success; `tecNO_TARGET` if the escrow does + * not exist; a `requireAuth` error code if the escrow account + * is no longer authorized to hold the asset. + */ static TER preclaim(PreclaimContext const& ctx); + /** Apply the EscrowCancel transaction to the mutable ledger. + * + * Locates the escrow by `{sfOwner, sfOfferSequence}`, enforces that + * `sfCancelAfter` is set and has elapsed (returning `tecNO_PERMISSION` + * if either condition fails), then: + * + * 1. Removes the escrow from the owner's directory. + * 2. Removes the escrow from the recipient's directory if + * `sfDestinationNode` is present. + * 3. Returns the locked amount to the owner: + * - XRP: credited directly to the owner's `sfBalance`. + * - Non-XRP (`featureTokenEscrow` required): routed through + * `escrowUnlockApplyHelper`, followed by removal from the issuer's + * directory via `sfIssuerNode`. + * 4. Decrements the owner's reserve count via `adjustOwnerCount`. + * 5. Erases the escrow SLE. + * + * If the escrow is absent at apply time with `featureTokenEscrow` active, + * `tecINTERNAL` is returned (considered unreachable in practice; marked + * `LCOV_EXCL_LINE`). The legacy path returns `tecNO_TARGET`. + * + * @return `tesSUCCESS` on successful cancellation; `tecNO_PERMISSION` if + * `sfCancelAfter` is absent or has not yet elapsed; + * `tecINTERNAL` or `tecNO_TARGET` if the escrow is missing at + * apply time; `tefBAD_LEDGER` if a directory removal fails + * (indicates ledger corruption). + */ TER doApply() override; + /** No transaction-specific invariant entries to inspect yet. + * + * Reserved for future per-entry post-condition checks. Currently a + * no-op; the base-class invariant framework still runs its protocol-level + * checkers. + * + * @param isDelete true if the entry was erased. + * @param before entry state before the transaction (nullptr if new). + * @param after entry state after the transaction. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** No transaction-specific finalization invariants yet. + * + * Reserved for future post-condition checks after all entries have been + * visited. Currently returns `true` unconditionally. + * + * @param tx the transaction being applied. + * @param result the tentative TER result. + * @param fee fee consumed by the transaction. + * @param view read-only view of the ledger after the transaction. + * @param j journal for logging invariant failures. + * @return always `true`. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/escrow/EscrowCreate.h b/include/xrpl/tx/transactors/escrow/EscrowCreate.h index b3b04b71b6..635f70ae62 100644 --- a/include/xrpl/tx/transactors/escrow/EscrowCreate.h +++ b/include/xrpl/tx/transactors/escrow/EscrowCreate.h @@ -4,33 +4,177 @@ namespace xrpl { +/** Transactor for the EscrowCreate transaction type. + * + * Locks XRP or tokens (IOU/MPT) into an escrow ledger object that can + * later be released by `EscrowFinish` or reclaimed by `EscrowCancel`. + * The held amount is unavailable to either party until one of those + * outcomes occurs. + * + * Unlike its siblings, this transactor uses `kCONSEQUENCES_FACTORY = + * Custom` and supplies `makeTxConsequences()`. The custom factory is + * required because the XRP cost of an `EscrowCreate` scales with + * `sfAmount`: XRP escrows lock the principal (increasing the effective + * spend), whereas token escrows carry zero additional XRP impact. + * + * @see EscrowFinish + * @see EscrowCancel + */ class EscrowCreate : public Transactor { public: + /** Uses a custom consequence model to capture the locked XRP principal. */ static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Custom; explicit EscrowCreate(ApplyContext& ctx) : Transactor(ctx) { } + /** Compute the fee consequences of an EscrowCreate transaction. + * + * For XRP escrows the consequences include the locked principal amount + * so the transaction queue can correctly reason about account spend. + * For token escrows the XRP consequence is `beast::zero` because no + * XRP leaves the account. + * + * @param ctx stateless preflight context carrying the raw transaction. + * @return `TxConsequences` with the appropriate potential XRP spend. + */ static TxConsequences makeTxConsequences(PreflightContext const& ctx); + /** Stateless preflight validation for EscrowCreate. + * + * Validates the `sfAmount` field by asset type: + * - **XRP**: must be strictly positive. + * - **IOU** (`Issue`): requires `featureTokenEscrow`; rejects native + * amounts, non-positive values, and the bad-currency sentinel. + * - **MPT** (`MPTIssue`): additionally requires `featureMPTokensV1`. + * + * Timing rules enforced here (all field-level, no ledger access): + * - At least one of `sfCancelAfter` or `sfFinishAfter` must be present. + * - When both are provided, `sfCancelAfter` must be strictly after + * `sfFinishAfter`. + * - When `sfFinishAfter` is absent, `sfCondition` must be present — + * an escrow with no finish time and no condition could be completed + * immediately with no unlock mechanism. + * - If `sfCondition` is present its wire encoding must deserialize + * without error; a parse failure returns `temMALFORMED`. + * + * @param ctx stateless preflight context carrying the raw transaction. + * @return `tesSUCCESS` on success; `temBAD_AMOUNT` for invalid or + * unsupported amounts; `temBAD_EXPIRATION` if timing + * constraints are violated; `temMALFORMED` if the condition + * field is unparseable; `temDISABLED` if a required amendment + * is not active. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Read-only ledger checks for EscrowCreate. + * + * Confirms the destination account exists (`tecNO_DST` otherwise) and + * is not a pseudo-account (`tecNO_PERMISSION` otherwise). The + * pseudo-account check is unconditional rather than amendment-gated + * because the ledger writes that create pseudo-account discriminator + * fields are themselves amendment-gated, keeping behaviour consistent + * with whichever amendments are active. + * + * For token escrows, dispatches to a template helper via `std::visit`: + * - **IOU** (`escrowCreatePreclaimHelper`): verifies + * `lsfAllowTrustLineLocking` on the issuer, trust-line existence for + * both sender and destination, freeze status, `requireAuth` + * authorization for both parties, sufficient spendable balance, and + * an IOU-arithmetic precision guard (`canAdd`). + * - **MPT** (`escrowCreatePreclaimHelper`): verifies the + * MPT issuance exists, that `lsfMPTCanEscrow` is set, that the sender + * holds an `MPToken`, freeze/lock status for both parties, transfer + * eligibility (`canTransfer`), and sufficient spendable balance. + * + * @param ctx read-only preclaim context with ledger access. + * @return `tesSUCCESS` on success; `tecNO_DST` if the destination + * does not exist; `tecNO_PERMISSION` for pseudo-account + * destination or other authorization failures; + * `tecNO_LINE`/`tecOBJECT_NOT_FOUND` if a required trust + * line or MPT object is absent; `tecFROZEN`/`tecLOCKED` for + * frozen or lock-restricted assets; `tecINSUFFICIENT_FUNDS` + * if the sender lacks sufficient balance; `tecPRECISION_LOSS` + * for IOU arithmetic edge cases. + */ static TER preclaim(PreclaimContext const& ctx); + /** Apply the EscrowCreate transaction to the mutable ledger. + * + * Re-validates that neither `sfCancelAfter` nor `sfFinishAfter` (when + * present) has already elapsed relative to the ledger's + * `parentCloseTime`. This time-lapse check is intentionally repeated + * here: time advances between preflight and apply, and creating an + * already-expired escrow must be prevented at this stage. + * + * Then checks that the sender's XRP balance covers the incremented + * owner-count reserve plus, for XRP escrows, the locked principal. + * Verifies that the destination's `lsfRequireDestTag` flag is satisfied + * when set. + * + * Constructs and inserts the escrow SLE. When `fixIncludeKeyletFields` + * is active, copies `sfSequence` into the SLE for off-ledger keylet + * derivation. For token escrows, snapshots the issuer's transfer rate + * at creation time into `sfTransferRate`, freezing the fee that will + * apply when `EscrowFinish` later executes — the issuer cannot change + * the rate in the interim. + * + * After insertion, registers the escrow in owner directories: + * - Always added to the sender's `ownerDir`. + * - Added to the destination's `ownerDir` unless this is a self-escrow. + * - For IOU escrows only, added to the issuer's `ownerDir` so the issuer + * can enumerate all locked balances. MPT escrows skip this step + * because the MPT issuance object tracks its locked balance directly. + * + * Debits the sender's balance: + * - XRP: deducted directly from `sfBalance`. + * - IOU: transferred fee-free to the issuer via `directSendNoFee`. + * - MPT: atomically moves tokens to the locked field via `lockEscrowMPT`. + * + * @return `tesSUCCESS` on successful creation; `tecNO_PERMISSION` if a + * time bound has already elapsed; `tecINSUFFICIENT_RESERVE` if + * the sender cannot meet the new reserve requirement; + * `tecUNFUNDED` if the XRP balance cannot cover both reserve and + * principal; `tecNO_DST` if the destination is absent at apply + * time; `tecDST_TAG_NEEDED` if a required destination tag is + * missing; `tecDIR_FULL` if an owner directory has no room. + */ TER doApply() override; + /** Per-entry invariant hook — currently a no-op. + * + * Reserved for future transaction-specific post-condition checks. + * The base-class invariant framework still runs protocol-level checkers + * independently of this method. + * + * @param isDelete true if the entry was erased by this transaction. + * @param before entry state before the transaction (nullptr if new). + * @param after entry state after the transaction. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Post-apply invariant hook — currently a no-op. + * + * Reserved for future post-condition checks after all modified entries + * have been visited. Currently returns `true` unconditionally. + * + * @param tx the transaction being applied. + * @param result the tentative TER result from `doApply`. + * @param fee fee consumed by the transaction. + * @param view read-only view of the ledger after the transaction. + * @param j journal for logging invariant failures. + * @return always `true`. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/escrow/EscrowFinish.h b/include/xrpl/tx/transactors/escrow/EscrowFinish.h index 420f5f7324..5ad7f3dcf4 100644 --- a/include/xrpl/tx/transactors/escrow/EscrowFinish.h +++ b/include/xrpl/tx/transactors/escrow/EscrowFinish.h @@ -4,39 +4,219 @@ namespace xrpl { +/** Transactor for the EscrowFinish transaction type. + * + * `EscrowFinish` is the success path in the three-escrow family: it + * verifies that all release conditions are satisfied and transfers the + * locked funds to the intended recipient. `EscrowCreate` locks funds; + * `EscrowCancel` reclaims them on the failure path; this transactor + * completes the happy path. + * + * It is the most feature-rich of the three escrow transactors: it + * validates PREIMAGE-SHA-256 crypto-conditions (Interledger spec), + * scales its fee with fulfillment size to prevent DoS, and performs + * multi-asset eligibility checks in `preclaim` for both IOU and MPT + * escrows under `featureTokenEscrow`. + * + * The `kCONSEQUENCES_FACTORY` is `Normal` because releasing funds has + * standard fee semantics. Contrast with `EscrowCreate`, which uses + * `Custom` to account for the arbitrary amount of XRP or tokens it locks. + * + * @see EscrowCreate + * @see EscrowCancel + */ class EscrowFinish : public Transactor { public: + /** Uses the standard fee-consequence model; no custom factory needed. */ static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Normal; explicit EscrowFinish(ApplyContext& ctx) : Transactor(ctx) { } + /** Amendment gate for `sfCredentialIDs` support. + * + * Returns `false` — causing `invokePreflight` to short-circuit with + * `temDISABLED` — when the transaction carries `sfCredentialIDs` but + * the `featureCredentials` amendment is not yet active. If the field + * is absent, or if the amendment is active, returns `true` and + * processing continues normally. + * + * This hook runs before `preflight` and keeps amendment gating cleanly + * separated from field-level validation. + * + * @param ctx stateless preflight context carrying the raw transaction. + * @return `false` if `sfCredentialIDs` is present without the + * `featureCredentials` amendment; `true` otherwise. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Stateless preflight validation for EscrowFinish. + * + * Enforces the pairing rule for crypto-condition fields: `sfCondition` + * and `sfFulfillment` must either both be present or both be absent. + * Submitting one without the other returns `temMALFORMED`. + * + * Expensive crypto-condition validation is deliberately deferred to + * `preflightSigValidated` so it cannot be triggered by unauthenticated + * input. + * + * @param ctx stateless preflight context carrying the raw transaction. + * @return `tesSUCCESS` if the condition/fulfillment pairing is + * valid; `temMALFORMED` if exactly one of the two fields is + * present. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Post-signature crypto-condition validation hook. + * + * Runs after `preflight2` has verified the transaction's cryptographic + * signature, ensuring condition validation is never performed on + * unauthenticated input. + * + * When both `sfCondition` and `sfFulfillment` are present, checks the + * fulfillment against the condition via PREIMAGE-SHA-256 verification + * and stores the boolean outcome in the `HashRouter` using the private + * flag bits `SF_CF_VALID` / `SF_CF_INVALID`. A failed check does NOT + * return an error here — the result is cached for `doApply` to enforce, + * keeping preflight non-blocking for transaction broadcasting. + * + * Also validates `sfCredentialIDs` format via `credentials::checkFields` + * when the field is present. + * + * @param ctx stateless preflight context carrying the raw transaction. + * @return `tesSUCCESS` if credential fields are well-formed (or + * absent); a credential field error code otherwise. + * @note The hash-router cache entry set here is consumed by `doApply`. + * If the entry has expired by the time `doApply` runs, the + * condition check is repeated and re-cached at that point. + */ static NotTEC preflightSigValidated(PreflightContext const& ctx); + /** Compute the fee for an EscrowFinish transaction. + * + * Adds a surcharge on top of the base fee when a fulfillment is + * attached, proportional to its size: + * + * @code + * extraFee = base_fee * (32 + fulfillment_size / 16) + * @endcode + * + * This prevents DoS via large fulfillments: bigger payloads impose + * more validation cost on validators, and the surcharge ensures that + * cost is borne by the submitter. + * + * @param view read-only ledger view supplying the current fee schedule. + * @param tx the raw transaction being evaluated. + * @return the base fee plus any fulfillment surcharge. + */ static XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx); + /** Read-only ledger checks for EscrowFinish. + * + * Handles two independent concerns, each gated by its own amendment: + * + * - **`featureCredentials`**: validates `sfCredentialIDs` authorization + * via `credentials::valid`. + * - **`featureTokenEscrow`**: for non-XRP escrows, dispatches to a + * template helper via `std::visit`: + * - `Issue` (IOU) — checks `requireAuth` authorization and verifies + * the destination is not deep-frozen by the issuer. + * - `MPTIssue` (MPToken) — verifies the issuance object exists, + * checks `requireAuth` (using `WeakAuth` semantics), and checks + * for MPT-level freeze. + * + * These asset eligibility checks are placed in `preclaim` (rather than + * `doApply`) so that authorization failures do not charge a fee. + * + * @param ctx read-only preclaim context with ledger access. + * @return `tesSUCCESS` on success; a credential error if + * `sfCredentialIDs` authorization fails; `tecNO_TARGET` if + * the escrow object does not exist; `tecNO_PERMISSION` or + * a `requireAuth` error if the destination is unauthorized; + * `tecFROZEN` for a deep-frozen IOU; `tecLOCKED` for a + * frozen MPT; `tecOBJECT_NOT_FOUND` if the MPT issuance + * is absent. + */ static TER preclaim(PreclaimContext const& ctx); + /** Apply the EscrowFinish transaction to the mutable ledger. + * + * Enforces temporal and cryptographic release conditions, then performs + * the ledger state mutations: + * + * 1. **Time window**: compares the ledger's `parentCloseTime` against + * `sfFinishAfter` (too early → `tecNO_PERMISSION`) and + * `sfCancelAfter` (too late → `tecNO_PERMISSION`). + * 2. **Condition re-check**: reads the cached `SF_CF_VALID`/`SF_CF_INVALID` + * hash-router flags set during `preflightSigValidated`. If the cache + * has expired, the fulfillment is re-validated and re-cached. A + * failed check returns `tecCRYPTOCONDITION_ERROR`. Also enforces + * that the condition presented in the transaction matches the one + * stored in the escrow object at creation time. + * 3. **Deposit pre-auth**: `verifyDepositPreauth` ensures the + * destination's allow-list is satisfied if required. + * 4. **Directory cleanup**: removes the escrow from the originating + * account's owner directory, the recipient's owner directory (when + * `sfDestinationNode` is present), and — for non-XRP escrows — the + * issuer's owner directory. Any directory removal failure returns + * `tefBAD_LEDGER` (indicates ledger corruption). + * 5. **Fund transfer**: for XRP, credits the destination's `sfBalance` + * directly. For non-XRP, routes through `escrowUnlockApplyHelper`, + * applying the transfer rate locked at escrow creation (using + * `parityRate` when none was recorded). + * 6. **Owner count**: decrements the originating account's owner count + * by one via `adjustOwnerCount`. + * 7. **Erase**: removes the escrow SLE from the ledger. + * + * @return `tesSUCCESS` on success; `tecNO_TARGET` (or `tecINTERNAL` + * with `featureTokenEscrow` active) if the escrow is missing at + * apply time; `tecNO_PERMISSION` if a time bound is violated; + * `tecCRYPTOCONDITION_ERROR` if the fulfillment is invalid or + * the condition does not match; `tecNO_DST` if the destination + * account no longer exists; a deposit pre-auth error if the + * destination's allow-list is not satisfied; `tefBAD_LEDGER` if + * a directory removal fails. + * @note Escrow payments cannot be used to fund new accounts — the + * destination account must already exist at apply time. + */ TER doApply() override; + /** Per-entry invariant hook — currently a no-op. + * + * Reserved for future transaction-specific post-condition checks. + * The base-class invariant framework still runs protocol-level checkers + * independently of this method. + * + * @param isDelete true if the entry was erased by this transaction. + * @param before entry state before the transaction (nullptr if new). + * @param after entry state after the transaction. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Post-apply invariant hook — currently a no-op. + * + * Reserved for future post-condition checks after all modified entries + * have been visited. Currently returns `true` unconditionally. + * + * @param tx the transaction being applied. + * @param result the tentative TER result from `doApply`. + * @param fee fee consumed by the transaction. + * @param view read-only view of the ledger after the transaction. + * @param j journal for logging invariant failures. + * @return always `true`. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/lending/LoanBrokerCoverClawback.h b/include/xrpl/tx/transactors/lending/LoanBrokerCoverClawback.h index 73781fa4bd..8cbcf43fa2 100644 --- a/include/xrpl/tx/transactors/lending/LoanBrokerCoverClawback.h +++ b/include/xrpl/tx/transactors/lending/LoanBrokerCoverClawback.h @@ -4,6 +4,17 @@ namespace xrpl { +/** Transactor for `ttLOAN_BROKER_COVER_CLAWBACK` (type 78). + * + * Lets the issuer of a vault's underlying asset forcibly reclaim a portion + * of the cover funds held in a loan broker's pseudo-account. Cover funds + * are tracked in `sfCoverAvailable`; the reclaimed amount is capped so the + * broker cannot be driven below the contractual minimum cover floor + * (`sfDebtTotal × sfCoverRateMinimum`). + * + * @see LoanBrokerCoverDeposit voluntary cover deposit by the broker operator + * @see LoanBrokerCoverWithdraw voluntary cover withdrawal by the broker operator + */ class LoanBrokerCoverClawback : public Transactor { public: @@ -13,24 +24,95 @@ public: { } + /** Gate on the `featureLendingProtocol` amendment and its dependencies. + * + * Delegates to `checkLendingProtocolDependencies`; returns false (and + * therefore `temDISABLED`) if the lending protocol or any required + * prerequisite amendment is not yet active. + * + * @param ctx preflight context carrying the current rules set. + * @return true if all required amendments are enabled, false otherwise. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Stateless field-level validation. + * + * Requires at least one of `sfLoanBrokerID` or `sfAmount` to be present. + * If `sfLoanBrokerID` is absent the broker identity will be inferred from + * the IOU's issuer field in `sfAmount`, so in that case `sfAmount` must + * be an IOU (not MPT) and its holder must differ from the submitting + * account. If `sfAmount` is provided it must be non-XRP and + * non-negative; zero is explicitly allowed as "take all surplus above the + * minimum cover floor." + * + * @param ctx preflight context; no ledger access. + * @return `tesSUCCESS` on valid input; a `tem*` code otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Read-only ledger validation. + * + * Resolves the broker identity (using `sfLoanBrokerID` if present, or by + * reading the pseudo-account named in the IOU issuer field), confirms the + * submitting account is the vault asset issuer, verifies the appropriate + * clawback permission flag (`lsfAllowTrustLineClawback` for IOUs, + * `lsfMPTCanClawback` for MPTs), and computes the effective claw amount. + * Performs a defensive `accountHolds` check to confirm the pseudo-account + * actually holds the computed amount — a mismatch indicates ledger state + * corruption and returns `tecINTERNAL`. + * + * @param ctx preclaim context with read-only ledger view. + * @return `tesSUCCESS` if the transaction may proceed; a `tec*` or + * `tef*` code otherwise. + */ static TER preclaim(PreclaimContext const& ctx); + /** Apply the clawback to the mutable ledger. + * + * Re-resolves the broker ID and claw amount (defensive re-computation + * against speculative-apply divergence; failures return `tecINTERNAL` + * and are marked `LCOV_EXCL_LINE`), decrements `sfCoverAvailable` on the + * broker SLE, and transfers the claw amount from the broker pseudo-account + * to the issuer via `accountSend` with `WaiveTransferFee::Yes`. Calls + * `associateAsset` as the final step to re-round stored values to asset + * precision. + * + * @return `tesSUCCESS` on success; `tecINTERNAL` if broker or vault state + * unexpectedly diverged from what `preclaim` observed. + */ TER doApply() override; + /** No-op stub for transaction-specific invariant entry collection. + * + * No per-entry invariants are currently defined for this transaction type. + * Reserved for future work. + * + * @param isDelete true if the entry was erased. + * @param before SLE state before the transaction. + * @param after SLE state after the transaction. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** No-op stub for transaction-specific invariant finalization. + * + * No post-conditions are currently defined for this transaction type. + * Always returns true. Reserved for future work. + * + * @param tx the transaction being applied. + * @param result the tentative TER result. + * @param fee fee consumed by the transaction. + * @param view read-only ledger view after the transaction. + * @param j journal for logging. + * @return true unconditionally. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/lending/LoanBrokerCoverDeposit.h b/include/xrpl/tx/transactors/lending/LoanBrokerCoverDeposit.h index 4ced4747bb..899e0e3c7e 100644 --- a/include/xrpl/tx/transactors/lending/LoanBrokerCoverDeposit.h +++ b/include/xrpl/tx/transactors/lending/LoanBrokerCoverDeposit.h @@ -4,6 +4,14 @@ namespace xrpl { +/** Transactor for depositing cover assets into a LoanBroker. + * + * Transfers assets from the broker owner's account into the broker's + * pseudo-account, increasing `sfCoverAvailable`. Cover backs broker + * obligations (fees and risk coverage) within the lending protocol (XLS-66). + * + * @see LoanBrokerCoverWithdraw, LoanBrokerCoverClawback + */ class LoanBrokerCoverDeposit : public Transactor { public: @@ -13,24 +21,97 @@ public: { } + /** Gates the transaction on the lending protocol amendment. + * + * Delegates to `checkLendingProtocolDependencies`. Amendment checks + * belong here, not in `preflight`. + * + * @param ctx Preflight context providing ledger rules. + * @return `true` if the lending protocol amendment is enabled; + * `false` otherwise. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Stateless validation of transaction fields. + * + * Rejects a zero `sfLoanBrokerID` (null broker reference) and verifies + * that `sfAmount` is positive and passes `isLegalNet`. No ledger access + * is performed. + * + * @param ctx Preflight context carrying the transaction fields. + * @return `temINVALID` if `sfLoanBrokerID` is zero; `temBAD_AMOUNT` if + * `sfAmount` is non-positive or fails `isLegalNet`; `tesSUCCESS` + * otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Validates the deposit against current ledger state (read-only). + * + * Checks in order: + * 1. The `LoanBroker` object exists (`tecNO_ENTRY`). + * 2. The sender is the broker's `sfOwner` (`tecNO_PERMISSION`). + * 3. The broker's associated vault exists (`tefBAD_LEDGER` — indicates + * ledger corruption; structurally unreachable under normal conditions). + * 4. The deposited asset matches the vault's `sfAsset` (`tecWRONG_ASSET`). + * 5. Asset transfer guards: non-transferable (`canTransfer`), source-side + * freeze (`checkFrozen`), deep-freeze on the broker pseudo-account + * (`checkDeepFrozen`), and strong authorization (`requireAuth`). + * 6. The sender holds sufficient spendable balance (`tecINSUFFICIENT_FUNDS`), + * treating frozen or unauthorized balances as zero. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return A `TER` indicating the first failing check, or `tesSUCCESS`. + * @note The missing-vault branch is excluded from coverage + * (`LCOV_EXCL_*`) because ledger integrity constraints make it + * unreachable in practice. + */ static TER preclaim(PreclaimContext const& ctx); + /** Executes the cover deposit against the mutable ledger view. + * + * Transfers `sfAmount` from the sender to the broker's pseudo-account + * via `accountSend` with `WaiveTransferFee::Yes` (cover deposits are + * exempt from transfer fees), increments `sfCoverAvailable` on the + * `LoanBroker` SLE, and calls `associateAsset` to keep the broker's + * asset tracking consistent. + * + * @return `tesSUCCESS` on success; `tecINTERNAL` if the broker or + * vault SLE is missing (indicates ledger corruption — unreachable + * under normal conditions). + */ TER doApply() override; + /** Per-entry hook for transaction-specific invariant checks. + * + * No transaction-specific invariants are currently registered; this + * override is a placeholder for future work. + * + * @param isDelete `true` if the entry is being deleted. + * @param before SLE state before the transaction, or `nullptr` for new entries. + * @param after SLE state after the transaction, or `nullptr` for deleted entries. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Finalizes transaction-specific invariant checks. + * + * No transaction-specific invariants are currently registered; always + * returns `true`. Placeholder for future work. + * + * @param tx The transaction being applied. + * @param result The `TER` result from `doApply`. + * @param fee The XRP fee charged for the transaction. + * @param view Read-only view of the ledger after application. + * @param j Journal for diagnostic logging. + * @return `true` unconditionally. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.h b/include/xrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.h index 8dc370283a..cf43e7ccd1 100644 --- a/include/xrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.h +++ b/include/xrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.h @@ -4,6 +4,19 @@ namespace xrpl { +/** Transactor for withdrawing cover assets from a LoanBroker. + * + * Lets the broker owner reclaim cover funds held in the broker's + * pseudo-account, crediting the owner's account or a named third-party + * destination. Enforces the minimum cover ratio + * (`sfDebtTotal × sfCoverRateMinimum`) so that outstanding loan + * obligations remain adequately backed after the withdrawal. + * + * Part of the XRPL lending protocol (XLS-66). + * + * @see LoanBrokerCoverDeposit voluntary cover deposit by the broker owner + * @see LoanBrokerCoverClawback forced cover reclaim by the vault asset issuer + */ class LoanBrokerCoverWithdraw : public Transactor { public: @@ -13,24 +26,109 @@ public: { } + /** Gates the transaction on the lending protocol amendment and its dependencies. + * + * Delegates to `checkLendingProtocolDependencies`. Amendment checks + * belong here, not in `preflight`. + * + * @param ctx Preflight context providing the current ledger rules. + * @return `true` if all required amendments are enabled; `false` otherwise, + * causing `invokePreflight` to return `temDISABLED`. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Stateless validation of transaction fields. + * + * Rejects a zero `sfLoanBrokerID`, a non-positive or legally-invalid + * `sfAmount`, and a zero-value `sfDestination` when that field is + * present. No ledger access is performed. + * + * @param ctx Preflight context carrying the transaction fields. + * @return `temINVALID` if `sfLoanBrokerID` is zero; `temBAD_AMOUNT` if + * `sfAmount` is non-positive or fails `isLegalNet`; `temMALFORMED` + * if `sfDestination` is present but zero; `tesSUCCESS` otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Validates the withdrawal against current ledger state (read-only). + * + * Checks in order: + * 1. Destination (defaulting to the submitter) is not a pseudo-account + * (`tecPSEUDO_ACCOUNT`). + * 2. The `LoanBroker` object exists (`tecNO_ENTRY`). + * 3. The sender is the broker's `sfOwner` (`tecNO_PERMISSION`). + * 4. The broker's associated vault exists (`tefBAD_LEDGER` — indicates + * ledger corruption; structurally unreachable under normal conditions). + * 5. `sfAmount` asset matches the vault's `sfAsset` (`tecWRONG_ASSET`). + * 6. Asset transfer guards: non-transferable (`canTransfer`), source-side + * freeze (`checkFrozen`), and deep-freeze on the destination + * (`checkDeepFrozen`). Freeze checks are skipped when sending directly + * to the asset issuer. + * 7. When `sfDestination` names a third party, `canWithdraw` is called + * and `authType` is upgraded to `StrongAuth`, requiring the destination + * to have already consented to receive the asset (`requireAuth`). + * 8. Minimum cover enforcement: `(sfCoverAvailable - sfAmount)` must be + * at least `roundUp(tenthBipsOfValue(sfDebtTotal, sfCoverRateMinimum))`. + * Both the cover-availability and the minimum-cover-ratio conditions + * return `tecINSUFFICIENT_FUNDS`. + * 9. A final `accountHolds` guard against the broker pseudo-account + * confirms the asset is actually present (`tecINSUFFICIENT_FUNDS`). + * + * @param ctx Preclaim context providing read-only ledger access. + * @return A `TER` indicating the first failing check, or `tesSUCCESS`. + * @note The missing-vault branch is excluded from coverage + * (`LCOV_EXCL_*`) because ledger integrity constraints make it + * unreachable in practice. + * @note The minimum cover calculation uses `NumberRoundModeGuard` set to + * `Number::upward` so fractional asset units are always rounded up, + * never truncated. + */ static TER preclaim(PreclaimContext const& ctx); + /** Executes the cover withdrawal against the mutable ledger view. + * + * Decrements `sfCoverAvailable` on the `LoanBroker` SLE by `sfAmount`, + * calls `associateAsset` to re-round stored values to asset precision, + * then delegates the actual token movement to `doWithdraw`, debiting + * the broker's pseudo-account and crediting the destination. + * + * @return `tesSUCCESS` on success; `tecINTERNAL` if the broker or vault + * SLE is unexpectedly absent (indicates ledger corruption — + * unreachable under normal conditions; marked `LCOV_EXCL_LINE`). + */ TER doApply() override; + /** No-op stub for transaction-specific invariant entry collection. + * + * No per-entry invariants are currently defined for this transaction type. + * Reserved for future work. + * + * @param isDelete `true` if the entry is being deleted. + * @param before SLE state before the transaction, or `nullptr` for new entries. + * @param after SLE state after the transaction, or `nullptr` for deleted entries. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** No-op stub for transaction-specific invariant finalization. + * + * No post-conditions are currently defined for this transaction type. + * Always returns `true`. Reserved for future work. + * + * @param tx The transaction being applied. + * @param result The `TER` result from `doApply`. + * @param fee The XRP fee charged for the transaction. + * @param view Read-only view of the ledger after application. + * @param j Journal for diagnostic logging. + * @return `true` unconditionally. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/lending/LoanBrokerDelete.h b/include/xrpl/tx/transactors/lending/LoanBrokerDelete.h index 9f40986909..d1185cfba4 100644 --- a/include/xrpl/tx/transactors/lending/LoanBrokerDelete.h +++ b/include/xrpl/tx/transactors/lending/LoanBrokerDelete.h @@ -4,6 +4,16 @@ namespace xrpl { +/** Transactor for deleting a LoanBroker and recovering its cover assets. + * + * Processes `ttLOAN_BROKER_DELETE` (type 75) transactions. A `LoanBroker` + * intermediates between a vault and individual borrowers, holding cover + * collateral in an associated pseudo-account. This transactor tears down + * the broker once all loans are closed: it returns any remaining cover to + * the human owner, removes the pseudo-account, and erases both SLEs. + * + * @see LoanBrokerSet, LoanBrokerCoverDeposit, LoanBrokerCoverWithdraw + */ class LoanBrokerDelete : public Transactor { public: @@ -13,24 +23,109 @@ public: { } + /** Gates the transaction on the lending protocol amendment. + * + * Delegates to `checkLendingProtocolDependencies`. Amendment checks + * belong here, not in `preflight`. + * + * @param ctx Preflight context providing ledger rules. + * @return `true` if the lending protocol amendment is enabled; + * `false` otherwise. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Stateless validation of transaction fields. + * + * Rejects a zero `sfLoanBrokerID` (null broker reference). No ledger + * access is performed. + * + * @param ctx Preflight context carrying the transaction fields. + * @return `temINVALID` if `sfLoanBrokerID` is zero; `tesSUCCESS` otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Validates broker deletion eligibility against current ledger state (read-only). + * + * Checks in order: + * 1. The `LoanBroker` object exists (`tecNO_ENTRY`). + * 2. The sender is the broker's `sfOwner` (`tecNO_PERMISSION`). + * 3. The broker's `sfOwnerCount` is zero — no active `Loan` objects + * reference it (`tecHAS_OBLIGATIONS`). + * 4. The associated vault exists (`tefBAD_LEDGER` — ledger corruption; + * structurally unreachable under normal conditions). + * 5. Any residual `sfDebtTotal` rounds to zero against the vault's asset + * scale (`tecHAS_OBLIGATIONS` — defensive guard; should have been + * cleared by the last `LoanDelete`). + * 6. If `sfCoverAvailable > 0`, the broker owner is not deep-frozen for + * the vault asset (`tecFROZEN`) — cover will be returned on deletion, + * so the owner must be able to receive it. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return A `TER` indicating the first failing check, or `tesSUCCESS`. + * @note The missing-vault and non-zero-rounded-debt branches are excluded + * from coverage (`LCOV_EXCL_*`) because they are unreachable in + * practice under a consistent ledger. + */ static TER preclaim(PreclaimContext const& ctx); + /** Executes broker deletion against the mutable ledger view. + * + * In order: + * 1. Removes the broker from both the human owner's directory + * (`sfOwnerNode`) and the vault pseudo-account's directory + * (`sfVaultNode`). + * 2. Transfers any `sfCoverAvailable` from the broker pseudo-account to + * the human owner via `accountSend` with `WaiveTransferFee::Yes` + * (cleanup transfer, not user-initiated). + * 3. Calls `removeEmptyHolding` to clear the trust line or MPT position + * on the broker pseudo-account for the vault asset. + * 4. Validates that the pseudo-account has no remaining balance, owner + * count, or directory — guards are `LCOV_EXCL` because prior steps + * should have eliminated all obligations. + * 5. Erases the broker pseudo-account SLE, then the broker SLE. + * 6. Decrements the human owner's `sfOwnerCount` by 2 (one for the + * `LoanBroker` object, one for its pseudo-account). + * 7. Calls `associateAsset` to record the vault asset touched by this + * transaction. + * + * @return `tesSUCCESS` on success; `tefBAD_LEDGER` if a required SLE is + * missing or a directory operation fails (indicates ledger corruption); + * `tecHAS_OBLIGATIONS` if the pseudo-account retains residual state + * after cleanup (defensive, should be unreachable). + */ TER doApply() override; + /** Per-entry hook for transaction-specific invariant checks. + * + * No transaction-specific invariants are currently registered; this + * override is a placeholder for future work. + * + * @param isDelete `true` if the entry is being deleted. + * @param before SLE state before the transaction, or `nullptr` for new entries. + * @param after SLE state after the transaction, or `nullptr` for deleted entries. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Finalizes transaction-specific invariant checks. + * + * No transaction-specific invariants are currently registered; always + * returns `true`. Placeholder for future work. + * + * @param tx The transaction being applied. + * @param result The `TER` result from `doApply`. + * @param fee The XRP fee charged for the transaction. + * @param view Read-only view of the ledger after application. + * @param j Journal for diagnostic logging. + * @return `true` unconditionally. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/lending/LoanBrokerSet.h b/include/xrpl/tx/transactors/lending/LoanBrokerSet.h index adbb3217ba..bcaf7e7312 100644 --- a/include/xrpl/tx/transactors/lending/LoanBrokerSet.h +++ b/include/xrpl/tx/transactors/lending/LoanBrokerSet.h @@ -4,6 +4,26 @@ namespace xrpl { +/** Transactor for creating or updating a LoanBroker ledger object (XLS-66). + * + * Processes `ttLOAN_BROKER_SET` transactions. A `LoanBroker` intermediates + * between a `Vault` (the liquidity pool) and individual borrowers, holding + * collateral in an associated pseudo-account and earning a management fee + * from loan interest. This transactor handles both initial creation and + * subsequent configuration updates via the same "set" (upsert) convention + * used throughout the ledger: creation is implicit when `sfLoanBrokerID` is + * absent; update is triggered when it is present. + * + * Only the vault owner may create or modify a broker associated with their + * vault. Rate fields (`sfManagementFeeRate`, `sfCoverRateMinimum`, + * `sfCoverRateLiquidation`) are immutable after creation — they cannot be + * renegotiated once loans may be outstanding. On creation, the owner count + * is incremented by two: one for the `LoanBroker` SLE and one for its + * pseudo-account. + * + * @see LoanBrokerDelete, LoanBrokerCoverDeposit, LoanBrokerCoverWithdraw, + * LoanSet + */ class LoanBrokerSet : public Transactor { public: @@ -13,27 +33,142 @@ public: { } + /** Gates the transaction on the lending protocol amendment. + * + * Delegates to `checkLendingProtocolDependencies`. Amendment checks + * belong here, not in `preflight`. + * + * @param ctx Preflight context providing ledger rules. + * @return `true` if all lending protocol amendments are enabled; + * `false` otherwise. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Stateless validation of transaction fields. + * + * Validates the following without ledger access: + * - `sfData`, if present, must not exceed `maxDataPayloadLength`. + * - `sfManagementFeeRate`, `sfCoverRateMinimum`, `sfCoverRateLiquidation`: + * each independently checked against its protocol maximum via + * `validNumericRange`; absent fields pass. + * - `sfDebtMaximum`, if present, must lie in `[0, maxMPTokenAmount]`. + * - In update mode (`sfLoanBrokerID` present): `sfManagementFeeRate`, + * `sfCoverRateMinimum`, and `sfCoverRateLiquidation` are rejected — + * these rate fields are immutable after creation. + * - `sfLoanBrokerID` and `sfVaultID`, if present, must be non-zero. + * - Cover-rate pairing: `sfCoverRateMinimum` and `sfCoverRateLiquidation` + * must either both be zero or both be non-zero; a one-sided threshold + * is incoherent. + * + * @param ctx Preflight context carrying the transaction fields. + * @return `temINVALID` if any field fails validation; `tesSUCCESS` otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Returns the set of optional numeric fields that must round-trip cleanly + * through the vault's asset representation. + * + * Currently contains only `~sfDebtMaximum`. `preclaim` iterates this + * list and rejects any value where `STAmount{asset, *value} != *value`, + * catching precision loss for non-IOU assets (XRP integer drops, MPTokens). + * The vector is static to avoid repeated heap allocation. + * + * @return A reference to the static vector of optional `STNumber` fields + * subject to asset-precision checks. + */ static std::vector> const& getValueFields(); + /** Validates the transaction against current ledger state (read-only). + * + * Checks in order: + * 1. The referenced vault (`sfVaultID`) exists (`tecNO_ENTRY`). + * 2. The sender (`sfAccount`) is the vault's `sfOwner` (`tecNO_PERMISSION`). + * 3. **Update mode** (`sfLoanBrokerID` present): + * a. The broker object exists (`tecNO_ENTRY`). + * b. The broker's `sfVaultID` matches the transaction's `sfVaultID` — + * vault association is immutable (`tecNO_PERMISSION`). + * c. The sender owns the broker (`tecNO_PERMISSION`). + * d. If `sfDebtMaximum` is set to a non-zero value below the broker's + * current `sfDebtTotal`, the update is rejected (`tecLIMIT_EXCEEDED`) + * — this would strand outstanding loans above the new limit. + * 4. **Creation mode** (`sfLoanBrokerID` absent): + * a. `canAddHolding` confirms the vault can accept a new asset holding. + * b. `checkFrozen` ensures the vault's pseudo-account is not frozen + * for the vault asset. + * 5. All fields in `getValueFields()` are verified to round-trip cleanly + * through `STAmount{asset, *value}` (`tecPRECISION_LOSS` on mismatch). + * + * @param ctx Preclaim context providing read-only ledger access. + * @return A `TER` indicating the first failing check, or `tesSUCCESS`. + */ static TER preclaim(PreclaimContext const& ctx); + /** Applies the create or update to the mutable ledger view. + * + * **Update path** (`sfLoanBrokerID` present): writes `sfData` and + * `sfDebtMaximum` to the existing broker SLE (rate fields are + * intentionally omitted, enforcing immutability at apply time), then + * calls `view.update()` and `associateAsset`. + * + * **Create path** (`sfLoanBrokerID` absent): + * 1. Derives the broker keylet from `(account_, sequence)`. + * 2. Creates two directory links via `dirLink`: one in the human owner's + * account directory, and one in the vault pseudo-account's directory + * (`sfVaultNode`) so the vault can enumerate its brokers. + * 3. Increments `sfOwnerCount` by **two** — one for the `LoanBroker` + * SLE, one for its pseudo-account — then checks `preFeeBalance_` + * against the updated reserve requirement. + * 4. Creates the broker pseudo-account via `createPseudoAccount`, keyed + * on the broker's ledger key with `sfLoanBrokerID` as the back-reference. + * 5. Calls `addEmptyHolding` to establish the broker's asset position on + * the pseudo-account. + * 6. Initializes all SLE fields: `sfSequence`, `sfVaultID`, `sfOwner`, + * `sfAccount`, `sfLoanSequence` (starts at 1), and any optional fields + * present in the transaction. `sfLoanSequence` is consumed by `LoanSet` + * to index loans issued through this broker. + * 7. Inserts the broker SLE and calls `associateAsset`. + * + * @return `tesSUCCESS` on success; `tefBAD_LEDGER` if a required SLE is + * unexpectedly absent (indicates ledger corruption — these branches + * are `LCOV_EXCL` because preclaim guards against them); + * `tecINTERNAL` if the vault disappears between preclaim and doApply; + * `tecINSUFFICIENT_RESERVE` if the owner's pre-fee balance cannot + * cover the two-unit reserve increase. + */ TER doApply() override; + /** Per-entry hook for transaction-specific invariant checks. + * + * No transaction-specific invariants are currently registered; this + * override is a placeholder for future work. + * + * @param isDelete `true` if the entry is being deleted. + * @param before SLE state before the transaction, or `nullptr` for new entries. + * @param after SLE state after the transaction, or `nullptr` for deleted entries. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Finalizes transaction-specific invariant checks. + * + * No transaction-specific invariants are currently registered; always + * returns `true`. Placeholder for future work. + * + * @param tx The transaction being applied. + * @param result The `TER` result from `doApply`. + * @param fee The XRP fee charged for the transaction. + * @param view Read-only view of the ledger after application. + * @param j Journal for diagnostic logging. + * @return `true` unconditionally. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/lending/LoanDelete.h b/include/xrpl/tx/transactors/lending/LoanDelete.h index 6f14530101..044755b145 100644 --- a/include/xrpl/tx/transactors/lending/LoanDelete.h +++ b/include/xrpl/tx/transactors/lending/LoanDelete.h @@ -4,6 +4,18 @@ namespace xrpl { +/** Closes out a fully-repaid loan object from the XRP Ledger. + * + * Removes a `Loan` SLE after the borrower has satisfied all payment + * obligations (`sfPaymentRemaining == 0`). Releasing the loan releases the + * owner-count reserves held against both the borrower's account and the loan + * broker's pseudo-account. Either the broker owner or the borrower may + * submit this transaction. + * + * @note This transactor is part of the on-chain lending protocol (XLS-66). + * Deletion of an active loan is rejected with `tecHAS_OBLIGATIONS`. + * @see LoanSet, LoanPay, LoanManage + */ class LoanDelete : public Transactor { public: @@ -13,24 +25,103 @@ public: { } + /** Gates execution on the lending protocol amendments being active. + * + * Delegates to `checkLendingProtocolDependencies()` so the framework + * rejects the transaction before any field-level validation when the + * required amendments are not enabled. + * + * @param ctx Preflight context carrying current ledger rules. + * @return `true` if all required lending-protocol amendments are active; + * `false` otherwise (transaction will be rejected). + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Validates that `sfLoanID` is not the zero hash. + * + * All other checks — existence, ownership, and repayment state — require + * ledger access and are deferred to `preclaim`. + * + * @param ctx Preflight context carrying the transaction fields. + * @return `temINVALID` if `sfLoanID` is the zero hash; `tesSUCCESS` + * otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Verifies that the loan exists, is fully repaid, and the caller is + * authorized to delete it. + * + * Two invariants are enforced before allowing the transaction to proceed: + * - The loan referenced by `sfLoanID` must exist (`tecNO_ENTRY` if not). + * - `sfPaymentRemaining` must be zero; a non-zero value returns + * `tecHAS_OBLIGATIONS`. + * - The submitting account must be either the broker owner (resolved via + * the `LoanBroker` SLE) or the direct borrower (`sfBorrower`); any + * other account returns `tecNO_PERMISSION`. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if all checks pass; `tecNO_ENTRY`, + * `tecHAS_OBLIGATIONS`, `tecNO_PERMISSION`, or `tecINTERNAL` on + * failure. + */ static TER preclaim(PreclaimContext const& ctx); + /** Removes the loan object and releases associated reserves. + * + * Performs the following steps in order: + * 1. Evicts the loan key from the broker pseudo-account's owner directory + * (using `sfLoanBrokerNode` as the hint) and from the borrower's owner + * directory (using `sfOwnerNode`). + * 2. Erases the `Loan` SLE. + * 3. Decrements the broker's `sfOwnerCount` (which tracks outstanding + * loans, distinct from the pseudo-account's own count). + * 4. If the broker's `sfOwnerCount` reaches zero, any residual non-zero + * `sfDebtTotal` is forcibly zeroed. An `XRPL_ASSERT_PARTS` verifies + * the remainder rounds to zero at the vault's asset scale — this + * prevents sub-precision rounding dust from permanently stranding the + * broker. + * 5. Decrements the borrower's `sfOwnerCount`, releasing the XRP reserve + * locked at loan creation. + * 6. Calls `associateAsset` on the loan, broker, and vault SLEs as a + * defensive consistency measure. + * + * @return `tesSUCCESS` on success; `tefBAD_LEDGER` if any SLE that + * `preclaim` guaranteed to exist is unexpectedly absent (marked + * `LCOV_EXCL_LINE` — indicates ledger corruption). + */ TER doApply() override; + /** Per-entry invariant visitor — reserved for future use. + * + * No transaction-specific invariants are currently checked. The override + * is a placeholder for future additions. + * + * @param isDelete `true` if the entry is being deleted. + * @param before The SLE state before the transaction, or `nullptr`. + * @param after The SLE state after the transaction, or `nullptr`. + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Finalizes per-transaction invariants — reserved for future use. + * + * No transaction-specific invariants are currently checked. Always + * returns `true`. + * + * @param tx The transaction being applied. + * @param result The TER code returned by `doApply`. + * @param fee The fee deducted for this transaction. + * @param view Read-only view of the ledger after application. + * @param j Journal for diagnostic logging. + * @return `true` unconditionally. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/lending/LoanManage.h b/include/xrpl/tx/transactors/lending/LoanManage.h index b21f618b19..1078db5c25 100644 --- a/include/xrpl/tx/transactors/lending/LoanManage.h +++ b/include/xrpl/tx/transactors/lending/LoanManage.h @@ -4,28 +4,116 @@ namespace xrpl { +/** Transactor for the ttLOAN_MANAGE transaction type (XLS-66). + * + * Transitions a loan through its three credit-quality states — + * **unimpaired → impaired → defaulted** — on behalf of the `LoanBroker` + * owner that issued the loan. State regressions (impaired → unimpaired) + * are also supported; once a loan reaches `lsfLoanDefault` it is + * permanently frozen. + * + * Exactly one of `tfLoanDefault`, `tfLoanImpair`, or `tfLoanUnimpair` may + * be set per transaction. Omitting all flags is a valid (no-op) form. + * + * @note The three core operations (`defaultLoan`, `impairLoan`, + * `unimpairLoan`) are exposed as public static helpers so other + * transactors can invoke the same accounting logic without constructing + * a synthetic transaction. + */ class LoanManage : public Transactor { public: + /** Consequence factory type — fee is claimed under normal conditions. */ static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Normal; + /** Construct a LoanManage transactor for the given apply context. + * + * @param ctx The apply context supplied by the transaction pipeline. + */ explicit LoanManage(ApplyContext& ctx) : Transactor(ctx) { } + /** Gate the transactor on the amendments required by the lending protocol. + * + * Delegates to `checkLendingProtocolDependencies`, which requires + * `featureSingleAssetVault` and `featureMPTokensV1` (plus + * `featurePermissionedDomains` when `sfDomainID` is present). + * Returning `false` causes `invokePreflight` to return `temDISABLED`. + * + * @param ctx The preflight context. + * @return `true` if all required amendments are enabled, `false` otherwise. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Return the legal transaction-flag bits for this transactor. + * + * The mask covers exactly `tfLoanDefault`, `tfLoanImpair`, and + * `tfLoanUnimpair`; any other flag bits produce `temINVALID_FLAG`. + * + * @param ctx The preflight context (unused; present for pipeline conformance). + * @return `tfLoanManageMask`. + */ static std::uint32_t getFlagsMask(PreflightContext const& ctx); + /** Stateless validation of a ttLOAN_MANAGE transaction. + * + * Checks that `sfLoanID` is non-zero and that at most one of the three + * action flags is set (mutual exclusivity is tested with the + * `(flags & (flags - 1)) != 0` idiom). No ledger access occurs here. + * + * @param ctx The preflight context carrying the transaction. + * @return `tesSUCCESS` on success; `temBAD_TRANSACTION` if `sfLoanID` is + * zero or multiple action flags are set simultaneously. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Read-only ledger checks that enforce the loan state machine. + * + * In addition to verifying that the loan, broker, and vault objects + * exist, this method enforces: + * - A defaulted loan cannot be modified further (`lsfLoanDefault`). + * - A fully-paid loan (`sfPaymentRemaining == 0`) cannot be modified. + * - An already-impaired loan cannot be impaired again. + * - An unimpaired loan cannot be unimpaired. + * - Defaulting is only permitted after `sfNextPaymentDueDate` plus the + * grace period has elapsed on the current ledger. + * - Only the account that owns the `LoanBroker` may submit this transaction. + * + * @param ctx The preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if the state transition is legal; an appropriate + * `tec` or `ter` code otherwise. + */ static TER preclaim(PreclaimContext const& ctx); - /** Helper function that might be needed by other transactors + /** Realize a loan default, absorbing losses via first-loss capital. + * + * Implements XLS-66 §3.2.3.2. The broker's cover capital absorbs the + * loss up to `sfCoverRateMinimum` / `sfCoverRateLiquidation` (in + * tenth-basis-points), further capped by `sfCoverAvailable`. Rounding + * for the minimum coverage is always upward so the broker cannot + * under-cover. The remaining unabsorbed loss is deducted from the + * vault's `sfAssetsTotal`. Any previously recorded `sfLossUnrealized` + * (from a prior impairment) is cleared since the loss is now realized. + * All outstanding balances (`sfTotalValueOutstanding`, + * `sfPrincipalOutstanding`, `sfManagementFeeOutstanding`, + * `sfPaymentRemaining`, `sfNextPaymentDueDate`) are zeroed and + * `lsfLoanDefault` is set. The covered amount is transferred from the + * broker pseudo-account to the vault pseudo-account via `accountSend` + * with `WaiveTransferFee::Yes`. + * + * @param view Mutable ledger view. + * @param loanSle Mutable SLE for the Loan object. + * @param brokerSle Mutable SLE for the LoanBroker object. + * @param vaultSle Mutable SLE for the associated Vault object. + * @param vaultAsset The asset denomination of the vault. + * @param j Journal for diagnostic logging. + * @return `tesSUCCESS` on success; `tecINTERNAL` or `tefBAD_LEDGER` if + * ledger state is inconsistent (should be unreachable in a valid ledger). */ static TER defaultLoan( @@ -36,7 +124,26 @@ public: Asset const& vaultAsset, beast::Journal j); - /** Helper function that might be needed by other transactors + /** Mark a loan as impaired, recording a paper loss in the vault. + * + * Sets `lsfLoanImpaired` on the loan and writes + * `sfTotalValueOutstanding - sfManagementFeeOutstanding` into + * `sfLossUnrealized` on the vault, reducing the effective NAV of vault + * shares without moving any funds. If `sfNextPaymentDueDate` has not + * yet passed, it is advanced to the current ledger close time to + * accelerate the payment schedule. The operation is rejected with + * `tecLIMIT_EXCEEDED` if the unrealized loss would exceed the vault's + * unavailable assets (`sfAssetsTotal - sfAssetsAvailable`), which would + * leave the vault in an inconsistent state. + * + * @param view Mutable ledger view. + * @param loanSle Mutable SLE for the Loan object. + * @param vaultSle Mutable SLE for the associated Vault object. + * @param vaultAsset The asset denomination of the vault. + * @param j Journal for diagnostic logging. + * @return `tesSUCCESS` on success; `tecLIMIT_EXCEEDED` if the unrealized + * loss would exceed available capacity; `tecINTERNAL` on ledger + * inconsistency. */ static TER impairLoan( @@ -46,7 +153,25 @@ public: Asset const& vaultAsset, beast::Journal j); - /** Helper function that might be needed by other transactors + /** Reverse a prior impairment, restoring the loan to normal status. + * + * Clears `lsfLoanImpaired` and removes the `sfLossUnrealized` entry + * from the vault, reversing the paper loss recorded by `impairLoan`. + * The `sfNextPaymentDueDate` is restored: if the original date has not + * yet passed, the amortization schedule is unchanged; otherwise the due + * date is reset to the current ledger close time plus one payment + * interval, giving the borrower a full interval to make the next payment. + * + * The `[[nodiscard]]` attribute enforces that callers (e.g., `LoanPay`) + * always propagate failures — silently ignoring a failed rollback would + * leave the vault's NAV accounting in an inconsistent state. + * + * @param view Mutable ledger view. + * @param loanSle Mutable SLE for the Loan object. + * @param vaultSle Mutable SLE for the associated Vault object. + * @param vaultAsset The asset denomination of the vault. + * @param j Journal for diagnostic logging. + * @return `tesSUCCESS` on success; `tecINTERNAL` on ledger inconsistency. */ [[nodiscard]] static TER unimpairLoan( @@ -56,15 +181,54 @@ public: Asset const& vaultAsset, beast::Journal j); + /** Execute the loan state transition. + * + * Fetches the mutable loan, broker, and vault SLEs via `view().peek()`, + * then dispatches to `defaultLoan`, `impairLoan`, or `unimpairLoan` + * based on the transaction flags. No flags is a valid no-op form. + * After the dispatch, calls `associateAsset` on all three SLEs when + * the `fixSecurity3_1_3` amendment is active, ensuring stored numeric + * values are rounded to the asset's precision. + * + * @return `tesSUCCESS` on success; a `tec` code from the dispatched + * helper on failure; `tefBAD_LEDGER` if required SLEs are missing. + */ TER doApply() override; + /** Accumulate per-entry state for the `ValidLoan` invariant checker. + * + * Called once per modified SLE during the invariant-check phase. + * Records before/after snapshots of Loan, Vault, and LoanBroker entries + * so that `finalizeInvariants` can verify conservation of value across + * the transaction. + * + * @param isDelete `true` if the entry was erased. + * @param before SLE state before the transaction (may be null for + * newly created entries). + * @param after SLE state after the transaction (may be null for + * deleted entries). + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Verify that the `ValidLoan` invariant holds after the transaction. + * + * Checks that outstanding balances, unrealized-loss accounting, and + * vault asset totals are internally consistent after the state + * transition. Returns `false` (triggering `tecINVARIANT_FAILED`) if + * any conservation property is violated. + * + * @param tx The applied transaction. + * @param result The TER returned by `doApply`. + * @param fee The fee deducted from the submitting account. + * @param view Read-only view of the post-transaction ledger. + * @param j Journal for invariant-failure diagnostics. + * @return `true` if all invariants pass, `false` otherwise. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/lending/LoanPay.h b/include/xrpl/tx/transactors/lending/LoanPay.h index 59f4f20149..cd01ccf82b 100644 --- a/include/xrpl/tx/transactors/lending/LoanPay.h +++ b/include/xrpl/tx/transactors/lending/LoanPay.h @@ -4,39 +4,227 @@ namespace xrpl { +/** Transactor for the `ttLOAN_PAY` transaction type (XLS-66). + * + * Allows a borrower to repay an outstanding loan created by the XRPL + * Lending Protocol. Each invocation may process one or more amortization + * installments in a single transaction, routing funds simultaneously to + * the vault pseudo-account (principal + interest) and the broker payee + * (service fee). + * + * Four mutually exclusive payment modes are supported via transaction flags: + * - No flag: regular on-time periodic payment. + * - `tfLoanLatePayment`: payment after the due date; includes late interest. + * - `tfLoanFullPayment`: early full payoff; may apply a discount or penalty. + * - `tfLoanOverpayment`: pay more than one installment; triggers + * re-amortization of the remaining balance. + * + * The fee scales with the estimated number of installments the submitted + * `sfAmount` would cover, charging one base-fee unit per + * `kLOAN_PAYMENTS_PER_FEE_INCREMENT` payments so that multi-installment + * submissions do not impose disproportionate cost on the network. + * + * @note `ConsequencesFactory{Normal}` means a failed `LoanPay` does not + * block later transactions from the same account in the queue. + */ class LoanPay : public Transactor { public: + /** Consequence factory type — fee is claimed under normal conditions. */ static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Normal; + /** Construct a `LoanPay` transactor for the given apply context. + * + * @param ctx The apply context supplied by the transaction pipeline. + */ explicit LoanPay(ApplyContext& ctx) : Transactor(ctx) { } + /** Gate the transactor on the amendments required by the lending protocol. + * + * Delegates to `checkLendingProtocolDependencies`, which verifies that + * all feature flags the lending protocol depends on (including + * `featureSingleAssetVault`, `featureMPTokensV1`, and optionally + * `featurePermissionedDomains`) are active. Returning `false` causes + * `invokePreflight` to short-circuit with `temDISABLED` before any + * field parsing occurs. + * + * @param ctx The preflight context. + * @return `true` if all required amendments are enabled, `false` otherwise. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** Return the legal transaction-flag bits for this transactor. + * + * The mask covers `tfLoanLatePayment`, `tfLoanFullPayment`, and + * `tfLoanOverpayment`; any other flag bits produce `temINVALID_FLAG`. + * + * @param ctx The preflight context (unused; present for pipeline conformance). + * @return `tfLoanPayMask`. + */ static std::uint32_t getFlagsMask(PreflightContext const& ctx); + /** Stateless structural validation of a `ttLOAN_PAY` transaction. + * + * Performs two field checks with no ledger access: + * - `sfLoanID` must be non-zero. + * - `sfAmount` must be positive. + * + * Enforces mutual exclusivity of the payment-mode flags by counting + * set bits with `std::popcount`; a `static_assert` at compile time + * verifies that the three flag bits exactly cover the bits not in + * `tfLoanPayMask | tfUniversal`, keeping the mask and flag definitions + * in sync. + * + * @param ctx The preflight context carrying the transaction. + * @return `tesSUCCESS` on success; `temINVALID` if `sfLoanID` is zero; + * `temBAD_AMOUNT` if `sfAmount` is non-positive; `temINVALID_FLAG` + * if more than one payment-mode flag is set simultaneously. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Compute the transaction fee scaled to the estimated payment count. + * + * Most transactors delegate entirely to `Transactor::calculateBaseFee`. + * `LoanPay` is unusual because a single transaction can process many + * amortization installments, and charging a flat fee regardless of + * volume would create an attack surface for forcing expensive computation + * at low cost. + * + * The fee estimate reads the ledger hierarchy loan → loanbroker → vault + * to determine the asset, periodic payment, and service fee, then + * computes `numPaymentEstimate = amount / regularPayment`. One base-fee + * unit is charged per `kLOAN_PAYMENTS_PER_FEE_INCREMENT` estimated + * payments (rounded up, minimum 1). When `fixSecurity3_1_3` is active, + * the total is capped at `kLOAN_MAXIMUM_PAYMENTS_PER_TRANSACTION / + * kLOAN_PAYMENTS_PER_FEE_INCREMENT` increments — the hard processing + * cap that `loanMakePayment` enforces at execution time. + * + * Late and full payments always return the normal single-base fee + * because they perform a bounded, fixed amount of work regardless of + * `sfAmount`. Overpayments use upward rounding for the payment + * estimate (conservative billing), while regular payments use downward + * rounding. + * + * If any ledger object along the loan → broker → vault chain is missing + * or the asset is mismatched, the method falls back to the normal fee + * and defers the error to `preclaim`. + * + * @param view The read-only ledger view used to look up loan objects. + * @param tx The serialized transaction. + * @return The scaled fee in drops; at least one base-fee unit. + */ static XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx); + /** Read-only ledger checks for a `ttLOAN_PAY` transaction. + * + * Loads the `Loan`, `LoanBroker`, and `Vault` objects and enforces: + * - **Ownership**: `sfBorrower` on the loan must equal `sfAccount` on + * the transaction (`tecNO_PERMISSION`). + * - **Overpayment permission**: if `tfLoanOverpayment` is set but the + * loan's `lsfLoanOverpayment` flag is absent, returns `tecNO_PERMISSION` + * when `fixSecurity3_1_3` is active, or `temINVALID_FLAG` otherwise + * (a versioned correction to historical behaviour). + * - **Loan completeness**: if `sfPaymentRemaining == 0` or + * `sfPrincipalOutstanding == 0` the loan is already discharged + * (`tecKILLED`). + * - **Asset consistency**: `sfAmount`'s asset must match the vault's + * `sfAsset` (`tecWRONG_ASSET`). + * - **Freeze and authorization**: the borrower must not be frozen for + * the asset; the vault pseudo-account must not be deep-frozen; the + * borrower must hold the required authorization. + * - **Balance sufficiency**: the borrower must hold at least the full + * `sfAmount`; partial-payment semantics are explicitly rejected. + * + * @note The "broker does not exist" and "vault does not exist" branches + * are marked `LCOV_EXCL_*` because the protocol maintains referential + * integrity between Loan → LoanBroker → Vault — reaching them + * implies ledger corruption. + * + * @param ctx The preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if all checks pass; a `tec`/`ter` code otherwise. + */ static TER preclaim(PreclaimContext const& ctx); + /** Execute the loan repayment and update all affected ledger objects. + * + * Mutates three ledger objects atomically: `Loan`, `LoanBroker`, and + * `Vault`. The sequence is: + * + * 1. **Impairment unwind**: if the loan carries `lsfLoanImpaired`, + * calls `LoanManage::unimpairLoan` to restore tracked fields before + * payment arithmetic runs. Aborts on failure. + * 2. **Payment dispatch**: maps transaction flags to a `LoanPaymentType` + * enum value and calls `loanMakePayment`, which performs the + * amortization computation and modifies `loanSle` in-place, returning + * a `LoanPaymentParts` struct (`principalPaid`, `interestPaid`, + * `feePaid`, `valueChange`). + * 3. **Broker fee routing**: directs the service fee to the broker + * owner's account if cover available ≥ minimum required (rounded up), + * the owner is not deep-frozen, and the owner holds authorization; + * otherwise accumulates the fee in the broker pseudo-account (the + * first-loss cover pool). If both the owner and the pseudo-account + * are deep-frozen, the transaction fails. + * 4. **Vault accounting**: credits `sfAssetsAvailable` by + * `totalPaidToVaultRounded` (principal + interest rounded down to + * vault scale) and adjusts `sfAssetsTotal` by `valueChange`. Adjusts + * the broker's `sfDebtTotal` via `adjustImpreciseNumber`, which + * re-rounds to vault scale and floors at zero to absorb accumulated + * rounding drift. + * 5. **Fund transfer**: a single `accountSendMulti` call moves funds + * from the borrower to the vault pseudo-account and the broker payee + * with `WaiveTransferFee::Yes` — transfer fees are waived because + * the lending protocol's amortization schedule dictates exact amounts. + * + * @note In debug builds, two `#if !NDEBUG` blocks verify conservation of + * funds and that `sfAssetsAvailable` agrees with the vault + * pseudo-account's actual token balance before and after the transfer. + * + * @return `tesSUCCESS` on success; `tefBAD_LEDGER` if any required SLE + * is missing; a `tec` code from `loanMakePayment` or + * `LoanManage::unimpairLoan` on computation failure; `tecINTERNAL` + * if vault invariants are violated post-accounting. + */ TER doApply() override; + /** Accumulate per-entry state for transaction-specific invariant checks. + * + * Reserved for future `LoanPay`-specific invariant logic; currently a + * no-op. The framework calls this once per modified SLE during the + * invariant-check phase. + * + * @param isDelete `true` if the entry was erased. + * @param before SLE state before the transaction (may be null for + * newly created entries). + * @param after SLE state after the transaction (may be null for + * deleted entries). + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Verify that transaction-specific invariants hold after `doApply`. + * + * Reserved for future `LoanPay`-specific invariant logic; currently + * always returns `true`. Returning `false` would trigger + * `tecINVARIANT_FAILED`. + * + * @param tx The applied transaction. + * @param result The TER returned by `doApply`. + * @param fee The fee deducted from the submitting account. + * @param view Read-only view of the post-transaction ledger. + * @param j Journal for invariant-failure diagnostics. + * @return `true` always (no checks implemented yet). + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/lending/LoanSet.h b/include/xrpl/tx/transactors/lending/LoanSet.h index 6517212a18..63cccda7d3 100644 --- a/include/xrpl/tx/transactors/lending/LoanSet.h +++ b/include/xrpl/tx/transactors/lending/LoanSet.h @@ -5,6 +5,24 @@ namespace xrpl { +/** + * Loan origination transactor for the XLS-66 lending protocol. + * + * `LoanSet` creates a new `Loan` ledger entry, transferring principal from a + * vault's pseudo-account to a borrower, deducting any origination fee to the + * broker owner, and recording the full amortization schedule. It is the + * entry point to the loan lifecycle; `LoanPay` services existing loans and + * `LoanDelete` terminates them. + * + * Every `LoanSet` requires a cryptographic signature from the counterparty + * (the broker owner), because the transaction binds both the lender's vault + * and the borrower. The sole exception is when the transaction runs as a + * Batch inner transaction (`tfInnerBatchTxn`), where the outer batch + * authorization subsumes the counterparty signature. + * + * @note `ConsequencesFactory` is `Normal` — this transaction does not + * unconditionally block later transactions from the same account. + */ class LoanSet : public Transactor { public: @@ -14,36 +32,211 @@ public: { } + /** + * Gates the entire transactor on all XLS-66 prerequisite amendments. + * + * Delegates to `checkLendingProtocolDependencies`, which verifies that + * every required feature flag is active in `ctx.rules`. Returning + * `false` causes the framework to reject the transaction before preflight + * runs. + * + * @param ctx Preflight context providing active amendment rules. + * @return `true` if all XLS-66 dependencies are enabled; `false` otherwise. + */ static bool checkExtraFeatures(PreflightContext const& ctx); + /** + * Returns the set of flags valid for this transaction type. + * + * Only `tfLoanOverpayment` (which permits the borrower to pay ahead of + * schedule) is meaningful. Any other flag bits are rejected by + * `preflight0`. + * + * @param ctx Preflight context (unused; present for framework uniformity). + * @return `tfLoanSetMask` — the bitmask of allowed transaction flags. + */ static std::uint32_t getFlagsMask(PreflightContext const& ctx); + /** + * Stateless validation pass — no ledger access. + * + * Enforces the following invariants in order: + * - A Batch inner transaction without `sfCounterparty` is rejected + * (`temBAD_SIGNER`), as batch authorization replaces the counterparty + * signature only when `sfCounterparty` is explicitly supplied. + * - A non-batch transaction without `sfCounterpartySignature` is rejected + * (`temBAD_SIGNER`). + * - Rate fields (`sfInterestRate`, `sfLateInterestRate`, + * `sfCloseInterestRate`, `sfOverpaymentInterestRate`, `sfOverpaymentFee`) + * are range-checked against their respective protocol maxima. + * - Fee fields (`sfLoanServiceFee`, `sfLatePaymentFee`, + * `sfClosePaymentFee`) must be non-negative. + * - `sfPrincipalRequested` must be positive; `sfLoanOriginationFee` + * must not exceed it. + * - `sfPaymentInterval` must be ≥ `kMIN_PAYMENT_INTERVAL`. + * - `sfGracePeriod`, when present, must be in + * [`kDEFAULT_GRACE_PERIOD`, `paymentInterval`]. + * - `sfLoanBrokerID` must not be the zero hash. + * + * @param ctx Preflight context carrying the transaction and active rules. + * @return `tesSUCCESS` on success; a `tem*` code on any validation failure. + */ static NotTEC preflight(PreflightContext const& ctx); + /** + * Verifies both the primary and counterparty signatures. + * + * Calls the base-class `Transactor::checkSign` for the transaction + * submitter, then resolves the counterparty identity: if `sfCounterparty` + * is present in the transaction it is used directly; otherwise the broker's + * `sfOwner` field is read from the ledger view. The resolved identity is + * then passed back into `Transactor::checkSign` with the + * `sfCounterpartySignature` sub-object, which may itself be a single + * signature or a full multisig quorum. + * + * @param ctx Preclaim context providing the read-only ledger view. + * @return `tesSUCCESS` if both signatures are valid; `temBAD_SIGNER` if + * the counterparty cannot be resolved; otherwise a signature-check + * error from the base class. + */ static NotTEC checkSign(PreclaimContext const& ctx); + /** + * Prices the transaction, adding one base fee per counterparty signer. + * + * The base cost is computed by `Transactor::calculateBaseFee`. Each + * signer in `sfCounterpartySignature` — whether a single-signature entry + * (`sfTxnSignature` present) or each member of a multisig quorum + * (`sfSigners` present) — adds one additional `baseFee` unit. This + * mirrors how the base class charges for entries in `sfSigners` on + * multisig transactions. + * + * @param view Read-only ledger view used to obtain the current base fee. + * @param tx The transaction being fee-priced. + * @return Total fee in drops: normal cost + (counterparty signer count × + * base fee). + */ static XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx); + /** + * Returns the `STNumber` fields that must be representable in the vault's + * asset type without precision loss. + * + * The returned list covers: `sfPrincipalRequested`, `sfLoanOriginationFee`, + * `sfLoanServiceFee`, `sfLatePaymentFee`, and `sfClosePaymentFee`. + * Overpayment fee is excluded because it is stored as a rate, not an + * absolute amount. + * + * This list is checked twice: once coarsely in `preclaim` (before the + * final loan scale is known, catching obvious type mismatches for integral + * asset types), and again in `doApply` after `computeLoanProperties` + * establishes the exact scale (necessary for IOU assets, where decimal + * precision can push a value below the type's resolution). + * + * @return A static reference to the list of optioned `STNumber` fields. + */ static std::vector> const& getValueFields(); + /** + * Ledger-state-dependent validation — read-only. + * + * Performs the following checks in order: + * 1. **Schedule overflow guard**: verifies that + * `startDate + (interval × total) + gracePeriod` cannot exceed + * `std::numeric_limits::max()`. Returns `tecKILLED` if any + * intermediate value would overflow, before loading any SLEs. + * 2. Confirms the `LoanBroker` SLE exists; returns `tecNO_ENTRY` if not. + * 3. Verifies that the transaction submitter or explicit counterparty is + * the broker's owner; returns `tecNO_PERMISSION` otherwise. + * 4. Confirms the borrower account exists; returns `terNO_ACCOUNT` if not. + * 5. Checks that the vault's `sfAssetsTotal` has not reached + * `sfAssetsMaximum`; returns `tecLIMIT_EXCEEDED` if so. + * 6. Validates coarse representability of all value fields in the vault + * asset type; returns `tecPRECISION_LOSS` on failure. + * 7. Confirms there is capacity to add a new holding; delegates to + * `canAddHolding`. + * 8. Checks that the vault pseudo-account, broker pseudo-account, borrower, + * and broker owner are not frozen for the loan asset. + * + * @param ctx Preclaim context providing the read-only ledger view. + * @return `tesSUCCESS` on success; a `tec*` or `ter*` code on failure. + */ static TER preclaim(PreclaimContext const& ctx); + /** + * Executes the loan origination, mutating ledger state. + * + * The apply sequence is: + * 1. Recompute `computeLoanProperties` (amortization schedule, periodic + * payment, management fees) and verify the interest due would not push + * the vault over its `sfAssetsMaximum`. + * 2. Re-check value-field precision with the final loan scale (IOU assets + * only; integral types were fully checked in `preclaim`). + * 3. Run `checkLoanGuards` to enforce numeric precision invariants at the + * computed scale. + * 4. Verify the broker's `sfDebtTotal` would not exceed `sfDebtMaximum`. + * 5. Verify `sfCoverAvailable` meets `sfCoverRateMinimum` after the new + * loan is added — minimum cover is rounded upward to protect solvency. + * 6. Adjust the borrower's owner count (+1) and check XRP reserve. + * 7. Create trust-line / MPT holdings for the borrower and broker owner on + * demand; require `StrongAuth` authorization for both. + * 8. Disburse funds atomically via `accountSendMulti`: + * `(principalRequested − originationFee)` to the borrower and + * `originationFee` to the broker owner, both in one operation. + * 9. Insert the `Loan` SLE with all amortization fields populated. + * 10. Decrement `sfAssetsAvailable` and increment `sfAssetsTotal` on the + * vault. + * 11. Increment `sfDebtTotal`, `sfLoanSequence`, and owner count on the + * broker SLE (rolls back with `tecMAX_SEQUENCE_REACHED` on overflow). + * 12. Link the loan into the broker pseudo-account's directory and the + * borrower's owner directory. + * 13. Call `associateAsset` on the vault, broker, and loan SLEs. + * + * @return `tesSUCCESS` on success; `tec*` or `tef*` on failure. + * Any `tec*` return causes the framework to discard all state changes + * via `ApplyContext::discard()` and re-apply only the fee. + */ TER doApply() override; + /** + * Per-entry callback for the transaction's invariant checker. + * + * No transaction-specific invariants are registered yet; this is a + * placeholder for future work. + * + * @param isDelete `true` if the entry is being deleted. + * @param before SLE state before the transaction (may be null for new + * entries). + * @param after SLE state after the transaction (may be null for deleted + * entries). + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** + * Final invariant check called once after all entries are visited. + * + * No transaction-specific invariants are registered yet; always returns + * `true`. + * + * @param tx The transaction that was applied. + * @param result The TER returned by `doApply`. + * @param fee The fee charged to the submitting account. + * @param view Read-only view of the post-apply ledger state. + * @param j Journal for diagnostic logging. + * @return `true` — no invariant violations detected. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, @@ -53,14 +246,24 @@ public: beast::Journal const& j) override; public: + /// Minimum number of scheduled payments; allows balloon (single-payment) + /// loans as the degenerate case. static std::uint32_t constexpr kMIN_PAYMENT_TOTAL = 1; + + /// Default number of scheduled payments when `sfPaymentTotal` is absent. static std::uint32_t constexpr kDEFAULT_PAYMENT_TOTAL = 1; static_assert(kDEFAULT_PAYMENT_TOTAL >= kMIN_PAYMENT_TOTAL); + /// Minimum payment cadence in seconds; prevents loans that fire faster than + /// ledger close times can reliably track. static std::uint32_t constexpr kMIN_PAYMENT_INTERVAL = 60; + + /// Default payment interval in seconds when `sfPaymentInterval` is absent. static std::uint32_t constexpr kDEFAULT_PAYMENT_INTERVAL = 60; static_assert(kDEFAULT_PAYMENT_INTERVAL >= kMIN_PAYMENT_INTERVAL); + /// Default grace period in seconds when `sfGracePeriod` is absent. + /// Must be ≥ `kMIN_PAYMENT_INTERVAL` to avoid negative-time schedules. static std::uint32_t constexpr kDEFAULT_GRACE_PERIOD = 60; static_assert(kDEFAULT_GRACE_PERIOD >= kMIN_PAYMENT_INTERVAL); }; diff --git a/include/xrpl/tx/transactors/nft/NFTokenAcceptOffer.h b/include/xrpl/tx/transactors/nft/NFTokenAcceptOffer.h index 9cdb609ad8..02e7770e0f 100644 --- a/include/xrpl/tx/transactors/nft/NFTokenAcceptOffer.h +++ b/include/xrpl/tx/transactors/nft/NFTokenAcceptOffer.h @@ -4,18 +4,101 @@ namespace xrpl { +/** + * Transactor for the `NFTokenAcceptOffer` transaction type. + * + * Settles an NFT trade by transferring ownership of a token and routing payment + * among up to four parties: buyer, seller, issuer (royalty), and an optional broker. + * + * Two mutually exclusive operation modes are supported: + * + * - **Direct mode**: exactly one of `sfNFTokenBuyOffer` or `sfNFTokenSellOffer` is + * supplied. The submitter is either the current NFT owner accepting a buy offer, + * or a buyer paying into a sell offer. Payment routing is handled by `acceptOffer()`. + * + * - **Brokered mode**: both offer IDs are supplied. The submitter is a broker who + * matches the buyer's bid against the seller's ask without owning the token. An + * optional `sfNFTokenBrokerFee` lets the broker claim a cut. Payment sequencing + * in this mode is enforced in `doApply()`: broker fee first, then issuer royalty + * computed on the remainder, then seller receives what is left. This ordering + * prevents total payouts from exceeding what the buyer authorised. + * + * @note `ConsequencesFactory` is `Normal`; this transaction imposes no extraordinary + * sequencing constraints on other transactions in the same ledger. + */ class NFTokenAcceptOffer : public Transactor { private: + /** + * Transfer `amount` from `from` to `to` and verify both balances remain non-negative. + * + * Wraps `accountSend` with a post-payment sanity check: a successful `accountSend` + * can still leave a balance negative in pathological IOU-with-transfer-fee scenarios. + * If either sender or receiver ends up with a negative effective balance (as reported + * by `accountFunds`), the method returns `tecINSUFFICIENT_FUNDS` before any ledger + * write is committed. + * + * @param from Account debited. + * @param to Account credited. + * @param amount Amount to transfer; must be non-negative. + * @return `tesSUCCESS` on success, or a `tec*` code if the transfer fails or + * leaves either party with a negative balance. + */ TER pay(AccountID const& from, AccountID const& to, STAmount const& amount); + /** + * Execute a direct (non-brokered) offer acceptance. + * + * Determines buyer and seller roles from the offer's `lsfSellNFToken` flag, then: + * 1. Computes the issuer's royalty cut via `nft::getTransferFee`; skips royalty if + * buyer or seller is the issuer. + * 2. Pays the royalty to the issuer via `pay()`. + * 3. Pays the remaining proceeds to the seller via `pay()`. + * 4. Transfers the token from seller to buyer via `transferNFToken()`. + * + * @param offer The active sell or buy offer SLE being accepted. + * @return `tesSUCCESS` on success, or a `tec*` code propagated from `pay()` or + * `transferNFToken()`. + */ TER acceptOffer(std::shared_ptr const& offer); + /** + * Execute a brokered offer acceptance, matching `buy` against `sell`. + * + * @note This overload is not used directly; brokered-mode payment sequencing is + * implemented inline in `doApply()` to enforce the broker-first, then + * issuer-royalty, then seller ordering invariant. + * + * @param buy The buyer's NFToken offer SLE. + * @param sell The seller's NFToken offer SLE. + * @return `tesSUCCESS` on success, or a `tec*` code. + */ TER bridgeOffers(std::shared_ptr const& buy, std::shared_ptr const& sell); + /** + * Transfer ownership of an NFToken from `seller` to `buyer`. + * + * Calls `nft::removeToken` to strip the token from the seller's NFToken page, + * then `nft::insertToken` to place it into the buyer's page (allocating a new + * page if needed). + * + * When `fixNFTokenReserve` is enabled and the buyer's owner count increased + * (indicating a new NFToken page was allocated), the method checks the buyer's + * current post-purchase balance against the reserve requirement for the new owner + * count. The current balance — not `preFeeBalance_` — is used because the buyer + * may have already paid for the token; using the pre-fee balance would overstate + * available funds and allow an under-reserved purchase. + * + * @param buyer Account receiving the token. + * @param seller Account surrendering the token. + * @param nfTokenID The 256-bit token identifier. + * @return `tesSUCCESS` on success; `tecINSUFFICIENT_RESERVE` if the buyer cannot + * meet the reserve for the new page; `tecINTERNAL` if the token cannot be + * located (ledger corruption sentinel, normally unreachable). + */ TER transferNFToken(AccountID const& buyer, AccountID const& seller, uint256 const& nfTokenID); @@ -26,21 +109,109 @@ public: { } + /** + * Stateless validation of the `NFTokenAcceptOffer` transaction fields. + * + * Enforces: + * - At least one of `sfNFTokenBuyOffer` or `sfNFTokenSellOffer` must be present. + * - `sfNFTokenBrokerFee` is only valid in brokered mode (both offer IDs present) + * and must be strictly positive. + * + * @param ctx Preflight context; no ledger access is performed. + * @return `tesSUCCESS` if the transaction is well-formed, `temMALFORMED` otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** + * Read-only ledger validation for the `NFTokenAcceptOffer` transaction. + * + * For each offer ID present in the transaction, verifies: + * - The offer SLE exists and has a non-negative `sfAmount`. + * - Expiration handling: before `fixSecurity3_1_3`, expired offers return + * `tecEXPIRED` immediately, leaving the ledger object stranded; after the + * amendment, expired offers are allowed through to `doApply()` where they are + * deleted before returning `tecEXPIRED`. + * + * In **brokered mode**, additionally confirms: + * - Both offers reference the same token ID and the same payment asset. + * - The buyer's bid is at least as large as the seller's ask. + * - After deducting `sfNFTokenBrokerFee`, the seller's ask is still satisfied. + * - Neither party is selling to themselves. + * - The broker, buyer, and seller each have an authorised, non-deep-frozen trust + * line for the IOU being transferred (`fixEnforceNFTokenTrustlineV2`). + * + * In **direct mode**, additionally confirms: + * - The transaction submitter owns the token (when accepting a buy offer). + * - The buyer has sufficient funds. + * - All payment recipients have authorised, non-deep-frozen trust lines for the + * IOU (`fixEnforceNFTokenTrustlineV2`). + * + * Royalty trust-line hygiene: + * - `fixEnforceNFTokenTrustline`: prevents granting an unintended trust line to + * the issuer when no line exists for the royalty IOU. + * - `fixEnforceNFTokenTrustlineV2`: extends the check to every payment recipient. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if all checks pass, or a `tec*`/`tem*` code describing + * the first violation found. + */ static TER preclaim(PreclaimContext const& ctx); + /** + * Apply the `NFTokenAcceptOffer` transaction to the mutable ledger view. + * + * Expired offer cleanup (when `fixSecurity3_1_3` is enabled): if any referenced + * offer has expired since `preclaim`, it is deleted from the ledger and + * `tecEXPIRED` is returned, ensuring proper garbage collection. + * + * Accepted offers are deleted from the ledger regardless of whether they are buy + * or sell offers. + * + * **Direct mode** (only one offer ID present): delegates to `acceptOffer()`. + * + * **Brokered mode** (both offer IDs present): payments are sequenced as follows to + * preserve the correctness invariant that total payouts never exceed what the buyer + * authorised: + * 1. Broker receives `sfNFTokenBrokerFee` from the buyer. + * 2. Issuer royalty is computed on the buyer's remaining amount after the broker + * cut; issuer is paid via `pay()`. + * 3. Seller receives whatever balance remains after both deductions. + * 4. NFToken ownership is transferred via `transferNFToken()`. + * + * @return `tesSUCCESS` on a fully committed sale; `tecEXPIRED` if an expired + * offer was cleaned up; other `tec*` codes propagated from `pay()` or + * `transferNFToken()`; `tecINTERNAL` on ledger corruption (normally unreachable). + */ TER doApply() override; + /** + * Per-entry invariant visitor; reserved for future transaction-specific invariants. + * + * @param isDelete True if the entry is being deleted. + * @param before The SLE state before the transaction (may be null for insertions). + * @param after The SLE state after the transaction (may be null for deletions). + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** + * Post-apply invariant finalizer; reserved for future transaction-specific invariants. + * + * Currently a no-op that always returns `true`. + * + * @param tx The transaction being applied. + * @param result The TER result from `doApply()`. + * @param fee The fee charged for this transaction. + * @param view Read-only view of the ledger after apply. + * @param j Journal for diagnostic logging. + * @return `true` (no invariants checked yet). + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/nft/NFTokenBurn.h b/include/xrpl/tx/transactors/nft/NFTokenBurn.h index e8341cf26a..116ccf887e 100644 --- a/include/xrpl/tx/transactors/nft/NFTokenBurn.h +++ b/include/xrpl/tx/transactors/nft/NFTokenBurn.h @@ -4,6 +4,16 @@ namespace xrpl { +/** Transactor for the `NFTokenBurn` transaction type. + * + * Permanently destroys a single NFToken: removes it from its owner's + * NFTokenPage, increments the issuer's `sfBurnedNFTokens` counter, and + * deletes associated buy/sell offers up to `kMAX_DELETABLE_TOKEN_OFFER_ENTRIES` + * (500 total). Offers beyond that cap are not removed by this transaction. + * + * @note `kCONSEQUENCES_FACTORY` is `Normal`; burn imposes no extraordinary + * sequencing constraints on other transactions from the same account. + */ class NFTokenBurn : public Transactor { public: @@ -13,21 +23,80 @@ public: { } + /** Stateless validation of the `NFTokenBurn` transaction fields. + * + * All generic field checks (fee, sequence, signing key format) are handled + * by `preflight1` and `preflight2` inside `invokePreflight`. There are no + * burn-specific stateless constraints, so this always returns `tesSUCCESS`. + * + * @param ctx Preflight context; no ledger access is performed. + * @return `tesSUCCESS` unconditionally. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Read-only ledger validation for the `NFTokenBurn` transaction. + * + * Performs two checks: + * 1. **Token existence**: `nft::findToken` searches the owner's NFTokenPage + * for `sfNFTokenID`. Returns `tecNO_ENTRY` if the token is not found. + * 2. **Burn permission**: the token owner may always burn their own token. + * If `sfAccount` differs from the owner (`sfOwner` field when present), + * the token's `nft::kFLAG_BURNABLE` bit must be set; otherwise + * `tecNO_PERMISSION` is returned. Even with the flag set, only the + * issuer — or the account designated as the issuer's `sfNFTokenMinter` + * — may burn a token they do not hold. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if all checks pass; `tecNO_ENTRY` if the token does + * not exist; `tecNO_PERMISSION` if the submitter is not authorised to + * burn the token. + */ static TER preclaim(PreclaimContext const& ctx); + /** Apply the `NFTokenBurn` transaction to the mutable ledger view. + * + * Execution proceeds in three steps: + * 1. **Token removal**: `nft::removeToken` strips the NFToken from its + * owner's NFTokenPage SLE. A post-condition check guards against the + * token having disappeared since `preclaim` (ledger corruption sentinel; + * normally unreachable under consensus). + * 2. **Issuer accounting**: the issuer's `sfBurnedNFTokens` counter is + * incremented (seeding from zero via `value_or(0)` on first burn). + * 3. **Offer cleanup**: sell offers are deleted first (their directory is + * typically smaller), then any remaining budget from the 500-offer cap + * is applied to buy offers. Offers beyond the cap are left in the ledger. + * + * @return `tesSUCCESS` on success; propagates any non-success `TER` from + * `nft::removeToken` (normally unreachable after a successful `preclaim`). + */ TER doApply() override; + /** Per-entry invariant visitor; reserved for future transaction-specific invariants. + * + * @param isDelete True if the entry is being deleted. + * @param before SLE state before the transaction (null for insertions). + * @param after SLE state after the transaction (null for deletions). + */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Post-apply invariant finalizer; reserved for future transaction-specific invariants. + * + * Currently a no-op that always returns `true`. + * + * @param tx The transaction being applied. + * @param result The TER result from `doApply()`. + * @param fee The fee charged for this transaction. + * @param view Read-only view of the ledger after apply. + * @param j Journal for diagnostic logging. + * @return `true` (no invariants checked yet). + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/include/xrpl/tx/transactors/nft/NFTokenCancelOffer.h b/include/xrpl/tx/transactors/nft/NFTokenCancelOffer.h index faa6c0c029..5aa5abaf94 100644 --- a/include/xrpl/tx/transactors/nft/NFTokenCancelOffer.h +++ b/include/xrpl/tx/transactors/nft/NFTokenCancelOffer.h @@ -4,30 +4,102 @@ namespace xrpl { +/** Transactor for the `NFTokenCancelOffer` transaction type. + * + * Removes one or more outstanding `NFTokenOffer` ledger objects that were + * created via `NFTokenCreateOffer`. Up to `kMAX_TOKEN_OFFER_CANCEL_COUNT` + * (500) offers may be cancelled in a single transaction. The operation is + * idempotent with respect to already-consumed or already-cancelled offers: + * missing IDs are silently skipped in both `preclaim` and `doApply`. + * + * Permission to cancel an offer is deliberately broad: the offer's creator, + * its designated destination (if any), and any account once the offer has + * expired are all entitled to cancel. This reflects the NFT design goal of + * making expired and directed offers easy to clean up without requiring the + * original creator to be online. + */ class NFTokenCancelOffer : public Transactor { public: + /** Standard consequences factory tag. + * + * `Normal` indicates this transaction has ordinary reserve consequences + * and does not block later transactions from the same account. + */ static constexpr auto kCONSEQUENCES_FACTORY = ConsequencesFactoryType::Normal; explicit NFTokenCancelOffer(ApplyContext& ctx) : Transactor(ctx) { } + /** Stateless structural validation of the offer-ID list. + * + * Checks two invariants without consulting ledger state: + * - The `sfNFTokenOffers` field must be non-empty and must not exceed + * `kMAX_TOKEN_OFFER_CANCEL_COUNT` (500) entries. An empty list has no + * meaningful effect; an oversized list is rejected as a DoS vector. + * - The list must contain no duplicate IDs. Duplicates are detected by + * sorting a local copy and scanning for adjacent equal elements via + * `std::adjacent_find`. A copy is used because the original field + * object is conceptually immutable at this stage. + * + * @param ctx The preflight context carrying the transaction and rules. + * @return `temMALFORMED` if the list is empty, exceeds the maximum, or + * contains duplicates; `tesSUCCESS` otherwise. + */ static NotTEC preflight(PreflightContext const& ctx); + /** Verifies that the submitting account may cancel every listed offer. + * + * Iterates `sfNFTokenOffers` and short-circuits on the first entry that + * the submitter is not entitled to cancel. For each ID: + * - If the offer no longer exists in the ledger it is silently skipped + * (idempotent with respect to already-consumed or already-cancelled + * offers). + * - If the ID resolves to a ledger object that is not an + * `ltNFTOKEN_OFFER`, the submitter has no permission regardless of + * account ownership. + * - If the offer has expired (`hasExpired` returns true), any account + * may cancel it; the check returns allowed immediately. + * - The offer's `sfOwner` or its optional `sfDestination` (if it matches + * the submitting account) grants cancellation rights. + * + * @param ctx The preclaim context providing a read-only ledger view. + * @return `tecNO_PERMISSION` if any listed offer exists, is the wrong + * type, has not expired, and belongs neither to the submitter nor + * names the submitter as its destination; `tesSUCCESS` otherwise. + */ static TER preclaim(PreclaimContext const& ctx); + /** Deletes each listed `NFTokenOffer` from the ledger. + * + * Iterates `sfNFTokenOffers` and calls `nft::deleteTokenOffer` on each + * entry that still exists (via `keylet::nftoffer`). Missing offers are + * skipped silently, consistent with the idempotency stance of `preclaim`. + * If deletion fails for a present offer — a situation deemed impossible + * under correct invariants and excluded from code coverage — a fatal log + * message is emitted and `tefBAD_LEDGER` is returned to signal internal + * ledger corruption rather than a user error. + * + * @return `tesSUCCESS` on success; `tefBAD_LEDGER` if `deleteTokenOffer` + * returns false for a live offer (indicates ledger corruption). + */ TER doApply() override; + /** Per-entry invariant hook (no-op; reserved for future use). */ void visitInvariantEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) override; + /** Invariant finalization hook (no-op; reserved for future use). + * + * @return Always `true`. + */ [[nodiscard]] bool finalizeInvariants( STTx const& tx, diff --git a/src/libxrpl/ledger/helpers/AMMHelpers.cpp b/src/libxrpl/ledger/helpers/AMMHelpers.cpp index cd1796468c..250691e9ea 100644 --- a/src/libxrpl/ledger/helpers/AMMHelpers.cpp +++ b/src/libxrpl/ledger/helpers/AMMHelpers.cpp @@ -1,3 +1,21 @@ +/** @file AMMHelpers.cpp + * Implementation of the AMM mathematical engine and ledger-state utilities. + * + * Provides the closed-form deposit/withdrawal formulas, quadratic solvers, + * directional rounding wrappers, pool balance readers, and AMM account + * lifecycle management. The companion header additionally defines the + * inline swap and spot-price-quality templates that depend on these + * primitives. + * + * All arithmetic that touches pool balances obeys the invariant: + * @code + * sqrt(asset1 × asset2) >= LPTokenBalance + * @endcode + * Rounding is always applied in the direction that keeps the pool at least + * as large as required by the invariant. The `fixAMMv1_3` amendment + * introduces explicit per-step directional rounding; pre-amendment paths are + * preserved for historic replay. + */ #include #include @@ -53,11 +71,17 @@ ammLPTokens(STAmount const& asset1, STAmount const& asset2, Asset const& lptIssu return toSTAmount(lptIssue, tokens); } -/* - * Equation 3: - * t = T * [(b/B - (sqrt(f2**2 - b/(B*f1)) - f2)) / - * (1 + sqrt(f2**2 - b/(B*f1)) - f2)] - * where f1 = 1 - tfee, f2 = (1 - tfee/2)/f1 +/** LP tokens minted for a single-asset deposit (Equation 3). + * + * Implements the closed-form formula: + * @code + * t = T * [(b/B - (sqrt(f2**2 - b/(B*f1)) - f2)) / + * (1 + sqrt(f2**2 - b/(B*f1)) - f2)] + * @endcode + * where `f1 = 1 - tfee`, `f2 = (1 - tfee/2) / f1`, `b` is the deposit + * amount, `B` is the current pool balance, and `T` is the current LP token + * supply. Under `fixAMMv1_3` the final multiplication is rounded downward + * so that fewer tokens are issued, preserving the pool invariant. */ STAmount lpTokensOut( @@ -81,16 +105,16 @@ lpTokensOut( return multiply(lptAMMBalance, frac, Number::RoundingMode::Downward); } -/* Equation 4 solves equation 3 for b: - * Let f1 = 1 - tfee, f2 = (1 - tfee/2)/f1, t1 = t/T, t2 = 1 + t1, R = b/B - * then - * t1 = [R - sqrt(f2**2 + R/f1) + f2] / [1 + sqrt(f2**2 + R/f1] - f2] => - * sqrt(f2**2 + R/f1)*(t1 + 1) = R + f2 + t1*f2 - t1 => - * sqrt(f2**2 + R/f1)*t2 = R + t2*f2 - t1 => - * sqrt(f2**2 + R/f1) = R/t2 + f2 - t1/t2, let d = f2 - t1/t2 => - * sqrt(f2**2 + R/f1) = R/t2 + d => - * f2**2 + R/f1 = (R/t2)**2 +2*d*R/t2 + d**2 => - * (R/t2)**2 + R*(2*d/t2 - 1/f1) + d**2 - f2**2 = 0 +/** Required asset deposit for a desired LP token amount (Equation 4). + * + * Solves Equation 3 for the deposit `b` given desired tokens `t`. + * Let `f1 = 1 - tfee`, `f2 = (1 - tfee/2) / f1`, `t1 = t/T`, `t2 = 1 + t1`, + * `R = b/B`, `d = f2 - t1/t2`. Rearranging Equation 3 yields: + * @code + * (R/t2)**2 + R*(2*d/t2 - 1/f1) + d**2 - f2**2 = 0 + * @endcode + * which is solved by `solveQuadraticEq()`. Under `fixAMMv1_3` the result is + * rounded upward so the depositor pays more, preserving the pool invariant. */ STAmount ammAssetIn( @@ -117,9 +141,15 @@ ammAssetIn( return multiply(asset1Balance, frac, Number::RoundingMode::Upward); } -/* Equation 7: - * t = T * (c - sqrt(c**2 - 4*R))/2 - * where R = b/B, c = R*fee + 2 - fee +/** LP tokens to burn for a single-asset withdrawal (Equation 7). + * + * Implements the closed-form formula: + * @code + * t = T * (c - sqrt(c**2 - 4*R)) / 2 + * @endcode + * where `R = b/B` (withdrawal fraction of pool balance), `c = R*fee + 2 - fee`, + * and `fee = tfee / 100000`. Under `fixAMMv1_3` the final multiplication is + * rounded upward so more tokens must be burned, preserving the pool invariant. */ STAmount lpTokensIn( @@ -142,15 +172,16 @@ lpTokensIn( return multiply(lptAMMBalance, frac, Number::RoundingMode::Upward); } -/* Equation 8 solves equation 7 for b: - * c - 2*t/T = sqrt(c**2 - 4*R) => - * c**2 - 4*c*t/T + 4*t**2/T**2 = c**2 - 4*R => - * -4*c*t/T + 4*t**2/T**2 = -4*R => - * -c*t/T + t**2/T**2 = -R -=> - * substitute c = R*f + 2 - f => - * -(t/T)*(R*f + 2 - f) + (t/T)**2 = -R, let t1 = t/T => - * -t1*R*f -2*t1 +t1*f +t1**2 = -R => - * R = (t1**2 + t1*(f - 2)) / (t1*f - 1) +/** Asset returned for a given LP token burn (Equation 8). + * + * Solves Equation 7 for the withdrawal `b` given tokens `t`. Let + * `t1 = t/T` and `f = tfee / 100000`. Substituting `c = R*f + 2 - f` and + * rearranging gives the direct rational formula: + * @code + * R = (t1**2 + t1*(f - 2)) / (t1*f - 1) + * @endcode + * where `R = b/B`. Under `fixAMMv1_3` the final multiplication is rounded + * downward so the withdrawer receives less, preserving the pool invariant. */ STAmount ammAssetOut( @@ -172,6 +203,7 @@ ammAssetOut( return multiply(assetBalance, frac, Number::RoundingMode::Downward); } +/** Return the square of @p n. */ Number square(Number const& n) { @@ -199,7 +231,6 @@ adjustAmountsByLPTokens( std::uint16_t tfee, IsDeposit isDeposit) { - // AMMv1_3 amendment adjusts tokens and amounts in deposit/withdraw if (isFeatureEnabled(fixAMMv1_3)) return std::make_tuple(amount, amount2, lpTokens); @@ -272,7 +303,17 @@ solveQuadraticEq(Number const& a, Number const& b, Number const& c) return (-b + root2(b * b - 4 * a * c)) / (2 * a); } -// Minimize takerGets or takerPays +/** Smallest positive root of `a*x**2 + b*x + c = 0`, used to minimize + * takerGets or takerPays in offer generation. + * + * Uses the numerically stable "citardauq" formula (Blinn 2006): when `b > 0` + * it computes `2c / (-b - sqrt(d))` instead of the standard + * `(-b + sqrt(d)) / 2a`, avoiding catastrophic cancellation when the two + * terms under subtraction are nearly equal. + * + * @return The smallest positive root, or `std::nullopt` when the discriminant + * is negative (no real solution exists). + */ std::optional solveQuadraticEqSmallest(Number const& a, Number const& b, Number const& c) { @@ -289,6 +330,13 @@ solveQuadraticEqSmallest(Number const& a, Number const& b, Number const& c) return (2 * c) / (-b + root2(d)); } +/** Multiply @p amount by @p frac under the specified rounding mode @p rm. + * + * Installs @p rm for the duration of both the `Number` multiplication and the + * subsequent `toSTAmount()` conversion, so that rounding is applied + * consistently at the final step — not accumulated through intermediate + * operations. + */ STAmount multiply(STAmount const& amount, Number const& frac, Number::RoundingMode rm) { @@ -405,6 +453,14 @@ adjustAssetOutByTokens( return {tokensAdj, std::min(amount, assetAdj)}; } +/** Recalculate the pool fraction after LP token adjustment. + * + * When `fixAMMv1_3` is active, the adjusted token count (post-precision-loss + * correction) may differ from the originally requested count, so the fraction + * `tokens / lptAMMBalance` is recomputed from the adjusted value. Under + * earlier amendments the original @p frac is returned unchanged because + * `adjustLPTokens()` has not yet been applied. + */ Number adjustFracByTokens( Rules const& rules, @@ -502,6 +558,17 @@ ammHolds( return std::make_tuple(amount1, amount2, ammSle[sfLPTokenBalance]); } +/** LP token balance for a given account, checking only the LP token trustline. + * + * Intentionally does not delegate to `accountHolds()`. Under the + * `fixFrozenLPTokenTransfer` amendment, `accountHolds()` also checks whether + * the AMM's underlying pool assets are frozen, which would incorrectly block + * LP token transfers. This function only checks whether the LP token + * trustline itself is frozen, which is the correct policy for balance queries. + * + * Trust-line orientation: if `lpAccount > ammAccount` the raw `sfBalance` is + * stored from the AMM's perspective and must be negated before returning. + */ STAmount ammLPHolds( ReadView const& view, @@ -511,10 +578,6 @@ ammLPHolds( AccountID const& lpAccount, beast::Journal const j) { - // This function looks similar to `accountHolds`. However, it only checks if - // a LPToken holder has enough balance. On the other hand, `accountHolds` - // checks if the underlying assets of LPToken are frozen with the - // fixFrozenLPTokenTransfer amendment auto const currency = ammLPTCurrency(asset1, asset2); STAmount amount; @@ -562,6 +625,17 @@ ammLPHolds( return ammLPHolds(view, ammSle[sfAsset], ammSle[sfAsset2], ammSle[sfAccount], lpAccount, j); } +/** Effective trading fee for @p account on the given AMM. + * + * Returns `sfDiscountedFee` if the AMM's auction slot is unexpired and + * @p account is either the slot owner or one of its authorized accounts; + * otherwise returns the global `sfTradingFee`. Expiration is compared + * against `parentCloseTime` in seconds (the slot stores + * `parentCloseTime + TOTAL_TIME_SLOT_SECS` at creation time, i.e. 24 hours). + * + * @note When `fixInnerObjTemplate` is active an `sfAuctionSlot` object is + * always present; the assert verifies this invariant in debug builds. + */ std::uint16_t getTradingFee(ReadView const& view, SLE const& ammSle, AccountID const& account) { @@ -592,10 +666,16 @@ getTradingFee(ReadView const& view, SLE const& ammSle, AccountID const& account) return ammSle[sfTradingFee]; } +/** Raw pool-asset balance of the AMM account, bypassing balance hooks. + * + * Unlike `accountHolds()`, this function does not invoke `balanceHookIOU` or + * `balanceHookMPT` so the result is unaffected by `PaymentSandbox` deferred- + * credit accounting. Used when the AMM needs its own unmodified balance for + * mathematical calculations, not for payment routing. + */ STAmount ammAccountHolds(ReadView const& view, AccountID const& ammAccountID, Asset const& asset) { - // Get the actual AMM balance without factoring in the balance hook return asset.visit( [&](MPTIssue const& issue) { if (auto const sle = view.read(keylet::mptoken(issue, ammAccountID)); @@ -624,6 +704,17 @@ ammAccountHolds(ReadView const& view, AccountID const& ammAccountID, Asset const }); } +/** Delete zero-balance IOU trustlines from the AMM account's owner directory. + * + * Walks the owner directory via `cleanupOnAccountDelete`, skipping `ltAMM` + * and `ltMPTOKEN` entries. Every `ltRIPPLE_STATE` (IOU trustline) is + * deleted via `deleteAMMTrustLine()`; a non-zero balance is a protocol + * invariant violation and returns `tecINTERNAL` (annotated `LCOV_EXCL`). + * + * @param maxTrustlinesToDelete Maximum number of trustlines to remove in a + * single call. Returns `tecINCOMPLETE` if the directory still has + * entries after the limit is reached, enabling multi-transaction deletion. + */ static TER deleteAMMTrustLines( Sandbox& sb, @@ -664,6 +755,15 @@ deleteAMMTrustLines( maxTrustlinesToDelete); } +/** Delete MPToken objects held by the AMM account. + * + * Must be called only after `deleteAMMTrustLines()` has succeeded; if any + * `ltRIPPLE_STATE` entry remains, the function returns `tecINTERNAL` because + * trustlines must be fully cleared before MPTokens are removed. + * The directory is expected to contain at most three entries: two MPToken + * objects (one per pool-side MPT) plus the AMM SLE itself, so a fixed limit + * of 3 is sufficient. + */ static TER deleteAMMMPTokens(Sandbox& sb, AccountID const& ammAccountID, beast::Journal j) { @@ -709,6 +809,22 @@ deleteAMMMPTokens(Sandbox& sb, AccountID const& ammAccountID, beast::Journal j) 3); // At most two MPToken plus AMM object } +/** Fully delete the AMM account, its SLE, and all associated ledger objects. + * + * Deletion order is significant: + * 1. `deleteAMMTrustLines()` — removes IOU trustlines (up to + * `kMAX_DELETABLE_AMM_TRUST_LINES`); returns `tecINCOMPLETE` if more + * remain, allowing a follow-up transaction to continue. + * 2. `deleteAMMMPTokens()` — removes MPToken objects; only runs after all + * trustlines are gone so that the AMM can be re-created via deposit if + * step 1 returned `tecINCOMPLETE`. + * 3. Owner-directory link removal, `emptyDirDelete`, and erase of both the + * AMM SLE and AMM root `AccountRoot` SLE. + * + * @return `tesSUCCESS` on full deletion, `tecINCOMPLETE` if trustlines + * remain, or `tecINTERNAL` for unexpected ledger inconsistencies + * (annotated `LCOV_EXCL` — unreachable under correct business logic). + */ TER deleteAMMAccount(Sandbox& sb, Asset const& asset, Asset const& asset2, beast::Journal j) { @@ -736,9 +852,6 @@ deleteAMMAccount(Sandbox& sb, Asset const& asset, Asset const& asset2, beast::Jo !isTesSuccess(ter)) return ter; - // Delete AMM's MPTokens only if all trustlines are deleted. If trustlines - // are not deleted then AMM can be re-created with Deposit and - // AMM's MPToken(s) must exist. if (auto const ter = deleteAMMMPTokens(sb, ammAccountID, j); !isTesSuccess(ter)) return ter; @@ -765,6 +878,22 @@ deleteAMMAccount(Sandbox& sb, Asset const& asset, Asset const& asset2, beast::Jo return tesSUCCESS; } +/** Initialize the fee vote slot and auction slot on a new or re-created AMM. + * + * Called both on `AMMCreate` and on `AMMDeposit` when the AMM was empty + * (all liquidity withdrawn). Writes a single vote entry with + * `kVOTE_WEIGHT_SCALE_FACTOR` (100% weight) for @p account, then + * sets up the auction slot with: + * - expiration = `parentCloseTime + kTOTAL_TIME_SLOT_SECS` (24 hours) + * - `sfPrice` = 0 (the creator receives the slot for free) + * - `sfDiscountedFee` = `tfee / kAUCTION_SLOT_DISCOUNTED_FEE_FRACTION` (1/10 + * of the full fee) + * + * Both fee fields are removed via `makeFieldAbsent()` when the computed value + * is zero, preserving canonical absent-field serialization. Under + * `fixCleanup3_2_0`, any stale `sfAuthAccounts` from a previous slot owner + * is also cleared. + */ void initializeFeeAuctionVote( ApplyView& view, @@ -774,7 +903,6 @@ initializeFeeAuctionVote( std::uint16_t tfee) { auto const& rules = view.rules(); - // AMM creator gets the voting slot. STArray voteSlots; STObject voteEntry = STObject::makeInnerObject(sfVoteEntry); if (tfee != 0) @@ -783,9 +911,8 @@ initializeFeeAuctionVote( voteEntry.setAccountID(sfAccount, account); voteSlots.pushBack(voteEntry); ammSle->setFieldArray(sfVoteSlots, voteSlots); - // AMM creator gets the auction slot for free. - // AuctionSlot is created on AMMCreate and updated on AMMDeposit - // when AMM is in an empty state + // Under fixInnerObjTemplate the slot object must be explicitly created if + // absent (first AMMCreate path); on AMMDeposit re-init it already exists. if (rules.enabled(fixInnerObjTemplate) && !ammSle->isFieldPresent(sfAuctionSlot)) { STObject auctionSlot = STObject::makeInnerObject(sfAuctionSlot); @@ -793,14 +920,12 @@ initializeFeeAuctionVote( } STObject& auctionSlot = ammSle->peekFieldObject(sfAuctionSlot); auctionSlot.setAccountID(sfAccount, account); - // current + sec in 24h auto const expiration = std::chrono::duration_cast( view.header().parentCloseTime.time_since_epoch()) .count() + kTOTAL_TIME_SLOT_SECS; auctionSlot.setFieldU32(sfExpiration, expiration); auctionSlot.setFieldAmount(sfPrice, STAmount{lptAsset, 0}); - // Set the fee if (tfee != 0) { ammSle->setFieldU16(sfTradingFee, tfee); @@ -822,33 +947,34 @@ initializeFeeAuctionVote( auctionSlot.makeFieldAbsent(sfAuthAccounts); } +/** Determine whether @p lpAccount is the sole remaining liquidity provider. + * + * Walks up to 10 pages of the AMM account's owner directory (sufficient for + * at most four objects: 1 AMM SLE + 1 LPToken trustline + ≤2 IOU/MPT + * pool-asset entries). Counters track: + * - `nLPTokenTrustLines` — must be exactly 1 for `lpAccount`; any other + * LPToken trustline implies a second LP and returns `false` immediately. + * - `nIOUTrustLines` — IOU pool-asset trustlines (not LPToken); ≤2 each. + * - `nMPT` — MPToken objects for pool-side MPTs; ≤2. + * - `hasAMM` — exactly one `ltAMM` SLE must be present. + * + * Final check: exactly 1 LPToken trustline and between 1–2 pool-asset + * entries (IOU + MPT combined); otherwise `tecINTERNAL`. + * + * @return `true` if @p lpAccount is the only LP, `false` if other LPs exist, + * or `Unexpected(tecINTERNAL)` for any unexpected directory state. + */ Expected isOnlyLiquidityProvider(ReadView const& view, Issue const& ammIssue, AccountID const& lpAccount) { - // Liquidity Provider (LP) must have one LPToken trustline std::uint8_t nLPTokenTrustLines = 0; - // AMM account has at most two IOU (pool tokens, not LPToken) trustlines. - // One or both trustlines could be to the LP if LP is the issuer, - // or a different account if LP is not an issuer. For instance, - // if AMM has two tokens USD and EUR and LP is not the issuer of the tokens - // then the trustlines are between AMM account and the issuer. - // There is one LPToken trustline for each LP. Only remaining LP has - // exactly one LPToken trustlines and at most two IOU trustline for each - // pool token. One or both tokens could be MPT. std::uint8_t nIOUTrustLines = 0; - // There are at most two MPT objects, one for each side of the pool. std::uint8_t nMPT = 0; - // There is only one AMM object bool hasAMM = false; - // AMM LP has at most three trustlines, at most two MPTs, and only one - // AMM object must exist. If there are more than four objects then - // it's either an error or there are more than one LP. Ten pages should - // be sufficient to include four objects. std::uint8_t limit = 10; auto const root = keylet::ownerDir(ammIssue.account); auto currentIndex = root; - // Iterate over AMM owner directory objects. while (limit-- >= 1) { auto const ownerDir = view.read(currentIndex); @@ -882,28 +1008,24 @@ isOnlyLiquidityProvider(ReadView const& view, Issue const& ammIssue, AccountID c auto const isLPTokenTrustline = lowLimit.asset() == ammIssue || highLimit.asset() == ammIssue; - // Liquidity Provider trustline if (isLPTrustline) { - // LPToken trustline if (isLPTokenTrustline) { // LP has exactly one LPToken trustline if (++nLPTokenTrustLines > 1) return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE } - // AMM account has at most two IOU trustlines else if (++nIOUTrustLines > 2) { return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE } } - // Another Liquidity Provider LPToken trustline + // Another LP's LPToken trustline: more than one LP exists. else if (isLPTokenTrustline) { return false; } - // AMM account has at most two IOU trustlines else if (++nIOUTrustLines > 2) { return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE @@ -922,6 +1044,22 @@ isOnlyLiquidityProvider(ReadView const& view, Issue const& ammIssue, AccountID c return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE } +/** Reconcile the AMM's stored `sfLPTokenBalance` with the last LP's actual + * trustline balance. + * + * Due to the 16-significant-digit limit of `STAmount`, the AMM's running + * `sfLPTokenBalance` may differ from the LP's trustline balance by a small + * rounding error. This function: + * 1. Confirms @p account is the only remaining LP via `isOnlyLiquidityProvider()`. + * 2. If so, checks that the discrepancy is within 0.1% (tolerance `1e-3`). + * 3. If within tolerance, updates `sfLPTokenBalance` to match @p lpTokens + * so the final withdrawal leaves the AMM in a fully consistent state. + * + * @return `true` when the balance was reconciled or no adjustment was needed + * (other LPs exist), `Unexpected(tecAMM_INVALID_TOKENS)` if the + * discrepancy exceeds tolerance, or `Unexpected(tecINTERNAL)` on an + * unexpected directory error. + */ Expected verifyAndAdjustLPTokenBalance( Sandbox& sb, diff --git a/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp b/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp index 40a41d236a..12527102f3 100644 --- a/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp +++ b/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp @@ -1,3 +1,11 @@ +/** @file + * Implements AccountRoot ledger-object helper functions. + * + * Covers freeze-state queries, spendable XRP balance, owner-count + * bookkeeping, transfer fees, destination-tag enforcement, and the + * creation and detection of pseudo-accounts (AMM, Vault, LoanBroker). + * Almost every transaction processor depends on at least one function here. + */ #include #include @@ -41,12 +49,20 @@ isGlobalFrozen(ReadView const& view, AccountID const& issuer) return false; } -// An owner count cannot be negative. If adjustment would cause a negative -// owner count, clamp the owner count at 0. Similarly for overflow. This -// adjustment allows the ownerCount to be adjusted up or down in multiple steps. -// If id != std::nullopt, then do error reporting. -// -// Returns adjusted owner count. +/** Clamp a signed owner-count adjustment to the valid `[0, UINT32_MAX]` range. + * + * Uses well-defined unsigned wrap to detect over/underflow: after adding a + * positive @p adjustment, `adjusted < current` means overflow; after adding a + * negative @p adjustment, `adjusted > current` means underflow. Both are + * clamped and a fatal-level log entry is emitted when @p id is provided. + * + * @param current Current owner count stored in the SLE. + * @param adjustment Signed delta to apply; may be zero. + * @param id Account being adjusted; when provided, enables error + * logging. Pass `std::nullopt` for speculative (reserve-only) calls. + * @param j Journal used for fatal-level diagnostics. + * @return Clamped owner count after applying @p adjustment. + */ static std::uint32_t confineOwnerCount( std::uint32_t current, @@ -83,6 +99,17 @@ confineOwnerCount( return adjusted; } +/** @copydoc xrpLiquid(ReadView const&, AccountID const&, std::int32_t, beast::Journal) + * + * @note Both the balance read and the owner-count read are routed through + * virtual hook methods (`balanceHookIOU`, `ownerCountHook`) on the view. + * In normal transaction processing these are identity functions, but when + * called from inside a `PaymentSandbox` they return conservative values + * that account for credits and owner-count changes made earlier in the + * same payment path — without any branching in this function. + * Pseudo-accounts bypass the reserve calculation entirely because + * protocol-controlled accounts are not subject to reserve requirements. + */ XRPAmount xrpLiquid(ReadView const& view, AccountID const& id, std::int32_t ownerCountAdj, beast::Journal j) { @@ -90,11 +117,10 @@ xrpLiquid(ReadView const& view, AccountID const& id, std::int32_t ownerCountAdj, if (sle == nullptr) return beast::kZERO; - // Return balance minus reserve std::uint32_t const ownerCount = confineOwnerCount(view.ownerCountHook(id, sle->getFieldU32(sfOwnerCount)), ownerCountAdj); - // Pseudo-accounts have no reserve requirement + // Pseudo-accounts have no reserve requirement; all other accounts clamp at 0. auto const reserve = isPseudoAccount(sle) ? XRPAmount{0} : view.fees().accountReserve(ownerCount); @@ -124,6 +150,15 @@ transferRate(ReadView const& view, AccountID const& issuer) return kPARITY_RATE; } +/** @copydoc adjustOwnerCount(ApplyView&, std::shared_ptr const&, std::int32_t, beast::Journal) + * + * @note `view.adjustOwnerCountHook()` is called before writing the new count + * to the SLE. `PaymentSandbox` overrides this hook to record the + * high-water-mark owner count so that subsequent `ownerCountHook` reads + * within the same payment use the most conservative (highest) count seen, + * preventing a transient mid-payment count reduction from bypassing + * reserve checks. + */ void adjustOwnerCount( ApplyView& view, @@ -142,10 +177,22 @@ adjustOwnerCount( view.update(sle); } +/** @copydoc pseudoAccountAddress(ReadView const&, uint256 const&) + * + * Derives a candidate `AccountID` by hashing + * `(attempt_index, ledger.parentHash, pseudoOwnerKey)` through `sha512Half` + * then `ripesha_hasher`, retrying up to `kMAX_ACCOUNT_ATTEMPTS` times until + * an address with no existing `AccountRoot` is found. Incorporating + * `parentHash` prevents precomputation of collisions. Returns `beast::kZERO` + * on exhaustion; `createPseudoAccount` propagates that as `tecDUPLICATE`. + * + * @note `kMAX_ACCOUNT_ATTEMPTS` is consensus-critical and must not be changed + * without an amendment, as it determines the pseudo-account address space. + */ AccountID pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey) { - // This number must not be changed without an amendment + // Must not be changed without an amendment — alters the address space. constexpr std::uint16_t kMAX_ACCOUNT_ATTEMPTS = 256; for (std::uint16_t i = 0; i < kMAX_ACCOUNT_ATTEMPTS; ++i) { @@ -159,12 +206,20 @@ pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey) return beast::kZERO; } -// Pseudo-account designator fields MUST be maintained by including the -// SField::sMD_PseudoAccount flag in the SField definition. (Don't forget to -// "| SField::sMD_Default"!) The fields do NOT need to be amendment-gated, -// since a non-active amendment will not set any field, by definition. -// Specific properties of a pseudo-account are NOT checked here, that's what -// InvariantCheck is for. +/** @copydoc getPseudoAccountFields() + * + * Scans the `ltACCOUNT_ROOT` `SOTemplate` from `LedgerFormats::getInstance()` + * at first call and caches the result in a `static` local. A field qualifies + * if `shouldMeta(SField::kSMD_PSEUDO_ACCOUNT)` returns `true`. + * + * @note Fields do NOT need to be amendment-gated here: a non-active amendment + * will never set the field, so the list is always accurate. Do NOT + * check pseudo-account invariants here — that is `InvariantCheck`'s job. + * @note Every pseudo-account designator `SField` must carry the + * `SField::sMD_PseudoAccount` metadata flag (combined with + * `SField::sMD_Default`) in its definition, or it will be silently + * omitted from this list. + */ [[nodiscard]] std::vector const& getPseudoAccountFields() { @@ -191,6 +246,13 @@ getPseudoAccountFields() return kPSEUDO_FIELDS; } +/** @copydoc isPseudoAccount(std::shared_ptr, std::set const&) + * + * @note The null-pointer and `ltACCOUNT_ROOT` type guards are intentionally + * defensive: callers may already guarantee them, but keeping them here + * ensures the semantics of a `true` return value are always unambiguous + * at negligible cost. + */ [[nodiscard]] bool isPseudoAccount( std::shared_ptr sleAcct, @@ -198,8 +260,6 @@ isPseudoAccount( { auto const& fields = getPseudoAccountFields(); - // Intentionally use defensive coding here because it's cheap and makes the - // semantics of true return value clean. return sleAcct && sleAcct->getType() == ltACCOUNT_ROOT && std::count_if( fields.begin(), fields.end(), [&sleAcct, &pseudoFieldFilter](SField const* sf) -> bool { @@ -208,6 +268,26 @@ isPseudoAccount( }) > 0; } +/** @copydoc createPseudoAccount(ApplyView&, uint256 const&, SField const&) + * + * Asserts (in debug builds) that @p ownerField carries the + * `SField::sMD_PseudoAccount` flag; misuse is caught at development time, not + * at runtime. The caller is responsible for performing amendment checks + * before invoking this function. + * + * The new `AccountRoot` is assembled with: + * - Zero balance and — when `featureSingleAssetVault` or + * `featureLendingProtocol` is enabled — sequence number `0`, making + * pseudo-accounts visually distinguishable and preventing transaction + * submission even if `lsfDisableMaster` were somehow bypassed. + * - `lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth`: no key-based + * transactions; trust-line flows enabled for AMM/vault assets; unsolicited + * incoming payments blocked. + * - The @p ownerField back-link stores @p pseudoOwnerKey in the new SLE. + * + * @return The new SLE on success, or `tecDUPLICATE` if no collision-free + * address could be found within the attempt limit. + */ Expected, TER> createPseudoAccount(ApplyView& view, uint256 const& pseudoOwnerKey, SField const& ownerField) { @@ -224,25 +304,17 @@ createPseudoAccount(ApplyView& view, uint256 const& pseudoOwnerKey, SField const if (accountId == beast::kZERO) return Unexpected(tecDUPLICATE); - // Create pseudo-account. auto account = std::make_shared(keylet::account(accountId)); account->setAccountID(sfAccount, accountId); account->setFieldAmount(sfBalance, STAmount{}); - // Pseudo-accounts can't submit transactions, so set the sequence number - // to 0 to make them easier to spot and verify, and add an extra level - // of protection. std::uint32_t const seqno = // view.rules().enabled(featureSingleAssetVault) || // view.rules().enabled(featureLendingProtocol) // ? 0 // : view.seq(); account->setFieldU32(sfSequence, seqno); - // Ignore reserves requirement, disable the master key, allow default - // rippling, and enable deposit authorization to prevent payments into - // pseudo-account. account->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); - // Link the pseudo-account with its owner object. account->setFieldH256(ownerField, pseudoOwnerKey); view.insert(account); @@ -250,16 +322,21 @@ createPseudoAccount(ApplyView& view, uint256 const& pseudoOwnerKey, SField const return account; } +/** @copydoc checkDestinationAndTag(SLE::const_ref, bool) + * + * @note Destination tags are opaque to the protocol; their content is + * meaningful only to the receiving account (e.g. exchange user IDs). + * The ledger enforces the presence requirement (`lsfRequireDestTag`) but + * never interprets the tag value itself. + */ [[nodiscard]] TER checkDestinationAndTag(SLE::const_ref toSle, bool hasDestinationTag) { if (toSle == nullptr) return tecNO_DST; - // The tag is basically account-specific information we don't - // understand, but we can require someone to fill it in. if (toSle->isFlag(lsfRequireDestTag) && !hasDestinationTag) - return tecDST_TAG_NEEDED; // Cannot send without a tag + return tecDST_TAG_NEEDED; return tesSUCCESS; } diff --git a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp index 05e45a404c..8583540902 100644 --- a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp +++ b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp @@ -34,6 +34,19 @@ namespace xrpl { namespace credentials { +/** Test whether a credential SLE has passed its expiration time. + * + * Reads `sfExpiration` from @p sleCredential, defaulting to + * `std::numeric_limits::max()` when the field is absent, so + * credentials with no expiration field never expire. The comparison is + * against `closed.time_since_epoch().count()`, matching the NetClock + * epoch used by `view.header().parentCloseTime`. + * + * @param sleCredential The credential SLE to inspect. + * @param closed The parent ledger's close time (deterministic across + * all validators; do not pass wall-clock time). + * @return `true` if the credential has expired, `false` otherwise. + */ bool checkExpired(SLE const& sleCredential, NetClock::time_point const& closed) { @@ -43,6 +56,28 @@ checkExpired(SLE const& sleCredential, NetClock::time_point const& closed) return now > exp; } +/** Delete all expired credentials from a vector of credential keys. + * + * Iterates @p arr and, for each key whose SLE exists and has expired (per + * `checkExpired`), calls `deleteSLE` to remove the SLE from the ledger and + * both owner directories. Deletion happens unconditionally — even if the + * enclosing transaction ultimately fails — acting as passive garbage + * collection for stale credential objects. + * + * Existence and ownership of each credential are assumed to have been + * verified in preclaim; only expiration is re-checked here. + * + * Under `fixSecurity3_1_3`, any `deleteSLE` failure is propagated as an + * error; prior to that amendment, deletion errors are silently ignored and + * the loop continues. + * + * @param view Mutable ledger view; credential SLEs are erased through it. + * @param arr Vector of credential keylet hashes to inspect. + * @param j Journal for trace/error logging. + * @return `Expected`: holds `true` if any expired credential was + * found and deleted, `false` if none were expired, or an unexpected `TER` + * error if deletion failed under `fixSecurity3_1_3`. + */ [[nodiscard]] static Expected removeExpired(ApplyView& view, STVector256 const& arr, beast::Journal const j) @@ -70,6 +105,31 @@ removeExpired(ApplyView& view, STVector256 const& arr, beast::Journal const j) return foundExpired; } +/** Remove a credential SLE and its entries from both owner directories. + * + * A credential is indexed in two owner directories — the issuer's and the + * subject's — using the `sfIssuerNode` and `sfSubjectNode` page offsets + * stored in the SLE. Both entries must be removed and owner-count reserves + * adjusted correctly. + * + * Reserve accounting follows credential acceptance state: + * - Before acceptance (`lsfAccepted` unset): only the issuer holds the + * reserve, so `adjustOwnerCount(-1)` is applied to the issuer only. + * - After acceptance: the subject holds the reserve, so adjustment shifts + * to the subject side. + * - When issuer and subject are the same account, only one directory + * removal is performed. + * + * Paths that indicate ledger corruption (missing account SLE or failed + * `dirRemove`) are wrapped in `LCOV_EXCL_START`/`STOP` and are considered + * unreachable under normal operation. + * + * @param view Mutable ledger view through which the SLE is erased. + * @param sleCredential The credential SLE to delete; must not be null. + * @param j Journal for fatal-level error logging. + * @return `tesSUCCESS` on success, `tecNO_ENTRY` if `sleCredential` is null, + * `tecINTERNAL` or `tefBAD_LEDGER` on internal directory inconsistency. + */ TER deleteSLE(ApplyView& view, std::shared_ptr const& sleCredential, beast::Journal j) { @@ -87,7 +147,6 @@ deleteSLE(ApplyView& view, std::shared_ptr const& sleCredential, beast::Jou // LCOV_EXCL_STOP } - // Remove object from owner directory std::uint64_t const page = sleCredential->getFieldU64(node); if (!view.dirRemove(keylet::ownerDir(account), page, sleCredential->key(), false)) { @@ -118,12 +177,26 @@ deleteSLE(ApplyView& view, std::shared_ptr const& sleCredential, beast::Jou return err; } - // Remove object from ledger view.erase(sleCredential); return tesSUCCESS; } +/** Validate the `sfCredentialIDs` field of a transaction at preflight time. + * + * Enforces three constraints on the `STVector256` of credential object hashes: + * - Non-empty + * - At most `kMAX_CREDENTIALS_ARRAY_SIZE` (8) entries + * - No duplicate hashes + * + * If `sfCredentialIDs` is absent the function returns `tesSUCCESS` immediately, + * as credentials are optional for most transaction types. + * + * @param tx The transaction under preflight validation. + * @param j Journal for trace-level malformed-transaction logging. + * @return `tesSUCCESS` if the field is absent or valid; `temMALFORMED` if the + * array is empty, too large, or contains duplicates. + */ NotTEC checkFields(STTx const& tx, beast::Journal j) { @@ -152,6 +225,27 @@ checkFields(STTx const& tx, beast::Journal j) return tesSUCCESS; } +/** Verify that all credentials in a transaction exist, belong to the sender, + * and have been accepted — for use in preclaim only. + * + * Checks each credential ID in `sfCredentialIDs` against the ledger: + * the SLE must exist, its `sfSubject` must equal @p src, and `lsfAccepted` + * must be set. Expiration is deliberately **not** checked here; expired + * credentials are deleted in doApply by `verifyDepositPreauth` or + * `verifyValidDomain`. + * + * @note Call `verifyDepositPreauth` in doApply when this function succeeds in + * preclaim — that pairing ensures expired credentials are garbage-collected + * even for transactions that ultimately fail. + * + * @param tx The transaction whose `sfCredentialIDs` field is inspected. + * @param view Read-only ledger view for SLE lookups. + * @param src The account that must own every listed credential. + * @param j Journal for trace-level logging. + * @return `tesSUCCESS` if `sfCredentialIDs` is absent or all credentials are + * valid; `tecBAD_CREDENTIALS` if any credential is missing, belongs to a + * different account, or has not been accepted. + */ TER valid(STTx const& tx, ReadView const& view, AccountID const& src, beast::Journal j) { @@ -179,13 +273,36 @@ valid(STTx const& tx, ReadView const& view, AccountID const& src, beast::Journal JLOG(j.trace()) << "Credential isn't accepted. Cred: " << h; return tecBAD_CREDENTIALS; } - - // Expiration checks are in doApply } return tesSUCCESS; } +/** Check whether @p subject holds a live, accepted credential for a domain — + * for use in preclaim only. + * + * Reads the `PermissionedDomain` SLE, iterates its `sfAcceptedCredentials` + * array, and looks up each corresponding credential SLE for @p subject. + * A credential qualifies when it exists, has not expired, and has `lsfAccepted` + * set. If at least one qualifying credential is found, `tesSUCCESS` is + * returned immediately. + * + * Expired credentials cannot be deleted from a `ReadView`, so this function + * tracks them and returns `tecEXPIRED` when all encountered credentials are + * expired (allowing the caller to suppress that specific code and proceed to + * doApply where `verifyValidDomain` will physically remove them). + * + * @note If this returns `tecEXPIRED` in preclaim, the caller must invoke + * `verifyValidDomain` in doApply to garbage-collect the expired objects, + * even if the transaction ultimately fails. + * + * @param view Read-only ledger view. + * @param domainID Key of the `PermissionedDomain` SLE to check against. + * @param subject Account that must hold a qualifying credential. + * @return `tesSUCCESS` if a live accepted credential exists; `tecEXPIRED` if + * only expired credentials were found; `tecNO_AUTH` if no matching + * credential exists; `tecOBJECT_NOT_FOUND` if the domain does not exist. + */ TER validDomain(ReadView const& view, uint256 domainID, AccountID const& subject) { @@ -203,11 +320,6 @@ validDomain(ReadView const& view, uint256 domainID, AccountID const& subject) auto const keyletCredential = keylet::credential(subject, issuer, makeSlice(type)); auto const sleCredential = view.read(keyletCredential); - // We cannot delete expired credentials, that would require ApplyView& - // However we can check if credentials are expired. Expected transaction - // flow is to use `validDomain` in preclaim, converting tecEXPIRED to - // tesSUCCESS, then proceed to call `verifyValidDomain` in doApply. This - // allows expired credentials to be deleted by any transaction. if (sleCredential) { if (checkExpired(*sleCredential, closeTime)) @@ -227,6 +339,29 @@ validDomain(ReadView const& view, uint256 domainID, AccountID const& subject) return foundExpired ? tecEXPIRED : tecNO_AUTH; } +/** Check whether a set of credential IDs matches a `DepositPreauth` entry for + * the destination account. + * + * Builds a sorted `std::set>` of + * `(issuer, credentialType)` pairs from the submitted credential IDs, then + * tests for the existence of `keylet::depositPreauth(dst, sorted)` in the + * ledger. The sorted representation matches the canonical key used at + * `DepositPreauth` creation time. + * + * The `lifeExtender` vector keeps every `shared_ptr` alive for the + * duration of the function. `Slice` members in @p sorted are non-owning views + * into SLE storage; releasing the shared pointers early would dangle them. + * + * @note Credential existence has already been confirmed in preclaim; a missing + * SLE here indicates an internal consistency error and returns `tefINTERNAL`. + * + * @param view Read-only ledger view used for SLE and keylet lookups. + * @param credIDs The `sfCredentialIDs` vector from the transaction. + * @param dst The destination account whose `DepositPreauth` is checked. + * @return `tesSUCCESS` if a matching `DepositPreauth` object exists; + * `tecNO_PERMISSION` if none exists; `tefINTERNAL` if a credential SLE + * is unexpectedly missing or a duplicate pair is encountered. + */ TER authorizedDepositPreauth(ReadView const& view, STVector256 const& credIDs, AccountID const& dst) { @@ -251,6 +386,16 @@ authorizedDepositPreauth(ReadView const& view, STVector256 const& credIDs, Accou return tesSUCCESS; } +/** Build a sorted `(issuer, credentialType)` set from a credentials array. + * + * Produces the canonical representation used to key `DepositPreauth` objects. + * Each element of @p credentials must carry `sfIssuer` and `sfCredentialType`. + * + * @param credentials The `STArray` of credential pairs, as stored in a + * `DepositPreauth` or `PermissionedDomainSet` transaction. + * @return A sorted set of `(AccountID, Slice)` pairs; returns an empty set if + * any duplicate `(issuer, credentialType)` pair is detected. + */ std::set> makeSorted(STArray const& credentials) { @@ -264,6 +409,26 @@ makeSorted(STArray const& credentials) return out; } +/** Validate a credential array in `DepositPreauth` or `PermissionedDomainSet` + * transactions at preflight time. + * + * Credentials in these transactions are `(issuer, credentialType)` pairs + * rather than object hashes. This function enforces: + * - Non-empty array and size at most @p maxSize (returns `temARRAY_EMPTY` / + * `temARRAY_TOO_LARGE` respectively). + * - Valid issuer `AccountID` for each element. + * - `sfCredentialType` length in `[1, kMAX_CREDENTIAL_TYPE_LENGTH]` bytes. + * - No logical duplicates, detected via `sha512Half(issuer, credentialType)` + * to catch pairs that are semantically identical regardless of encoding. + * + * @param credentials The `STArray` of credential pairs to validate. + * @param maxSize Maximum permitted array length (caller-supplied per + * transaction type). + * @param j Journal for trace-level malformed-transaction logging. + * @return `tesSUCCESS` if all entries are valid; `temARRAY_EMPTY`, + * `temARRAY_TOO_LARGE`, `temINVALID_ACCOUNT_ID`, or `temMALFORMED` + * on the first constraint violation found. + */ NotTEC checkArray(STArray const& credentials, unsigned maxSize, beast::Journal j) { @@ -310,6 +475,28 @@ checkArray(STArray const& credentials, unsigned maxSize, beast::Journal j) } // namespace credentials +/** Verify domain-credential authorization and delete any expired credentials. + * + * The doApply counterpart to `credentials::validDomain`. Collects all + * credential SLEs for @p account that match the `sfAcceptedCredentials` list + * of the `PermissionedDomain` at @p domainID, calls `credentials::removeExpired` + * to physically delete any that have passed their expiration time, then + * re-checks whether at least one live, accepted credential remains. + * + * The two-pass design — collect first, then expire, then re-validate — + * ensures expired objects are garbage-collected even when the surrounding + * transaction ultimately fails. + * + * @param view Mutable ledger view; expired credential SLEs are erased. + * @param account Account whose credentials are being verified. + * @param domainID Key of the `PermissionedDomain` SLE. + * @param j Journal for trace/error logging. + * @return `tesSUCCESS` if a live accepted credential for the domain exists; + * `tecEXPIRED` if only expired credentials were found; `tecNO_PERMISSION` + * if no matching credential exists; `tecOBJECT_NOT_FOUND` if the domain + * SLE is missing; or a `TER` error propagated from `removeExpired` under + * `fixSecurity3_1_3`. + */ TER verifyValidDomain(ApplyView& view, AccountID const& account, uint256 domainID, beast::Journal j) { @@ -317,8 +504,6 @@ verifyValidDomain(ApplyView& view, AccountID const& account, uint256 domainID, b if (!slePD) return tecOBJECT_NOT_FOUND; - // Collect all matching credentials on a side, so we can remove expired ones - // We may finish the loop with this collection empty, it's fine. STVector256 credentials; for (auto const& h : slePD->getFieldArray(sfAcceptedCredentials)) { @@ -346,6 +531,35 @@ verifyValidDomain(ApplyView& view, AccountID const& account, uint256 domainID, b return *foundExpired ? tecEXPIRED : tecNO_PERMISSION; } +/** Enforce deposit pre-authorization in doApply, deleting expired credentials. + * + * Called by the Payment, EscrowFinish, and PaymentChannelClaim transactors + * when the destination account has `lsfDepositAuth` set. Authorization + * succeeds when any of the following hold: + * - `src == dst` (self-payment always allowed) + * - `keylet::depositPreauth(dst, src)` exists (account-level pre-auth) + * - A credential-set `DepositPreauth` object exists for the credentials + * submitted in the transaction (via `credentials::authorizedDepositPreauth`) + * + * If `sfCredentialIDs` is present, `credentials::removeExpired` is called + * unconditionally before the authorization tests — expired credentials are + * deleted as a side effect even if the transaction ultimately fails. If any + * credential was expired, the function returns `tecEXPIRED` immediately + * without proceeding to the authorization checks. + * + * @param tx The transaction under doApply; may carry `sfCredentialIDs`. + * @param view Mutable ledger view; expired credential SLEs may be erased. + * @param src The sending account. + * @param dst The destination account. + * @param sleDst The destination account's SLE; used to test `lsfDepositAuth`. + * If null, `lsfDepositAuth` is treated as unset and the function returns + * `tesSUCCESS`. + * @param j Journal for trace/error logging. + * @return `tesSUCCESS` if authorized or `lsfDepositAuth` is not set; + * `tecEXPIRED` if submitted credentials have expired; `tecNO_PERMISSION` + * if no matching pre-authorization exists; or a propagated error from + * `removeExpired` or `authorizedDepositPreauth`. + */ TER verifyDepositPreauth( STTx const& tx, @@ -355,12 +569,6 @@ verifyDepositPreauth( std::shared_ptr const& sleDst, beast::Journal j) { - // If depositPreauth is enabled, then an account that requires - // authorization has at least two ways to get a payment in: - // 1. If src == dst, or - // 2. If src is deposit preauthorized by dst (either by account or by - // credentials). - bool const credentialsPresent = tx.isFieldPresent(sfCredentialIDs); if (credentialsPresent) diff --git a/src/libxrpl/ledger/helpers/DirectoryHelpers.cpp b/src/libxrpl/ledger/helpers/DirectoryHelpers.cpp index 8b4eeae7b7..d92f021a6b 100644 --- a/src/libxrpl/ledger/helpers/DirectoryHelpers.cpp +++ b/src/libxrpl/ledger/helpers/DirectoryHelpers.cpp @@ -1,3 +1,19 @@ +/** @file + * Low-level traversal utilities for ledger directory nodes (`ltDIR_NODE`). + * + * Directory nodes are linked-list structures that associate a root keylet + * with a paged sequence of ledger-object keys (`sfIndexes`). This file + * provides the concrete implementations of the traversal helpers declared + * in `DirectoryHelpers.h`: the deprecated step-iterator wrappers + * (`dirFirst`/`dirNext`/`cdirFirst`/`cdirNext`), the exhaustive and + * cursor-paginated higher-order walkers (`forEachItem`, + * `forEachItemAfter`), the emptiness predicate (`dirIsEmpty`), and the + * new-page initialisation factory (`describeOwnerDir`). + * + * New code should prefer the `Dir` range adaptor over the step-iterator + * API. + */ + #include #include @@ -17,6 +33,13 @@ namespace xrpl { +// --- Deprecated step-iterator wrappers --- +// dirFirst/dirNext (mutable) and cdirFirst/cdirNext (read-only) are thin +// forwarding shells. The shared template implementations +// detail::internalDirFirst and detail::internalDirNext select between +// view.peek() and view.read() at compile time based on whether the SLE +// pointer is const, preserving type safety without code duplication. + bool dirFirst( ApplyView& view, @@ -61,6 +84,24 @@ cdirNext( return detail::internalDirNext(view, root, page, index, entry); } +/** Exhaustively walks every page of a directory, invoking @p f for every + * child SLE in `sfIndexes` order. + * + * Iteration terminates when `sfIndexNext` is zero (end of chain) or when + * a page SLE is missing — missing pages are treated as end-of-directory + * rather than an error. There is no early-exit mechanism; the callback's + * return type is `void`. + * + * A two-tier guard enforces the `ltDIR_NODE` precondition: an + * `XRPL_ASSERT` fires in instrumented builds while an explicit `if` guard + * silently returns in release builds, preventing a crash on stale data. + * + * @param view The read-only ledger view to query. + * @param root Keylet of the directory's root (anchor) page; must have + * type `ltDIR_NODE`. + * @param f Callback invoked with each child SLE (may be `nullptr` if the + * child key is not present in the view). + */ void forEachItem( ReadView const& view, @@ -88,6 +129,44 @@ forEachItem( } } +/** Cursor-paginated directory walk used by RPC handlers such as + * `account_offers`, `account_lines`, and `account_channels`. + * + * When @p after is non-zero the function first tries the @p hint page + * to locate the cursor without a full linear scan (the common case for + * well-behaved clients that persist the hint from the previous response). + * If the hint is stale or wrong the search falls back to a linear scan + * from the root page until @p after is found. + * + * The callback @p f receives each child SLE that comes after the cursor + * and returns `bool`: `true` to continue iteration, `false` to stop early + * regardless of @p limit. The @p limit counter is decremented on each + * `true`-returning call; when it reaches one the walk stops — callers + * conventionally request `limit + 1` items and detect a non-empty next + * page by checking whether exactly `limit + 1` items were delivered. + * + * When @p after is zero the function starts from the root page and always + * returns `true` (modulo missing SLEs), so the return value is only + * meaningful as a cursor-validity signal in the paginated case. + * + * @param view The read-only ledger view to query. + * @param root Keylet of the directory's root page; must have type + * `ltDIR_NODE`. + * @param after Cursor key: iteration delivers only items that follow this + * key in directory order. Pass `uint256()` (zero) to start from the + * beginning. + * @param hint Page number expected to contain @p after, used as a fast- + * path optimisation; ignored when @p after is zero. + * @param limit Maximum number of callback invocations before stopping; + * the walk stops when the callback returns `true` and this count + * reaches one. + * @param f Callback invoked for each qualifying child SLE (may be + * `nullptr` if the child key is absent in the view). Return `true` + * to continue, `false` to stop early. + * @return `true` if the @p after key was found (or @p after is zero); + * `false` if the cursor key was never located, which indicates a + * stale or invalid marker that callers should surface as an error. + */ bool forEachItemAfter( ReadView const& view, @@ -167,6 +246,20 @@ forEachItemAfter( } } +/** Returns `true` only when the directory contains no entries. + * + * An empty `sfIndexes` array on the root page is necessary but not + * sufficient: the root is an anchor page and may legitimately have an + * empty index while `sfIndexNext` still points to a populated subsequent + * page. Both conditions — empty `sfIndexes` *and* `sfIndexNext == 0` — + * must hold before declaring the directory empty. + * + * A missing root SLE is treated as an empty directory. + * + * @param view The read-only ledger view to query. + * @param k Keylet of the directory's root page. + * @return `true` if the directory has no entries; `false` otherwise. + */ bool dirIsEmpty(ReadView const& view, Keylet const& k) { @@ -175,12 +268,24 @@ dirIsEmpty(ReadView const& view, Keylet const& k) return true; if (!sleNode->getFieldV256(sfIndexes).empty()) return false; - // The first page of a directory may legitimately be empty even if there - // are other pages (the first page is the anchor page) so check to see if - // there is another page. If there is, the directory isn't empty. + // The root page may be empty while subsequent pages are not — check + // sfIndexNext before concluding the directory is truly empty. return sleNode->getFieldU64(sfIndexNext) == 0; } +/** Returns a callback that stamps a new directory page with @p account as + * its owner. + * + * The callback is passed directly to `ApplyView::dirInsert`; whenever + * `dirInsert` allocates a new overflow page it calls this function to set + * `sfOwner` on the new `ltDIR_NODE` SLE. This keeps the owning account + * ID out of the generic insertion logic while making the caller's intent + * explicit at the `dirInsert` call site. + * + * @param account The `AccountID` to record on each new page. + * @return A `void(SLE::ref)` callable suitable for use as the `describe` + * argument to `ApplyView::dirInsert`. + */ std::function describeOwnerDir(AccountID const& account) { diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 89d5ed8e35..76bad5dfbc 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -1,3 +1,13 @@ +/** @file + * Numerical core of the XRPL lending protocol (XLS-66). + * + * Implements every mathematical operation in a loan's life cycle: computing + * amortized periodic payments, splitting each payment into principal, + * interest, and management-fee components, and handling late, full (early- + * closure), and overpayment scenarios. The top-level entry point + * `loanMakePayment()` implements the `make_payment` function from XLS-66 + * §3.2.4.4. All equation references below are to Section A-2 of that spec. + */ #include #include @@ -28,6 +38,16 @@ namespace xrpl { +/** Verify all amendment prerequisites for the lending protocol are active. + * + * Every lending transactor calls this in `checkExtraFeatures()`. Adding a + * new prerequisite here gates all lending transactions atomically. + * + * @param rules Active amendment rules for the current ledger. + * @param tx The transaction being validated. + * @return `true` if all required amendments are enabled and the transaction + * is consistent with them; `false` if the transaction must be rejected. + */ bool checkLendingProtocolDependencies(Rules const& rules, STTx const& tx) { @@ -43,6 +63,15 @@ checkLendingProtocolDependencies(Rules const& rules, STTx const& tx) return true; } +/** Accumulate payment parts from multiple consecutive payment rounds. + * + * Used by `loanMakePayment()` to sum regular payments made in a single + * transaction when the borrower supplies enough funds to cover more than + * one installment. All component fields of `other` must be non-negative. + * + * @param other Payment parts from the next completed payment round. + * @return Reference to `*this` with accumulated totals. + */ LoanPaymentParts& LoanPaymentParts::operator+=(LoanPaymentParts const& other) { @@ -67,6 +96,11 @@ LoanPaymentParts::operator+=(LoanPaymentParts const& other) return *this; } +/** Compare two `LoanPaymentParts` for exact equality across all fields. + * + * @param other The parts to compare against. + * @return `true` if all four fields are equal. + */ bool LoanPaymentParts::operator==(LoanPaymentParts const& other) const { @@ -74,10 +108,14 @@ LoanPaymentParts::operator==(LoanPaymentParts const& other) const valueChange == other.valueChange && feePaid == other.feePaid; } -/* Converts annualized interest rate to per-payment-period rate. - * The rate is prorated based on the payment interval in seconds. +/** Convert an annualized interest rate to a per-payment-period rate. * - * Equation (1) from XLS-66 spec, Section A-2 Equation Glossary + * Prorates the annual rate by the fraction `paymentInterval / secondsInYear`. + * Implements Equation (1) from XLS-66, Section A-2 Equation Glossary. + * + * @param interestRate Annual interest rate in tenth-of-a-basis-point units. + * @param paymentInterval Length of one payment period in seconds. + * @return The per-period rate as a `Number` at full floating-point precision. */ Number loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval) @@ -86,9 +124,16 @@ loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval) return tenthBipsOfValue(Number(paymentInterval), interestRate) / kSECONDS_IN_YEAR; } -/* Checks if a value is already rounded to the specified scale. - * Returns true if rounding down and rounding up produce the same result, - * indicating no further precision exists beyond the scale. +/** Check whether a value is already rounded to the given scale. + * + * Compares the downward- and upward-rounded forms; equality means no + * sub-scale precision remains. Used as a precondition guard and post- + * condition assertion throughout the payment pipeline. + * + * @param asset Asset whose representable precision constrains rounding. + * @param value The value to test. + * @param scale Exponent that defines the target precision. + * @return `true` if `roundDown(value) == roundUp(value)` at `scale`. */ bool isRounded(Asset const& asset, Number const& value, std::int32_t scale) @@ -99,6 +144,12 @@ isRounded(Asset const& asset, Number const& value, std::int32_t scale) namespace detail { +/** Clamp all delta fields to zero from below. + * + * Rounding can occasionally produce tiny negative deltas when the theoretical + * target exceeds the current rounded state by a sub-scale amount. This method + * eliminates those artifacts before the deltas are used as payment amounts. + */ void LoanStateDeltas::nonNegative() { @@ -110,17 +161,26 @@ LoanStateDeltas::nonNegative() managementFee = kNUM_ZERO; } -/* Computes (1 + r)^n - 1 accurately even for near-zero r, where direct - * subtraction of `power(1 + r, n) - 1` suffers catastrophic cancellation. +/** Compute `(1 + r)^n - 1` accurately for near-zero `r` via binomial expansion. * - * The binomial expansion gives - * (1 + r)^n - 1 = sum_{k=1}^{n} C(n,k) r^k - * = nr + C(n,2) r^2 + ... + r^n - * which is a sum of positive terms when r >= 0, avoiding cancellation. - * Each term is computed from the previous via - * term_{k+1} = term_k * r * (n - k) / (k + 1) + * Direct subtraction `power(1 + r, n) - 1` suffers catastrophic cancellation + * when `r` is small: the result `~r*n` sits far below the leading `1` in + * `(1+r)^n`, consuming most of Number's 19-digit mantissa. The binomial + * expansion avoids this: * - * The loop terminates early once the next term is below Number precision. + * @code + * (1 + r)^n - 1 = nr + C(n,2) r^2 + ... + r^n + * @endcode + * + * Each term is derived from the previous as `term_{k+1} = term_k * r * (n-k) / (k+1)`. + * The loop terminates early once adding the next term leaves the running sum + * unchanged (below Number's precision floor). + * + * @param periodicRate Per-period rate `r`; must be >= 0. + * @param paymentsRemaining Number of periods `n`. + * @return `(1 + r)^n - 1`, or 0 if `r == 0` or `n == 0`. + * @note For `r * n >= 1e-9` the closed-form path in `computePowerMinusOneHybrid` + * is ~30-500x faster and equally accurate; prefer the hybrid for production use. */ Number computePowerMinusOne(Number const& periodicRate, std::uint32_t paymentsRemaining) @@ -149,17 +209,20 @@ computePowerMinusOne(Number const& periodicRate, std::uint32_t paymentsRemaining return sum; } -/* Hybrid evaluator of (1 + r)^n - 1. +/** Compute `(1 + r)^n - 1`, selecting the numerically stable path automatically. * - * The closed-form `power(1 + r, n) - 1` loses sig digits to cancellation - * when `r * n` is small: the result `~r*n` sits well below the `1` that - * dominates `(1+r)^n`, so most of Number's stored precision is consumed - * by the leading `1`. + * When `r * n >= 1e-9` the closed-form `power(1 + r, n) - 1` retains enough + * precision and is ~30-500x faster than the binomial expansion. Below that + * threshold cancellation becomes severe — the `~r*n` result sits well below + * the `1` consumed by the leading term of `(1+r)^n` — so the call is + * forwarded to `computePowerMinusOne()`. * - * A threshold of `1e-9` preserves the closed-form path for any rate the - * lending code actually sees in practice (fixtures at moderate rates are bit-exact), - * while routing the pathological near-zero regime through the binomial - * expansion where cancellation is severe. + * @param periodicRate Per-period rate `r`; must be >= 0. + * @param paymentsRemaining Number of periods `n`. + * @return `(1 + r)^n - 1`, or 0 if `r == 0` or `n == 0`. + * @note The threshold `1e-9` is chosen so that both paths agree to within + * Number's post-subtraction precision (~10 significant digits) at the + * crossover, verified by `testComputePowerMinusOneHybrid`. */ Number computePowerMinusOneHybrid(Number const& periodicRate, std::uint32_t paymentsRemaining) @@ -184,10 +247,20 @@ computePowerMinusOneHybrid(Number const& periodicRate, std::uint32_t paymentsRem return computePowerMinusOne(periodicRate, paymentsRemaining); } -/* Computes the payment factor used in standard amortization formulas. - * This factor converts principal to periodic payment amount. +/** Compute the standard amortization payment factor `r(1+r)^n / ((1+r)^n - 1)`. * - * Equation (6) from XLS-66 spec, Section A-2 Equation Glossary + * Multiplying this factor by the outstanding principal yields the fixed + * periodic payment. Implements Equation (6) from XLS-66, Section A-2. + * + * When `fixCleanup3_2_0` is enabled the denominator `(1+r)^n - 1` is + * evaluated via `computePowerMinusOneHybrid()` to avoid catastrophic + * cancellation at near-zero rates. The pre-amendment path uses the direct + * `power(1+r, n) - 1` form and is preserved for historic replay. + * + * @param rules Active amendment rules (gates the hybrid path). + * @param periodicRate Per-period rate `r`; must be >= 0. + * @param paymentsRemaining Number of remaining payments `n`. + * @return The payment factor, or `1/n` when `r == 0`, or 0 when `n == 0`. */ Number computePaymentFactor( @@ -219,10 +292,18 @@ computePaymentFactor( return (periodicRate * raisedRate) / (raisedRate - 1); } -/* Calculates the periodic payment amount using standard amortization formula. - * For interest-free loans, returns principal divided equally across payments. +/** Compute the fixed installment amount for a standard amortized loan. * - * Equation (7) from XLS-66 spec, Section A-2 Equation Glossary + * Implements `principal * paymentFactor(r, n)`. For zero-interest loans the + * formula degenerates to equal principal slices (`principal / n`). Implements + * Equation (7) from XLS-66, Section A-2 Equation Glossary. + * + * @param rules Active amendment rules (passed to `computePaymentFactor`). + * @param principalOutstanding Current outstanding principal. + * @param periodicRate Per-period interest rate. + * @param paymentsRemaining Number of payments left in the schedule. + * @return The unrounded periodic payment, or 0 if `principalOutstanding == 0` + * or `paymentsRemaining == 0`. */ Number loanPeriodicPayment( @@ -241,10 +322,18 @@ loanPeriodicPayment( return principalOutstanding * computePaymentFactor(rules, periodicRate, paymentsRemaining); } -/* Reverse-calculates principal from periodic payment amount. - * Used to determine theoretical principal at any point in the schedule. +/** Reverse-calculate the outstanding principal implied by a given periodic payment. * - * Equation (10) from XLS-66 spec, Section A-2 Equation Glossary + * The inverse of `loanPeriodicPayment()`: recovers what the principal should be + * at a given point in the amortization schedule, used by `computeTheoreticalLoanState()` + * and the early-closure path. Implements Equation (10) from XLS-66, Section A-2. + * + * @param rules Active amendment rules (passed to `computePaymentFactor`). + * @param periodicPayment Fixed installment amount. + * @param periodicRate Per-period interest rate. + * @param paymentsRemaining Number of payments remaining. + * @return Theoretical outstanding principal, or 0 if `paymentsRemaining == 0`, or + * `periodicPayment * paymentsRemaining` when `periodicRate == 0`. */ Number loanPrincipalFromPeriodicPayment( @@ -262,10 +351,17 @@ loanPrincipalFromPeriodicPayment( return periodicPayment / computePaymentFactor(rules, periodicRate, paymentsRemaining); } -/* - * Computes the interest and management fee parts from interest amount. +/** Split a gross interest amount into net interest (vault) and management fee (broker). * - * Equation (33) from XLS-66 spec, Section A-2 Equation Glossary + * Computes `fee = computeManagementFee(interest, managementFeeRate)` and + * returns `(interest - fee, fee)`. Implements Equation (33) from XLS-66, + * Section A-2 Equation Glossary. + * + * @param asset Asset used for rounding the fee. + * @param interest Gross interest amount to split. + * @param managementFeeRate Broker's share of gross interest in tenth-bips. + * @param loanScale Exponent for rounding the fee. + * @return Pair `(netInterest, fee)` where `netInterest + fee == interest`. */ std::pair computeInterestAndFeeParts( @@ -279,10 +375,18 @@ computeInterestAndFeeParts( return std::make_pair(interest - fee, fee); } -/* Calculates penalty interest accrued on overdue payments. - * Returns 0 if payment is not late. +/** Compute penalty interest that has accrued on an overdue payment. * - * Equation (16) from XLS-66 spec, Section A-2 Equation Glossary + * Calculates `principal * loanPeriodicRate(lateInterestRate, secondsOverdue)`. + * Returns 0 if the payment is on time or early, if `principalOutstanding == 0`, + * or if `lateInterestRate == 0`. Implements Equation (16) from XLS-66, + * Section A-2 Equation Glossary. + * + * @param principalOutstanding Current outstanding principal. + * @param lateInterestRate Annualized penalty rate in tenth-of-a-basis-point units. + * @param parentCloseTime Close time of the parent ledger (the "now" for overdue calc). + * @param nextPaymentDueDate The timestamp when the payment was originally due. + * @return Unrounded late penalty interest, or 0 if the payment is not overdue. */ Number loanLatePaymentInterest( @@ -312,10 +416,22 @@ loanLatePaymentInterest( return principalOutstanding * rate; } -/* Calculates interest accrued since the last payment based on time elapsed. - * Returns 0 if loan is paid ahead of schedule. +/** Compute interest accrued since the last payment, prorated by elapsed time. * - * Equation (27) from XLS-66 spec, Section A-2 Equation Glossary + * Computes `principal * periodicRate * secondsSinceLastPayment / paymentInterval`, + * where `lastPaymentDate = max(prevPaymentDate, startDate)`. Multiplication + * is performed before division to minimise rounding amplification. Returns 0 + * if the loan is paid ahead of schedule (i.e. `now <= lastPaymentDate`). + * Implements Equation (27) from XLS-66, Section A-2 Equation Glossary. + * + * @param principalOutstanding Current outstanding principal. + * @param periodicRate Per-period interest rate. + * @param parentCloseTime Close time of the parent ledger (current time). + * @param startDate Unix timestamp when the loan started accruing. + * @param prevPaymentDate Due date of the most recently completed payment. + * @param paymentInterval Length of one payment period in seconds. + * @return Unrounded accrued interest, or 0 if `periodicRate == 0`, `paymentInterval == 0`, + * or the loan is ahead of schedule. */ Number loanAccruedInterest( @@ -349,14 +465,30 @@ loanAccruedInterest( return principalOutstanding * periodicRate * secondsSinceLastPayment / paymentInterval; } -/* Applies a payment to the loan state and returns the breakdown of amounts - * paid. +/** Apply a fully-computed payment to the loan state and return the payment breakdown. * - * This is the core function that updates the Loan ledger object fields based on - * a computed payment. - - * The function is templated to work with both direct Number/uint32_t values - * (for testing/simulation) and ValueProxy types (for actual ledger updates). + * The core commit step: subtracts the `payment` deltas from the three outstanding + * balance proxies and advances the payment schedule (`paymentRemaining`, + * `prevPaymentDate`, `nextDueDate`). For a `PaymentSpecialCase::Final` payment all + * balances are zeroed and `nextDueDate` is cleared, marking the loan as paid off. + * For a `PaymentSpecialCase::Extra` (overpayment) the schedule is not advanced. + * + * Templated on proxy types so the same function can run against `ValueProxy` + * objects (which write through to the Loan SLE) or plain value types for unit + * tests and simulation. + * + * @tparam NumberProxy Type exposing `Number` read/write semantics. + * @tparam UInt32Proxy Type exposing `uint32_t` read/write semantics. + * @tparam UInt32OptionalProxy Type exposing optional-`uint32_t` read/write semantics. + * @param payment Fully-computed payment components to apply. + * @param totalValueOutstandingProxy Proxy for `sfTotalValueOutstanding`. + * @param principalOutstandingProxy Proxy for `sfPrincipalOutstanding`. + * @param managementFeeOutstandingProxy Proxy for `sfManagementFeeOutstanding`. + * @param paymentRemainingProxy Proxy for `sfPaymentRemaining`. + * @param prevPaymentDateProxy Proxy for `sfPreviousPaymentDueDate`. + * @param nextDueDateProxy Proxy for `sfNextPaymentDueDate`; must be set. + * @param paymentInterval Payment period length in seconds. + * @return Breakdown of amounts paid, suitable for return from `loanMakePayment()`. */ template LoanPaymentParts @@ -471,15 +603,33 @@ doPayment( .feePaid = payment.trackedManagementFeeDelta + payment.untrackedManagementFee}; } -/* Simulates an overpayment to validate it won't break the loan's amortization. +/** Simulate a principal overpayment and re-amortize the loan in a sandbox. * - * When a borrower pays more than the scheduled amount, the loan needs to be - * re-amortized with a lower principal. This function performs that calculation - * in a "sandbox" using temporary variables, allowing the caller to validate - * the result before committing changes to the actual ledger. + * When a borrower pays more than the scheduled amount the remaining schedule + * must be re-amortized from a lower principal. This function: + * 1. Computes the theoretical (unrounded) current state. + * 2. Measures accumulated rounding error vs. the actual ledger state. + * 3. Reduces the theoretical principal by `overpaymentComponents.trackedPrincipalDelta`. + * 4. Calls `computeLoanProperties()` for the new schedule. + * 5. Adds the preserved rounding errors back before re-rounding. + * 6. Validates the result via `checkLoanGuards()`; rejects silently if invalid. * - * The function preserves accumulated rounding errors across the re-amortization - * to ensure the loan state remains consistent with its payment history. + * All mutations target local copies — no proxies are written. On success, + * `doOverpayment()` commits the result. + * + * @param rules Active amendment rules. + * @param asset Loan asset for rounding. + * @param loanScale Exponent for rounding all loan values. + * @param overpaymentComponents Pre-computed payment components for the overpayment. + * @param roundedOldState Current rounded loan state from the ledger. + * @param periodicPayment Current periodic payment amount. + * @param periodicRate Per-period interest rate. + * @param paymentRemaining Number of payments still remaining. + * @param managementFeeRate Broker fee rate in tenth-bips. + * @param j Journal for diagnostic logging. + * @return On success, a pair of `LoanPaymentParts` and the new `LoanProperties`. + * Returns `Unexpected(tesSUCCESS)` to signal "silently ignore the overpayment" + * (not an error), or `Unexpected(otherTER)` on a genuine failure. */ Expected, TER> tryOverpayment( @@ -654,16 +804,31 @@ tryOverpayment( newLoanProperties); } -/* Validates and applies an overpayment to the loan state. +/** Validate and commit a principal overpayment to the loan ledger object. * - * This function acts as a wrapper around tryOverpayment(), performing the - * re-amortization calculation in a sandbox (using temporary copies of the - * loan state), then validating the results before committing them to the - * actual ledger via the proxy objects. + * Wraps `tryOverpayment()` in a two-phase pattern: the sandbox calculation + * runs first against local copies of the loan state. Only after all guard + * conditions pass — including that the principal strictly decreased — are the + * proxy objects updated with the new balances and periodic payment. * - * The two-step process (try in sandbox, then commit) ensures that if the - * overpayment would leave the loan in an invalid state, we can reject it - * gracefully without corrupting the ledger data. + * Returns `Unexpected(tesSUCCESS)` when `tryOverpayment` rejects the overpayment + * silently (invalid state, zero principal reduction, etc.), propagating the + * signal up to `loanMakePayment()` which continues without the overpayment step. + * + * @tparam NumberProxy Type exposing `Number` read/write semantics. + * @param rules Active amendment rules. + * @param asset Loan asset for rounding. + * @param loanScale Exponent for rounding. + * @param overpaymentComponents Pre-computed overpayment components. + * @param totalValueOutstandingProxy Proxy for `sfTotalValueOutstanding`. + * @param principalOutstandingProxy Proxy for `sfPrincipalOutstanding`. + * @param managementFeeOutstandingProxy Proxy for `sfManagementFeeOutstanding`. + * @param periodicPaymentProxy Proxy for `sfPeriodicPayment`. + * @param periodicRate Per-period interest rate. + * @param paymentRemaining Remaining payment count. + * @param managementFeeRate Broker fee rate in tenth-bips. + * @param j Journal for diagnostic logging. + * @return Payment parts on success; `Unexpected(TER)` on failure or silent skip. */ template Expected @@ -773,18 +938,30 @@ doOverpayment( return loanPaymentParts; } -/* Computes the payment components for a late payment. +/** Compute payment components for a payment made after the due date. * - * A late payment is made after the grace period has expired and includes: - * 1. All components of a regular periodic payment - * 2. Late payment penalty interest (accrued since the due date) - * 3. Late payment fee charged by the broker + * Extends the regular `periodic` components with two extra untracked amounts: + * - Late penalty interest (`loanLatePaymentInterest()`), which increases the + * loan's total value (`valueChange > 0`). + * - A fixed late payment fee charged by the broker. * - * The late penalty interest increases the loan's total value (the borrower - * owes more than scheduled), while the regular payment components follow - * the normal amortization schedule. + * Both are split by `computeInterestAndFeeParts()` before being added. + * Implements Equation (15) from XLS-66, Section A-2 Equation Glossary. * - * Implements equation (15) from XLS-66 spec, Section A-2 Equation Glossary + * @param asset Loan asset for rounding. + * @param view Apply view supplying `parentCloseTime` and expiry check. + * @param principalOutstanding Current outstanding principal. + * @param nextDueDate The payment's scheduled due date. + * @param periodic Pre-computed regular periodic payment components. + * @param lateInterestRate Annualized penalty rate in tenth-of-a-basis-point units. + * @param loanScale Exponent for rounding. + * @param latePaymentFee Fixed broker fee for a late payment. + * @param amount Amount the borrower offered; must cover `late.totalDue`. + * @param managementFeeRate Broker fee rate for splitting the late interest. + * @param j Journal for diagnostic logging. + * @return Extended components including late penalty on success; + * `Unexpected(tecTOO_SOON)` if the due date has not yet passed; + * `Unexpected(tecINSUFFICIENT_PAYMENT)` if `amount < late.totalDue`. */ Expected computeLatePayment( @@ -861,24 +1038,40 @@ computeLatePayment( return late; } -/* Computes payment components for paying off a loan early (before final - * payment). +/** Compute payment components for early loan closure (before the final scheduled payment). * - * A full payment closes the loan immediately, paying off all outstanding - * balances plus a prepayment penalty and any accrued interest since the last - * payment. This is different from the final scheduled payment, which has no - * prepayment penalty. + * Disallowed when only one payment remains — the final scheduled payment + * should follow the regular path instead. Pays off all remaining balances + * (`trackedValueDelta = principal + interest + fee`) marked `PaymentSpecialCase::Final`, + * plus two untracked charges: + * - Accrued interest since the last payment (`loanAccruedInterest()`), Eq. 27. + * - Prepayment penalty (`closeInterestRate` applied to theoretical principal), Eq. 28. * - * The function calculates: - * - Accrued interest since last payment (time-based) - * - Prepayment penalty (percentage of remaining principal) - * - Close payment fee (fixed fee for early closure) - * - All remaining principal and outstanding fees + * `untrackedInterest = roundedFullInterest - totalInterestOutstanding`; this + * drives `LoanPaymentParts::valueChange` and can be negative (early payoff saves + * more interest than the penalty costs). Implements Equation (26) from XLS-66, + * Section A-2. * - * The loan's value may increase or decrease depending on whether the prepayment - * penalty exceeds the scheduled interest that would have been paid. - * - * Implements equation (26) from XLS-66 spec, Section A-2 Equation Glossary + * @param asset Loan asset for rounding. + * @param view Apply view supplying `parentCloseTime` and `rules`. + * @param principalOutstanding Current outstanding principal. + * @param managementFeeOutstanding Current outstanding management fee. + * @param periodicPayment Current fixed installment amount. + * @param paymentRemaining Remaining payment count; must be > 1. + * @param prevPaymentDate Due date of the most recently completed payment. + * @param startDate Loan start date (for accrued-interest calculation). + * @param paymentInterval Payment period length in seconds. + * @param closeInterestRate Prepayment penalty rate in tenth-bips. + * @param loanScale Exponent for rounding. + * @param totalInterestOutstanding Total interest still due on the loan. + * @param periodicRate Per-period interest rate. + * @param closePaymentFee Fixed broker fee for early closure. + * @param amount Amount the borrower offered; must cover `full.totalDue`. + * @param managementFeeRate Broker fee rate for splitting the full-payment interest. + * @param j Journal for diagnostic logging. + * @return Extended components on success; + * `Unexpected(tecKILLED)` if `paymentRemaining <= 1`; + * `Unexpected(tecINSUFFICIENT_PAYMENT)` if `amount < full.totalDue`. */ Expected computeFullPayment( @@ -991,29 +1184,44 @@ computeFullPayment( return full; } +/** Derive the tracked interest portion of this payment. + * + * Computed as `trackedValueDelta - trackedPrincipalDelta - trackedManagementFeeDelta`, + * representing the net interest paid to the vault from the scheduled amortization. + * Untracked interest (e.g., late penalty interest) is not included here. + * + * @return The tracked interest component as a `Number`. + */ Number PaymentComponents::trackedInterestPart() const { return trackedValueDelta - (trackedPrincipalDelta + trackedManagementFeeDelta); } -/* Computes the breakdown of a regular periodic payment into principal, - * interest, and management fee components. +/** Compute how a single scheduled payment splits into principal, interest, and fee. * - * This function determines how a single scheduled payment should be split among - * the three tracked loan components. The calculation accounts for accumulated - * rounding errors. + * Rather than recomputing from the amortization formula, this function asks + * "what should the loan state be after this payment?" by calling + * `computeTheoreticalLoanState(paymentRemaining - 1)` and taking the delta + * between the current ledger state and that target. This naturally absorbs + * accumulated rounding errors. After computing raw deltas the function applies + * `nonNegative()` and a series of `std::min` caps to ensure no component + * exceeds its available balance or the rounded periodic payment. Excess is + * redistributed by the `addressExcess` lambda (interest first, then fee, then + * principal). Implements `compute_payment_due()` from XLS-66 §3.2.4.4. * - * The algorithm: - * 1. Calculate what the loan state SHOULD be after this payment (target) - * 2. Compare current state to target to get deltas - * 3. Adjust deltas to handle rounding artifacts and edge cases - * 4. Ensure deltas don't exceed available balances or payment amount - * - * Special handling for the final payment: all remaining balances are paid off - * regardless of the periodic payment amount. - * - * Implements the pseudo-code function `compute_payment_due()`. + * @param rules Active amendment rules. + * @param asset Loan asset for rounding. + * @param scale Exponent for rounding. + * @param totalValueOutstanding Current `sfTotalValueOutstanding`. + * @param principalOutstanding Current `sfPrincipalOutstanding`. + * @param managementFeeOutstanding Current `sfManagementFeeOutstanding`. + * @param periodicPayment Current scheduled installment (unrounded). + * @param periodicRate Per-period interest rate. + * @param paymentRemaining Number of payments remaining; must be > 0. + * @param managementFeeRate Broker fee rate in tenth-bips. + * @return `PaymentComponents` with `specialCase = Final` if this is the last + * payment or if `totalValueOutstanding <= roundedPeriodicPayment`. */ PaymentComponents computePaymentComponents( @@ -1211,23 +1419,27 @@ computePaymentComponents( }; } -/* Computes payment components for an overpayment scenario. +/** Compute payment components for a principal overpayment. * - * An overpayment occurs when a borrower pays more than the scheduled periodic - * payment amount. The overpayment is treated as extra principal reduction, - * but incurs a fee and potentially a penalty interest charge. + * An overpayment pays more than the scheduled installment; the surplus reduces + * principal immediately but incurs a fixed fee and a one-time penalty interest + * charge. The decomposition (XLS-66 §3.2.4.2.3, Equations 20-22): * - * The calculation (Section 3.2.4.2.3 from XLS-66 spec): - * 1. Calculate gross penalty interest on the overpayment amount - * 2. Split the gross interest into net interest and management fee - * 3. Calculate the penalty fee - * 4. Determine the principal portion by subtracting the interest (gross) and - * management fee from the overpayment amount + * 1. `overpaymentFee = round(overpayment * overpaymentFeeRate)` (Eq. 22). + * 2. Gross penalty interest on the full overpayment, split into net interest + * and management fee via `computeInterestAndFeeParts()` (Eqs. 20-21). + * 3. `trackedPrincipalDelta = overpayment - grossInterest - overpaymentFee`. * - * Unlike regular payments which follow the amortization schedule, overpayments - * apply to principal, reducing the loan balance and future interest costs. + * The result is tagged `PaymentSpecialCase::Extra` so `doPayment()` knows not + * to advance the payment schedule. * - * Equations (20), (21) and (22) from XLS-66 spec, Section A-2 Equation Glossary + * @param asset Loan asset for rounding. + * @param loanScale Exponent for rounding all components. + * @param overpayment Amount being overpaid; must be > 0 and already rounded. + * @param overpaymentInterestRate One-time penalty rate applied to the overpayment, in tenth-bips. + * @param overpaymentFeeRate Fixed broker fee rate on the overpayment, in tenth-bips. + * @param managementFeeRate Broker's share of the penalty interest, in tenth-bips. + * @return `ExtendedPaymentComponents` with `specialCase = Extra`. */ ExtendedPaymentComponents computeOverpaymentComponents( @@ -1286,6 +1498,17 @@ computeOverpaymentComponents( } // namespace detail +/** Compute the component-wise difference between two loan states. + * + * Used to measure accumulated rounding error between the current rounded + * ledger state and the theoretical state, and to compute payment deltas during + * re-amortization. The resulting `LoanStateDeltas` does not include a + * `valueOutstanding` delta; callers derive it via `LoanStateDeltas::total()`. + * + * @param lhs The minuend loan state (typically the current rounded state). + * @param rhs The subtrahend loan state (typically the theoretical target). + * @return Component-wise deltas `lhs - rhs`. + */ detail::LoanStateDeltas operator-(LoanState const& lhs, LoanState const& rhs) { @@ -1298,6 +1521,15 @@ operator-(LoanState const& lhs, LoanState const& rhs) return result; } +/** Subtract `LoanStateDeltas` from a `LoanState`. + * + * Used to apply payment deltas to a loan state, producing the post-payment + * state. `valueOutstanding` is adjusted by `rhs.total()`. + * + * @param lhs The base loan state. + * @param rhs The deltas to subtract. + * @return New `LoanState` with each field reduced by the corresponding delta. + */ LoanState operator-(LoanState const& lhs, detail::LoanStateDeltas const& rhs) { @@ -1311,6 +1543,15 @@ operator-(LoanState const& lhs, detail::LoanStateDeltas const& rhs) return result; } +/** Add `LoanStateDeltas` to a `LoanState`. + * + * Used by `tryOverpayment()` to re-apply preserved rounding errors to the + * newly re-amortized theoretical state before rounding to the loan scale. + * + * @param lhs The base loan state. + * @param rhs The deltas to add. + * @return New `LoanState` with each field increased by the corresponding delta. + */ LoanState operator+(LoanState const& lhs, detail::LoanStateDeltas const& rhs) { @@ -1324,6 +1565,30 @@ operator+(LoanState const& lhs, detail::LoanStateDeltas const& rhs) return result; } +/** Validate that computed loan properties satisfy precision and amortization invariants. + * + * Enforces four guards in sequence, each returning `tecPRECISION_LOSS` on violation: + * 1. If `expectInterest`, total interest over the loan's life must be a measurable + * positive value; if not, the amortization table is meaningless. + * 2. The first-payment's principal share (`properties.firstPaymentPrincipal`) must + * be positive at full precision — if it rounds to zero the principal can never + * be paid down. + * 3. The rounded periodic payment must not be zero (prevents division-by-zero in + * downstream calculations). + * 4. `floor(totalValue / roundedPayment)` must equal `paymentTotal`, ensuring the + * loan will complete in exactly the specified number of installments. + * + * Called from loan creation (`LoanSet`) and after each overpayment re-amortization. + * + * @param vaultAsset Asset used for rounding the periodic payment. + * @param principalRequested Loan principal, used to compute total interest outstanding. + * @param expectInterest `true` if the loan has a non-zero interest rate. + * @param paymentTotal Total number of scheduled payments. + * @param properties Computed loan properties to validate. + * @param j Journal for diagnostic logging. + * @return `tesSUCCESS` if all guards pass; `tecPRECISION_LOSS` or `tecINTERNAL` + * on violation. + */ TER checkLoanGuards( Asset const& vaultAsset, @@ -1402,11 +1667,21 @@ checkLoanGuards( return tesSUCCESS; } -/* - * This function calculates the full payment interest accrued since the last - * payment, plus any prepayment penalty. +/** Compute the total interest charge for an early full payment. * - * Equations (27) and (28) from XLS-66 spec, Section A-2 Equation Glossary + * Sums two components: + * - Accrued interest since the last payment (`loanAccruedInterest()`), Eq. 27. + * - Prepayment penalty (`closeInterestRate` applied to the theoretical principal + * outstanding), Eq. 28. Zero when `closeInterestRate == 0`. + * + * @param theoreticalPrincipalOutstanding Unrounded principal derived from the payment schedule. + * @param periodicRate Per-period interest rate. + * @param parentCloseTime Close time of the parent ledger. + * @param paymentInterval Payment period length in seconds. + * @param prevPaymentDate Due date of the most recently completed payment. + * @param startDate Loan start date (for accrued-interest calculation). + * @param closeInterestRate Prepayment penalty rate in tenth-of-a-basis-point units. + * @return `accruedInterest + prepaymentPenalty`, both non-negative. */ Number computeFullPaymentInterest( @@ -1444,27 +1719,20 @@ computeFullPaymentInterest( return accruedInterest + prepaymentPenalty; } -/* Calculates the theoretical loan state at maximum precision for a given point - * in the amortization schedule. +/** Compute the theoretically correct loan state at full arithmetic precision. * - * This function computes what the loan's outstanding balances should be based - * on the periodic payment amount and number of payments remaining, - * without considering any rounding that may have been applied to the actual - * Loan object's state. This "theoretical" (unrounded) state is used as a target - * for computing payment components and validating that the loan's tracked state - * hasn't drifted too far from the theoretical values. + * Derives what each outstanding balance *should be* purely from the payment + * schedule, without any ledger-rounding effects. Used as a target state in + * `computePaymentComponents()` and `tryOverpayment()` to measure and correct + * accumulated rounding drift. Implements `calculate_true_loan_state` from + * XLS-66 §3.2.4.4. Equations 30-33 from Section A-2. * - * The theoretical state serves several purposes: - * 1. Computing the expected payment breakdown (principal, interest, fees) - * 2. Detecting and correcting rounding errors that accumulate over time - * 3. Validating that overpayments are calculated correctly - * 4. Ensuring the loan will be fully paid off at the end of its term - * - * If paymentRemaining is 0, returns a fully zeroed-out LoanState, - * representing a completely paid-off loan. - * - * Implements the `calculate_true_loan_state` function from the XLS-66 spec - * section 3.2.4.4 Transaction Pseudo-code + * @param rules Active amendment rules (passed to `loanPrincipalFromPeriodicPayment`). + * @param periodicPayment Fixed installment amount. + * @param periodicRate Per-period interest rate. + * @param paymentRemaining Number of payments still remaining after this point. + * @param managementFeeRate Broker fee rate in tenth-bips. + * @return Unrounded `LoanState`, or a fully-zeroed state if `paymentRemaining == 0`. */ LoanState computeTheoreticalLoanState( @@ -1507,25 +1775,18 @@ computeTheoreticalLoanState( }; }; -/* Constructs a LoanState from rounded Loan ledger object values. +/** Build a `LoanState` from the three directly-tracked loan balances. * - * This function creates a LoanState structure from the three tracked values - * stored in a Loan ledger object. Unlike calculateTheoreticalLoanState(), which - * computes theoretical unrounded values, this function works with values - * that have already been rounded to the loan's scale. + * Derives `interestDue = totalValueOutstanding - principalOutstanding - managementFeeOutstanding` + * rather than accepting it as a parameter, ensuring the LoanState invariant + * `interestDue + managementFeeDue == valueOutstanding - principalOutstanding` + * always holds. Use `computeTheoreticalLoanState()` when working at full + * arithmetic precision; use this function when working from rounded ledger values. * - * The key difference from calculateTheoreticalLoanState(): - * - calculateTheoreticalLoanState: Computes theoretical values at full - * precision - * - constructRoundedLoanState: Builds state from actual rounded ledger values - * - * The interestDue field is derived from the other three values rather than - * stored directly, since it can be calculated as: - * interestDue = totalValueOutstanding - principalOutstanding - - * managementFeeOutstanding - * - * This ensures consistency across the codebase and prevents copy-paste errors - * when creating LoanState objects from Loan ledger data. + * @param totalValueOutstanding Total value still owed by the borrower. + * @param principalOutstanding Principal component still outstanding. + * @param managementFeeOutstanding Management fee component still outstanding. + * @return Consistent `LoanState` with `interestDue` derived from the other fields. */ LoanState constructLoanState( @@ -1542,6 +1803,15 @@ constructLoanState( .managementFeeDue = managementFeeOutstanding}; } +/** Build a `LoanState` directly from a Loan ledger entry's stored fields. + * + * Convenience wrapper that reads `sfTotalValueOutstanding`, + * `sfPrincipalOutstanding`, and `sfManagementFeeOutstanding` from the SLE + * and delegates to `constructLoanState()`. + * + * @param loan A const reference to the Loan SLE. + * @return `LoanState` reflecting the current rounded ledger values. + */ LoanState constructRoundedLoanState(SLE::const_ref loan) { @@ -1551,11 +1821,17 @@ constructRoundedLoanState(SLE::const_ref loan) loan->at(sfManagementFeeOutstanding)); } -/* - * This function calculates the fee owed to the broker based on the asset, - * value, and management fee rate. +/** Compute the broker's management fee on a given interest amount. * - * Equation (32) from XLS-66 spec, Section A-2 Equation Glossary + * Calculates `roundDown(tenthBipsOfValue(value, managementFeeRate), scale)`. + * Downward rounding ensures the vault never receives less than its share. + * Implements Equation (32) from XLS-66, Section A-2 Equation Glossary. + * + * @param asset Asset used to constrain rounding. + * @param value Gross interest amount from which the fee is taken. + * @param managementFeeRate Broker's rate in tenth-of-a-basis-point units. + * @param scale Exponent for rounding the result downward. + * @return Broker fee, rounded down to the loan scale. */ Number computeManagementFee( @@ -1568,13 +1844,21 @@ computeManagementFee( asset, tenthBipsOfValue(value, managementFeeRate), scale, Number::RoundingMode::Downward); } -/* - * Given the loan parameters, compute the derived properties of the loan. +/** Compute all derived loan properties from the raw input parameters. * - * Pulls together several formulas from the XLS-66 spec, which are noted at each - * step, plus the concepts from 3.2.4.3 Conceptual Loan Value. They are used for - * to check some of the conditions in 3.2.1.5 Failure Conditions for the LoanSet - * transaction. + * Convenience overload that converts `interestRate` and `paymentInterval` to a + * periodic rate via `loanPeriodicRate()` and delegates to the `periodicRate` + * overload. See that overload's documentation for full details. + * + * @param rules Active amendment rules. + * @param asset Loan asset. + * @param principalOutstanding Requested or remaining principal. + * @param interestRate Annual interest rate in tenth-of-a-basis-point units. + * @param paymentInterval Length of one payment period in seconds. + * @param paymentsRemaining Total number of scheduled payments. + * @param managementFeeRate Broker fee rate in tenth-bips. + * @param minimumScale Floor on the derived `loanScale`. + * @return `LoanProperties` suitable for use in `checkLoanGuards()`. */ LoanProperties computeLoanProperties( @@ -1599,13 +1883,24 @@ computeLoanProperties( minimumScale); } -/* - * Given the loan parameters, compute the derived properties of the loan. +/** Compute all derived loan properties from a pre-converted periodic rate. * - * Pulls together several formulas from the XLS-66 spec, which are noted at each - * step, plus the concepts from 3.2.4.3 Conceptual Loan Value. They are used for - * to check some of the conditions in 3.2.1.5 Failure Conditions for the LoanSet - * transaction. + * Calculates `periodicPayment`, the rounded total value outstanding, the + * `loanScale` (derived from the `STAmount` exponent of the total value, + * clamped to `minimumScale`), and `firstPaymentPrincipal`. The results are + * intended for `checkLoanGuards()` and populate `LoanProperties` for storage + * in the Loan ledger object. Called at loan creation and after overpayment + * re-amortization. Implements concepts from XLS-66 §3.2.4.3 and equations + * 30-33 from Section A-2. + * + * @param rules Active amendment rules. + * @param asset Loan asset. + * @param principalOutstanding Requested or remaining principal. + * @param periodicRate Pre-computed per-period interest rate. + * @param paymentsRemaining Total number of scheduled payments. + * @param managementFeeRate Broker fee rate in tenth-bips. + * @param minimumScale Floor on the derived `loanScale`. + * @return `LoanProperties` with all fields computed and ready for validation. */ LoanProperties computeLoanProperties( @@ -1685,11 +1980,33 @@ computeLoanProperties( }; } -/* - * This is the main function to make a loan payment. - * This function handles regular, late, full, and overpayments. - * It is an implementation of the make_payment function from the XLS-66 - * spec. Section 3.2.4.4 +/** Execute a loan payment transaction and return the breakdown of amounts paid. + * + * The top-level entry point called by `LoanPay::doApply()`. Reads all relevant + * fields from the Loan SLE via `ValueProxy` objects that write through on + * assignment, then dispatches to the appropriate calculation path based on + * `paymentType`: + * + * - **Regular / Overpayment**: loops up to `kLOAN_MAXIMUM_PAYMENTS_PER_TRANSACTION` + * times applying `computePaymentComponents()` + `doPayment()`. If + * `paymentType == Overpayment` and funds remain after all regular payments, + * `computeOverpaymentComponents()` + `doOverpayment()` handle re-amortization. + * - **Late**: calls `computeLatePayment()` then `doPayment()`. + * - **Full**: calls `computeFullPayment()` then `doPayment()`. + * + * Any overdue payment not flagged `Late` is rejected with `tecEXPIRED`. Loan + * completion (all proxies zeroed) and schedule advancement are handled inside + * `doPayment()`. Implements `make_payment` from XLS-66 §3.2.4.4. + * + * @param asset Loan asset (for rounding and balance operations). + * @param view Apply view providing rules, parent close time, and SLE mutation. + * @param loan Mutable reference to the Loan SLE. + * @param brokerSle Const reference to the LoanBroker SLE (supplies `sfManagementFeeRate`). + * @param amount Amount the borrower is paying. + * @param paymentType One of `Regular`, `Late`, `Full`, or `Overpayment`. + * @param j Journal for diagnostic logging. + * @return `Expected` with payment breakdown on success, or + * an error TER (e.g. `tecEXPIRED`, `tecINSUFFICIENT_PAYMENT`, `tecKILLED`). */ Expected loanMakePayment( diff --git a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp index 75fe61b34b..4209487cda 100644 --- a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp @@ -1,3 +1,14 @@ +/** @file + * MPT-specific ledger helper implementations. + * + * Covers freeze checking, transfer-rate encoding, holding lifecycle, + * two-phase authorization, escrow accounting, and supply-overflow safety + * for Multi-Purpose Tokens (MPT). Functions here are the MPT counterpart to + * `RippleStateHelpers.cpp`; the asset-agnostic `TokenHelpers.cpp` dispatches + * to them via `std::visit` on the `Asset` variant. + * + * @see RippleStateHelpers.cpp, TokenHelpers.cpp + */ #include #include @@ -38,6 +49,15 @@ namespace xrpl { +/** Check whether an entire MPT issuance is globally frozen. + * + * Reads the `MPTIssuance` SLE and tests `lsfMPTLocked`. A missing issuance + * SLE is treated as unfrozen (returns `false`). + * + * @param view The ledger state to query. + * @param mptIssue The MPT issuance to check. + * @return `true` if `lsfMPTLocked` is set on the issuance; `false` otherwise. + */ bool isGlobalFrozen(ReadView const& view, MPTIssue const& mptIssue) { @@ -46,6 +66,16 @@ isGlobalFrozen(ReadView const& view, MPTIssue const& mptIssue) return false; } +/** Check whether a specific account's MPToken holding is individually frozen. + * + * Reads the per-account `MPToken` SLE and tests `lsfMPTLocked`. A missing + * `MPToken` SLE (account has no holding) is treated as unfrozen. + * + * @param view The ledger state to query. + * @param account The account whose holding is checked. + * @param mptIssue The MPT issuance to check against. + * @return `true` if the account's `MPToken` carries `lsfMPTLocked`; `false` otherwise. + */ bool isIndividualFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue) { @@ -54,6 +84,19 @@ isIndividualFrozen(ReadView const& view, AccountID const& account, MPTIssue cons return false; } +/** Check whether an account's access to an MPT issuance is frozen by any tier. + * + * Applies three checks in order: global issuance lock (`isGlobalFrozen`), + * per-account holding lock (`isIndividualFrozen`), and vault pseudo-account + * freeze (`isVaultPseudoAccountFrozen`). Short-circuits on the first match. + * + * @param view The ledger state to query. + * @param account The account to check. + * @param mptIssue The MPT issuance to check against. + * @param depth Recursion depth passed to `isVaultPseudoAccountFrozen`; guards + * against pathological nested-vault configurations. + * @return `true` if any freeze tier applies; `false` otherwise. + */ bool isFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue, int depth) { @@ -61,6 +104,20 @@ isFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssu isVaultPseudoAccountFrozen(view, account, mptIssue, depth); } +/** Check whether any account in a set is frozen for an MPT issuance. + * + * Applies all three freeze tiers but deliberately sequences them across + * separate passes to minimise cost: global freeze is tested once and + * short-circuits immediately; individual freezes are checked for every + * account before the more expensive vault pseudo-account recursion begins. + * + * @param view The ledger state to query. + * @param accounts The set of accounts to check. + * @param mptIssue The MPT issuance to check against. + * @param depth Recursion depth guard forwarded to `isVaultPseudoAccountFrozen`. + * @return `true` if the global freeze is set, or if any account carries an + * individual freeze, or if any account is a frozen vault pseudo-account. + */ [[nodiscard]] bool isAnyFrozen( ReadView const& view, @@ -86,12 +143,22 @@ isAnyFrozen( return false; } +/** Convert the `sfTransferFee` field of an MPT issuance to the XRPL `Rate` type. + * + * `sfTransferFee` is a `uint16` in the range 0–50,000 representing 0–50%. + * The `Rate` encoding adds it to the `1,000,000,000` parity base using the + * formula `1,000,000,000 + (10,000 × fee)`, so a 50% fee becomes + * `1,500,000,000`. When `sfTransferFee` is absent, `kPARITY_RATE` + * (`1,000,000,000`) is returned, indicating no fee. + * + * @param view The ledger state to query. + * @param issuanceID The `MPTokenIssuanceID` of the issuance. + * @return The transfer rate as a `Rate` value; `kPARITY_RATE` when no fee + * is configured or the issuance SLE is absent. + */ Rate transferRate(ReadView const& view, MPTID const& issuanceID) { - // fee is 0-50,000 (0-50%), rate is 1,000,000,000-2,000,000,000 - // For example, if transfer fee is 50% then 10,000 * 50,000 = 500,000 - // which represents 50% of 1,000,000,000 if (auto const sle = view.read(keylet::mptIssuance(issuanceID)); sle && sle->isFieldPresent(sfTransferFee)) { @@ -103,6 +170,18 @@ transferRate(ReadView const& view, MPTID const& issuanceID) return kPARITY_RATE; } +/** Read-only pre-check: verify that an independent holding can be created. + * + * Validates two preconditions before `addEmptyHolding` mutates the ledger: + * the `MPTIssuance` must exist, and it must carry `lsfMPTCanTransfer`. + * Tokens without `lsfMPTCanTransfer` can only move directly between the + * issuer and counterparties, making independent holdings meaningless. + * + * @param view The ledger state to query. + * @param mptIssue The MPT issuance the caller wants to hold. + * @return `tesSUCCESS`, `tecOBJECT_NOT_FOUND` if the issuance SLE is + * absent, or `tecNO_AUTH` if `lsfMPTCanTransfer` is not set. + */ [[nodiscard]] TER canAddHolding(ReadView const& view, MPTIssue const& mptIssue) { @@ -120,6 +199,24 @@ canAddHolding(ReadView const& view, MPTIssue const& mptIssue) return tesSUCCESS; } +/** Create a zero-balance `MPToken` holding for `accountID`. + * + * Validates that the issuance exists and is not globally locked, rejects + * duplicates, and silently succeeds for the issuer (who never holds a + * `MPToken` SLE for their own issuance). Otherwise delegates to + * `authorizeMPToken` which enforces the reserve requirement and inserts + * the SLE into the owner directory. + * + * @param view The mutable ledger state. + * @param accountID The account requesting the holding. + * @param priorBalance XRP balance before this transaction; used for the + * reserve check inside `authorizeMPToken`. + * @param mptIssue The MPT issuance to hold. + * @param journal Logging sink. + * @return `tesSUCCESS`, `tecDUPLICATE` if a holding already exists, + * `tecINSUFFICIENT_RESERVE` if reserves are too low, or `tefINTERNAL` + * if the issuance is missing or globally locked (invariant violations). + */ [[nodiscard]] TER addEmptyHolding( ApplyView& view, @@ -142,6 +239,31 @@ addEmptyHolding( return authorizeMPToken(view, priorBalance, mptID, accountID, journal); } +/** Core MPToken SLE lifecycle function — create, delete, or toggle authorization. + * + * The `holderID` parameter determines which role `account` plays: + * - When `holderID` is `nullopt`, `account` is the holder submitting the + * transaction. Without `tfMPTUnauthorize`, a new `MPToken` SLE is created + * and linked into the owner directory (after a reserve check). With + * `tfMPTUnauthorize`, the existing SLE is removed and owner count decremented. + * Reserve is only enforced when `ownerCount >= 2`, mirroring trust-line policy. + * - When `holderID` is set, `account` must be the issuance's issuer. The + * function toggles `lsfMPTAuthorized` on the holder's existing `MPToken` SLE. + * + * @param view The mutable ledger state. + * @param priorBalance XRP balance before this transaction; used only for the + * reserve check when creating a new holding (`holderID` is `nullopt` and + * `tfMPTUnauthorize` is not set). + * @param mptIssuanceID The issuance being authorized or deauthorized. + * @param account Submitting account: the holder (when `holderID` is absent) + * or the issuer (when `holderID` is set). + * @param journal Logging sink. + * @param flags Transaction flags; `tfMPTUnauthorize` selects the + * delete/deauthorize path. + * @param holderID When set, `account` is the issuer and this is the holder + * whose `lsfMPTAuthorized` flag is toggled. + * @return `tesSUCCESS` or a `tec`/`tef` error code. + */ [[nodiscard]] TER authorizeMPToken( ApplyView& view, @@ -156,14 +278,8 @@ authorizeMPToken( if (!sleAcct) return tecINTERNAL; // LCOV_EXCL_LINE - // If the account that submitted the tx is a holder - // Note: `account_` is holder's account - // `holderID` is NOT used if (!holderID) { - // When a holder wants to unauthorize/delete a MPT, the ledger must - // - delete mptokenKey from owner directory - // - delete the MPToken if ((flags & tfMPTUnauthorize) != 0u) { auto const mptokenKey = keylet::mptoken(mptIssuanceID, account); @@ -183,15 +299,8 @@ authorizeMPToken( return tesSUCCESS; } - // A potential holder wants to authorize/hold a mpt, the ledger must: - // - add the new mptokenKey to the owner directory - // - create the MPToken object for the holder - - // The reserve that is required to create the MPToken. Note - // that although the reserve increases with every item - // an account owns, in the case of MPTokens we only - // *enforce* a reserve if the user owns more than two - // items. This is similar to the reserve requirements of trust lines. + // Reserve is only enforced when the account already owns more than two + // objects; the first two items are free (same policy as trust lines). std::uint32_t const uOwnerCount = sleAcct->getFieldU32(sfOwnerCount); XRPAmount const reserveCreate( (uOwnerCount < 2) ? XRPAmount(beast::kZERO) @@ -200,7 +309,6 @@ authorizeMPToken( if (priorBalance < reserveCreate) return tecINSUFFICIENT_RESERVE; - // Defensive check before we attempt to create MPToken for the issuer auto const mpt = view.read(keylet::mptIssuance(mptIssuanceID)); if (!mpt || mpt->getAccountID(sfIssuer) == account) { @@ -221,7 +329,6 @@ authorizeMPToken( (*mptoken)[sfFlags] = 0; view.insert(mptoken); - // Update owner count. adjustOwnerCount(view, sleAcct, 1, journal); return tesSUCCESS; @@ -231,9 +338,6 @@ authorizeMPToken( if (!sleMptIssuance) return tecINTERNAL; // LCOV_EXCL_LINE - // If the account that submitted this tx is the issuer of the MPT - // Note: `account_` is issuer's account - // `holderID` is holder's account if (account != (*sleMptIssuance)[sfIssuer]) return tecINTERNAL; // LCOV_EXCL_LINE @@ -244,18 +348,10 @@ authorizeMPToken( std::uint32_t const flagsIn = sleMpt->getFieldU32(sfFlags); std::uint32_t flagsOut = flagsIn; - // Issuer wants to unauthorize the holder, unset lsfMPTAuthorized on - // their MPToken if ((flags & tfMPTUnauthorize) != 0u) - { flagsOut &= ~lsfMPTAuthorized; - } - // Issuer wants to authorize a holder, set lsfMPTAuthorized on their - // MPToken else - { flagsOut |= lsfMPTAuthorized; - } if (flagsIn != flagsOut) sleMpt->setFieldU32(sfFlags, flagsOut); @@ -264,6 +360,25 @@ authorizeMPToken( return tesSUCCESS; } +/** Delete a zero-balance `MPToken` holding. + * + * Deletion mirror of `addEmptyHolding`. Succeeds immediately if the caller + * is the issuer and no `MPToken` SLE exists (the normal case). If an SLE is + * found for the issuer it is deleted defensively — MPT accounting does not + * create such a situation, but the guard prevents ledger imbalance. Rejects + * with `tecHAS_OBLIGATIONS` if `sfMPTAmount` or (under `fixSecurity3_1_3`) + * `sfLockedAmount` is non-zero, because deleting a token with a live balance + * would corrupt `sfOutstandingAmount` on the issuance. On all other paths, + * delegates to `authorizeMPToken` with `tfMPTUnauthorize` to remove the SLE + * and decrement the owner count without duplicating that logic here. + * + * @param view The mutable ledger state. + * @param accountID The account whose holding is being removed. + * @param mptIssue The MPT issuance. + * @param journal Logging sink. + * @return `tesSUCCESS`, `tecOBJECT_NOT_FOUND` if no holding exists (non-issuer), + * or `tecHAS_OBLIGATIONS` if the holding still carries a balance. + */ [[nodiscard]] TER removeEmptyHolding( ApplyView& view, @@ -271,18 +386,11 @@ removeEmptyHolding( MPTIssue const& mptIssue, beast::Journal journal) { - // If the account is the issuer, then no token should exist. MPTs do not - // have the legacy ability to create such a situation, but check anyway. If - // a token does exist, it will get deleted. If not, return success. bool const accountIsIssuer = accountID == mptIssue.getIssuer(); auto const& mptID = mptIssue.getMptID(); auto const mptoken = view.peek(keylet::mptoken(mptID, accountID)); if (!mptoken) return accountIsIssuer ? (TER)tesSUCCESS : (TER)tecOBJECT_NOT_FOUND; - // Unlike a trust line, if the account is the issuer, and the token has a - // balance, it can not just be deleted, because that will throw the issuance - // accounting out of balance, so fail. Since this should be impossible - // anyway, I'm not going to put any effort into it. if (mptoken->at(sfMPTAmount) != 0 || (view.rules().enabled(fixSecurity3_1_3) && (*mptoken)[~sfLockedAmount].valueOr(0) != 0)) return tecHAS_OBLIGATIONS; @@ -297,6 +405,32 @@ removeEmptyHolding( ); } +/** Preclaim (read-only) authorization check for an MPT holding. + * + * Issuers are always authorized. Vault and `LoanBroker` pseudo-accounts are + * implicitly authorized (under `featureSingleAssetVault`). When the issuance + * carries `sfDomainID`, credential-based authorization via + * `credentials::validDomain` takes precedence over `lsfMPTAuthorized` — a + * passing domain check succeeds even if no `MPToken` SLE exists, and even if + * the SLE's `lsfMPTAuthorized` flag is not set. + * + * When the issuance belongs to a vault pseudo-account, the function recurses + * into the vault's underlying asset's `requireAuth` (bounded by `depth` + * vs. `kMAX_ASSET_CHECK_DEPTH`). This recursion is purely defensive; the + * ledger does not currently permit nested-vault MPT configurations. + * + * `WeakAuth` intentionally allows a missing `MPToken` SLE (used in MPToken V2 + * flows where the token is created on demand during apply). + * + * @param view The ledger state to query (read-only; called in preclaim). + * @param mptIssue The MPT issuance being accessed. + * @param account The account requesting access. + * @param authType Controls leniency toward missing `MPToken` SLEs. + * @param depth Current recursion depth; guards against theoretical infinite + * recursion through nested vault configurations. + * @return `tesSUCCESS` if authorized, `tecOBJECT_NOT_FOUND` if the issuance + * is absent, or `tecNO_AUTH` / `tecEXPIRED` on authorization failure. + */ [[nodiscard]] TER requireAuth( ReadView const& view, @@ -312,7 +446,6 @@ requireAuth( auto const mptIssuer = sleIssuance->getAccountID(sfIssuer); - // issuer is always "authorized" if (mptIssuer == account) // Issuer won't have MPToken return tesSUCCESS; @@ -323,7 +456,6 @@ requireAuth( if (depth >= kMAX_ASSET_CHECK_DEPTH) return tecINTERNAL; // LCOV_EXCL_LINE - // requireAuth is recursive if the issuer is a vault pseudo-account auto const sleIssuer = view.read(keylet::account(mptIssuer)); if (!sleIssuer) return tefINTERNAL; // LCOV_EXCL_LINE @@ -348,7 +480,6 @@ requireAuth( auto const mptokenID = keylet::mptoken(mptID.key, account); auto const sleToken = view.read(mptokenID); - // if account has no MPToken, fail if (!sleToken && (authType == AuthType::StrongAuth || authType == AuthType::Legacy)) return tecNO_AUTH; @@ -376,12 +507,10 @@ requireAuth( if (featureSAVEnabled) { - // Implicitly authorize Vault and LoanBroker pseudo-accounts if (isPseudoAccount(view, account, {&sfVaultID, &sfLoanBrokerID})) return tesSUCCESS; } - // mptoken must be authorized if issuance enabled requireAuth if (sleIssuance->isFlag(lsfMPTRequireAuth) && (!sleToken || !sleToken->isFlag(lsfMPTAuthorized))) return tecNO_AUTH; @@ -389,6 +518,28 @@ requireAuth( return tesSUCCESS; // Note: sleToken might be null } +/** Apply-phase (mutating) MPT authorization enforcement. + * + * Called from `doApply` after `requireAuth` passed in preclaim. Handles the + * case preclaim cannot: when a domain-authorized account lacks a `MPToken` + * SLE, this function materializes it on the fly via `authorizeMPToken`. + * `verifyValidDomain` is called here (not in preclaim) so that any expired + * credential objects are deleted as a side effect. + * + * The implementation is a complete case analysis on + * `(authorizedByDomain, sleToken != nullptr)`. Each branch carries an + * `XRPL_ASSERT` documenting the expected invariant; an `UNREACHABLE` guard + * on the final else branch confirms that all cases are covered. + * + * @param view The mutable ledger state. + * @param mptIssuanceID The issuance being accessed. + * @param account The account being authorized; must not be the issuer. + * @param priorBalance XRP balance; forwarded to `authorizeMPToken` when a + * new `MPToken` SLE must be created for a domain-authorized account. + * @param j Logging sink. + * @return `tesSUCCESS` if the account is authorized; `tecNO_AUTH` or + * `tecEXPIRED` if not; `tefINTERNAL` on invariant violations. + */ [[nodiscard]] TER enforceMPTokenAuthorization( ApplyView& view, @@ -489,6 +640,21 @@ enforceMPTokenAuthorization( // LCOV_EXCL_STOP } +/** Check whether a transfer between two accounts is permitted by the issuance. + * + * When `lsfMPTCanTransfer` is absent, third-party transfers are blocked; + * however, transfers where either `from` or `to` is the issuer are always + * allowed regardless of the flag. This mirrors the IOU trust-line policy + * that lets issuers send and receive their own tokens unconditionally. + * + * @param view The ledger state to query. + * @param mptIssue The MPT issuance involved in the transfer. + * @param from The sending account. + * @param to The receiving account. + * @return `tesSUCCESS` if the transfer is permitted, `tecOBJECT_NOT_FOUND` + * if the issuance SLE is absent, or `tecNO_AUTH` if `lsfMPTCanTransfer` + * is unset and neither endpoint is the issuer. + */ TER canTransfer( ReadView const& view, @@ -509,6 +675,17 @@ canTransfer( return tesSUCCESS; } +/** Check whether an asset may be traded on the DEX. + * + * Dispatches via `asset.visit`: XRP/IOU assets always succeed; for MPT, + * reads the issuance SLE and checks `lsfMPTCanTrade`. + * + * @param view The ledger state to query. + * @param asset The asset to check; non-MPT assets always pass. + * @return `tesSUCCESS` if trading is permitted, `tecOBJECT_NOT_FOUND` if + * the MPT issuance SLE is absent, or `tecNO_PERMISSION` if + * `lsfMPTCanTrade` is not set. + */ TER canTrade(ReadView const& view, Asset const& asset) { @@ -524,6 +701,22 @@ canTrade(ReadView const& view, Asset const& asset) }); } +/** Move MPT funds from a holder's spendable balance into escrow. + * + * Decrements `sfMPTAmount` and increments `sfLockedAmount` on the sender's + * `MPToken` SLE, then increments `sfLockedAmount` on the `MPTIssuance` SLE. + * `sfOutstandingAmount` on the issuance is deliberately left unchanged — + * tokens in escrow remain in circulation until the escrow completes. + * All arithmetic is guarded by `canSubtract`/`canAdd` checks; failures are + * marked `LCOV_EXCL_LINE` because they indicate pre-condition violations. + * + * @param view The mutable ledger state. + * @param sender The account placing tokens in escrow; must not be the issuer. + * @param amount The MPT amount to lock; must be a valid `MPTIssue` amount. + * @param j Logging sink. + * @return `tesSUCCESS`, or a `tec`/`tef` error if the issuance or `MPToken` + * SLE is missing, the sender is the issuer, or an arithmetic guard fires. + */ TER lockEscrowMPT(ApplyView& view, AccountID const& sender, STAmount const& amount, beast::Journal j) { @@ -542,8 +735,6 @@ lockEscrowMPT(ApplyView& view, AccountID const& sender, STAmount const& amount, return tecINTERNAL; } // LCOV_EXCL_STOP - // 1. Decrease the MPT Holder MPTAmount - // 2. Increase the MPT Holder EscrowedAmount { auto const mptokenID = keylet::mptoken(mptID.key, sender); auto sle = view.peek(mptokenID); @@ -556,7 +747,6 @@ lockEscrowMPT(ApplyView& view, AccountID const& sender, STAmount const& amount, auto const amt = sle->getFieldU64(sfMPTAmount); auto const pay = amount.mpt().value(); - // Underflow check for subtraction if (!canSubtract(STAmount(mptIssue, amt), STAmount(mptIssue, pay))) { // LCOV_EXCL_START JLOG(j.error()) << "lockEscrowMPT: insufficient MPTAmount for " << to_string(sender) @@ -566,7 +756,6 @@ lockEscrowMPT(ApplyView& view, AccountID const& sender, STAmount const& amount, (*sle)[sfMPTAmount] = amt - pay; - // Overflow check for addition uint64_t const locked = (*sle)[~sfLockedAmount].valueOr(0); if (!canAdd(STAmount(mptIssue, locked), STAmount(mptIssue, pay))) @@ -588,13 +777,10 @@ lockEscrowMPT(ApplyView& view, AccountID const& sender, STAmount const& amount, view.update(sle); } - // 1. Increase the Issuance EscrowedAmount - // 2. DO NOT change the Issuance OutstandingAmount { uint64_t const issuanceEscrowed = (*sleIssuance)[~sfLockedAmount].valueOr(0); auto const pay = amount.mpt().value(); - // Overflow check for addition if (!canAdd(STAmount(mptIssue, issuanceEscrowed), STAmount(mptIssue, pay))) { // LCOV_EXCL_START JLOG(j.error()) << "lockEscrowMPT: overflow on issuance " @@ -617,6 +803,29 @@ lockEscrowMPT(ApplyView& view, AccountID const& sender, STAmount const& amount, return tesSUCCESS; } +/** Release MPT funds from escrow and credit the recipient. + * + * Always decrements `sfLockedAmount` on both the sender's `MPToken` SLE and + * the `MPTIssuance` SLE by `grossAmount`. The recipient path then diverges: + * - Receiver is the issuer: `sfOutstandingAmount` on the issuance is + * decremented by `netAmount` — tokens return to the issuer and retire. + * - Receiver is a third party: `sfMPTAmount` on the receiver's `MPToken` is + * incremented by `netAmount`. + * When `fixTokenEscrowV1` is enabled, `grossAmount` may exceed `netAmount`; + * the difference (the transfer fee) is additionally subtracted from + * `sfOutstandingAmount` because those tokens effectively retire as fee income. + * All arithmetic is guarded by `canSubtract`/`canAdd`; failures are + * `LCOV_EXCL_LINE` and indicate invariant violations. + * + * @param view The mutable ledger state. + * @param sender The escrow grantor; must not be the issuer. + * @param receiver The escrow grantee (may be the issuer). + * @param netAmount The net MPT amount credited to the receiver after fees. + * @param grossAmount The gross MPT amount unlocked from escrow (>= netAmount). + * @param j Logging sink. + * @return `tesSUCCESS`, or a `tec`/`tef` error on missing SLEs or + * arithmetic guard failure. + */ TER unlockEscrowMPT( ApplyView& view, @@ -641,7 +850,6 @@ unlockEscrowMPT( return tecOBJECT_NOT_FOUND; } // LCOV_EXCL_STOP - // Decrease the Issuance EscrowedAmount { if (!sleIssuance->isFieldPresent(sfLockedAmount)) { // LCOV_EXCL_START @@ -653,7 +861,6 @@ unlockEscrowMPT( auto const locked = sleIssuance->getFieldU64(sfLockedAmount); auto const redeem = grossAmount.mpt().value(); - // Underflow check for subtraction if (!canSubtract(STAmount(mptIssue, locked), STAmount(mptIssue, redeem))) { // LCOV_EXCL_START JLOG(j.error()) << "unlockEscrowMPT: insufficient locked amount for " @@ -675,7 +882,6 @@ unlockEscrowMPT( if (issuer != receiver) { - // Increase the MPT Holder MPTAmount auto const mptokenID = keylet::mptoken(mptID.key, receiver); auto sle = view.peek(mptokenID); if (!sle) @@ -687,7 +893,6 @@ unlockEscrowMPT( auto current = sle->getFieldU64(sfMPTAmount); auto delta = netAmount.mpt().value(); - // Overflow check for addition if (!canAdd(STAmount(mptIssue, current), STAmount(mptIssue, delta))) { // LCOV_EXCL_START JLOG(j.error()) << "unlockEscrowMPT: overflow on MPTAmount for " << to_string(receiver) @@ -700,11 +905,9 @@ unlockEscrowMPT( } else { - // Decrease the Issuance OutstandingAmount auto const outstanding = sleIssuance->getFieldU64(sfOutstandingAmount); auto const redeem = netAmount.mpt().value(); - // Underflow check for subtraction if (!canSubtract(STAmount(mptIssue, outstanding), STAmount(mptIssue, redeem))) { // LCOV_EXCL_START JLOG(j.error()) << "unlockEscrowMPT: insufficient outstanding amount for " @@ -722,7 +925,6 @@ unlockEscrowMPT( "cannot unlock MPTs."; return tecINTERNAL; } // LCOV_EXCL_STOP - // Decrease the MPT Holder EscrowedAmount auto const mptokenID = keylet::mptoken(mptID.key, sender); auto sle = view.peek(mptokenID); if (!sle) @@ -740,7 +942,6 @@ unlockEscrowMPT( auto const locked = sle->getFieldU64(sfLockedAmount); auto const delta = grossAmount.mpt().value(); - // Underflow check for subtraction if (!canSubtract(STAmount(mptIssue, locked), STAmount(mptIssue, delta))) { // LCOV_EXCL_START JLOG(j.error()) << "unlockEscrowMPT: insufficient locked amount for " << to_string(sender) @@ -767,7 +968,6 @@ unlockEscrowMPT( if (diff != 0) { auto const outstanding = sleIssuance->getFieldU64(sfOutstandingAmount); - // Underflow check for subtraction if (!canSubtract(STAmount(mptIssue, outstanding), STAmount(mptIssue, diff))) { // LCOV_EXCL_START JLOG(j.error()) << "unlockEscrowMPT: insufficient outstanding amount for " @@ -781,6 +981,19 @@ unlockEscrowMPT( return tesSUCCESS; } +/** Low-level primitive: insert a new `MPToken` SLE and link it into the owner directory. + * + * Inserts the SLE unconditionally; does not check for duplicates, enforce + * reserves, or verify issuance validity. Callers must perform those checks + * before invoking this function. Called by `checkCreateMPT` and + * `authorizeMPToken`. + * + * @param view The mutable ledger state. + * @param mptIssuanceID The issuance the token belongs to. + * @param account The account that will own the `MPToken`. + * @param flags Initial `sfFlags` value for the new SLE. + * @return `tesSUCCESS`, or `tecDIR_FULL` if the owner directory is full. + */ TER createMPToken( ApplyView& view, @@ -807,6 +1020,21 @@ createMPToken( return tesSUCCESS; } +/** Idempotently ensure a `MPToken` holding exists for `holder`. + * + * Succeeds immediately if `holder` is the issuer or if the `MPToken` SLE + * already exists. Otherwise calls `createMPToken` and increments the owner + * count. Suitable for apply-phase callers that need to auto-create a holding + * without performing the full reserve and issuance validity checks that + * `addEmptyHolding` performs. + * + * @param view The mutable ledger state. + * @param mptIssue The MPT issuance the holder will hold. + * @param holder The account to receive the holding. + * @param j Logging sink. + * @return `tesSUCCESS`, `tecDIR_FULL` if the owner directory is full, or + * `tecINTERNAL` if the holder account SLE is missing. + */ TER checkCreateMPT( xrpl::ApplyView& view, @@ -836,12 +1064,30 @@ checkCreateMPT( return tesSUCCESS; } +/** Return the configured supply cap for an MPT issuance. + * + * Returns `sfMaximumAmount` when present, or `kMAX_MP_TOKEN_AMOUNT` (2^63−1) + * when the field is absent, representing an uncapped issuance. The result is + * always non-negative and fits in a `std::int64_t`. + * + * @param sleIssuance The `MPTIssuance` SLE to query. + * @return The maximum allowed outstanding amount. + */ std::int64_t maxMPTAmount(SLE const& sleIssuance) { return sleIssuance[~sfMaximumAmount].value_or(kMAX_MP_TOKEN_AMOUNT); } +/** Compute remaining issuance headroom from a pre-read SLE. + * + * Returns `maxMPTAmount(sleIssuance) - sfOutstandingAmount`. May transiently + * be negative when the payment engine allows `OutstandingAmount` to exceed + * `MaximumAmount` under `AllowMPTOverflow::Yes`. + * + * @param sleIssuance The `MPTIssuance` SLE to query. + * @return Headroom as a signed 64-bit integer; may be negative. + */ std::int64_t availableMPTAmount(SLE const& sleIssuance) { @@ -850,6 +1096,18 @@ availableMPTAmount(SLE const& sleIssuance) return max - outstanding; } +/** Compute remaining issuance headroom by reading the SLE from the view. + * + * Convenience overload that performs the SLE lookup. Unlike the SLE-taking + * overload, throws `std::runtime_error` if the issuance SLE is missing — + * a missing issuance at this call site indicates ledger consistency failure + * rather than a user error, so throwing is appropriate. + * + * @param view The ledger state to query. + * @param mptID The `MPTID` of the issuance. + * @return Headroom as a signed 64-bit integer; may be negative. + * @throws std::runtime_error if the `MPTIssuance` SLE is absent. + */ std::int64_t availableMPTAmount(ReadView const& view, MPTID const& mptID) { @@ -859,6 +1117,23 @@ availableMPTAmount(ReadView const& view, MPTID const& mptID) return availableMPTAmount(*sle); } +/** Determine whether crediting `sendAmount` would overflow the outstanding supply. + * + * Two distinct overflow thresholds are used: + * - `AllowMPTOverflow::No` (direct send): checks `outstandingAmount + sendAmount` + * against `maximumAmount`. Used by `directSendNoFee` where a strict cap is + * enforced. + * - `AllowMPTOverflow::Yes` (payment engine): uses `UINT64_MAX` as the ceiling + * to allow transient in-flight values that exceed `maximumAmount` while + * routing is in progress. The caller is responsible for validating the + * final settled amount. + * + * @param sendAmount The proposed additional issuance; must be non-negative. + * @param outstandingAmount Current `sfOutstandingAmount` from the issuance SLE. + * @param maximumAmount The configured cap (`sfMaximumAmount` or `kMAX_MP_TOKEN_AMOUNT`). + * @param allowOverflow Selects which ceiling to apply. + * @return `true` if adding `sendAmount` would exceed the applicable limit. + */ bool isMPTOverflow( std::int64_t sendAmount, @@ -872,6 +1147,19 @@ isMPTOverflow( return (sendAmount > maximumAmount || outstandingAmount > (limit - sendAmount)); } +/** Determine how much of an MPT an issuer may sell via their own offer. + * + * Reads the available headroom from the issuance SLE and passes it through + * the `ReadView::balanceHookSelfIssueMPT` hook, which in a `PaymentSandbox` + * context deducts `selfDebit` to prevent double-counting tokens already + * committed by in-flight issuer sell steps. + * + * Returns a zero `STAmount` for the issue if the issuance SLE is absent. + * + * @param view The ledger state (or sandbox) to query. + * @param issue The MPT issuance. + * @return The spendable issuer balance after the hook adjustment. + */ STAmount issuerFundsToSelfIssue(ReadView const& view, MPTIssue const& issue) { @@ -884,6 +1172,17 @@ issuerFundsToSelfIssue(ReadView const& view, MPTIssue const& issue) return view.balanceHookSelfIssueMPT(issue, available); } +/** Record that the issuer has sold `amount` of their own MPT in the current step. + * + * Delegates to `ApplyView::issuerSelfDebitHookMPT`, which in a + * `PaymentSandbox` context updates the `selfDebit` field in `DeferredCredits` + * so that `issuerFundsToSelfIssue` correctly limits subsequent issuer sell + * steps within the same payment. + * + * @param view The mutable apply view (typically a `PaymentSandbox`). + * @param issue The MPT issuance. + * @param amount The amount the issuer just sold. + */ void issuerSelfDebitHookMPT(ApplyView& view, MPTIssue const& issue, std::uint64_t amount) { @@ -891,6 +1190,28 @@ issuerSelfDebitHookMPT(ApplyView& view, MPTIssue const& issue, std::uint64_t amo view.issuerSelfDebitHookMPT(issue, amount, available); } +/** Layered permission check for MPT use in DEX and payment transactions. + * + * Non-MPT assets (XRP, IOU) pass immediately. For MPT assets the following + * conditions must all hold: + * 1. The issuer account exists. + * 2. The `MPTIssuance` SLE exists. + * 3. The issuance is not globally locked (`lsfMPTLocked`). + * 4. The issuance has `lsfMPTCanTrade` set. + * 5. For non-issuers: `lsfMPTCanTransfer` is set and the account's `MPToken` + * (if present) is not individually locked. + * + * A missing `MPToken` for a non-issuer returns `tesSUCCESS` intentionally — + * certain transaction types auto-create the holding during apply; those + * transactors perform their own check for a missing `MPToken` when it matters. + * + * @param view The ledger state to query. + * @param txType The transaction type; must be one of the MPT-allowed types. + * @param asset The asset being used; non-MPT assets always pass. + * @param accountID The account whose permissions are checked. + * @return `tesSUCCESS` if permitted; `tecLOCKED`, `tecNO_PERMISSION`, + * `tecNO_ISSUER`, `tecOBJECT_NOT_FOUND`, or `tefINTERNAL` otherwise. + */ static TER checkMPTAllowed(ReadView const& view, TxType txType, Asset const& asset, AccountID const& accountID) { @@ -918,7 +1239,6 @@ checkMPTAllowed(ReadView const& view, TxType txType, Asset const& asset, Account if ((flags & lsfMPTLocked) != 0u) return tecLOCKED; // LCOV_EXCL_LINE - // Offer crossing and Payment if ((flags & lsfMPTCanTrade) == 0) return tecNO_PERMISSION; @@ -928,8 +1248,8 @@ checkMPTAllowed(ReadView const& view, TxType txType, Asset const& asset, Account return tecNO_PERMISSION; auto const mptSle = view.read(keylet::mptoken(issuanceKey.key, accountID)); - // Allow to succeed since some tx create MPToken if it doesn't exist. - // Tx's have their own check for missing MPToken. + // Allow a missing MPToken to succeed: some transaction types create the + // MPToken during apply and perform their own missing-token check later. if (!mptSle) return tesSUCCESS; @@ -940,6 +1260,18 @@ checkMPTAllowed(ReadView const& view, TxType txType, Asset const& asset, Account return tesSUCCESS; } +/** Public wrapper around `checkMPTAllowed` for non-payment transaction types. + * + * Asserts that the caller is not passing `ttPAYMENT` — payment paths use a + * separate permission check (`isDEXAllowed`) and must not call this function + * directly. + * + * @param view The ledger state to query. + * @param txType The transaction type; must not be `ttPAYMENT`. + * @param asset The asset being used. + * @param accountID The account whose permissions are checked. + * @return The result of `checkMPTAllowed`. + */ TER checkMPTTxAllowed( ReadView const& view, @@ -947,7 +1279,6 @@ checkMPTTxAllowed( Asset const& asset, AccountID const& accountID) { - // use isDEXAllowed for payment/offer crossing XRPL_ASSERT(txType != ttPAYMENT, "xrpl::checkMPTTxAllowed : not payment"); return checkMPTAllowed(view, txType, asset, accountID); } diff --git a/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp b/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp index eb69ec93d0..10d6e96455 100644 --- a/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp @@ -1,3 +1,22 @@ +/** + * @file NFTokenHelpers.cpp + * @brief Implementation of NFT paged-directory and offer management helpers. + * + * All NFT lifecycle operations (mint, burn, transfer, offer create/cancel) + * delegate to these helpers instead of manipulating ledger state directly. + * The file owns three families of logic: + * + * 1. **Page management** (`locatePage`, `getPageForToken`, `mergePages`, + * `insertToken`, `removeToken`): maintain the doubly-linked + * `ltNFTOKEN_PAGE` chain and its sorted-token invariants. + * + * 2. **Offer management** (`deleteTokenOffer`, `removeTokenOffersWithLimit`, + * `tokenOfferCreatePreflight/Preclaim/Apply`): insert and remove offers + * from the per-token buy/sell directories and the owner directory. + * + * 3. **Repair** (`repairNFTokenDirectoryLinks`): defensive walk-and-fix of + * broken page links, introduced alongside `fixNFTokenPageLinks`. + */ #include #include @@ -41,32 +60,86 @@ namespace xrpl::nft { +/** Find the read-only NFToken page that may contain `id` for `owner`. + * + * Computes the theoretical lower-bound page key for the token, then calls + * `view.succ()` to find the first actual page key strictly greater than it + * within the owner's range. This O(log N) B-tree lookup avoids walking + * the linked list. Falls back to the max-page key if `succ()` yields + * nothing, so the read returns `nullptr` when no page at all exists. + * + * @param view Read-only ledger view. + * @param owner Account whose NFToken pages are searched. + * @param id Full 256-bit NFToken ID. + * @return The candidate page SLE, or `nullptr` if no page can contain `id`. + */ static std::shared_ptr locatePage(ReadView const& view, AccountID const& owner, uint256 const& id) { auto const first = keylet::nftpage(keylet::nftpageMin(owner), id); auto const last = keylet::nftpageMax(owner); - // This NFT can only be found in the first page with a key that's strictly - // greater than `first`, so look for that, up until the maximum possible - // page. return view.read( Keylet(ltNFTOKEN_PAGE, view.succ(first.key, last.key.next()).value_or(last.key))); } +/** Find the mutable NFToken page that may contain `id` for `owner`. + * + * Same lookup strategy as the `ReadView const&` overload but calls + * `view.peek()` so the returned SLE can be mutated and passed back to + * `view.update()` or `view.erase()` on the same view instance. + * + * @param view Mutable ledger view. + * @param owner Account whose NFToken pages are searched. + * @param id Full 256-bit NFToken ID. + * @return The candidate page SLE, or `nullptr` if no page can contain `id`. + */ static std::shared_ptr locatePage(ApplyView& view, AccountID const& owner, uint256 const& id) { auto const first = keylet::nftpage(keylet::nftpageMin(owner), id); auto const last = keylet::nftpageMax(owner); - // This NFT can only be found in the first page with a key that's strictly - // greater than `first`, so look for that, up until the maximum possible - // page. return view.peek( Keylet(ltNFTOKEN_PAGE, view.succ(first.key, last.key.next()).value_or(last.key))); } +/** Locate or create the NFToken page that should hold `id`, splitting a full + * page when necessary. + * + * If no candidate page exists, a new empty page is created at + * `keylet::nftpage_max(owner)` and `createCallback` is invoked to + * increment the owner reserve count. + * + * If the candidate page is full (`kDIR_MAX_TOKENS_PER_PAGE` tokens), it is + * split at the first equivalent-group boundary on or after the midpoint. + * Splitting strategy: + * - Start at the midpoint, advance past any run of equivalent tokens (same + * low-96-bit prefix). + * - If the entire back half is equivalent, search from the front instead. + * - If `splitIter == narr.end()` after both searches, the page is entirely + * one equivalence class and the new token cannot be placed — return + * `nullptr`. + * - If `splitIter == narr.begin()`, the page holds one class but the + * incoming token belongs to a *different* class: + * - Token sorts higher → leave all current tokens in `narr`; new token + * goes into empty `carr`. + * - Token sorts lower → move all to `carr`; new token fills `narr`. + * After splitting, a new SLE is inserted for the lower half and doubly-linked + * pointers on up to three pages (new, existing, predecessor) are updated. + * `createCallback` fires once more to account for the extra page reserve. + * The new page key is set to `narr.back().next()` when `narr` is still full, + * or to `carr.front()` otherwise, preserving the page-key invariant. + * + * @param view Mutable ledger view. + * @param owner Account that will own the new token. + * @param id Full 256-bit NFToken ID of the token about to be inserted. + * @param createCallback Invoked each time a new page SLE is created; must + * call `adjustOwnerCount` (or equivalent) to charge the reserve. + * @return The mutable SLE of the page that should receive the new token, or + * `nullptr` if the token's equivalence class has exhausted available + * page space. + */ static std::shared_ptr getPageForToken( ApplyView& view, @@ -78,13 +151,9 @@ getPageForToken( auto const first = keylet::nftpage(base, id); auto const last = keylet::nftpageMax(owner); - // This NFT can only be found in the first page with a key that's strictly - // greater than `first`, so look for that, up until the maximum possible - // page. auto cp = view.peek(Keylet(ltNFTOKEN_PAGE, view.succ(first.key, last.key.next()).value_or(last.key))); - // A suitable page doesn't exist; we'll have to create one. if (!cp) { STArray const arr; @@ -97,18 +166,9 @@ getPageForToken( STArray narr = cp->getFieldArray(sfNFTokens); - // The right page still has space: we're good. if (narr.size() != kDIR_MAX_TOKENS_PER_PAGE) return cp; - // We need to split the page in two: the first half of the items in this - // page will go into the new page; the rest will stay with the existing - // page. - // - // Note we can't always split the page exactly in half. All equivalent - // NFTs must be kept on the same page. So when the page contains - // equivalent NFTs, the split may be lopsided in order to keep equivalent - // NFTs on the same page. STArray carr; { // We prefer to keep equivalent NFTs on a page boundary. That gives @@ -117,16 +177,13 @@ getPageForToken( uint256 const cmp = narr[(kDIR_MAX_TOKENS_PER_PAGE / 2) - 1].getFieldH256(sfNFTokenID) & nft::kPAGE_MASK; - // Note that the calls to find_if_not() and (later) find_if() - // rely on the fact that narr is kept in sorted order. + // The calls to find_if_not() and (later) find_if() rely on narr + // being kept in sorted order. auto splitIter = std::find_if_not( narr.begin() + (kDIR_MAX_TOKENS_PER_PAGE / 2), narr.end(), [&cmp](STObject const& obj) { return (obj.getFieldH256(sfNFTokenID) & nft::kPAGE_MASK) == cmp; }); - // If we get all the way from the middle to the end with only - // equivalent NFTokens then check the front of the page for a - // place to make the split. if (splitIter == narr.end()) { splitIter = std::ranges::find_if(narr, [&cmp](STObject const& obj) { @@ -139,33 +196,16 @@ getPageForToken( if (splitIter == narr.end()) return nullptr; - // If splitIter == begin(), then the entire page is filled with - // equivalent tokens. This requires special handling. if (splitIter == narr.begin()) { auto const relation{(id & nft::kPAGE_MASK) <=> cmp}; if (relation == 0) - { - // If the passed in id belongs exactly on this (full) page - // this account simply cannot store the NFT. return nullptr; - } if (relation > 0) - { - // We need to leave the entire contents of this page in - // narr so carr stays empty. The new NFT will be - // inserted in carr. This keeps the NFTs that must be - // together all on their own page. splitIter = narr.end(); - } - - // If neither of those conditions apply then put all of - // narr into carr and produce an empty narr where the new NFT - // will be inserted. Leave the split at narr.begin(). } - // Split narr at splitIter. STArray newCarr(std::make_move_iterator(splitIter), std::make_move_iterator(narr.end())); narr.erase(splitIter, narr.end()); std::swap(carr, newCarr); @@ -212,11 +252,6 @@ getPageForToken( bool compareTokens(uint256 const& a, uint256 const& b) { - // The sort of NFTokens needs to be fully deterministic, but the sort - // is weird because we sort on the low 96-bits first. But if the low - // 96-bits are identical we still need a fully deterministic sort. - // So we sort on the low 96-bits first. If those are equal we sort on - // the whole thing. if (auto const lowBitsCmp{(a & nft::kPAGE_MASK) <=> (b & nft::kPAGE_MASK)}; lowBitsCmp != 0) return lowBitsCmp < 0; @@ -232,11 +267,9 @@ changeTokenURI( { std::shared_ptr const page = locatePage(view, owner, nftokenID); - // If the page couldn't be found, the given NFT isn't owned by this account if (!page) return tecINTERNAL; // LCOV_EXCL_LINE - // Locate the NFT in the page STArray& arr = page->peekFieldArray(sfNFTokens); auto const nftIter = std::ranges::find_if( @@ -258,15 +291,11 @@ changeTokenURI( return tesSUCCESS; } -/** Insert the token in the owner's token directory. */ TER insertToken(ApplyView& view, AccountID owner, STObject&& nft) { XRPL_ASSERT(nft.isFieldPresent(sfNFTokenID), "xrpl::nft::insertToken : has NFT token"); - // First, we need to locate the page the NFT belongs to, creating it - // if necessary. This operation may fail if it is impossible to insert - // the NFT. std::shared_ptr const page = getPageForToken(view, owner, nft[sfNFTokenID], [](ApplyView& view, AccountID const& owner) { adjustOwnerCount( @@ -295,6 +324,22 @@ insertToken(ApplyView& view, AccountID owner, STObject&& nft) return tesSUCCESS; } +/** Merge two adjacent NFToken pages into the higher-keyed page if they fit. + * + * Validates that `p1` and `p2` are genuinely adjacent (correct key order + * and matching forward/backward link fields), then merges only when the + * combined token count does not exceed `kDIR_MAX_TOKENS_PER_PAGE`. On + * success the merged tokens are written into `p2`, `p1` is erased, and + * the predecessor of `p1` (if any) is relinked to `p2`. + * + * @param view Mutable ledger view. + * @param p1 The lower-keyed page (will be erased on a successful merge). + * @param p2 The higher-keyed page (will receive the merged tokens). + * @return `true` if the pages were merged; `false` if the combined token + * count exceeds the page capacity. + * @throws std::runtime_error if `p1`/`p2` are out of order or their link + * fields are inconsistent, indicating corrupted ledger state. + */ static bool mergePages(ApplyView& view, std::shared_ptr const& p1, std::shared_ptr const& p2) { @@ -310,10 +355,6 @@ mergePages(ApplyView& view, std::shared_ptr const& p1, std::shared_ptr auto const p1arr = p1->getFieldArray(sfNFTokens); auto const p2arr = p2->getFieldArray(sfNFTokens); - // Now check whether to merge the two pages; it only makes sense to do - // this it would mean that one of them can be deleted as a result of - // the merge. - if (p1arr.size() + p2arr.size() > kDIR_MAX_TOKENS_PER_PAGE) return false; @@ -326,10 +367,6 @@ mergePages(ApplyView& view, std::shared_ptr const& p1, std::shared_ptr p2->setFieldArray(sfNFTokens, x); - // So, at this point we need to unlink "p1" (since we just emptied it) but - // we need to first relink the directory: if p1 has a previous page (p0), - // load it, point it to p2 and point p2 to it. - p2->makeFieldAbsent(sfPreviousPageMin); if (auto const ppm = (*p1)[~sfPreviousPageMin]) @@ -351,20 +388,17 @@ mergePages(ApplyView& view, std::shared_ptr const& p1, std::shared_ptr return true; } -/** Remove the token from the owner's token directory. */ TER removeToken(ApplyView& view, AccountID const& owner, uint256 const& nftokenID) { std::shared_ptr const page = locatePage(view, owner, nftokenID); - // If the page couldn't be found, the given NFT isn't owned by this account if (!page) return tecNO_ENTRY; return removeToken(view, owner, nftokenID, page); } -/** Remove the token from the owner's token directory. */ TER removeToken( ApplyView& view, @@ -372,7 +406,6 @@ removeToken( uint256 const& nftokenID, std::shared_ptr const& curr) { - // We found a page, but the given NFT may not be in it. auto arr = curr->getFieldArray(sfNFTokens); { @@ -385,7 +418,6 @@ removeToken( arr.erase(x); } - // Page management: auto const loadPage = [&view](std::shared_ptr const& page1, SF_UINT256 const& field) { std::shared_ptr page2; @@ -409,9 +441,6 @@ removeToken( if (!arr.empty()) { - // The current page isn't empty. Update it and then try to consolidate - // pages. Note that this consolidation attempt may actually merge three - // pages into one! curr->setFieldArray(sfNFTokens, arr); view.update(curr); @@ -437,17 +466,14 @@ removeToken( if (prev) { - // With fixNFTokenPageLinks... - // The page is empty and there is a prev. If the last page of the - // directory is empty then we need to: - // 1. Move the contents of the previous page into the last page. - // 2. Fix up the link from prev's previous page. - // 3. Fix up the owner count. - // 4. Erase the previous page. + // Under fixNFTokenPageLinks: when the emptied page is the chain's + // tail (key ends with pageMask = nftpage_max sentinel bits), move + // the predecessor's tokens into it and erase the predecessor. This + // preserves the invariant that the final page always sits at the + // stable keylet::nftpage_max address. if (view.rules().enabled(fixNFTokenPageLinks) && ((curr->key() & nft::kPAGE_MASK) == kPAGE_MASK)) { - // Copy all relevant information from prev to curr. curr->peekFieldArray(sfNFTokens) = prev->peekFieldArray(sfNFTokens); if (auto const prevLink = prev->at(~sfPreviousPageMin)) @@ -475,8 +501,6 @@ removeToken( return tesSUCCESS; } - // The page is empty and not the last page, so we can just unlink it - // and then remove it. if (next) { prev->setFieldH256(sfNextPageMin, next->key()); @@ -491,7 +515,6 @@ removeToken( if (next) { - // Make our next page point to our previous page: if (prev) { next->setFieldH256(sfPreviousPageMin, prev->key()); @@ -508,14 +531,10 @@ removeToken( int cnt = 1; - // Since we're here, try to consolidate the previous and current pages - // of the page we removed (if any) into one. mergePages() _should_ - // always return false. Since tokens are burned one at a time, there - // should never be a page containing one token sitting between two pages - // that have few enough tokens that they can be merged. - // - // But, in case that analysis is wrong, it's good to leave this code here - // just in case. + // Defensively attempt to merge prev and next now that curr is gone. + // In practice mergePages() should always return false here — tokens + // are burned one at a time, so a single-token page between two nearly- + // full pages is implausible — but the merge is cheap and safe. if (prev && next && mergePages( view, @@ -537,11 +556,9 @@ findToken(ReadView const& view, AccountID const& owner, uint256 const& nftokenID { std::shared_ptr const page = locatePage(view, owner, nftokenID); - // If the page couldn't be found, the given NFT isn't owned by this account if (!page) return std::nullopt; - // We found a candidate page, but the given NFT may not be in it. for (auto const& t : page->getFieldArray(sfNFTokens)) { if (t[sfNFTokenID] == nftokenID) @@ -556,16 +573,14 @@ findTokenAndPage(ApplyView& view, AccountID const& owner, uint256 const& nftoken { std::shared_ptr page = locatePage(view, owner, nftokenID); - // If the page couldn't be found, the given NFT isn't owned by this account if (!page) return std::nullopt; - // We found a candidate page, but the given NFT may not be in it. for (auto const& t : page->getFieldArray(sfNFTokens)) { if (t[sfNFTokenID] == nftokenID) { - // This std::optional constructor is explicit, so it is spelled out. + // std::optional constructor is explicit — must spell it out. return std::optional(std::in_place, t, std::move(page)); } } @@ -587,18 +602,15 @@ removeTokenOffersWithLimit(ApplyView& view, Keylet const& directory, std::size_t if (!page) break; - // We get the index of the next page in case the current - // page is deleted after all of its entries have been removed + // Read next-page index before mutating: a fully-drained page is erased + // by deleteTokenOffer, which would invalidate a post-deletion read. pageIndex = (*page)[~sfIndexNext]; auto offerIndexes = page->getFieldV256(sfIndexes); - // We reverse-iterate the offer directory page to delete all entries. - // Deleting an entry in a NFTokenOffer directory page won't cause - // entries from other pages to move to the current, so, it is safe to - // delete entries one by one in the page. It is required to iterate - // backwards to handle iterator invalidation for vector, as we are - // deleting during iteration. + // Reverse-iterate: NFTokenOffer directory pages are vector-backed and + // deleting an entry shifts subsequent indices, so backward traversal + // avoids index corruption without requiring a copy. for (int i = offerIndexes.size() - 1; i >= 0; --i) { if (auto const offer = view.peek(keylet::nftoffer(offerIndexes[i]))) @@ -713,30 +725,20 @@ repairNFTokenDirectoryLinks(ApplyView& view, AccountID const& owner) } if (nextPage->key() == last.key) - { - // We need special handling for the last page. break; - } page = nextPage; } - // When we arrive here, nextPage should have the same index as last. - // If not, then that's something we need to fix. if (!nextPage) { - // It turns out that page is the last page for this owner, but - // that last page does not have the expected final index. We need - // to move the contents of the current last page into a page with the - // correct index. - // - // The owner count does not need to change because, even though - // we're adding a page, we'll also remove the page that used to be - // last. + // `page` is the last page but does not carry the expected + // nftpage_max key — migrate its tokens to a new SLE with the + // correct key. Owner count is unchanged because the old page is + // removed at the same time. didRepair = true; nextPage = std::make_shared(last); - // Copy all relevant information from prev to curr. nextPage->peekFieldArray(sfNFTokens) = page->peekFieldArray(sfNFTokens); if (auto const prevLink = page->at(~sfPreviousPageMin)) @@ -781,10 +783,7 @@ tokenOfferCreatePreflight( std::uint32_t txFlags) { if (amount.negative()) - { - // An offer for a negative amount makes no sense. return temBAD_AMOUNT; - } if (!isXRP(amount)) { @@ -795,8 +794,7 @@ tokenOfferCreatePreflight( return temBAD_AMOUNT; } - // If this is an offer to buy, you must offer something; if it's an - // offer to sell, you can ask for nothing. + // Buy offers must carry a non-zero amount; sell offers may ask for nothing. bool const isSellOffer = (txFlags & tfSellNFToken) != 0u; if (!isSellOffer && !amount) return temBAD_AMOUNT; @@ -804,19 +802,17 @@ tokenOfferCreatePreflight( if (expiration.has_value() && expiration.value() == 0) return temBAD_EXPIRATION; - // The 'Owner' field must be present when offering to buy, but can't - // be present when selling (it's implicit): + // sfOwner must be present for buy offers (identifies token holder) and + // absent for sell offers (seller is implicit). if (owner.has_value() == isSellOffer) return temMALFORMED; if (owner && owner == acctID) return temMALFORMED; - // The destination can't be the account executing the transaction. if (dest && dest == acctID) - { return temMALFORMED; - } + return tesSUCCESS; } @@ -838,8 +834,8 @@ tokenOfferCreatePreclaim( if (!view.exists(keylet::account(nftIssuer))) return tecNO_ISSUER; - // If the IOU issuer and the NFToken issuer are the same, then that - // issuer does not need a trust line to accept their fee. + // Under featureNFTokenMintOffer, an IOU issuer paying royalties in + // their own currency needs no trust line to themselves. if (view.rules().enabled(featureNFTokenMintOffer)) { if (nftIssuer != amount.getIssuer() && @@ -867,27 +863,22 @@ tokenOfferCreatePreclaim( if (isFrozen(view, acctID, amount.get().currency, amount.getIssuer())) return tecFROZEN; - // If this is an offer to buy the token, the account must have the - // needed funds at hand; but note that funds aren't reserved and the - // offer may later become unfunded. + // Buy offers must be funded at submission time; funds are not reserved + // and the offer may later become unfunded. IOU issuers may use their + // own currency, so accountFunds with ZeroIfFrozen is the correct check. if ((txFlags & tfSellNFToken) == 0) { - // We allow an IOU issuer to make a buy offer - // using their own currency. if (accountFunds(view, acctID, amount, FreezeHandling::ZeroIfFrozen, j).signum() <= 0) return tecUNFUNDED_OFFER; } if (dest) { - // If a destination is specified, the destination must already be in - // the ledger. auto const sleDst = view.read(keylet::account(*dest)); if (!sleDst) return tecNO_DST; - // check if the destination has disallowed incoming offers if ((sleDst->getFlags() & lsfDisallowIncomingNFTokenOffer) != 0u) return tecNO_PERMISSION; } @@ -896,8 +887,6 @@ tokenOfferCreatePreclaim( { auto const sleOwner = view.read(keylet::account(*owner)); - // defensively check - // it should not be possible to specify owner that doesn't exist if (!sleOwner) return tecNO_TARGET; @@ -907,11 +896,9 @@ tokenOfferCreatePreclaim( if (view.rules().enabled(fixEnforceNFTokenTrustlineV2) && !amount.native()) { - // If this is a sell offer, check that the account is allowed to - // receive IOUs. If this is a buy offer, we have to check that trustline - // is authorized, even though we previously checked it's balance via - // accountHolds. This is due to a possibility of existence of - // unauthorized trustlines with balance + // Even for buy offers where we already checked balance via + // accountFunds, trust-line authorization must be verified separately: + // unauthorized trust lines can carry a non-zero balance. auto const res = nft::checkTrustlineAuthorized(view, acctID, j, amount.asset().get()); if (!isTesSuccess(res)) @@ -940,9 +927,7 @@ tokenOfferCreateApply( auto const offerID = keylet::nftoffer(acctID, seqProxy.value()); - // Create the offer: { - // Token offers are always added to the owner's owner directory: auto const ownerNode = view.dirInsert(keylet::ownerDir(acctID), offerID, describeOwnerDir(acctID)); @@ -951,8 +936,6 @@ tokenOfferCreateApply( bool const isSellOffer = (txFlags & tfSellNFToken) != 0u; - // Token offers are also added to the token's buy or sell offer - // directory auto const offerNode = view.dirInsert( isSellOffer ? keylet::nftSells(nftokenID) : keylet::nftBuys(nftokenID), offerID, @@ -986,7 +969,6 @@ tokenOfferCreateApply( view.insert(offer); } - // Update owner count. adjustOwnerCount(view, view.peek(acctKeylet), 1, j); return tesSUCCESS; @@ -999,7 +981,6 @@ checkTrustlineAuthorized( beast::Journal const j, Issue const& issue) { - // Only valid for custom currencies XRPL_ASSERT(!isXRP(issue.currency), "xrpl::nft::checkTrustlineAuthorized : valid to check."); if (view.rules().enabled(fixEnforceNFTokenTrustlineV2)) @@ -1014,30 +995,24 @@ checkTrustlineAuthorized( return tecNO_ISSUER; } - // An account can not create a trustline to itself, so no line can - // exist to be authorized. Additionally, an issuer can always accept - // its own issuance. + // An issuer cannot hold a trust line to itself, so no authorization + // check is possible or needed. if (issue.account == id) - { return tesSUCCESS; - } if (issuerAccount->isFlag(lsfRequireAuth)) { auto const trustLine = view.read(keylet::line(id, issue.account, issue.currency)); if (!trustLine) - { return tecNO_LINE; - } - // Entries have a canonical representation, determined by a - // lexicographical "greater than" comparison employing strict - // weak ordering. Determine which entry we need to access. + // Trust-line endpoints are stored in canonical order determined + // by AccountID comparison (strict weak ordering). The flag slot + // (lsfLowAuth vs lsfHighAuth) therefore depends on which side + // `id` occupies. if (!trustLine->isFlag(id > issue.account ? lsfLowAuth : lsfHighAuth)) - { return tecNO_AUTH; - } } } @@ -1051,7 +1026,6 @@ checkTrustlineDeepFrozen( beast::Journal const j, Issue const& issue) { - // Only valid for custom currencies XRPL_ASSERT(!isXRP(issue.currency), "xrpl::nft::checkTrustlineDeepFrozen : valid to check."); if (view.rules().enabled(featureDeepFreeze)) @@ -1066,30 +1040,22 @@ checkTrustlineDeepFrozen( return tecNO_ISSUER; } - // An account can not create a trustline to itself, so no line can - // exist to be frozen. Additionally, an issuer can always accept its - // own issuance. + // An issuer cannot hold a trust line to itself, so no freeze check + // is possible or needed. if (issue.account == id) - { return tesSUCCESS; - } auto const trustLine = view.read(keylet::line(id, issue.account, issue.currency)); if (!trustLine) - { return tesSUCCESS; - } - // There's no difference which side enacted deep freeze, accepting - // tokens shouldn't be possible. + // Either side may enact deep freeze; check both flag slots. bool const deepFrozen = ((*trustLine)[sfFlags] & (lsfLowDeepFreeze | lsfHighDeepFreeze)) != 0u; if (deepFrozen) - { return tecFROZEN; - } } return tesSUCCESS; diff --git a/src/libxrpl/ledger/helpers/OfferHelpers.cpp b/src/libxrpl/ledger/helpers/OfferHelpers.cpp index 03a1170aad..b6652b7641 100644 --- a/src/libxrpl/ledger/helpers/OfferHelpers.cpp +++ b/src/libxrpl/ledger/helpers/OfferHelpers.cpp @@ -1,3 +1,15 @@ +/** @file + * Implements `offerDelete`, the single shared helper for atomically removing + * an offer from an `ApplyView`. + * + * Offer deletion is a multi-step sequence: (1) remove the offer key from the + * owner's personal directory, (2) remove it from the order-book quality + * directory, (3) for hybrid Permissioned DEX offers, remove it from each + * additional domain-specific book directory recorded in `sfAdditionalBooks`, + * (4) decrement the owner's reserve count via `adjustOwnerCount`, and + * (5) erase the SLE. All steps execute within the transaction-scoped + * `ApplyView` buffer and are rolled back if the enclosing transaction fails. + */ #include #include diff --git a/src/libxrpl/ledger/helpers/PaymentChannelHelpers.cpp b/src/libxrpl/ledger/helpers/PaymentChannelHelpers.cpp index 31c206d85b..0fd0a88743 100644 --- a/src/libxrpl/ledger/helpers/PaymentChannelHelpers.cpp +++ b/src/libxrpl/ledger/helpers/PaymentChannelHelpers.cpp @@ -1,3 +1,11 @@ +/** @file + * Implements `closeChannel`, the shared teardown routine for XRPL payment + * channels. The four mutations are ordered deliberately: the channel SLE is + * read for `sfAmount`/`sfBalance` in step 3 and must not be erased until + * step 4. The `tefBAD_LEDGER` and `tefINTERNAL` branches are LCOV-excluded + * because they guard against ledger corruption that cannot arise under correct + * protocol operation. + */ #include #include @@ -24,7 +32,6 @@ closeChannel( beast::Journal j) { AccountID const src = (*slep)[sfAccount]; - // Remove PayChan from owner directory { auto const page = (*slep)[sfOwnerNode]; if (!view.dirRemove(keylet::ownerDir(src), page, key, true)) @@ -36,7 +43,6 @@ closeChannel( } } - // Remove PayChan from recipient's owner directory, if present. if (auto const page = (*slep)[~sfDestinationNode]) { auto const dst = (*slep)[sfDestination]; @@ -49,7 +55,6 @@ closeChannel( } } - // Transfer amount back to owner, decrement owner count auto const sle = view.peek(keylet::account(src)); if (!sle) return tefINTERNAL; // LCOV_EXCL_LINE @@ -60,7 +65,6 @@ closeChannel( adjustOwnerCount(view, sle, -1, j); view.update(sle); - // Remove PayChan from ledger view.erase(slep); return tesSUCCESS; } diff --git a/src/libxrpl/ledger/helpers/PermissionedDEXHelpers.cpp b/src/libxrpl/ledger/helpers/PermissionedDEXHelpers.cpp index 7b4194cccf..25b5e5ae3c 100644 --- a/src/libxrpl/ledger/helpers/PermissionedDEXHelpers.cpp +++ b/src/libxrpl/ledger/helpers/PermissionedDEXHelpers.cpp @@ -1,3 +1,10 @@ +/** @file + * Implements the Permissioned DEX membership predicates. + * + * `accountInDomain` and `offerInDomain` are the two authorization gatekeepers + * for credential-gated order books. They are invoked both from transaction + * preclaim validation and from live order-book traversal in `OfferStream`. + */ #include #include @@ -16,6 +23,22 @@ namespace xrpl::permissioned_dex { +/** Test whether an account currently qualifies as a member of a permissioned domain. + * + * Applies a two-tier check: the domain's `sfOwner` is unconditionally a member + * (preventing bootstrap lock-out); all other accounts must hold at least one + * credential from `sfAcceptedCredentials` that carries `lsfAccepted` and has + * not expired. Expiry is evaluated against `parentCloseTime` — that value is + * finalized and deterministic across all validators, whereas the current close + * time is not yet committed at preclaim phase. + * + * @param view The read-only ledger view to query. + * @param account The account whose domain membership is being tested. + * @param domainID The identifier of the `PermissionedDomain` ledger object. + * @return `true` if @p account is the domain owner or holds at least one + * accepted, non-expired credential listed in the domain; `false` if the + * domain object does not exist or no qualifying credential is found. + */ bool accountInDomain(ReadView const& view, AccountID const& account, Domain const& domainID) { @@ -23,7 +46,6 @@ accountInDomain(ReadView const& view, AccountID const& account, Domain const& do if (!sleDomain) return false; - // domain owner is in the domain if (sleDomain->getAccountID(sfOwner) == account) return true; @@ -41,6 +63,28 @@ accountInDomain(ReadView const& view, AccountID const& account, Domain const& do return inDomain; } +/** Test whether a specific offer is still legitimately part of a permissioned domain. + * + * Performs structural validation on the offer SLE (existence, `sfDomainID` + * presence and match) before delegating the live membership check to + * `accountInDomain`. The structural checks guard against internal invariant + * violations and are marked `LCOV_EXCL_LINE` because they cannot be triggered + * via any valid user-facing input path. Hybrid offers additionally require + * `sfAdditionalBooks`: post-`fixSecurity3_1_3` the array must contain exactly + * one entry; pre-amendment only its presence is required. + * + * @param view The read-only ledger view to query. + * @param offerID The hash identifier of the offer SLE to validate. + * @param domainID The permissioned domain the offer is expected to belong to. + * @param j Journal used to log an error if a hybrid offer has a missing + * or malformed `sfAdditionalBooks` field. + * @return `true` if the offer passes all structural checks and its owner is + * currently a member of @p domainID; `false` otherwise. + * + * @note When this returns `false`, `OfferStream` permanently removes the offer + * from the book via `permRmOffer`, handling the race between offer placement + * and subsequent credential expiry. + */ bool offerInDomain( ReadView const& view, @@ -50,9 +94,6 @@ offerInDomain( { auto const sleOffer = view.read(keylet::offer(offerID)); - // The following are defensive checks that should never happen, since this - // function is used to check against the order book offers, which should not - // have any of the following wrong behavior if (!sleOffer) return false; // LCOV_EXCL_LINE if (!sleOffer->isFieldPresent(sfDomainID)) diff --git a/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp b/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp index 58f44534cf..3869ba3998 100644 --- a/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp +++ b/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp @@ -1,3 +1,19 @@ +/** @file + * Implements trust-line (RippleState) lifecycle and IOU primitives for the + * XRP Ledger. + * + * Covers: credit-limit/balance queries, freeze enforcement, trust-line + * creation and deletion, IOU issuance and redemption (including automatic + * reserve cleanup), authorization checks, zero-balance holding management, + * and AMM-specific cleanup operations. Originally split across Credit.cpp + * and this file; the two halves were merged and the section banners below + * mark the original boundaries. + * + * @note The `sfBalance` field on every `ltRIPPLE_STATE` SLE is stored from + * the low account's perspective (positive = low account holds the IOU). + * Every function that exposes a balance to the caller inverts the sign + * when the querying account is the high side. + */ #include #include @@ -32,11 +48,7 @@ namespace xrpl { -//------------------------------------------------------------------------------ -// -// Credit functions (from Credit.cpp) -// -//------------------------------------------------------------------------------ +// --- Credit queries (merged from Credit.cpp) --- STAmount creditLimit( @@ -96,11 +108,7 @@ creditBalance( return result; } -//------------------------------------------------------------------------------ -// -// Freeze checking (IOU-specific) -// -//------------------------------------------------------------------------------ +// --- Freeze checks (IOU-specific) --- bool isIndividualFrozen( @@ -113,7 +121,6 @@ isIndividualFrozen( return false; if (issuer != account) { - // Check if the issuer froze the line auto const sle = view.read(keylet::line(account, issuer, currency)); if (sle && sle->isFlag((issuer > account) ? lsfHighFreeze : lsfLowFreeze)) return true; @@ -121,8 +128,6 @@ isIndividualFrozen( return false; } -// Can the specified account spend the specified currency issued by -// the specified issuer or does the freeze flag prohibit it? bool isFrozen( ReadView const& view, @@ -137,7 +142,6 @@ isFrozen( return true; if (issuer != account) { - // Check if the issuer froze the line sle = view.read(keylet::line(account, issuer, currency)); if (sle && sle->isFlag((issuer > account) ? lsfHighFreeze : lsfLowFreeze)) return true; @@ -171,11 +175,7 @@ isDeepFrozen( return sle->isFlag(lsfHighDeepFreeze) || sle->isFlag(lsfLowDeepFreeze); } -//------------------------------------------------------------------------------ -// -// Trust line operations -// -//------------------------------------------------------------------------------ +// --- Trust-line lifecycle --- TER trustCreate( @@ -183,16 +183,14 @@ trustCreate( bool const bSrcHigh, AccountID const& uSrcAccountID, AccountID const& uDstAccountID, - uint256 const& uIndex, // --> ripple state entry - SLE::ref sleAccount, // --> the account being set. - bool const bAuth, // --> authorize account. - bool const bNoRipple, // --> others cannot ripple through - bool const bFreeze, // --> funds cannot leave - bool bDeepFreeze, // --> can neither receive nor send funds - STAmount const& saBalance, // --> balance of account being set. - // Issuer should be noAccount() - STAmount const& saLimit, // --> limit for account being set. - // Issuer should be the account being set. + uint256 const& uIndex, + SLE::ref sleAccount, + bool const bAuth, + bool const bNoRipple, + bool const bFreeze, + bool bDeepFreeze, + STAmount const& saBalance, + STAmount const& saLimit, std::uint32_t uQualityIn, std::uint32_t uQualityOut, beast::Journal j) @@ -240,7 +238,6 @@ trustCreate( if (!slePeer) return tecNO_TARGET; - // Remember deletion hints. sleRippleState->setFieldU64(sfLowNode, *lowNode); sleRippleState->setFieldU64(sfHighNode, *highNode); @@ -276,14 +273,14 @@ trustCreate( if ((slePeer->getFlags() & lsfDefaultRipple) == 0) { - // The other side's default is no rippling + // Propagate the peer's preference: absent lsfDefaultRipple means the + // peer wants noRipple on by default for any new line. uFlags |= (bSetHigh ? lsfLowNoRipple : lsfHighNoRipple); } sleRippleState->setFieldU32(sfFlags, uFlags); adjustOwnerCount(view, sleAccount, 1, j); - // ONLY: Create ripple balance. sleRippleState->setFieldAmount(sfBalance, bSetHigh ? -saBalance : saBalance); view.creditHookIOU(uSrcAccountID, uDstAccountID, saBalance, saBalance.zeroed()); @@ -299,7 +296,6 @@ trustDelete( AccountID const& uHighAccountID, beast::Journal j) { - // Detect legacy dirs. std::uint64_t const uLowNode = sleRippleState->getFieldU64(sfLowNode); std::uint64_t const uHighNode = sleRippleState->getFieldU64(sfHighNode); @@ -323,12 +319,39 @@ trustDelete( return tesSUCCESS; } -//------------------------------------------------------------------------------ -// -// IOU issuance/redemption -// -//------------------------------------------------------------------------------ +// --- IOU issuance / redemption --- +/** Opportunistically release the sender's reserve and signal line deletion. + * + * Called by `issueIOU` and `redeemIOU` after every balance mutation. + * Releases the sender's owner-count reserve and clears `lsfLowReserve` / + * `lsfHighReserve` when **all** of the following are true: + * 1. The sender's balance transitioned from positive to zero or negative. + * 2. The sender's reserve flag is currently set. + * 3. The sender's `lsfNoRipple` state disagrees with the issuer's + * `lsfDefaultRipple` — meaning neither side wants this line kept alive. + * 4. The sender's side of the line is not frozen. + * 5. The sender's trust limit is zero. + * 6. The sender's `sfLowQualityIn` / `sfHighQualityIn` is zero. + * 7. The sender's `sfLowQualityOut` / `sfHighQualityOut` is zero. + * + * Returns `true` only when the reserve was cleared *and* the final balance + * is zero *and* the peer's reserve flag is also clear — meaning neither + * side holds a stake in the line and the caller should delete it. The + * caller must write the final balance onto the SLE before calling + * `trustDelete` so the deletion metadata captures accurate state. + * + * @param view Mutable ledger view. + * @param state The `ltRIPPLE_STATE` SLE, already peeked from @p view. + * @param bSenderHigh `true` if the sender occupies the high slot. + * @param sender The account sending IOUs (issuer in `issueIOU`, + * holder in `redeemIOU`). + * @param before Sender's balance before the transfer (sender perspective). + * @param after Sender's balance after the transfer (sender perspective). + * @param j Journal for trace/debug logging. + * @return `true` if the trust line should be deleted (neither side has a + * reserve and the balance is zero), `false` otherwise. + */ static bool updateTrustLine( ApplyView& view, @@ -347,33 +370,20 @@ updateTrustLine( if (!sle) return false; - // YYY Could skip this if rippling in reverse. - if (before > beast::kZERO - // Sender balance was positive. - && after <= beast::kZERO - // Sender is zero or negative. - && ((flags & (!bSenderHigh ? lsfLowReserve : lsfHighReserve)) != 0u) - // Sender reserve is set. - && static_cast(flags & (!bSenderHigh ? lsfLowNoRipple : lsfHighNoRipple)) != + if (before > beast::kZERO && after <= beast::kZERO && + ((flags & (!bSenderHigh ? lsfLowReserve : lsfHighReserve)) != 0u) && + static_cast(flags & (!bSenderHigh ? lsfLowNoRipple : lsfHighNoRipple)) != static_cast(sle->getFlags() & lsfDefaultRipple) && ((flags & (!bSenderHigh ? lsfLowFreeze : lsfHighFreeze)) == 0u) && - !state->getFieldAmount(!bSenderHigh ? sfLowLimit : sfHighLimit) - // Sender trust limit is 0. - && (state->getFieldU32(!bSenderHigh ? sfLowQualityIn : sfHighQualityIn) == 0u) - // Sender quality in is 0. - && (state->getFieldU32(!bSenderHigh ? sfLowQualityOut : sfHighQualityOut) == 0u)) - // Sender quality out is 0. + !state->getFieldAmount(!bSenderHigh ? sfLowLimit : sfHighLimit) && + (state->getFieldU32(!bSenderHigh ? sfLowQualityIn : sfHighQualityIn) == 0u) && + (state->getFieldU32(!bSenderHigh ? sfLowQualityOut : sfHighQualityOut) == 0u)) { - // VFALCO Where is the line being deleted? - // Clear the reserve of the sender, possibly delete the line! adjustOwnerCount(view, sle, -1, j); - - // Clear reserve flag. state->setFieldU32(sfFlags, flags & (!bSenderHigh ? ~lsfLowReserve : ~lsfHighReserve)); - // Balance is zero, receiver reserve is clear. - if (!after // Balance is zero. - && ((flags & (bSenderHigh ? lsfLowReserve : lsfHighReserve)) == 0u)) + // Neither side holds a stake: caller should delete the line. + if (!after && ((flags & (bSenderHigh ? lsfLowReserve : lsfHighReserve)) == 0u)) return true; } return false; @@ -390,11 +400,7 @@ issueIOU( XRPL_ASSERT( !isXRP(account) && !isXRP(issue.account), "xrpl::issueIOU : neither account nor issuer is XRP"); - - // Consistency check XRPL_ASSERT(issue == amount.get(), "xrpl::issueIOU : matching issue"); - - // Can't send to self! XRPL_ASSERT(issue.account != account, "xrpl::issueIOU : not issuer account"); JLOG(j.trace()) << "issueIOU: " << to_string(account) << ": " << amount.getFullText(); @@ -422,9 +428,8 @@ issueIOU( if (bSenderHigh) finalBalance.negate(); - // Adjust the balance on the trust line if necessary. We do this even - // if we are going to delete the line to reflect the correct balance - // at the time of deletion. + // Write the final balance even when mustDelete is true: deletion + // metadata must reflect accurate state at the moment of removal. state->setFieldAmount(sfBalance, finalBalance); if (mustDelete) { @@ -484,11 +489,7 @@ redeemIOU( XRPL_ASSERT( !isXRP(account) && !isXRP(issue.account), "xrpl::redeemIOU : neither account nor issuer is XRP"); - - // Consistency check XRPL_ASSERT(issue == amount.get(), "xrpl::redeemIOU : matching issue"); - - // Can't send to self! XRPL_ASSERT(issue.account != account, "xrpl::redeemIOU : not issuer account"); JLOG(j.trace()) << "redeemIOU: " << to_string(account) << ": " << amount.getFullText(); @@ -514,9 +515,8 @@ redeemIOU( if (bSenderHigh) finalBalance.negate(); - // Adjust the balance on the trust line if necessary. We do this even - // if we are going to delete the line to reflect the correct balance - // at the time of deletion. + // Write the final balance even when mustDelete is true: deletion + // metadata must reflect accurate state at the moment of removal. state->setFieldAmount(sfBalance, finalBalance); if (mustDelete) @@ -533,9 +533,8 @@ redeemIOU( return tesSUCCESS; } - // In order to hold an IOU, a trust line *MUST* exist to track the - // balance. If it doesn't, then something is very wrong. Don't try - // to continue. + // A holder cannot redeem a balance without an existing trust line — + // ledger state is corrupt if we reach here. // LCOV_EXCL_START JLOG(j.fatal()) << "redeemIOU: " << to_string(account) << " attempts to " << "redeem " << amount.getFullText() << " but no trust line exists!"; @@ -544,11 +543,7 @@ redeemIOU( // LCOV_EXCL_STOP } -//------------------------------------------------------------------------------ -// -// Authorization and transfer checks (IOU-specific) -// -//------------------------------------------------------------------------------ +// --- Authorization and transfer checks (IOU-specific) --- TER requireAuth(ReadView const& view, Issue const& issue, AccountID const& account, AuthType authType) @@ -557,12 +552,9 @@ requireAuth(ReadView const& view, Issue const& issue, AccountID const& account, return tesSUCCESS; auto const trustLine = view.read(keylet::line(account, issue.account, issue.currency)); - // If account has no line, and this is a strong check, fail if (!trustLine && authType == AuthType::StrongAuth) return tecNO_LINE; - // If this is a weak or legacy check, or if the account has a line, fail if - // auth is required and not set on the line if (auto const issuerAccount = view.read(keylet::account(issue.account)); issuerAccount && (((*issuerAccount)[sfFlags] & lsfRequireAuth) != 0u)) { @@ -593,8 +585,8 @@ canTransfer(ReadView const& view, Issue const& issue, AccountID const& from, Acc return tefINTERNAL; // LCOV_EXCL_LINE auto const isRippleDisabled = [&](AccountID account) -> bool { - // Line might not exist, but some transfers can create it. If this - // is the case, just check the default ripple on the issuer account. + // A payment may create the line on the fly; if none exists yet, fall + // back to the issuer's lsfDefaultRipple as the "intended" state. auto const line = view.read(keylet::line(account, issue)); if (line) { @@ -604,18 +596,13 @@ canTransfer(ReadView const& view, Issue const& issue, AccountID const& from, Acc return !sleIssuer->isFlag(lsfDefaultRipple); }; - // Fail if rippling disabled on both trust lines if (isRippleDisabled(from) && isRippleDisabled(to)) return terNO_RIPPLE; return tesSUCCESS; } -//------------------------------------------------------------------------------ -// -// Empty holding operations (IOU-specific) -// -//------------------------------------------------------------------------------ +// --- Empty holding operations (IOU-specific) --- TER addEmptyHolding( @@ -625,7 +612,6 @@ addEmptyHolding( Issue const& issue, beast::Journal journal) { - // Every account can hold XRP. An issuer can issue directly. if (issue.native() || accountID == issue.getIssuer()) return tesSUCCESS; @@ -644,11 +630,9 @@ addEmptyHolding( return tefINTERNAL; // LCOV_EXCL_LINE if (!sleSrc->isFlag(lsfDefaultRipple)) return tecINTERNAL; // LCOV_EXCL_LINE - // If the line already exists, don't create it again. if (view.read(index)) return tecDUPLICATE; - // Can the account cover the trust line reserve ? std::uint32_t const ownerCount = sleDst->at(sfOwnerCount); if (priorBalance < view.fees().accountReserve(ownerCount + 1)) return tecNO_LINE_INSUF_RESERVE; @@ -691,9 +675,6 @@ removeEmptyHolding( return tesSUCCESS; } - // `asset` is an IOU. - // If the account is the issuer, then no line should exist. Check anyway. - // If a line does exist, it will get deleted. If not, return success. bool const accountIsIssuer = accountID == issue.account; auto const line = view.peek(keylet::line(accountID, issue)); if (!line) @@ -701,32 +682,25 @@ removeEmptyHolding( if (!accountIsIssuer && line->at(sfBalance)->iou() != beast::kZERO) return tecHAS_OBLIGATIONS; - // Adjust the owner count(s) if (line->isFlag(lsfLowReserve)) { - // Clear reserve for low account. auto sleLowAccount = view.peek(keylet::account(line->at(sfLowLimit)->getIssuer())); if (!sleLowAccount) return tecINTERNAL; // LCOV_EXCL_LINE adjustOwnerCount(view, sleLowAccount, -1, journal); - // It's not really necessary to clear the reserve flag, since the line - // is about to be deleted, but this will make the metadata reflect an - // accurate state at the time of deletion. + // Clear now so deletion metadata reflects accurate owner-count state. line->clearFlag(lsfLowReserve); } if (line->isFlag(lsfHighReserve)) { - // Clear reserve for high account. auto sleHighAccount = view.peek(keylet::account(line->at(sfHighLimit)->getIssuer())); if (!sleHighAccount) return tecINTERNAL; // LCOV_EXCL_LINE adjustOwnerCount(view, sleHighAccount, -1, journal); - // It's not really necessary to clear the reserve flag, since the line - // is about to be deleted, but this will make the metadata reflect an - // accurate state at the time of deletion. + // Clear now so deletion metadata reflects accurate owner-count state. line->clearFlag(lsfHighReserve); } @@ -755,15 +729,12 @@ deleteAMMTrustLine( bool const ammLow = sleLow->isFieldPresent(sfAMMID); bool const ammHigh = sleHigh->isFieldPresent(sfAMMID); - // can't both be AMM if (ammLow && ammHigh) return tecINTERNAL; // LCOV_EXCL_LINE - // at least one must be if (!ammLow && !ammHigh) return terNO_AMM; - // one must be the target amm if (ammAccountID && (low != *ammAccountID && high != *ammAccountID)) return terNO_AMM; diff --git a/src/libxrpl/ledger/helpers/TokenHelpers.cpp b/src/libxrpl/ledger/helpers/TokenHelpers.cpp index 000f459ef6..e1e9ca4a03 100644 --- a/src/libxrpl/ledger/helpers/TokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/TokenHelpers.cpp @@ -1,3 +1,26 @@ +/** @file + * Unified token-dispatch layer for all non-XRP ledger token operations. + * + * This file implements the asset-agnostic public API declared in + * `TokenHelpers.h`. Every public function accepts an `Asset` + * (`std::variant`) and routes — via `std::visit` or + * `Asset::visit` — to the appropriate type-specific leaf: + * `RippleStateHelpers` for IOU trust lines or `MPTokenHelpers` for + * `MPToken`/`MPTokenIssuance` SLEs. + * + * The file is organized into four sections mirroring the header: + * 1. **Freeze checking** — global, individual, deep-freeze queries and TER + * conversion. + * 2. **Balance queries** — `accountHolds`, `accountFunds`, `transferRate`. + * 3. **Holding lifecycle** — `canAddHolding`, `addEmptyHolding`, + * `removeEmptyHolding`. + * 4. **Money transfers** — `directSendNoFee`, `accountSend`, + * `accountSendMulti`, `transferXRP`, and the static IOU/MPT helpers they + * delegate to. + * + * Higher-level code (payments, offers, AMM, vault operations) calls these + * functions so it never has to branch on token type itself. + */ #include #include @@ -149,6 +172,27 @@ checkDeepFrozen(ReadView const& view, AccountID const& account, Asset const& ass // //------------------------------------------------------------------------------ +/** Look up an IOU trust line and return it only if the account may use it. + * + * Returns `nullptr` (treat as zero balance) in any of these cases: + * - The trust line SLE does not exist. + * - `zeroIfFrozen == ZeroIfFrozen` and the line is individually or + * deep-frozen. + * - `zeroIfFrozen == ZeroIfFrozen`, `fixFrozenLPTokenTransfer` is enabled, + * the `issuer` is an AMM account (`sfAMMID` present on their + * `AccountRoot`), and either the AMM SLE is missing or one of the AMM's + * pool assets is frozen via `isLPTokenFrozen`. This check is a retrofit: + * earlier ledger versions allowed LP-token transfers even when the + * underlying pool assets were frozen. + * + * @param view Read-only ledger view. + * @param account The account whose trust-line balance is needed. + * @param currency The IOU currency. + * @param issuer The IOU issuer (low or high side resolved by keylet). + * @param zeroIfFrozen Whether to suppress the SLE when the line is frozen. + * @param j Journal for trace logging. + * @return The trust-line SLE if usable, `nullptr` otherwise. + */ static SLE::const_pointer getLineIfUsable( ReadView const& view, @@ -173,8 +217,8 @@ getLineIfUsable( return nullptr; } - // when fixFrozenLPTokenTransfer is enabled, if currency is lptoken, - // we need to check if the associated assets have been frozen + // fixFrozenLPTokenTransfer: if the issuer is an AMM account, also + // verify that neither of the AMM's underlying pool assets is frozen. if (view.rules().enabled(fixFrozenLPTokenTransfer)) { auto const sleIssuer = view.read(keylet::account(issuer)); @@ -198,6 +242,29 @@ getLineIfUsable( return sle; } +/** Compute an account-centric IOU trust-line balance from a raw SLE. + * + * Converts the ledger-internal `sfBalance` — which is always stored from the + * low-account's perspective — into the caller's account-centric view. + * If `includeOppositeLimit` is true, adds the *peer's* credit limit so the + * result represents the full spendable amount (the account can draw down the + * peer's limit). Returns zero cleared to the correct `Issue` when @p sle is + * null. + * + * The result is passed through `view.balanceHookIOU` so that + * `PaymentSandbox` can intercept it for deferred-credit accounting. + * + * @param view Read-only ledger view (for `balanceHookIOU`). + * @param sle Trust-line SLE, or null for a non-existent line. + * @param account The account whose perspective is taken. + * @param currency The IOU currency (used when @p sle is null). + * @param issuer The IOU issuer. + * @param includeOppositeLimit Whether to add the peer's credit limit + * (`shFULL_BALANCE` semantics). + * @param j Journal for trace logging. + * @return Balance from @p account's perspective, possibly with borrowed + * credit added. May be negative if the account owes the issuer. + */ static STAmount getTrustLineBalance( ReadView const& view, @@ -216,7 +283,8 @@ getTrustLineBalance( auto const& oppositeField = accountHigh ? sfLowLimit : sfHighLimit; if (accountHigh) { - // Put balance in account terms. + // Trust-line orientation: sfBalance is stored low-account-side; + // negate to convert to the high-account (sender) perspective. amount.negate(); } if (includeOppositeLimit) @@ -425,12 +493,24 @@ transferRate(ReadView const& view, STAmount const& amount) // //------------------------------------------------------------------------------ +/** Check whether a new IOU trust line can be created for @p issue. + * + * XRP always returns `tesSUCCESS`. For IOU, the issuer's `AccountRoot` must + * exist and have `lsfDefaultRipple` set; without it, a new trust line would + * be stuck in a `noRipple` state that prevents payments from routing through + * it. + * + * @param view Read-only ledger view. + * @param issue The IOU to check (XRP passes through unconditionally). + * @return `tesSUCCESS`, `terNO_ACCOUNT` if the issuer SLE is missing, or + * `terNO_RIPPLE` if the issuer has not enabled `lsfDefaultRipple`. + */ [[nodiscard]] TER canAddHolding(ReadView const& view, Issue const& issue) { if (issue.native()) { - return tesSUCCESS; // No special checks for XRP + return tesSUCCESS; } auto const issuer = view.read(keylet::account(issue.getIssuer())); @@ -515,10 +595,36 @@ canTransfer(ReadView const& view, Asset const& asset, AccountID const& from, Acc // //------------------------------------------------------------------------------ -// Direct send w/o fees: -// - Redeeming IOUs and/or sending sender's own IOUs. -// - Create trust line if needed. -// --> bCheckIssuer : normally require issuer to be involved. +/** Adjust an IOU trust-line balance directly, bypassing limits and fees. + * + * Handles two cases: + * - **Line exists**: adjusts `sfBalance` in the sender's direction. If after + * the adjustment the sender's balance crosses zero from positive to + * non-positive and the sender's side meets seven conditions (reserve set, + * no-ripple flag disagrees with `lsfDefaultRipple`, no freeze, zero trust + * limit, zero quality in/out), the function releases the sender's ledger + * reserve and clears the reserve flag. If the balance is then zero *and* + * the receiver's reserve flag is also clear, the trust line is deleted via + * `trustDelete`. + * - **Line does not exist**: creates a new trust line via `trustCreate` with + * the receiver's `noRipple` flag mirrored from their `lsfDefaultRipple` + * setting. This implicit creation only applies to direct (issuer-involved) + * sends. + * + * @note The seven-condition delete path is acknowledged as complex + * ("FIXME…NEEDS to be cleaned up") in the source; it must not be changed + * without careful replay testing. + * + * @param view Mutable ledger view. + * @param uSenderID Sending account; must not be XRP or `noAccount()`. + * @param uReceiverID Receiving account; must not be XRP or `noAccount()`; + * must differ from @p uSenderID. + * @param saAmount Amount to transfer; must carry an IOU asset. + * @param bCheckIssuer If `true`, asserts that the issuer equals sender or + * receiver (disabled for recursive calls from `directSendNoLimitIOU`). + * @param j Journal for trace/debug logging. + * @return `tesSUCCESS`, or a `tec`/`tef` from `trustCreate`/`trustDelete`. + */ static TER directSendNoFeeIOU( ApplyView& view, @@ -531,13 +637,11 @@ directSendNoFeeIOU( AccountID const& issuer = saAmount.getIssuer(); Currency const& currency = saAmount.get().currency; - // Make sure issuer is involved. XRPL_ASSERT( !bCheckIssuer || uSenderID == issuer || uReceiverID == issuer, "xrpl::directSendNoFeeIOU : matching issuer or don't care"); (void)issuer; - // Disallow sending to self. XRPL_ASSERT(uSenderID != uReceiverID, "xrpl::directSendNoFeeIOU : sender is not receiver"); bool const bSenderHigh = uSenderID > uReceiverID; @@ -550,13 +654,12 @@ directSendNoFeeIOU( !isXRP(uReceiverID) && uReceiverID != noAccount(), "xrpl::directSendNoFeeIOU : receiver is not XRP"); - // If the line exists, modify it accordingly. if (auto const sleRippleState = view.peek(index)) { STAmount saBalance = sleRippleState->getFieldAmount(sfBalance); if (bSenderHigh) - saBalance.negate(); // Put balance in sender terms. + saBalance.negate(); // Convert ledger-stored low-side balance to sender perspective. view.creditHookIOU(uSenderID, uReceiverID, saAmount, saBalance); @@ -575,42 +678,35 @@ directSendNoFeeIOU( // FIXME This NEEDS to be cleaned up and simplified. It's impossible // for anyone to understand. if (saBefore > beast::kZERO - // Sender balance was positive. && saBalance <= beast::kZERO - // Sender is zero or negative. && ((uFlags & (!bSenderHigh ? lsfLowReserve : lsfHighReserve)) != 0u) - // Sender reserve is set. && static_cast(uFlags & (!bSenderHigh ? lsfLowNoRipple : lsfHighNoRipple)) != static_cast( view.read(keylet::account(uSenderID))->getFlags() & lsfDefaultRipple) && ((uFlags & (!bSenderHigh ? lsfLowFreeze : lsfHighFreeze)) == 0u) && !sleRippleState->getFieldAmount(!bSenderHigh ? sfLowLimit : sfHighLimit) - // Sender trust limit is 0. && (sleRippleState->getFieldU32(!bSenderHigh ? sfLowQualityIn : sfHighQualityIn) == 0u) - // Sender quality in is 0. && (sleRippleState->getFieldU32(!bSenderHigh ? sfLowQualityOut : sfHighQualityOut) == 0u)) - // Sender quality out is 0. { - // Clear the reserve of the sender, possibly delete the line! + // All seven conditions met: release the sender's reserve. adjustOwnerCount(view, view.peek(keylet::account(uSenderID)), -1, j); - // Clear reserve flag. sleRippleState->setFieldU32( sfFlags, uFlags & (!bSenderHigh ? ~lsfLowReserve : ~lsfHighReserve)); - // Balance is zero, receiver reserve is clear. - bDelete = !saBalance // Balance is zero. + // If balance is now zero and receiver holds no reserve either, + // the line is eligible for deletion. + bDelete = !saBalance && ((uFlags & (bSenderHigh ? lsfLowReserve : lsfHighReserve)) == 0u); - // Receiver reserve is clear. } if (bSenderHigh) saBalance.negate(); - // Want to reflect balance to zero even if we are deleting line. + // Persist the new balance even when we are about to delete the line, + // so the object is in a consistent state for trustDelete. sleRippleState->setFieldAmount(sfBalance, saBalance); - // ONLY: Adjust balance. if (bDelete) { @@ -660,9 +756,29 @@ directSendNoFeeIOU( j); } -// Send regardless of limits. -// --> saAmount: Amount/currency/issuer to deliver to receiver. -// <-- saActual: Amount actually cost. Sender pays fees. +/** Send an IOU amount to a receiver, applying transfer fees for third-party sends. + * + * Handles two cases: + * - **Direct send** (sender or receiver is the issuer, or issuer is + * `noAccount()`): delegates to `directSendNoFeeIOU` with no fee; + * sets @p saActual equal to @p saAmount. + * - **Third-party transit** (sender, receiver, and issuer all distinct): + * computes the gross cost as `saAmount × transferRate(issuer)` (or + * `saAmount` when `WaiveTransferFee::Yes`), then executes two sequential + * `directSendNoFeeIOU` calls — issuer→receiver for the delivery amount, + * then sender→issuer for the gross amount including the fee. + * + * @param view Mutable ledger view. + * @param uSenderID Sending account; must not be XRP. + * @param uReceiverID Receiving account; must not be XRP; must differ from + * @p uSenderID. + * @param saAmount Amount to deliver to @p uReceiverID. + * @param saActual Out-parameter: amount actually debited from sender + * (delivery amount + fee for third-party sends). + * @param j Journal for debug logging. + * @param waiveFee Whether to skip the transfer fee. + * @return `tesSUCCESS`, or the first `tec`/`tef` from an inner send. + */ static TER directSendNoLimitIOU( ApplyView& view, @@ -690,10 +806,7 @@ directSendNoLimitIOU( return tesSUCCESS; } - // Sending 3rd party IOUs: transit. - - // Calculate the amount to transfer accounting - // for any transfer fees if the fee is not waived: + // Third-party transit: sender pays delivery amount + transfer fee. saActual = (waiveFee == WaiveTransferFee::Yes) ? saAmount : multiply(saAmount, transferRate(view, issuer)); @@ -709,9 +822,30 @@ directSendNoLimitIOU( return terResult; } -// Send regardless of limits. -// --> receivers: Amount/currency/issuer to deliver to receivers. -// <-- saActual: Amount actually cost to sender. Sender pays fees. +/** Send an IOU from one sender to multiple receivers in a single operation. + * + * Iterates @p receivers. For each destination: + * - **Direct send** (sender or receiver is the issuer): calls + * `directSendNoFeeIOU` and immediately adjusts @p actual. The issuer + * debit has already happened inside `directSendNoFeeIOU`, so the amount + * is *not* added to `takeFromSender`. + * - **Third-party transit**: applies the transfer fee (or uses the raw amount + * when `WaiveTransferFee::Yes`), delivers issuer→receiver via + * `directSendNoFeeIOU`, and accumulates the gross cost in `takeFromSender`. + * + * After the loop, a single `directSendNoFeeIOU(sender→issuer, takeFromSender)` + * call debits the sender for all third-party fees at once. + * + * @param view Mutable ledger view. + * @param senderID The sending account; must not be XRP. + * @param issue The IOU (currency + issuer) being sent. + * @param receivers List of (AccountID, Number) destination pairs. + * @param actual Out-parameter: total gross cost to the sender across all + * destinations, including all transfer fees. + * @param j Journal for debug logging. + * @param waiveFee Whether to skip transfer fees. + * @return `tesSUCCESS`, or the first `tec`/`tef` from an inner send. + */ static TER directSendNoLimitMultiIOU( ApplyView& view, @@ -726,19 +860,17 @@ directSendNoLimitMultiIOU( XRPL_ASSERT(!isXRP(senderID), "xrpl::directSendNoLimitMultiIOU : sender is not XRP"); - // These may diverge + // takeFromSender accumulates the transit-fee portion debited from sender + // to issuer after the loop. actual accumulates the full cost including + // direct sends. They diverge only when there are transfer fees. STAmount takeFromSender{issue}; actual = takeFromSender; - // Failures return immediately. for (auto const& r : receivers) { auto const& receiverID = r.first; STAmount const amount{issue, r.second}; - /* If we aren't sending anything or if the sender is the same as the - * receiver then we don't need to do anything. - */ if (!amount || (senderID == receiverID)) continue; @@ -747,19 +879,15 @@ directSendNoLimitMultiIOU( if (senderID == issuer || receiverID == issuer || issuer == noAccount()) { // Direct send: redeeming IOUs and/or sending own IOUs. + // directSendNoFeeIOU handles the issuer debit internally; do + // not add to takeFromSender. if (auto const ter = directSendNoFeeIOU(view, senderID, receiverID, amount, false, j)) return ter; actual += amount; - // Do not add amount to takeFromSender, because directSendNoFeeIOU took - // it. - continue; } - // Sending 3rd party IOUs: transit. - - // Calculate the amount to transfer accounting - // for any transfer fees if the fee is not waived: + // Third-party transit: accumulate gross cost for bulk sender debit. STAmount const actualSend = (waiveFee == WaiveTransferFee::Yes) ? amount : multiply(amount, transferRate(view, issuer)); @@ -784,6 +912,31 @@ directSendNoLimitMultiIOU( return tesSUCCESS; } +/** Send an IOU or XRP amount from sender to receiver, the IOU path. + * + * Dispatches on amount type: + * - **IOU**: delegates to `directSendNoLimitIOU`, which handles direct and + * third-party sends including transfer fees. + * - **XRP** (native): adjusts `sfBalance` directly on sender and receiver + * `AccountRoot` SLEs. Either account may be `beast::kZERO` (null SLE), a + * setup used during pathfinding where transfers are guaranteed balanced by + * the caller. + * + * Under `fixAMMv1_1`, a negative or MPT amount returns `tecINTERNAL` rather + * than asserting, to ensure the error surfaces in closed-ledger replay. + * + * @note For XRP, null sender/receiver are permitted by design; the caller is + * responsible for ensuring balanced books. + * + * @param view Mutable ledger view. + * @param uSenderID Sending account (`beast::kZERO` allowed for XRP). + * @param uReceiverID Receiving account (`beast::kZERO` allowed for XRP). + * @param saAmount Non-negative amount; must not be MPT. + * @param j Journal for trace logging. + * @param waiveFee Whether to skip the transfer fee (IOU only). + * @return `tesSUCCESS`, `telFAILED_PROCESSING`/`tecFAILED_PROCESSING` on + * insufficient XRP balance, or a `tec`/`tef` from `directSendNoLimitIOU`. + */ static TER accountSendIOU( ApplyView& view, @@ -809,9 +962,6 @@ accountSendIOU( // LCOV_EXCL_STOP } - /* If we aren't sending anything or if the sender is the same as the - * receiver then we don't need to do anything. - */ if (!saAmount || (uSenderID == uReceiverID)) return tesSUCCESS; @@ -866,8 +1016,6 @@ accountSendIOU( { auto const sndBal = sender->getFieldAmount(sfBalance); view.creditHookIOU(uSenderID, xrpAccount(), saAmount, sndBal); - - // Decrement XRP balance. sender->setFieldAmount(sfBalance, sndBal - saAmount); view.update(sender); } @@ -875,7 +1023,6 @@ accountSendIOU( if (tesSUCCESS == terResult && receiver) { - // Increment XRP balance. auto const rcvBal = receiver->getFieldAmount(sfBalance); receiver->setFieldAmount(sfBalance, rcvBal + saAmount); view.creditHookIOU(xrpAccount(), uReceiverID, saAmount, -rcvBal); @@ -901,6 +1048,29 @@ accountSendIOU( return terResult; } +/** Send an IOU or XRP amount to multiple receivers atomically, the IOU path. + * + * Dispatches on amount type: + * - **IOU**: delegates to `directSendNoLimitMultiIOU`, which batches + * transfer fees into a single sender→issuer debit after delivering to + * all receivers. + * - **XRP** (native): credits each receiver's `sfBalance` in turn, then + * debits the accumulated total from the sender in a single step after the + * loop. Null sender/receiver SLEs (`beast::kZERO`) are permitted for the + * same pathfinding reason as `accountSendIOU`. + * + * @note `receivers.size()` must be > 1 (asserted). + * + * @param view Mutable ledger view. + * @param senderID The sending account. + * @param issue The IOU or XRP issue being sent. + * @param receivers List of (AccountID, Number) destination pairs. Negative + * amounts return `tecINTERNAL`. + * @param j Journal for trace logging. + * @param waiveFee Whether to skip transfer fees (IOU only). + * @return `tesSUCCESS`, `tecFAILED_PROCESSING` on insufficient XRP balance, + * or a `tec`/`tef` from `directSendNoLimitMultiIOU`. + */ static TER accountSendMultiIOU( ApplyView& view, @@ -942,7 +1112,7 @@ accountSendMultiIOU( << receivers.size() << " receivers."; } - // Failures return immediately. + // Credit receivers first; accumulate total to debit sender after loop. STAmount takeFromSender{issue}; for (auto const& r : receivers) { @@ -954,9 +1124,6 @@ accountSendMultiIOU( return tecINTERNAL; // LCOV_EXCL_LINE } - /* If we aren't sending anything or if the sender is the same as the - * receiver then we don't need to do anything. - */ if (!amount || (senderID == receiverID)) continue; @@ -977,14 +1144,12 @@ accountSendMultiIOU( if (receiver) { - // Increment XRP balance. auto const rcvBal = receiver->getFieldAmount(sfBalance); receiver->setFieldAmount(sfBalance, rcvBal + amount); view.creditHookIOU(xrpAccount(), receiverID, amount, -rcvBal); view.update(receiver); - // Take what is actually sent takeFromSender += amount; } @@ -1009,8 +1174,6 @@ accountSendMultiIOU( } auto const sndBal = sender->getFieldAmount(sfBalance); view.creditHookIOU(senderID, xrpAccount(), takeFromSender, sndBal); - - // Decrement XRP balance. sender->setFieldAmount(sfBalance, sndBal - takeFromSender); view.update(sender); } @@ -1028,6 +1191,34 @@ accountSendMultiIOU( return tesSUCCESS; } +/** Adjust MPT balances directly without applying fees or limit checks. + * + * Modifies `sfMPTAmount` on sender/receiver `MPToken` SLEs and + * `sfOutstandingAmount` on the `MPTokenIssuance` SLE: + * - **Sender == issuer**: increments `sfOutstandingAmount` (new tokens + * enter circulation). Under `featureMPTokensV2`, also checks + * `isMPTOverflow` with `AllowMPTOverflow::Yes`; returns `tecPATH_DRY` + * on overflow. + * - **Sender != issuer**: decrements `sfMPTAmount` on the sender's + * `MPToken` SLE. Returns `tecNO_AUTH` if no SLE exists (holder is not + * authorized), `tecINSUFFICIENT_FUNDS` if balance is too low. + * - **Receiver == issuer**: decrements `sfOutstandingAmount` (tokens + * redeemed). Returns `tecINTERNAL` if `outstanding < amount` (should + * never happen in a valid ledger). + * - **Receiver != issuer**: increments `sfMPTAmount` on the receiver's + * `MPToken` SLE. Returns `tecNO_AUTH` if no SLE exists. + * + * Authorization must have been verified by the caller before this function + * is invoked. + * + * @param view Mutable ledger view. + * @param uSenderID Sending account. + * @param uReceiverID Receiving account; must differ from @p uSenderID. + * @param saAmount MPT amount to transfer. + * @param j Journal (unused; reserved for future tracing). + * @return `tesSUCCESS`, `tecOBJECT_NOT_FOUND`, `tecPATH_DRY`, + * `tecINSUFFICIENT_FUNDS`, `tecNO_AUTH`, or `tecINTERNAL`. + */ static TER directSendNoFeeMPT( ApplyView& view, @@ -1036,7 +1227,7 @@ directSendNoFeeMPT( STAmount const& saAmount, beast::Journal j) { - // Do not check MPT authorization here - it must have been checked earlier + // Authorization must have been checked by the caller. auto const mptID = keylet::mptIssuance(saAmount.get().getMptID()); auto const& issuer = saAmount.getIssuer(); auto sleIssuance = view.peek(mptID); @@ -1106,6 +1297,33 @@ directSendNoFeeMPT( return tesSUCCESS; } +/** Send an MPT amount to a receiver, applying transfer fees for third-party sends. + * + * Mirrors `directSendNoLimitIOU` for the MPT token model: + * - **Direct send** (sender or receiver is the issuer): validates that + * `OutstandingAmount + sendAmount <= MaximumAmount` for issuer-as-sender + * (gated by `AllowMPTOverflow` and `featureMPTokensV2`), then delegates to + * `directSendNoFeeMPT`. Sets @p saActual equal to @p saAmount. + * - **Third-party transit**: computes gross cost as + * `saAmount × transferRate(mptID)` (or `saAmount` when + * `WaiveTransferFee::Yes`), then executes two `directSendNoFeeMPT` calls: + * issuer→receiver for delivery, sender→issuer for gross cost. + * + * The `allowOverflow` flag is only meaningful when `featureMPTokensV2` is + * active; without it the flag is forced to `No`. + * + * @param view Mutable ledger view. + * @param uSenderID Sending account; must differ from @p uReceiverID. + * @param uReceiverID Receiving account. + * @param saAmount MPT amount to deliver to @p uReceiverID. + * @param saActual Out-parameter: gross cost to the sender. + * @param j Journal for debug logging. + * @param waiveFee Whether to skip the transfer fee. + * @param allowOverflow Whether MPT outstanding may transiently exceed + * maximum (only honored under `featureMPTokensV2`). + * @return `tesSUCCESS`, `tecOBJECT_NOT_FOUND`, `tecPATH_DRY`, or a + * `tec`/`tef` from `directSendNoFeeMPT`. + */ static TER directSendNoLimitMPT( ApplyView& view, @@ -1119,7 +1337,7 @@ directSendNoLimitMPT( { XRPL_ASSERT(uSenderID != uReceiverID, "xrpl::directSendNoLimitMPT : sender is not receiver"); - // Safe to get MPT since directSendNoLimitMPT is only called by accountSendMPT + // Only called by accountSendMPT, which guarantees saAmount holds MPTIssue. auto const& issuer = saAmount.getIssuer(); auto const sle = view.read(keylet::mptIssuance(saAmount.get().getMptID())); @@ -1128,14 +1346,13 @@ directSendNoLimitMPT( if (uSenderID == issuer || uReceiverID == issuer) { - // if sender is issuer, check that the new OutstandingAmount will not - // exceed MaximumAmount if (uSenderID == issuer) { auto const sendAmount = saAmount.mpt().value(); auto const maxAmount = maxMPTAmount(*sle); auto const outstanding = sle->getFieldU64(sfOutstandingAmount); auto const mptokensV2 = view.rules().enabled(featureMPTokensV2); + // AllowMPTOverflow::Yes is only effective under featureMPTokensV2. allowOverflow = (allowOverflow == AllowMPTOverflow::Yes && mptokensV2) ? AllowMPTOverflow::Yes : AllowMPTOverflow::No; @@ -1151,7 +1368,7 @@ directSendNoLimitMPT( return tesSUCCESS; } - // Sending 3rd party MPTs: transit. + // Third-party transit: sender pays delivery amount + transfer fee. saActual = (waiveFee == WaiveTransferFee::Yes) ? saAmount : multiply(saAmount, transferRate(view, saAmount.get().getMptID())); @@ -1167,6 +1384,40 @@ directSendNoLimitMPT( return directSendNoFeeMPT(view, uSenderID, issuer, saActual, j); } +/** Send an MPT amount from one sender to multiple receivers in a single operation. + * + * Mirrors `directSendNoLimitMultiIOU` for the MPT model. For each receiver: + * - **Direct send** (sender or receiver is the issuer): validates + * `MaximumAmount` for issuer-as-sender and calls `directSendNoFeeMPT`. + * The MPT issuance SLE is mutated inside `directSendNoFeeMPT`; do not + * add the amount to `takeFromSender`. + * - **Third-party transit**: accumulates the gross cost (with or without + * fee) into `takeFromSender`; delivers via issuer→receiver + * `directSendNoFeeMPT`. A single sender→issuer call follows the loop. + * + * **`MaximumAmount` enforcement for issuer-as-sender** differs by amendment: + * - Post-`fixSecurity3_1_3`: accumulates a `uint64_t totalSendAmount` + * across all iterations and performs a three-part overflow-safe check + * (`sendAmount > max || total > max - send || outstanding > max - send - + * total`). The subtraction order is critical — each condition guards the + * next against unsigned underflow; do not reorder. + * - Pre-`fixSecurity3_1_3`: per-iteration check against the stale + * `view.read()` snapshot. Retained for ledger replay compatibility. + * + * `uint64_t` arithmetic (not `STAmount`/`Number`) is used to avoid 16-digit + * mantissa precision loss when values approach `kMAX_MP_TOKEN_AMOUNT` + * (19 digits). + * + * @param view Mutable ledger view. + * @param senderID The sending account. + * @param mptIssue The MPT issuance being sent. + * @param receivers List of (AccountID, Number) destination pairs. + * @param actual Out-parameter: total gross cost to the sender. + * @param j Journal for debug logging. + * @param waiveFee Whether to skip transfer fees. + * @return `tesSUCCESS`, `tecOBJECT_NOT_FOUND`, `tecPATH_DRY`, + * `tecINTERNAL`, or a `tec`/`tef` from `directSendNoFeeMPT`. + */ static TER directSendNoLimitMultiMPT( ApplyView& view, @@ -1183,21 +1434,19 @@ directSendNoLimitMultiMPT( if (!sle) return tecOBJECT_NOT_FOUND; - // For the issuer-as-sender case, track the running total to validate - // against MaximumAmount. The read-only SLE (view.read) is not updated - // by directSendNoFeeMPT, so a per-iteration SLE read would be stale. - // Use uint64_t, not STAmount, to keep MaximumAmount comparisons in exact - // integer arithmetic. STAmount implicitly converts to Number, whose - // small-scale mantissa (~16 digits) can lose precision for values near - // maxMPTokenAmount (19 digits). + // Snapshot MaximumAmount and OutstandingAmount once. view.read() returns a + // const SLE that is NOT updated by directSendNoFeeMPT (which uses peek()), + // so per-iteration re-reads would be stale. totalSendAmount tracks the + // running aggregate for the post-fixSecurity3_1_3 overflow check. + // Use uint64_t — not STAmount/Number — to keep comparisons exact at + // 19-digit magnitudes near kMAX_MP_TOKEN_AMOUNT. std::uint64_t totalSendAmount{0}; std::uint64_t const maximumAmount = sle->at(~sfMaximumAmount).value_or(kMAX_MP_TOKEN_AMOUNT); std::uint64_t const outstandingAmount = sle->getFieldU64(sfOutstandingAmount); - // actual accumulates the total cost to the sender (includes transfer - // fees for third-party transit sends). takeFromSender accumulates only - // the transit portion that is debited to the issuer in bulk after the - // loop. They diverge when there are transfer fees. + // actual: total cost to the sender (delivery + fees). + // takeFromSender: transit-fee portion to debit sender→issuer after loop. + // They diverge only when there are transfer fees on third-party sends. STAmount takeFromSender{mptIssue}; actual = takeFromSender; @@ -1224,16 +1473,13 @@ directSendNoLimitMultiMPT( if (view.rules().enabled(fixSecurity3_1_3)) { - // Post-fixSecurity3_1_3: aggregate MaximumAmount - // check. WARNING: the order of conditions is - // critical — each guards the subtraction in the - // next against unsigned underflow. Do not reorder. + // Post-fixSecurity3_1_3: aggregate MaximumAmount check. + // WARNING: the order of conditions is critical — each + // guards the subtraction in the next against unsigned + // underflow. Do not reorder. bool const exceedsMaximumAmount = - // This send alone exceeds the max cap sendAmount > maximumAmount || - // The aggregate of all sends exceeds the max cap totalSendAmount > maximumAmount - sendAmount || - // Outstanding + aggregate exceeds the max cap outstandingAmount > maximumAmount - sendAmount - totalSendAmount; if (exceedsMaximumAmount) @@ -1242,27 +1488,24 @@ directSendNoLimitMultiMPT( } else { - // Pre-fixSecurity3_1_3: per-iteration MaximumAmount - // check. Reads sfOutstandingAmount from a stale - // view.read() snapshot — incorrect for multi-destination - // sends but retained for ledger replay compatibility. + // Pre-fixSecurity3_1_3: per-iteration check against + // stale snapshot — retained for ledger replay. if (sendAmount > maximumAmount || outstandingAmount > maximumAmount - sendAmount) return tecPATH_DRY; } } - // Direct send: redeeming MPTs and/or sending own MPTs. + // Direct send: directSendNoFeeMPT handles issuer debit + // internally; do not add to takeFromSender. if (auto const ter = directSendNoFeeMPT(view, senderID, receiverID, amount, j); !isTesSuccess(ter)) return ter; actual += amount; - // Do not add amount to takeFromSender, because directSendNoFeeMPT took it. - continue; } - // Sending 3rd party MPTs: transit. + // Third-party transit: accumulate gross cost for bulk sender debit. STAmount const actualSend = (waiveFee == WaiveTransferFee::Yes) ? amount : multiply(amount, transferRate(view, amount.get().getMptID())); @@ -1287,6 +1530,22 @@ directSendNoLimitMultiMPT( return tesSUCCESS; } +/** Send an MPT amount from sender to receiver, the MPT path of `accountSend`. + * + * Validates that @p saAmount is non-negative and holds `MPTIssue`, then + * delegates to `directSendNoLimitMPT`. No-ops when the amount is zero or + * sender equals receiver. + * + * @param view Mutable ledger view. + * @param uSenderID Sending account. + * @param uReceiverID Receiving account. + * @param saAmount Non-negative MPT amount to send. + * @param j Journal for logging. + * @param waiveFee Whether to skip the transfer fee. + * @param allowOverflow Whether MPT outstanding may transiently exceed + * maximum during payment-engine routing. + * @return `tesSUCCESS`, or a `tec`/`tef` from `directSendNoLimitMPT`. + */ static TER accountSendMPT( ApplyView& view, @@ -1301,9 +1560,6 @@ accountSendMPT( saAmount >= beast::kZERO && saAmount.holds(), "xrpl::accountSendMPT : minimum amount and MPT"); - /* If we aren't sending anything or if the sender is the same as the - * receiver then we don't need to do anything. - */ if (!saAmount || (uSenderID == uReceiverID)) return tesSUCCESS; @@ -1313,6 +1569,20 @@ accountSendMPT( view, uSenderID, uReceiverID, saAmount, saActual, j, waiveFee, allowOverflow); } +/** Send an MPT amount to multiple receivers atomically, the MPT path of + * `accountSendMulti`. + * + * Thin wrapper that discards the `actual` out-parameter and delegates to + * `directSendNoLimitMultiMPT`. + * + * @param view Mutable ledger view. + * @param senderID The sending account. + * @param mptIssue The MPT issuance being sent. + * @param receivers List of (AccountID, Number) destination pairs. + * @param j Journal for logging. + * @param waiveFee Whether to skip transfer fees. + * @return `tesSUCCESS` or the first error from `directSendNoLimitMultiMPT`. + */ static TER accountSendMultiMPT( ApplyView& view, diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index 8832e0078f..94b04a3f83 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -1,3 +1,27 @@ +/** @file + * Implements asset/share conversion arithmetic for the XLS-65d Single Asset + * Vault feature. + * + * The four functions here are the sole location where the deposit and + * withdrawal exchange rates are computed. Key design invariants: + * + * - **Deposit functions** use raw `sfAssetsTotal` as the pool size. + * Unrealized losses are not visible to incoming depositors — they are a + * risk socialised across existing shareholders, not a discount for new ones. + * + * - **Withdrawal functions** subtract `sfLossUnrealized` from `sfAssetsTotal` + * before computing the rate, so departing shareholders bear their pro-rata + * share of any recorded losses. + * + * - Every result is a whole number because vault shares are integer MPTs. + * Deposit functions always truncate (vault-favorable); the withdrawal + * direction supports optional truncation via `TruncateShares`. + * + * - All functions use a double-check guard: `XRPL_ASSERT` fires in debug + * builds on bad inputs; an identical `if` returns `std::nullopt` in + * production. The `nullopt` branches are marked `LCOV_EXCL_LINE` because + * callers have already validated preconditions in `preclaim`. + */ #include #include @@ -29,12 +53,17 @@ assetsToSharesDeposit( STAmount shares{vault->at(sfShareMPTID)}; if (assetTotal == 0) { + // Bootstrap: no assets in the vault yet, so establish the initial + // exchange rate as 1 asset = 10^sfScale shares. Scaling up share + // count reduces the first-depositor donation attack surface. return STAmount{ shares.asset(), Number(assets.mantissa(), assets.exponent() + vault->at(sfScale)).truncate()}; } Number const shareTotal = issuance->at(sfOutstandingAmount); + // Truncate so the depositor never receives more shares than strictly + // warranted; the rounding residue stays in the vault. shares = ((shareTotal * assets) / assetTotal).truncate(); return shares; } @@ -56,6 +85,8 @@ sharesToAssetsDeposit( STAmount assets{vault->at(sfAsset)}; if (assetTotal == 0) { + // Bootstrap inverse: reverse the exponent shift applied by + // assetsToSharesDeposit() to recover the original asset amount. return STAmount{ assets.asset(), shares.mantissa(), shares.exponent() - vault->at(sfScale), false}; } @@ -79,11 +110,13 @@ assetsToSharesWithdraw( if (assets.negative() || assets.asset() != vault->at(sfAsset)) return std::nullopt; // LCOV_EXCL_LINE + // Withdrawal path: deduct unrealized losses before computing the rate so + // departing holders bear their proportional share of any recorded loss. Number assetTotal = vault->at(sfAssetsTotal); assetTotal -= vault->at(sfLossUnrealized); STAmount shares{vault->at(sfShareMPTID)}; if (assetTotal == 0) - return shares; + return shares; // Fully insolvent vault; zero shares required. Number const shareTotal = issuance->at(sfOutstandingAmount); Number result = (shareTotal * assets) / assetTotal; if (truncate == TruncateShares::Yes) @@ -105,11 +138,13 @@ sharesToAssetsWithdraw( if (shares.negative() || shares.asset() != vault->at(sfShareMPTID)) return std::nullopt; // LCOV_EXCL_LINE + // Withdrawal path: deduct unrealized losses so redeemers receive the + // net asset value rather than the gross book value. Number assetTotal = vault->at(sfAssetsTotal); assetTotal -= vault->at(sfLossUnrealized); STAmount assets{vault->at(sfAsset)}; if (assetTotal == 0) - return assets; + return assets; // Fully insolvent vault; zero assets delivered. Number const shareTotal = issuance->at(sfOutstandingAmount); assets = (assetTotal * shares) / shareTotal; return assets; diff --git a/src/libxrpl/protocol/PublicKey.cpp b/src/libxrpl/protocol/PublicKey.cpp index dfce652c6e..b891524d2d 100644 --- a/src/libxrpl/protocol/PublicKey.cpp +++ b/src/libxrpl/protocol/PublicKey.cpp @@ -313,6 +313,24 @@ publicKeyType(Slice const& slice) return std::nullopt; } +/** Verify a secp256k1 ECDSA signature against a pre-computed digest. + * + * Validates the DER structure and canonicality of `sig` before invoking + * libsecp256k1. When the signature is merely `canonical` (i.e., `S > G/2`) + * and `mustBeFullyCanonical` is `false`, the S component is normalised to + * its low form via `secp256k1_ecdsa_signature_normalize` before + * verification. This preserves backward compatibility with old-style + * signatures without accepting truly invalid encodings. + * + * @param publicKey The secp256k1 public key; calling with any other key + * type calls `logicError` (programming error). + * @param digest The 256-bit digest over which the signature was produced. + * @param sig DER-encoded ECDSA signature. + * @param mustBeFullyCanonical If `true`, reject signatures where + * `S > G/2` (the default). If `false`, accept them after normalisation. + * @return `true` if the signature is valid for the given key and digest, + * `false` for any structural, canonicality, or cryptographic failure. + */ bool verifyDigest( PublicKey const& publicKey, @@ -361,6 +379,24 @@ verifyDigest( &pubkeyImp) == 1; } +/** Verify a signature over a raw message for either supported key type. + * + * Dispatches on the key type detected from `publicKey`: + * - **secp256k1**: hashes `m` with SHA-512 Half (yielding a 256-bit + * digest) and delegates to `verifyDigest` with `mustBeFullyCanonical` + * defaulting to `true`. + * - **Ed25519**: checks canonicality of `sig` first, then strips the + * XRPL-specific `0xED` prefix byte from `publicKey` before calling + * the underlying `ed25519_sign_open` library function. The prefix is + * an XRPL encoding artifact; the Ed25519 library is unaware of it. + * + * @param publicKey The public key against which to verify. + * @param m The message that was signed. + * @param sig The signature to verify. + * @return `true` if the signature is valid, `false` for any failure + * including unrecognised key type, non-canonical signature, or + * cryptographic mismatch. + */ bool verify(PublicKey const& publicKey, Slice const& m, Slice const& sig) noexcept { @@ -375,16 +411,25 @@ verify(PublicKey const& publicKey, Slice const& m, Slice const& sig) noexcept if (!ed25519Canonical(sig)) return false; - // We internally prefix Ed25519 keys with a 0xED - // byte to distinguish them from secp256k1 keys - // so when verifying the signature, we need to - // first strip that prefix. + // The 0xED prefix byte is an XRPL encoding artifact that + // distinguishes Ed25519 keys from secp256k1 keys; strip it + // before passing the raw 32-byte key to the library. return ed25519_sign_open(m.data(), m.size(), publicKey.data() + 1, sig.data()) == 0; } } return false; } +/** Derive the 160-bit node identity from a validator public key. + * + * Applies RIPEMD-160(SHA-256(pubkey)) — the same `ripesha_hasher` used + * to derive XRPL account IDs — to produce the 160-bit `NodeID` used in + * the peer-to-peer layer for validator routing and identification. + * + * @param pk The validator's public key (secp256k1 or Ed25519). + * @return The 160-bit `NodeID` uniquely identifying the validator on the + * peer-to-peer network. + */ NodeID calcNodeID(PublicKey const& pk) { diff --git a/src/libxrpl/protocol/Quality.cpp b/src/libxrpl/protocol/Quality.cpp index 35a3a3b3a5..bc6babe1e3 100644 --- a/src/libxrpl/protocol/Quality.cpp +++ b/src/libxrpl/protocol/Quality.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements `Quality` arithmetic for XRPL's offer-matching engine. + * + * A `Quality` is the exchange ratio `out / in` (output per unit of input) + * stored as an inverted 64-bit integer so that higher-quality (better for + * the taker) offers sort first under plain unsigned integer comparison. + * This file provides constructors, increment/decrement operators, + * proportional-scaling helpers (`ceilIn` / `ceilOut` and their strict + * variants), two-hop quality composition, and tick-size rounding. + */ #include #include @@ -10,14 +20,40 @@ namespace xrpl { +/** Construct from a raw packed integer in STAmount encoding. + * + * The top 8 bits are the biased exponent (actual exponent + 100) and the + * bottom 56 bits are the mantissa — identical to the IOU wire format used + * by `amountFromQuality()` and `getRate()`. + * + * @param value Packed 64-bit quality value; higher integers denote lower + * (worse) quality because the internal ordering is inverted. + */ Quality::Quality(std::uint64_t value) : value_(value) { } +/** Construct from an in/out amount pair. + * + * Encodes the ratio `amount.out / amount.in` using `getRate()`, which + * produces the same packed 64-bit format as the STAmount IOU encoding. + * For offers, `in` is TakerPays and `out` is TakerGets. + * + * @param amount The offer amounts; neither side should be zero. + */ Quality::Quality(Amounts const& amount) : value_(getRate(amount.out, amount.in)) { } +/** Advance to the next higher quality level (pre-increment). + * + * Because `value_` is an inverted encoding — lower integer = higher quality + * — this decrements `value_` by one. Each step represents the smallest + * distinguishable improvement in exchange rate. + * + * @pre `value_ > 0`; underflow is asserted. + * @return Reference to `*this` after advancement. + */ Quality& Quality::operator++() { @@ -26,6 +62,12 @@ Quality::operator++() return *this; } +/** Advance to the next higher quality level (post-increment). + * + * Returns the quality before advancement; see pre-increment for details. + * + * @return Copy of `*this` prior to the increment. + */ Quality Quality::operator++(int) { @@ -34,6 +76,14 @@ Quality::operator++(int) return prev; } +/** Retreat to the next lower quality level (pre-decrement). + * + * Because `value_` is an inverted encoding — higher integer = lower quality + * — this increments `value_` by one. + * + * @pre `value_ < UINT64_MAX`; overflow is asserted. + * @return Reference to `*this` after retreat. + */ Quality& Quality::operator--() { @@ -44,6 +94,12 @@ Quality::operator--() return *this; } +/** Retreat to the next lower quality level (post-decrement). + * + * Returns the quality before retreat; see pre-decrement for details. + * + * @return Copy of `*this` prior to the decrement. + */ Quality Quality::operator--(int) { @@ -52,6 +108,26 @@ Quality::operator--(int) return prev; } +/** Proportionally scale an offer down so that its input does not exceed a + * limit, using a caller-supplied division function. + * + * If `amount.in > limit`, sets `in = limit` and recomputes `out` via + * `DivRoundFunc(limit, quality.rate(), ...)`. A secondary clamp then + * ensures `result.out` never exceeds the original `amount.out` — this + * is the "no money creation" invariant. Returns `amount` unchanged when + * `amount.in <= limit`. + * + * The template parameter selects between `divRound` (legacy, ignores + * low-order bits) and `divRoundStrict` (full-precision). + * + * @tparam DivRoundFunc Division function with the signature + * `STAmount(STAmount const&, STAmount const&, Asset const&, bool)`. + * @param amount The current offer amounts (in = TakerPays, out = TakerGets). + * @param limit Maximum allowed input amount. + * @param roundUp Passed directly to `DivRoundFunc`. + * @param quality The offer's exchange rate used to recompute the output. + * @return Scaled amounts with `in <= limit` and `out <= amount.out`. + */ template static Amounts ceilInImpl(Amounts const& amount, STAmount const& limit, bool roundUp, Quality const& quality) @@ -59,7 +135,6 @@ ceilInImpl(Amounts const& amount, STAmount const& limit, bool roundUp, Quality c if (amount.in > limit) { Amounts result(limit, DivRoundFunc(limit, quality.rate(), amount.out.asset(), roundUp)); - // Clamp out if (result.out > amount.out) result.out = amount.out; XRPL_ASSERT(result.in == limit, "xrpl::ceilInImpl : result matches limit"); @@ -69,18 +144,55 @@ ceilInImpl(Amounts const& amount, STAmount const& limit, bool roundUp, Quality c return amount; } +/** Cap the input side of an offer at `limit`, scaling output proportionally. + * + * Uses `divRound` (legacy rounding) with `roundUp = true`. Callers that + * require full-precision rounding should use `ceilInStrict` instead. + * + * @param amount Current offer amounts. + * @param limit Maximum input; unchanged when `amount.in <= limit`. + * @return Scaled amounts satisfying `in <= limit` and `out <= amount.out`. + */ Amounts Quality::ceilIn(Amounts const& amount, STAmount const& limit) const { return ceilInImpl(amount, limit, /* roundUp */ true, *this); } +/** Cap the input side of an offer at `limit` with full-precision rounding. + * + * Unlike `ceilIn`, this uses `divRoundStrict`, which considers all low-order + * bits that `divRound` ignores. Introduced to fix subtle rounding bugs + * without changing the default behavior for existing callers. + * + * @param amount Current offer amounts. + * @param limit Maximum input; unchanged when `amount.in <= limit`. + * @param roundUp Whether to round the recomputed output up or down. + * @return Scaled amounts satisfying `in <= limit` and `out <= amount.out`. + */ Amounts Quality::ceilInStrict(Amounts const& amount, STAmount const& limit, bool roundUp) const { return ceilInImpl(amount, limit, roundUp, *this); } +/** Proportionally scale an offer down so that its output does not exceed a + * limit, using a caller-supplied multiplication function. + * + * If `amount.out > limit`, sets `out = limit` and recomputes `in` via + * `MulRoundFunc(limit, quality.rate(), ...)`. A secondary clamp then + * ensures `result.in` never exceeds the original `amount.in` — the + * "no money creation" invariant for the output direction. Returns + * `amount` unchanged when `amount.out <= limit`. + * + * @tparam MulRoundFunc Multiplication function with the signature + * `STAmount(STAmount const&, STAmount const&, Asset const&, bool)`. + * @param amount The current offer amounts. + * @param limit Maximum allowed output amount. + * @param roundUp Passed directly to `MulRoundFunc`. + * @param quality The offer's exchange rate used to recompute the input. + * @return Scaled amounts with `out <= limit` and `in <= amount.in`. + */ template static Amounts ceilOutImpl(Amounts const& amount, STAmount const& limit, bool roundUp, Quality const& quality) @@ -88,7 +200,6 @@ ceilOutImpl(Amounts const& amount, STAmount const& limit, bool roundUp, Quality if (amount.out > limit) { Amounts result(MulRoundFunc(limit, quality.rate(), amount.in.asset(), roundUp), limit); - // Clamp in if (result.in > amount.in) result.in = amount.in; XRPL_ASSERT(result.out == limit, "xrpl::ceilOutImpl : result matches limit"); @@ -98,18 +209,51 @@ ceilOutImpl(Amounts const& amount, STAmount const& limit, bool roundUp, Quality return amount; } +/** Cap the output side of an offer at `limit`, scaling input proportionally. + * + * Uses `mulRound` (legacy rounding) with `roundUp = true`. Callers that + * require full-precision rounding should use `ceilOutStrict` instead. + * + * @param amount Current offer amounts. + * @param limit Maximum output; unchanged when `amount.out <= limit`. + * @return Scaled amounts satisfying `out <= limit` and `in <= amount.in`. + */ Amounts Quality::ceilOut(Amounts const& amount, STAmount const& limit) const { return ceilOutImpl(amount, limit, /* roundUp */ true, *this); } +/** Cap the output side of an offer at `limit` with full-precision rounding. + * + * Unlike `ceilOut`, this uses `mulRoundStrict`, which considers all + * low-order bits that `mulRound` ignores. Introduced to fix subtle + * rounding bugs without changing the default behavior for existing callers. + * + * @param amount Current offer amounts. + * @param limit Maximum output; unchanged when `amount.out <= limit`. + * @param roundUp Whether to round the recomputed input up or down. + * @return Scaled amounts satisfying `out <= limit` and `in <= amount.in`. + */ Amounts Quality::ceilOutStrict(Amounts const& amount, STAmount const& limit, bool roundUp) const { return ceilOutImpl(amount, limit, roundUp, *this); } +/** Compute the effective end-to-end exchange rate for a two-hop path. + * + * If the first hop converts A→B at rate `lhs` and the second converts B→C + * at rate `rhs`, the composed quality is their product, re-encoded into + * the packed 64-bit format. The exponent must fit in 8 bits (1–255); the + * assertion fires if the composed rate is astronomically large or small, + * indicating a degenerate path. + * + * @param lhs Quality of the first leg (input → intermediate currency). + * @param rhs Quality of the second leg (intermediate → output currency). + * @return Composed quality representing the overall A→C rate. + * @note Both input rates must be non-zero (asserted). + */ Quality composedQuality(Quality const& lhs, Quality const& rhs) { @@ -130,10 +274,28 @@ composedQuality(Quality const& lhs, Quality const& rhs) return Quality((storedExponent << (64 - 8)) | storedMantissa); } +/** Return this quality rounded up to `digits` significant decimal digits. + * + * Used for tick-size enforcement: truncates mantissa precision so that + * offers differing only in low-order digits are treated as equivalent. + * Rounding is always upward (ceiling), which makes the encoded rate + * slightly higher (worse for the taker) and prevents a rounded quality + * from being mistakenly ranked better than the original. + * + * The bias-and-mask technique (`mantissa += mod - 1; mantissa -= mantissa % + * mod`) implements ceiling division without branching. + * + * @param digits Number of significant decimal digits to retain. + * Valid range: `kMIN_TICK_SIZE` (3) to `kMAX_TICK_SIZE` (16), + * enforced by callers. + * @return A new `Quality` with the mantissa rounded up to `digits` digits + * and the exponent unchanged. + */ Quality Quality::round(int digits) const { - // Modulus for mantissa + // Powers of ten indexed by digit count; kMOD[d] is the modulus that + // zeroes out all but the top `d` significant digits of a 16-digit mantissa. static std::uint64_t const kMOD[17] = { /* 0 */ 10000000000000000, /* 1 */ 1000000000000000, diff --git a/src/libxrpl/protocol/QualityFunction.cpp b/src/libxrpl/protocol/QualityFunction.cpp index 2e9eb5745a..cb52211708 100644 --- a/src/libxrpl/protocol/QualityFunction.cpp +++ b/src/libxrpl/protocol/QualityFunction.cpp @@ -1,3 +1,10 @@ +/** @file + * Implements `QualityFunction`: path average quality as a linear function + * of output amount, used by the single-path payment optimizer to find the + * maximum output that satisfies a caller-supplied quality limit without + * iterative approximation. + */ + #include #include @@ -10,6 +17,22 @@ namespace xrpl { +/** Construct a constant-quality (CLOB-like) quality function. + * + * For a CLOB offer, the exchange rate is independent of fill size, so the + * slope `m_` is zero and the intercept `b_` stores `1 / quality.rate()`. + * The same representation is used for AMM offers in multi-path mode, where + * the AMM's per-path allocation is fixed, making its quality effectively + * constant from this sub-path's perspective. + * + * The intercept is stored as a reciprocal rate because `combine()` and + * `outFromAvgQ()` both operate in reciprocal-rate space. + * + * @param quality The fixed quality (exchange rate) of the CLOB or + * multi-path AMM step. + * @throws std::runtime_error if `quality.rate()` is zero or negative, + * which would make `b_` infinite. + */ QualityFunction::QualityFunction(Quality const& quality, QualityFunction::CLOBLikeTag) : m_(0), b_(0), quality_(quality) { @@ -18,6 +41,23 @@ QualityFunction::QualityFunction(Quality const& quality, QualityFunction::CLOBLi b_ = 1 / quality.rate(); } +/** Chain this quality function with the next hop's quality function. + * + * Applies linear function composition in reciprocal-rate space: + * @code + * m_ += b_ * qf.m_; + * b_ *= qf.b_; + * @endcode + * This correctly accumulates the combined slope and intercept for a + * multi-hop strand such as XRP → USD → EUR. + * + * The `quality_` cache is cleared to `std::nullopt` when the result + * has a nonzero slope (i.e., when any AMM step is present), signalling + * to `StrandFlow` that `outFromAvgQ()` is required rather than a simple + * constant-quality pass/fail check. + * + * @param qf The quality function for the next step to compose in. + */ void QualityFunction::combine(QualityFunction const& qf) { @@ -27,6 +67,30 @@ QualityFunction::combine(QualityFunction const& qf) quality_ = std::nullopt; } +/** Solve for the output amount whose average quality equals the given limit. + * + * Inverts `q(out) = m_ * out + b_` by setting `q = 1 / quality.rate()` and + * solving: `out = (1/rate - b_) / m_`. The result is the maximum output + * the path strand should produce before AMM price impact degrades the + * average quality below the caller's limit. + * + * The rounding mode is set to `Upward` during the calculation so that the + * returned bound is conservative: the actual quality achieved will meet or + * exceed the limit. `SaveNumberRoundMode` restores the prior mode on exit, + * preventing the upward-rounding from leaking into unrelated arithmetic. + * + * Returns `std::nullopt` in three cases: + * - `m_ == 0`: the function is constant (CLOB-like); quality either passes + * or fails uniformly, so no output cap is meaningful. + * - `quality.rate() == zero`: guards against division by zero when forming + * `1 / rate`. + * - `out <= 0`: the quality limit is already unachievable at any positive + * output; the strand is effectively dead for this constraint. + * + * @param quality The minimum acceptable average exchange rate (quality limit). + * @return The output amount at which average quality equals `quality`, or + * `std::nullopt` if the function is constant or the limit is infeasible. + */ std::optional QualityFunction::outFromAvgQ(Quality const& quality) { diff --git a/src/libxrpl/protocol/RPCErr.cpp b/src/libxrpl/protocol/RPCErr.cpp index ec9a3dee9d..2f31e0ef23 100644 --- a/src/libxrpl/protocol/RPCErr.cpp +++ b/src/libxrpl/protocol/RPCErr.cpp @@ -1,3 +1,11 @@ +/** @file + * Deprecated compatibility shim for the XRPL RPC error API. + * + * Provides `rpcError()` and `isRpcError()` — older entry points that predate + * the `RPC` namespace error API. New code should use `RPC::makeError()` and + * `RPC::containsError()` from `ErrorCodes.h` instead. + */ + #include #include @@ -6,9 +14,19 @@ namespace xrpl { +// Orphaned forward declaration — no definition exists; retained as an artifact. struct RPCErr; -// VFALCO NOTE Deprecated function +/** Construct a JSON error object for the given error code. + * + * Delegates to `RPC::injectError()`, which populates the result with the + * canonical `error` token, `error_code`, and `error_message` fields from + * the static `ErrorInfo` registry. + * + * @param iError The RPC error code to encode. + * @return A new `Json::Value` object suitable for returning to an API client. + * @deprecated Use `RPC::makeError(code)` instead. + */ json::Value rpcError(ErrorCodeI iError) { @@ -17,7 +35,18 @@ rpcError(ErrorCodeI iError) return jvResult; } -// VFALCO NOTE Deprecated function +/** Return `true` if @p jvResult represents an RPC error response. + * + * Checks that the value is a JSON object and that it contains a member + * named `jss::error` — the key written by `RPC::injectError()` for every + * error response. + * + * @param jvResult The JSON value to inspect (taken by value). + * @return `true` if @p jvResult is an object containing an `"error"` member. + * @note The argument is taken by value rather than const reference; this is a + * minor inefficiency inherited from the original implementation. + * @deprecated Use `RPC::containsError(json)` instead. + */ bool isRpcError(json::Value jvResult) { diff --git a/src/libxrpl/protocol/Rate2.cpp b/src/libxrpl/protocol/Rate2.cpp index 27b17068e3..59456d60b9 100644 --- a/src/libxrpl/protocol/Rate2.cpp +++ b/src/libxrpl/protocol/Rate2.cpp @@ -1,3 +1,11 @@ +/** @file + * Implements transfer-rate arithmetic for IOU and NFT fees. + * + * Defines `kPARITY_RATE` (the 1:1 identity rate) and provides the six + * multiply/divide helpers declared in Rate.h, plus the NFT basis-point + * conversion in the `nft` sub-namespace. + */ + #include #include #include @@ -9,10 +17,29 @@ namespace xrpl { +/** The 1:1 transfer rate — sender pays exactly what the recipient receives. + * + * Equal to `QUALITY_ONE` (10^9). Every arithmetic function short-circuits + * immediately when it sees this value, so payment paths with no issuer fee + * never enter the `STAmount` multiply/divide path. + */ Rate const kPARITY_RATE(QUALITY_ONE); namespace detail { +/** Bridge a `Rate` value into the `STAmount` type system as a dimensionless decimal. + * + * Constructs an `STAmount` representing `rate.value × 10⁻⁹`, so that the + * existing `STAmount` multiply/divide infrastructure can apply a billion-scale + * integer rate without bespoke fixed-point arithmetic. For example, a 1% fee + * (`rate.value == 1,010,000,000`) becomes the `STAmount` `1.010000000`. + * + * @param rate The transfer rate to convert; must be nonzero. + * @return An `STAmount` with `noIssue()` currency carrying the decimal value + * of `rate`. + * @note `noIssue()` signals that the result is dimensionless — it carries no + * currency identity and must not be stored directly in a ledger field. + */ STAmount asAmount(Rate const& rate) { @@ -22,6 +49,23 @@ asAmount(Rate const& rate) } // namespace detail namespace nft { + +/** Convert an NFT transfer fee in basis points to a billion-scale `Rate`. + * + * NFT royalties are stored in the ledger as a `uint16_t` in basis points + * (hundredths of a percent, protocol maximum 50,000 = 50%). The `Rate` + * type uses 10^9 as its unit, so the conversion factor is 10,000: + * `50,000 bp × 10,000 = 500,000,000`, which is safely below `QUALITY_ONE` + * and fits within `uint32_t`. + * + * @param fee NFT transfer fee in basis points (0–50,000); enforced by + * transaction validation before the value reaches the ledger, so no + * range assertion is performed here. + * @return A `Rate` suitable for passing to `multiply()` or `multiplyRound()`. + * @note Do not call this for ordinary IOU transfer rates — those are already + * stored in billion-scale form in `sfTransferRate` and should be wrapped + * in `Rate` directly. + */ Rate transferFeeAsRate(std::uint16_t fee) { @@ -30,6 +74,17 @@ transferFeeAsRate(std::uint16_t fee) } // namespace nft +/** Scale an amount by a transfer rate, preserving its asset. + * + * Computes `amount × (rate / 10^9)`. Returns `amount` unchanged when + * `rate == kPARITY_RATE` (the common case for accounts with no transfer fee), + * avoiding the `STAmount` arithmetic path entirely. + * + * @param amount The value to scale. + * @param rate The transfer rate to apply; must be nonzero. + * @return The scaled `STAmount` denominated in the same asset as `amount`. + * @pre `rate.value != 0`; asserted in debug builds. + */ STAmount multiply(STAmount const& amount, Rate const& rate) { @@ -41,6 +96,19 @@ multiply(STAmount const& amount, Rate const& rate) return multiply(amount, detail::asAmount(rate), amount.asset()); } +/** Scale an amount by a transfer rate with controlled rounding, preserving its asset. + * + * Like `multiply()`, but delegates to `mulRound` so the caller can specify + * rounding direction. Used in IOU payment routing where fee calculations + * must stay in a single currency. + * + * @param amount The value to scale. + * @param rate The transfer rate to apply; must be nonzero. + * @param roundUp If `true`, round fractional results toward positive infinity; + * otherwise round toward zero. + * @return The scaled `STAmount` denominated in the same asset as `amount`. + * @pre `rate.value != 0`; asserted in debug builds. + */ STAmount multiplyRound(STAmount const& amount, Rate const& rate, bool roundUp) { @@ -52,6 +120,19 @@ multiplyRound(STAmount const& amount, Rate const& rate, bool roundUp) return mulRound(amount, detail::asAmount(rate), amount.asset(), roundUp); } +/** Scale an amount by a transfer rate with controlled rounding, emitting a specified asset. + * + * Overload for offer-crossing and cross-currency paths where the output + * must be denominated in a different asset than the input. + * + * @param amount The value to scale. + * @param rate The transfer rate to apply; must be nonzero. + * @param asset The asset type of the returned `STAmount`. + * @param roundUp If `true`, round fractional results toward positive infinity; + * otherwise round toward zero. + * @return The scaled `STAmount` denominated in `asset`. + * @pre `rate.value != 0`; asserted in debug builds. + */ STAmount multiplyRound(STAmount const& amount, Rate const& rate, Asset const& asset, bool roundUp) { @@ -65,6 +146,17 @@ multiplyRound(STAmount const& amount, Rate const& rate, Asset const& asset, bool return mulRound(amount, detail::asAmount(rate), asset, roundUp); } +/** Scale an amount by the inverse of a transfer rate, preserving its asset. + * + * Computes `amount / (rate / 10^9)` — the inverse of `multiply()`. Used when + * back-calculating the gross send amount needed to deliver a given net amount + * after fees. Returns `amount` unchanged for `kPARITY_RATE`. + * + * @param amount The value to scale. + * @param rate The transfer rate to invert; must be nonzero. + * @return The scaled `STAmount` denominated in the same asset as `amount`. + * @pre `rate.value != 0`; asserted in debug builds. + */ STAmount divide(STAmount const& amount, Rate const& rate) { @@ -76,6 +168,19 @@ divide(STAmount const& amount, Rate const& rate) return divide(amount, detail::asAmount(rate), amount.asset()); } +/** Scale an amount by the inverse of a transfer rate with controlled rounding, preserving its asset. + * + * Like `divide()`, but delegates to `divRound` so the caller can specify + * rounding direction. Used in IOU payment routing for single-currency + * gross-amount back-calculation. + * + * @param amount The value to scale. + * @param rate The transfer rate to invert; must be nonzero. + * @param roundUp If `true`, round fractional results toward positive infinity; + * otherwise round toward zero. + * @return The scaled `STAmount` denominated in the same asset as `amount`. + * @pre `rate.value != 0`; asserted in debug builds. + */ STAmount divideRound(STAmount const& amount, Rate const& rate, bool roundUp) { @@ -87,6 +192,19 @@ divideRound(STAmount const& amount, Rate const& rate, bool roundUp) return divRound(amount, detail::asAmount(rate), amount.asset(), roundUp); } +/** Scale an amount by the inverse of a transfer rate with controlled rounding, emitting a specified asset. + * + * Overload for offer-crossing and cross-currency paths where the output + * must be denominated in a different asset than the input. + * + * @param amount The value to scale. + * @param rate The transfer rate to invert; must be nonzero. + * @param asset The asset type of the returned `STAmount`. + * @param roundUp If `true`, round fractional results toward positive infinity; + * otherwise round toward zero. + * @return The scaled `STAmount` denominated in `asset`. + * @pre `rate.value != 0`; asserted in debug builds. + */ STAmount divideRound(STAmount const& amount, Rate const& rate, Asset const& asset, bool roundUp) { diff --git a/src/libxrpl/protocol/Rules.cpp b/src/libxrpl/protocol/Rules.cpp index 2c971749b6..7f480207c7 100644 --- a/src/libxrpl/protocol/Rules.cpp +++ b/src/libxrpl/protocol/Rules.cpp @@ -1,3 +1,15 @@ +/** @file + * Implements the amendment-rule snapshot (`Rules`) and the + * per-coroutine/per-thread slot that holds it during transaction processing. + * + * `Rules` is immutable after construction and cheap to copy (pimpl via + * `shared_ptr`). The thread/coroutine-local slot uses `LocalValue` so that + * coroutines sharing a thread do not stomp each other's rule context. + * `setCurrentTransactionRules` also pushes the `Number` mantissa scale as a + * side effect so that financial arithmetic uses the correct precision without + * querying rules on every operation. + */ + #include #include @@ -17,7 +29,15 @@ namespace xrpl { namespace { -// Use a static inside a function to help prevent order-of-initialization issues + +/** Returns the per-coroutine/per-thread slot for the active `Rules`. + * + * Wrapped in a function to guarantee the `LocalValue` is constructed on + * first use, avoiding C++ static-initialization-order issues that would arise + * if it were declared at namespace scope. + * + * @return A reference to the `LocalValue` holding `std::optional`. + */ LocalValue>& getCurrentTransactionRulesRef() { @@ -26,12 +46,37 @@ getCurrentTransactionRulesRef() } } // namespace +/** Returns the currently active transaction rules for this coroutine/thread. + * + * Delegates to the `LocalValue` slot so callers do not need to hold the + * slot reference directly. Returns `std::nullopt` when called outside a + * `Transactor` context (e.g., during startup or in unit tests that have not + * called `setCurrentTransactionRules`). + * + * @return A const reference to the optional `Rules` stored in the + * per-coroutine/per-thread slot. + */ std::optional const& getCurrentTransactionRules() { return *getCurrentTransactionRulesRef(); } +/** Installs `r` as the active transaction rules for this coroutine/thread. + * + * In addition to storing the new rules, this function pushes the `Number` + * mantissa scale that matches the enabled feature set. Specifically, when + * `featureSingleAssetVault` or `featureLendingProtocol` is active, the + * `MantissaScale::Large` range is required to avoid overflow in AMM and + * lending calculations. When `r` is `nullopt`, large numbers are permitted + * as a conservative default. + * + * The scale is pushed here rather than pulled inside `Number` arithmetic + * because `Number` operations are called millions of times per ledger; + * a single push per rules change is far cheaper. + * + * @param r The new ruleset to install, or `nullopt` to clear the slot. + */ void setCurrentTransactionRules(std::optional r) { @@ -47,18 +92,53 @@ setCurrentTransactionRules(std::optional r) *getCurrentTransactionRulesRef() = std::move(r); } +/** Private implementation of `Rules`. + * + * Stores the enabled-amendment set derived from a ledger's `sfAmendments` + * field (`set_`) and a reference to the externally-owned operator presets + * (`presets_`). Both sets are immutable after construction, so all member + * functions are thread-safe with no locking required. + * + * `set_` uses `HardenedHash` (xxHash + random seed) to guard against + * hash-flooding when inserting validator-supplied amendment IDs. `presets_` + * is operator-controlled and uses the lighter `beast::Uhash`. + * + * `digest_` caches the `uint256` hash of the on-ledger amendments object so + * that equality comparisons can avoid a full set comparison. + */ class Rules::Impl { private: + /** The enabled amendments from the ledger's `sfAmendments` field. */ std::unordered_set> set_; + + /** Hash of the amendments SLE; absent for genesis/preset-only rules. */ std::optional digest_; + + /** Node-operator-supplied features that are unconditionally enabled. */ std::unordered_set> const& presets_; public: + /** Constructs a genesis (preset-only) rule set. + * + * Used for the genesis ledger and unit tests. `set_` is empty and + * `digest_` is absent. + * + * @param presets Externally-owned set of always-on features. + */ explicit Impl(std::unordered_set> const& presets) : presets_(presets) { } + /** Constructs a rule set from a ledger's amendment list. + * + * Called exclusively by the private `Rules` constructor, which is in + * turn called only by the `makeRulesGivenLedger` friend functions. + * + * @param presets Externally-owned set of always-on features. + * @param digest Hash of the amendments SLE (used for fast equality). + * @param amendments The `sfAmendments` vector read from the ledger. + */ Impl( std::unordered_set> const& presets, std::optional const& digest, @@ -69,12 +149,20 @@ public: set_.insert(amendments.begin(), amendments.end()); } + /** Returns the operator-supplied preset features. */ [[nodiscard]] std::unordered_set> const& presets() const { return presets_; } + /** Returns `true` if `feature` is enabled. + * + * Checks `presets_` first (O(1)), then `set_` (O(1)). No locking is + * needed because both sets are immutable after construction. + * + * @param feature The amendment ID to test. + */ [[nodiscard]] bool enabled(uint256 const& feature) const { @@ -83,6 +171,17 @@ public: return set_.contains(feature); } + /** Returns `true` if two `Impl` objects represent the same rule set. + * + * Uses `digest_` rather than a full set comparison for efficiency. + * Two instances without a digest (genesis rules) are always equal. + * If exactly one has a digest, they are unequal. When both have + * digests, equality is determined by comparing the digests; an + * assertion also verifies that `presets_` match, since identical + * digests with different presets would produce different behavior. + * + * @param other The `Impl` to compare against. + */ bool operator==(Impl const& other) const { @@ -98,11 +197,16 @@ public: } }; +/** Constructs genesis (preset-only) rules; delegates to `Impl`. */ Rules::Rules(std::unordered_set> const& presets) : impl_(std::make_shared(presets)) { } +/** Constructs rules from a ledger's amendment list; delegates to `Impl`. + * + * Private — only callable by the `makeRulesGivenLedger` friend functions. + */ Rules::Rules( std::unordered_set> const& presets, std::optional const& digest, @@ -111,12 +215,14 @@ Rules::Rules( { } +/** Returns the operator-supplied preset features; delegates to `Impl`. */ std::unordered_set> const& Rules::presets() const { return impl_->presets(); } +/** Returns `true` if `feature` is enabled; delegates to `Impl::enabled`. */ bool Rules::enabled(uint256 const& feature) const { @@ -125,6 +231,11 @@ Rules::enabled(uint256 const& feature) const return impl_->enabled(feature); } +/** Returns `true` if `other` represents the same rule set. + * + * Short-circuits on pointer identity before delegating to + * `Impl::operator==`, making self-comparison O(1). + */ bool Rules::operator==(Rules const& other) const { @@ -134,12 +245,24 @@ Rules::operator==(Rules const& other) const return *impl_ == *other.impl_; } +/** Returns `true` if `other` represents a different rule set. */ bool Rules::operator!=(Rules const& other) const { return !(*this == other); } +/** Returns whether `feature` is enabled, with a caller-supplied fallback. + * + * Fetches the per-coroutine/per-thread `Rules` and delegates to + * `Rules::enabled`. When no rules are installed, returns `resultIfNoRules` + * instead of querying a null object. + * + * @param feature The amendment ID to test. + * @param resultIfNoRules Value to return when called outside a `Transactor` + * context (e.g., during startup or in tests without rules installed). + * @return Whether the feature is enabled, or `resultIfNoRules` if absent. + */ bool isFeatureEnabled(uint256 const& feature, bool resultIfNoRules) { @@ -149,6 +272,15 @@ isFeatureEnabled(uint256 const& feature, bool resultIfNoRules) return rules->enabled(feature); } +/** Returns whether `feature` is enabled; returns `false` if no rules are set. + * + * Convenience overload of `isFeatureEnabled(feature, resultIfNoRules)` with + * `resultIfNoRules = false`. The safe-default of `false` prevents accidental + * feature activation in code paths that have not installed a rule context. + * + * @param feature The amendment ID to test. + * @return Whether the feature is enabled, or `false` if no rules are set. + */ bool isFeatureEnabled(uint256 const& feature) { diff --git a/src/libxrpl/protocol/SField.cpp b/src/libxrpl/protocol/SField.cpp index 094c67d150..1af539259a 100644 --- a/src/libxrpl/protocol/SField.cpp +++ b/src/libxrpl/protocol/SField.cpp @@ -7,20 +7,61 @@ namespace xrpl { +/** @file + * Single authoritative source of all named XRPL protocol fields. + * + * Every field that can appear in a serialized XRPL object (transaction, + * ledger entry, validation, metadata) must be registered here before the + * process starts. Registration happens at static-initialization time via + * the `SField` and `TypedField` constructors, which insert each field + * into two global lookup tables keyed by field code and by name. + * + * The bulk of field definitions come from expanding + * `` twice: once with `TYPED_SFIELD` + * and `UNTYPED_SFIELD` producing `extern` declarations (in `SField.h`) + * and once producing `const` definitions (here). Four historical fields + * (`kSF_INVALID`, `kSF_GENERIC`, `kSF_HASH`, `kSF_INDEX`) are defined + * directly because they do not follow standard naming or code conventions. + */ + // Storage for static const members. SField::IsSigning const SField::kNOT_SIGNING; int SField::num = 0; std::unordered_map SField::knownCodeToField; std::unordered_map SField::knownNameToField; -// Give only this translation unit permission to construct SFields +/** Construction access guard for `SField`. + * + * `PrivateAccessTagT` is forward-declared as a public nested type in + * `SField`, but its definition appears only here. Every `SField` and + * `TypedField` constructor requires a value of this type as its first + * argument. Because the type is only constructible inside this translation + * unit, no external code can create new `SField` instances — they can only + * look up existing ones through `getField()`. + */ struct SField::PrivateAccessTagT { explicit PrivateAccessTagT() = default; }; +/** Sole token that satisfies the `PrivateAccessTagT` constructor parameter. + * + * Passed to every `SField` / `TypedField` constructor call in this file. + */ static SField::PrivateAccessTagT access; +/** Forwarding constructor for `TypedField`. + * + * Delegates all arguments to the `SField` constructor, registering the + * field in the global lookup tables. The template type parameter `T` + * is compile-time only; it is never stored at runtime. + * + * @tparam T The serialized C++ type associated with this field (e.g. + * `STInteger` for `SF_UINT32`). + * @tparam Args Constructor argument types forwarded to `SField`. + * @param pat Access token restricting construction to `SField.cpp`. + * @param args Arguments forwarded verbatim to the `SField` constructor. + */ template template TypedField::TypedField(PrivateAccessTagT pat, Args&&... args) @@ -28,10 +69,13 @@ TypedField::TypedField(PrivateAccessTagT pat, Args&&... args) { } +// --- Field registry population --- // Construct all compile-time SFields, and register them in the knownCodeToField -// and knownNameToField databases: +// and knownNameToField databases. // Use macros for most SField construction to enforce naming conventions. +// Each macro derives the human-readable field name by stripping the "sf" prefix +// from the variable name token via std::string_view(#sfName).substr(2). #pragma push_macro("UNTYPED_SFIELD") #undef UNTYPED_SFIELD #pragma push_macro("TYPED_SFIELD") @@ -52,12 +96,21 @@ TypedField::TypedField(PrivateAccessTagT pat, Args&&... args) std::string_view(#sfName).substr(2).data(), \ ##__VA_ARGS__); -// SFields which, for historical reasons, do not follow naming conventions. +/** Sentinel returned by `getField()` on a lookup miss; `fieldCode == -1`. */ SField const kSF_INVALID(access, -1, ""); +/** Catch-all field for untyped contexts; `fieldCode == 0`. */ SField const kSF_GENERIC(access, 0, "Generic"); -// The following two fields aren't used anywhere, but they break tests/have -// downstream effects. +/** JSON-only uint256 field carrying an object's hash; not binary-serializable. + * + * `fieldValue == 257 > 256` makes `isDiscardable()` return true, so this + * field is present in JSON representations of ledger state but excluded from + * binary encoding. + */ SField const kSF_HASH(access, STI_UINT256, 257, "hash"); +/** JSON-only uint256 field carrying an object's ledger key; not binary-serializable. + * + * Like `kSF_HASH`, `fieldValue == 258 > 256` marks this field as discardable. + */ SField const kSF_INDEX(access, STI_UINT256, 258, "index"); #include @@ -67,6 +120,22 @@ SField const kSF_INDEX(access, STI_UINT256, 258, "index"); #undef UNTYPED_SFIELD #pragma pop_macro("UNTYPED_SFIELD") +/** Construct a typed, named protocol field and register it in both lookup tables. + * + * Computes `fieldCode = (tid << 16) | fv` and inserts a pointer to `this` + * into `knownCodeToField` and `knownNameToField`. Duplicate codes or names + * are caught by `XRPL_ASSERT` in debug builds; in release builds with + * `NDEBUG` defined the checks compile away and a duplicate would silently + * shadow the earlier registration. + * + * @param tid Serialized type family (e.g. `STI_UINT32`). + * @param fv Per-type field index; must be < 256 for binary serialization. + * @param fn Human-readable field name (the `sf` prefix is stripped by the + * calling macro before this parameter is supplied). + * @param meta Bitmask of `kSMD_*` metadata flags; defaults to `kSMD_DEFAULT`. + * @param signing Whether this field is included in signing payloads; defaults + * to `IsSigning::Yes`. + */ SField::SField( PrivateAccessTagT, SerializedTypeID tid, @@ -93,6 +162,17 @@ SField::SField( knownNameToField[fieldName] = this; } +/** Construct a special-purpose field from a raw field code and name. + * + * Used only for the four historical outliers (`kSF_INVALID`, `kSF_GENERIC`, + * `kSF_HASH`, `kSF_INDEX`) whose codes cannot be derived from the standard + * `(tid << 16) | fv` formula. Sets `fieldType` to `STI_UNKNOWN` and + * `fieldMeta` to `kSMD_NEVER`; these fields carry no metadata and are + * never included in standard serialization. + * + * @param fc Raw field code; -1 for `kSF_INVALID`, 0 for `kSF_GENERIC`. + * @param fn Human-readable field name. + */ SField::SField(PrivateAccessTagT, int fc, char const* fn) : fieldCodeMem(fc) , fieldType(STI_UNKNOWN) @@ -112,6 +192,12 @@ SField::SField(PrivateAccessTagT, int fc, char const* fn) knownNameToField[fieldName] = this; } +/** Look up a field by its packed field code. + * + * @param code Packed field code `(SerializedTypeID << 16) | fieldValue`. + * @return The registered `SField`, or `kSF_INVALID` if no field with that + * code exists. + */ SField const& SField::getField(int code) { @@ -124,10 +210,21 @@ SField::getField(int code) return kSF_INVALID; } +/** Compare two fields by canonical binary-serialization order. + * + * Fields are ordered by `fieldCode = (SerializedTypeID << 16) | fieldValue`, + * which sorts first by type family and then by per-type index — matching + * the canonical order required by the XRPL wire format. + * + * @param f1 First field to compare. + * @param f2 Second field to compare. + * @return -1 if `f1` precedes `f2`, 1 if `f1` follows `f2`, or 0 if the + * comparison is illegal because either field has a non-positive code + * (i.e. `kSF_INVALID` or `kSF_GENERIC`). + */ int SField::compare(SField const& f1, SField const& f2) { - // -1 = f1 comes before f2, 0 = illegal combination, 1 = f1 comes after f2 if ((f1.fieldCodeMem <= 0) || (f2.fieldCodeMem <= 0)) return 0; @@ -140,6 +237,15 @@ SField::compare(SField const& f1, SField const& f2) return 0; } +/** Look up a field by its human-readable name. + * + * Names are stored without the `sf` prefix (e.g. the field `sfAmount` is + * registered under the key `"Amount"`). + * + * @param fieldName The field name string to look up (no `sf` prefix). + * @return The registered `SField`, or `kSF_INVALID` if no field with that + * name exists. + */ SField const& SField::getField(std::string const& fieldName) { diff --git a/src/libxrpl/protocol/SOTemplate.cpp b/src/libxrpl/protocol/SOTemplate.cpp index 708fd465e2..16fb0d66f4 100644 --- a/src/libxrpl/protocol/SOTemplate.cpp +++ b/src/libxrpl/protocol/SOTemplate.cpp @@ -1,3 +1,25 @@ +/** @file + * Implements the SOTemplate schema registry used by every serialized + * object type in the XRP Ledger. + * + * An SOTemplate is the immutable field schema for one transaction or + * ledger entry type (e.g., Payment, AccountRoot). It is constructed + * once at program startup as part of the TxFormats / LedgerFormats / + * InnerObjectFormats singletons and then queried on every + * serialization, deserialization, and field-validation call. + * + * The core data structure is a direct-address lookup table + * (`indices_`) sized to the total number of registered SFields. + * Given an SField, its position inside `elements_` is found in O(1) + * with a single array subscript — no hashing, no comparisons. + * + * The two constructor overloads (initializer_list and vector) share + * the same implementation; the initializer_list form delegates to the + * vector form. Both accept separate `uniqueFields` and `commonFields` + * lists so callers (KnownFormats) can maintain the common field set + * once and pass it to every format without duplication. + */ + #include #include @@ -13,6 +35,13 @@ namespace xrpl { +/** Convenience overload that converts initializer lists to vectors and + * delegates to the vector constructor. + * + * @param uniqueFields Fields specific to this object type. + * @param commonFields Fields shared across all object types (e.g., Fee, + * Sequence, Signature for transactions). + */ SOTemplate::SOTemplate( std::initializer_list uniqueFields, std::initializer_list commonFields) @@ -20,41 +49,70 @@ SOTemplate::SOTemplate( { } +/** Build the schema from two field lists and construct the O(1) index table. + * + * The two lists are merged into a single ordered sequence (`elements_`), + * unique fields first. The index table (`indices_`) is then populated so + * that `indices_[sField.getNum()]` holds the position of that field in + * `elements_`, enabling O(1) lookup in `getIndex()`. + * + * `indices_` is snapshotted at construction time from + * `SField::getNumFields()`. Fields registered after this template is + * constructed cannot be added to it, which is intentional — templates + * are immutable after construction. + * + * Two invariants are enforced for every field: + * - **Range:** `0 < fieldNum < indices_.size()`. Sentinel fields such as + * `sfInvalid` (non-positive field number) and any field registered after + * snapshot (field number ≥ table size) are rejected. + * - **Uniqueness:** a field may appear at most once across both lists. + * Duplicates would silently corrupt the index mapping, so the check is + * fatal: any violation throws immediately during program initialization. + * + * @param uniqueFields Fields specific to this object type; placed first in + * `elements_`. + * @param commonFields Fields shared across all object types; appended after + * unique fields. + * @throws std::runtime_error if any field has an out-of-range field number + * or appears more than once across the two lists. + */ SOTemplate::SOTemplate(std::vector uniqueFields, std::vector commonFields) - : indices_(SField::getNumFields() + 1, -1) // Unmapped indices == -1 + : indices_(SField::getNumFields() + 1, -1) { - // Add all SOElements. - // elements_ = std::move(uniqueFields); std::ranges::move(commonFields, std::back_inserter(elements_)); - // Validate and index elements_. - // for (std::size_t i = 0; i < elements_.size(); ++i) { SField const& sField{elements_[i].sField()}; - // Make sure the field's index is in range - // if (sField.getNum() <= 0 || sField.getNum() >= indices_.size()) Throw("Invalid field index for SOTemplate."); - // Make sure that this field hasn't already been assigned - // if (getIndex(sField) != -1) Throw("Duplicate field index for SOTemplate."); - // Add the field to the index mapping table - // indices_[sField.getNum()] = i; } } +/** Return the position of @p sField in `elements_`, or -1 if absent. + * + * Performs a direct array subscript into `indices_` using the field's + * registered number, giving O(1) cost regardless of template size. + * This is the hot path called on every field access during serialization + * and deserialization. + * + * @param sField The field to look up. + * @return Index into `elements_` where the field's SOElement lives, or + * -1 if the field is not part of this template. + * @throws std::runtime_error if @p sField has a non-positive or + * out-of-range field number (i.e., it is a sentinel field or was + * registered after this template was constructed). + */ int SOTemplate::getIndex(SField const& sField) const { - // The mapping table should be large enough for any possible field - // if (sField.getNum() <= 0 || sField.getNum() >= indices_.size()) Throw("Invalid field index for getIndex()."); diff --git a/src/libxrpl/protocol/STAccount.cpp b/src/libxrpl/protocol/STAccount.cpp index f561c9f930..114c04b05f 100644 --- a/src/libxrpl/protocol/STAccount.cpp +++ b/src/libxrpl/protocol/STAccount.cpp @@ -1,3 +1,15 @@ +/** @file + * Implements STAccount — the serialized-type container for XRPL account + * identifiers in ledger objects and transactions. + * + * Key design note: although `AccountID` is a fixed 160-bit value, the wire + * format intentionally preserves the variable-length (VL) blob encoding used + * by the original `STBlob`-based implementation so that existing ledger data + * remains byte-for-byte compatible. The `default_` flag tracks whether the + * field has been explicitly assigned; a default field serializes as a + * zero-length VL blob rather than 20 zero bytes to distinguish "unset" from + * "explicitly set to the zero account." + */ #include #include @@ -17,24 +29,47 @@ namespace xrpl { +/** Construct an anonymous, unset account field. + * + * Sets `value_` to `beast::kZERO` and marks `default_ = true`. An unset + * field serializes as a zero-length VL blob and returns an empty string + * from `getText()`. + */ STAccount::STAccount() : value_(beast::kZERO), default_(true) { } +/** Construct a named but unset account field. + * + * Binds the field to descriptor `n`, sets `value_` to zero, and marks + * `default_ = true`. Typical use: building a new ledger object before + * individual fields are populated. + * + * @param n The `SField` descriptor identifying this field (e.g. `sfAccount`). + */ STAccount::STAccount(SField const& n) : STBase(n), value_(beast::kZERO), default_(true) { } +/** Construct from a raw byte buffer, as produced by VL-blob deserialization. + * + * An empty buffer is the round-trip representation of a default (unset) + * field and leaves the object in the default state. A non-empty buffer must + * be exactly `uint160::kBYTES` (20) bytes; any other size throws. This is + * the path taken by `STAccount(SerialIter&, SField const&)`. + * + * @param n The `SField` descriptor for this field. + * @param v Raw bytes read from a VL-blob. Must be empty or exactly 20 bytes. + * @throws std::runtime_error if `v` is non-empty and not exactly 20 bytes. + * @note Throwing from a constructor is safe here because the only direct + * caller, `STVar::STVar(SerialIter&, SField const&)`, already propagates + * exceptions. + */ STAccount::STAccount(SField const& n, Buffer const& v) : STAccount(n) { if (v.empty()) return; // Zero is a valid size for a defaulted STAccount. - // Is it safe to throw from this constructor? Today (November 2015) - // the only place that calls this constructor is - // STVar::STVar (SerialIter&, SField const&) - // which throws. If STVar can throw in its constructor, then so can - // STAccount. if (v.size() != uint160::kBYTES) Throw("Invalid STAccount size"); @@ -42,20 +77,49 @@ STAccount::STAccount(SField const& n, Buffer const& v) : STAccount(n) memcpy(value_.begin(), v.data(), uint160::kBYTES); } +/** Deserialize an account field from a wire-format byte stream. + * + * Extracts the next VL-prefixed blob from `sit` and delegates to the + * `Buffer` constructor for size validation and value assignment. + * + * @param sit Forward cursor over the serialized byte stream; advanced past + * the VL blob on return. + * @param name The `SField` descriptor for this field. + * @throws std::runtime_error if the extracted blob is not empty or 20 bytes. + */ STAccount::STAccount(SerialIter& sit, SField const& name) : STAccount(name, sit.getVLBuffer()) { } +/** Construct from a typed `AccountID` value. + * + * The field is marked non-default regardless of whether `v` is the zero + * account. This is the standard path in application code when the account + * address is already known. + * + * @param n The `SField` descriptor for this field. + * @param v The 160-bit account identifier to store. + */ STAccount::STAccount(SField const& n, AccountID const& v) : STBase(n), value_(v), default_(false) { } +/** Place a copy of this object into the supplied buffer or heap-allocate. + * + * Delegates to `STBase::emplace()` for the small-object optimization used + * by `detail::STVar`. + */ STBase* STAccount::copy(std::size_t n, void* buf) const { return emplace(n, buf, *this); } +/** Place a moved instance into the supplied buffer or heap-allocate. + * + * Delegates to `STBase::emplace()` for the small-object optimization used + * by `detail::STVar`. + */ STBase* STAccount::move(std::size_t n, void* buf) { @@ -68,19 +132,42 @@ STAccount::getSType() const return STI_ACCOUNT; } +/** Append this field to a serializer in VL-blob wire format. + * + * Preserves byte-for-byte compatibility with the legacy `STBlob`-based + * encoding: a default (unset) field serializes as a zero-length VL blob + * (one `0x00` byte on the wire), while a non-default field serializes as + * a 20-byte VL blob. This distinction is what separates "field not set" + * from "field explicitly set to the zero account." + * + * @param s The serializer to append to. + * @note Asserts (debug builds only) that the associated `SField` is a binary + * field of type `STI_ACCOUNT`. + */ void STAccount::add(Serializer& s) const { XRPL_ASSERT(getFName().isBinary(), "xrpl::STAccount::add : field is binary"); XRPL_ASSERT(getFName().fieldType == STI_ACCOUNT, "xrpl::STAccount::add : valid field type"); - // Preserve the serialization behavior of an STBlob: - // o If we are default (all zeros) serialize as an empty blob. - // o Otherwise serialize 160 bits. int const size = isDefault() ? 0 : uint160::kBYTES; s.addVL(value_.data(), size); } +/** Check semantic equivalence with another serialized field. + * + * Two `STAccount` fields are equivalent only when both their `default_` + * flags and their 160-bit `value_` agree. The `SField` name is intentionally + * ignored — equivalence is purely about the stored account state, not which + * field slot it occupies. + * + * @param t The field to compare against. + * @return `true` if `t` is an `STAccount` with the same default flag and + * value; `false` if `t` is a different type or either attribute differs. + * @note This is the polymorphic path used by `STObject` for structural + * comparison. Callers that want to compare only the address (ignoring + * default state) should use `operator==` on the `value()` accessors. + */ bool STAccount::isEquivalent(STBase const& t) const { @@ -94,6 +181,15 @@ STAccount::isDefault() const return default_; } +/** Return the account address as a Base58Check string, or empty if unset. + * + * A default (unset) field returns an empty string rather than the Base58 + * encoding of the all-zeros pseudo-account, preserving the distinction + * between an unset field and a field explicitly set to the XRP issuer + * sentinel (`rrrrrrrrrrrrrrrrrrrrrhoLvTp`). + * + * @return Base58Check-encoded address, or `""` when `isDefault()` is true. + */ std::string STAccount::getText() const { diff --git a/src/libxrpl/protocol/STAmount.cpp b/src/libxrpl/protocol/STAmount.cpp index 20d3db45c0..34a86f241e 100644 --- a/src/libxrpl/protocol/STAmount.cpp +++ b/src/libxrpl/protocol/STAmount.cpp @@ -1,3 +1,19 @@ +/** @file + * Implementation of STAmount — the universal value type for the XRP Ledger. + * + * Every amount on the network (XRP drops, IOU tokens, or MPTs) is + * represented, serialized, compared, and arithmetically manipulated through + * this class and its companion free functions. The canonical internal form + * stores a `uint64_t` mantissa, an `int` base-10 exponent, and a sign bit; + * the exact valid range differs by asset type: + * + * - **XRP / MPT** (integral): `offset_ == 0`, `value_` is the raw drop/token + * count. + * - **IOU**: `value_` ∈ [10^15, 10^16 − 1], `offset_` ∈ [−96, +80]; + * zero is the special case `(value_=0, offset_=−100)`. + * + * See `STAmount.h` for the full public interface. + */ #include #include @@ -47,11 +63,28 @@ namespace xrpl { +/** 10^14 — scaling denominator used in IOU multiply to keep precision. */ static std::uint64_t const kTEN_TO14 = 100000000000000ull; +/** 10^14 − 1 — maximum rounding bias added before the final divide in + * `muldivRound` when the result is positive and rounding up. */ static std::uint64_t const kTEN_TO14M1 = kTEN_TO14 - 1; +/** 10^17 — scaling multiplier used in IOU divide to maintain full precision. */ static std::uint64_t const kTEN_TO17 = kTEN_TO14 * 1000; //------------------------------------------------------------------------------ + +/** Extract the integral value from an XRP or MPT amount as a signed 64-bit integer. + * + * Validates that `valid` is true, asserts the exponent is zero (canonical + * for integral types), and applies the sign bit before returning. Used + * internally by `getSNValue` and `getMPTValue`. + * + * @param amount The amount whose mantissa is extracted. + * @param valid Must be true; when false, throws with `error` as the message. + * @param error Error string thrown when `valid` is false. + * @return The signed integer value of `amount`. + * @throw std::runtime_error if `valid` is false. + */ static std::int64_t getInt64Value(STAmount const& amount, bool valid, char const* error) { @@ -71,18 +104,41 @@ getInt64Value(STAmount const& amount, bool valid, char const* error) return ret; } +/** Return the signed drop value of a native XRP amount. + * + * @param amount Must be a native (XRP) amount; throws otherwise. + * @return The signed drop count. + * @throw std::runtime_error if `amount` is not native. + */ static std::int64_t getSNValue(STAmount const& amount) { return getInt64Value(amount, amount.native(), "amount is not native!"); } +/** Return the signed value of an MPT amount. + * + * @param amount Must hold an `MPTIssue`; throws otherwise. + * @return The signed MPT token count. + * @throw std::runtime_error if `amount` does not hold an `MPTIssue`. + */ static std::int64_t getMPTValue(STAmount const& amount) { return getInt64Value(amount, amount.holds(), "amount is not MPT!"); } +/** Determine whether two amounts represent the same asset and can be compared + * or combined arithmetically. + * + * Two IOU amounts are comparable when they share both nativeness and currency. + * Two MPT amounts are comparable when their `MPTIssue` identities are equal. + * Cross-type pairs (one IOU, one MPT) are never comparable. + * + * @param v1 First amount. + * @param v2 Second amount. + * @return `true` if the amounts share the same asset identity. + */ static bool areComparable(STAmount const& v1, STAmount const& v2) { @@ -107,6 +163,18 @@ areComparable(STAmount const& v1, STAmount const& v2) static_assert(kINITIAL_XRP.drops() == STAmount::kMAX_NATIVE_N); +/** Deserialize an `STAmount` from the wire format. + * + * Decodes the 64-bit header word and, for issued currencies, the following + * 160-bit currency and 160-bit issuer fields; for MPT, the following 192-bit + * MPTID. Bit layout: + * - Bit 63 (`kISSUED_CURRENCY`): 0 = XRP or MPT, 1 = IOU. + * - Bit 62 (`kPOSITIVE`): 1 = positive, 0 = negative. + * - Bit 61 (`kMP_TOKEN`): 1 = MPT (only when bit 63 is 0). + * + * @throws std::runtime_error on negative-zero XRP, invalid IOU currency/ + * account, or mantissa/exponent outside the canonical range. + */ STAmount::STAmount(SerialIter& sit, SField const& name) : STBase(name) { std::uint64_t value = sit.get64(); @@ -189,12 +257,23 @@ STAmount::STAmount(SerialIter& sit, SField const& name) : STBase(name) canonicalize(); } +/** Construct a native XRP amount from a signed mantissa. + * + * Negative values are stored as positive mantissa + sign bit via `set()`. + * No `canonicalize()` call is made; the caller is responsible for ensuring + * the value is within the legal XRP drop range. + */ STAmount::STAmount(SField const& name, std::int64_t mantissa) : STBase(name), asset_(xrpIssue()), offset_(0) { set(mantissa); } +/** Construct a native XRP amount from an unsigned mantissa and explicit sign. + * + * The mantissa must not exceed `INT64_MAX`; the assertion enforces this. + * This constructor does not call `canonicalize()`. + */ STAmount::STAmount(SField const& name, std::uint64_t mantissa, bool negative) : STBase(name), asset_(xrpIssue()), value_(mantissa), offset_(0), isNegative_(negative) { @@ -204,6 +283,12 @@ STAmount::STAmount(SField const& name, std::uint64_t mantissa, bool negative) "mantissa input"); } +/** Copy-construct an STAmount while assigning a different SField name. + * + * Used when an amount value needs to be re-associated with a different + * serialized field (e.g. when promoting an inner object's amount into an + * outer context). Calls `canonicalize()` to ensure invariants hold. + */ STAmount::STAmount(SField const& name, STAmount const& from) : STBase(name) , asset_(from.asset_) @@ -219,6 +304,11 @@ STAmount::STAmount(SField const& name, STAmount const& from) //------------------------------------------------------------------------------ +/** Construct a bare (field-less) native XRP amount from an unsigned mantissa. + * + * A negative sign is suppressed for zero (`mantissa == 0 && negative` is + * treated as positive zero). Does not call `canonicalize()`. + */ STAmount::STAmount(std::uint64_t mantissa, bool negative) : asset_(xrpIssue()), value_(mantissa), offset_(0), isNegative_(mantissa != 0 && negative) { @@ -228,6 +318,11 @@ STAmount::STAmount(std::uint64_t mantissa, bool negative) "input"); } +/** Promote an `XRPAmount` into an `STAmount`. + * + * Preserves the sign of `amount` and calls `canonicalize()` to ensure + * the drop count is within the legal XRP range. + */ STAmount::STAmount(XRPAmount const& amount) : asset_(xrpIssue()), offset_(0), isNegative_(amount < beast::kZERO) { @@ -266,6 +361,11 @@ STAmount::move(std::size_t n, void* buf) // Conversion // //------------------------------------------------------------------------------ + +/** Convert to the lean `XRPAmount` representation. + * + * @throw std::logic_error if this amount is not native (XRP). + */ XRPAmount STAmount::xrp() const { @@ -281,6 +381,10 @@ STAmount::xrp() const return XRPAmount{drops}; } +/** Convert to the lean `IOUAmount` representation. + * + * @throw std::logic_error if this amount is integral (XRP or MPT). + */ IOUAmount STAmount::iou() const { @@ -296,6 +400,10 @@ STAmount::iou() const return {mantissa, exponent}; } +/** Convert to the lean `MPTAmount` representation. + * + * @throw std::logic_error if this amount does not hold an `MPTIssue`. + */ MPTAmount STAmount::mpt() const { @@ -311,6 +419,12 @@ STAmount::mpt() const return MPTAmount{value}; } +/** Assign from an `IOUAmount`, preserving the existing asset identity. + * + * The amount must already be an IOU (non-integral); the asset is unchanged. + * Does not call `canonicalize()` — the `IOUAmount` is already in canonical + * form. + */ STAmount& STAmount::operator=(IOUAmount const& iou) { @@ -328,6 +442,19 @@ STAmount::operator=(IOUAmount const& iou) return *this; } +/** Assign from a `Number`, with feature-gated conversion path. + * + * When `featureSingleAssetVault` or `featureLendingProtocol` is active, or + * when no current transaction rules are set (e.g. in unit tests), delegates + * to `fromNumber()` for asset-aware normalization. The legacy path directly + * copies the mantissa and exponent without asset-specific rounding; both + * paths call `canonicalize()` afterward. + * + * @note This operator exists to support the vault/loan transactor pattern + * where `Number` arithmetic results are assigned back into an `STAmount` + * field. Callers must ensure `associateAsset()` is called after all + * mutations if `sMD_NeedsAsset` fields are involved. + */ STAmount& STAmount::operator=(Number const& number) { @@ -367,6 +494,19 @@ STAmount::operator-=(STAmount const& a) return *this; } +/** Add two amounts of the same asset. + * + * For XRP, performs signed 64-bit integer addition on the drop counts. + * For MPT, performs integer addition on the token values. + * For IOU (legacy path), aligns exponents by truncating the lower-exponent + * operand, then adds; results within [−10, +10] after alignment are + * rounded to the asset's zero. + * When `getSTNumberSwitchover()` is active, delegates to `IOUAmount::operator+`. + * + * @note The int64 addition itself cannot overflow, but the resulting + * `STAmount` can overflow during `canonicalize()`, which then throws. + * @throw std::runtime_error if the amounts are not comparable or overflow. + */ STAmount operator+(STAmount const& v1, STAmount const& v2) { @@ -430,6 +570,12 @@ operator+(STAmount const& v1, STAmount const& v2) return STAmount{v1.getFName(), v1.asset(), static_cast(-fv), ov1, true}; } +/** Subtract two amounts of the same asset. + * + * Implemented as `v1 + (−v2)`. The unary negation has no effect on zero. + * + * @throw std::runtime_error if the amounts are not comparable or overflow. + */ STAmount operator-(STAmount const& v1, STAmount const& v2) { @@ -446,15 +592,19 @@ STAmount::setIssue(Asset const& asset) asset_ = asset; } -// Convert an offer into an index amount so they sort by rate. -// A taker will take the best, lowest, rate first. -// (e.g. a taker will prefer pay 1 get 3 over pay 1 get 2. -// --> offerOut: takerGets: How much the offerer is selling to the taker. -// --> offerIn: takerPays: How much the offerer is receiving from the taker. -// <-- uRate: normalize(offerIn/offerOut) -// A lower rate is better for the person taking the order. -// The taker gets more for less with a lower rate. -// Zero is returned if the offer is worthless. +/** Encode an order-book offer as a 64-bit sort key (rate = takerPays / takerGets). + * + * A lower value represents a better rate for the taker. The key packs + * `(exponent + 100) << 56 | mantissa`, exploiting the fact that the + * canonical IOU mantissa fits in 56 bits. Overflow or a zero quotient both + * return 0 (offer treated as worthless). + * + * @param offerOut `takerGets`: the amount the offerer sells. + * @param offerIn `takerPays`: the amount the offerer receives. + * @return A 64-bit sort key where lower is better for takers; 0 for worthless + * or overflow offers. + * @see kU_RATE_ONE for the canonical 1:1 rate constant. + */ std::uint64_t getRate(STAmount const& offerOut, STAmount const& offerIn) { @@ -479,36 +629,28 @@ getRate(STAmount const& offerOut, STAmount const& offerIn) } } -/** - * @brief Safely checks if two STAmount values can be added without overflow, - * underflow, or precision loss. +/** Determine whether adding two amounts is safe without overflow or unacceptable + * precision loss. * - * This function determines whether the addition of two STAmount objects is - * safe, depending on their type: - * - For XRP amounts, it checks for integer overflow and underflow. - * - For IOU amounts, it checks for acceptable precision loss. - * - For MPT amounts, it checks for overflow and underflow within 63-bit signed - * integer limits. - * - If either amount is zero, addition is always considered safe. - * - If the amounts are of different currencies or types, addition is not - * allowed. + * Returns `false` immediately for incomparable assets. For XRP and MPT, + * checks integer overflow/underflow bounds. For IOU, applies a round-trip + * relative error test: `|(a−b)+b − a| + |(b−a)+a − b| ≤ 10^−4`; this + * catches cases where a large exponent gap would lose too many significant + * digits, which could corrupt vault/AMM balances. * - * @param a The first STAmount to add. - * @param b The second STAmount to add. - * @return true if the addition is safe; false otherwise. + * @param a The first addend. + * @param b The second addend. + * @return `true` if `a + b` is safe to compute; `false` otherwise. */ bool canAdd(STAmount const& a, STAmount const& b) { - // cannot add different currencies if (!areComparable(a, b)) return false; - // special case: adding anything to zero is always fine if (a == beast::kZERO || b == beast::kZERO) return true; - // XRP case (overflow & underflow check) if (isXRP(a) && isXRP(b)) { XRPAmount const aVal = a.xrp(); @@ -521,7 +663,6 @@ canAdd(STAmount const& a, STAmount const& b) aVal < XRPAmount{std::numeric_limits::min()} - bVal)); } - // IOU case (precision check) auto const ret = std::visit( [&]( TIss1 const&, TIss2 const&) -> std::optional { @@ -534,7 +675,6 @@ canAdd(STAmount const& a, STAmount const& b) return ((rhs.negative() ? -rhs : rhs) + (lhs.negative() ? -lhs : lhs)) <= kMAX_LOSS; } - // MPT (overflow & underflow check) if constexpr (kIS_MPTISSUE_V && kIS_MPTISSUE_V) { MPTAmount const aVal = a.mpt(); @@ -557,44 +697,32 @@ canAdd(STAmount const& a, STAmount const& b) // LCOV_EXCL_STOP } -/** - * @brief Determines if it is safe to subtract one STAmount from another. +/** Determine whether subtracting `b` from `a` is safe. * - * This function checks whether subtracting amount `b` from amount `a` is valid, - * considering currency compatibility and underflow conditions for specific - * types. + * For XRP and MPT, checks integer underflow and overflow bounds. For IOU, + * subtraction is always considered safe because negative IOU balances are + * valid on the ledger. Returns `false` for incomparable assets. * - * - Subtracting zero is always allowed. - * - Subtraction is only allowed between comparable currencies. - * - For XRP amounts, ensures no underflow or overflow occurs. - * - For IOU amounts, subtraction is always allowed (no underflow). - * - For MPT amounts, ensures no underflow or overflow occurs. - * - * @param a The minuend (amount to subtract from). - * @param b The subtrahend (amount to subtract). - * @return true if subtraction is allowed, false otherwise. + * @param a The minuend. + * @param b The subtrahend. + * @return `true` if `a - b` is safe to compute; `false` otherwise. */ bool canSubtract(STAmount const& a, STAmount const& b) { - // Cannot subtract different currencies if (!areComparable(a, b)) return false; - // Special case: subtracting zero is always fine if (b == beast::kZERO) return true; - // XRP case (underflow & overflow check) if (isXRP(a) && isXRP(b)) { XRPAmount const aVal = a.xrp(); XRPAmount const bVal = b.xrp(); - // Check for underflow if (bVal > XRPAmount{0} && aVal < bVal) return false; - // Check for overflow if (bVal < XRPAmount{0} && aVal > XRPAmount{std::numeric_limits::max()} + bVal) return false; @@ -602,7 +730,6 @@ canSubtract(STAmount const& a, STAmount const& b) return true; } - // IOU case (no underflow) auto const ret = std::visit( [&]( TIss1 const&, TIss2 const&) -> std::optional { @@ -611,17 +738,14 @@ canSubtract(STAmount const& a, STAmount const& b) return true; } - // MPT case (underflow & overflow check) if constexpr (kIS_MPTISSUE_V && kIS_MPTISSUE_V) { MPTAmount const aVal = a.mpt(); MPTAmount const bVal = b.mpt(); - // Underflow check if (bVal > MPTAmount{0} && aVal < bVal) return false; - // Overflow check if (bVal < MPTAmount{0} && aVal > MPTAmount{std::numeric_limits::max()} + bVal) return false; @@ -639,6 +763,16 @@ canSubtract(STAmount const& a, STAmount const& b) // LCOV_EXCL_STOP } +/** Serialize this amount into a `Json::Value`. + * + * XRP amounts are emitted as a plain string (drop count). IOU and MPT amounts + * are emitted as a JSON object with `"value"` plus asset fields via + * `Asset::setJson()`. + * + * @note It is an error to call this for a non-native amount when the asset + * does not have a valid currency and issuer; `Asset::setJson()` will throw + * in that case. + */ void STAmount::setJson(json::Value& elem) const { @@ -669,6 +803,7 @@ STAmount::getSType() const return STI_AMOUNT; } +/** Return a human-readable string including the asset identity (e.g. `"100/XRP"`). */ std::string STAmount::getFullText() const { @@ -679,6 +814,10 @@ STAmount::getFullText() const return ret; } +/** Return a human-readable decimal string, applying decimal-point notation when + * the exponent is in the range [−25, −5) and scientific notation otherwise. + * Leading and trailing zeroes are stripped for readability. + */ std::string STAmount::getText() const { @@ -775,6 +914,10 @@ STAmount::getJson(JsonOptions) const return elem; } +/** Serialize to wire format, producing the bit pattern described in the class + * comment. Inverse of the `SerialIter` constructor; round-trips losslessly + * for any canonical amount. + */ void STAmount::add(Serializer& s) const { @@ -838,22 +981,23 @@ STAmount::isDefault() const //------------------------------------------------------------------------------ -// amount = value_ * [10 ^ offset_] -// Representation range is 10^80 - 10^(-80). -// -// On the wire: -// - high bit is 0 for XRP, 1 for issued currency -// - next bit is 1 for positive, 0 for negative (except 0 issued currency, which -// is a special case of 0x8000000000000000 -// - for issued currencies, the next 8 bits are (offset_+97). -// The +97 is so that this value is always positive. -// - The remaining bits are significant digits (mantissa) -// That's 54 bits for issued currency and 62 bits for native -// (but XRP only needs 57 bits for the max value of 10^17 drops) -// -// value_ is zero if the amount is zero, otherwise it's within the range -// 10^15 to (10^16 - 1) inclusive. -// offset_ is in the range -96 to +80. +/** Bring the amount into its canonical internal form. + * + * For **integral** types (XRP and MPT): repeatedly divides or multiplies + * `value_` by 10 while adjusting `offset_` until `offset_ == 0`, checking + * overflow bounds before each multiply. When `getSTNumberSwitchover()` is + * active, delegates to `XRPAmount` or `MPTAmount` conversion via `Number`. + * + * For **IOU**: nudges the mantissa into the canonical window [10^15, 10^16) + * by scaling up (multiply × 10, decrement offset) or down (divide ÷ 10, + * increment offset). Underflow below `kMIN_OFFSET` collapses the value to + * canonical zero `(value_=0, offset_=−100)`. Overflow throws. When + * `getSTNumberSwitchover()` is active, delegates to `iou()` which uses the + * `IOUAmount` normalizer. + * + * @throw std::runtime_error on XRP overflow (`> kMAX_NATIVE_N`), MPT overflow + * (`> maxMPTokenAmount`), or IOU overflow. + */ void STAmount::canonicalize() { @@ -985,6 +1129,7 @@ STAmount::canonicalize() (value_ != 0) || (offset_ != -100), "xrpl::STAmount::canonicalize : value or offset set"); } +/** Decompose a signed integer into `isNegative_` and `value_` (unsigned mantissa). */ void STAmount::set(std::int64_t v) { @@ -1002,6 +1147,16 @@ STAmount::set(std::int64_t v) //------------------------------------------------------------------------------ +/** Decode a 64-bit order-book sort key back into an `STAmount` quality. + * + * Inverts the encoding performed by `getRate()`: extracts the 8-bit + * `(exponent + 100)` from the high bits and the 56-bit mantissa from the + * low bits, returning the corresponding `noIssue()` amount. A zero rate + * returns a zero-valued `noIssue()` amount. + * + * @param rate A 64-bit key as produced by `getRate()`. + * @return An `STAmount` representing the decoded quality. + */ STAmount amountFromQuality(std::uint64_t rate) { @@ -1014,6 +1169,14 @@ amountFromQuality(std::uint64_t rate) return STAmount(noIssue(), mantissa, exponent); } +/** Parse an amount string into an `STAmount` for the given asset. + * + * @param asset The asset (XRP, IOU, or MPT) for the resulting amount. + * @param amount A decimal string such as `"100"` or `"1.5e2"`. + * @return The parsed `STAmount`. + * @throw std::runtime_error if `asset` is integral (XRP or MPT) and the + * string encodes a fractional value (negative exponent). + */ STAmount amountFromString(Asset const& asset, std::string const& amount) { @@ -1023,6 +1186,24 @@ amountFromString(Asset const& asset, std::string const& amount) return {asset, parts.mantissa, parts.exponent, parts.negative}; } +/** Parse a JSON value into an `STAmount`, supporting four input formats. + * + * Recognized formats: + * - **Object** with `"value"` plus `"currency"` / `"issuer"` for IOU, or + * `"mpt_issuance_id"` for MPT. + * - **Array** `[value, currency_or_mptid, issuer]`. + * - **Delimited string** `"value/currency/issuer"` (tabs, newlines, commas, or `/`). + * - **Bare number or string** treated as an XRP drop count. + * + * Asset type is inferred from which fields are present; fractional values + * are rejected for XRP and MPT since those are integer-only types. + * + * @param name The SField to associate with the returned `STAmount`. + * @param v The JSON value to parse. + * @return The parsed `STAmount`. + * @throw std::runtime_error on any format violation, invalid currency/issuer + * strings, or fractional XRP/MPT specification. + */ STAmount amountFromJson(SField const& name, json::Value const& v) { @@ -1147,6 +1328,16 @@ amountFromJson(SField const& name, json::Value const& v) return {name, asset, parts.mantissa, parts.exponent, parts.negative}; } +/** Non-throwing wrapper around `amountFromJson()`. + * + * On success, `result` is overwritten and `true` is returned. On any + * exception, the error is logged at WARN level, `result` is unchanged, and + * `false` is returned. + * + * @param result Output parameter set on success. + * @param jvSource The JSON value to parse. + * @return `true` on success; `false` if parsing threw. + */ bool amountFromJsonNoThrow(STAmount& result, json::Value const& jvSource) { @@ -1168,6 +1359,10 @@ amountFromJsonNoThrow(STAmount& result, json::Value const& jvSource) // //------------------------------------------------------------------------------ +/** Return `true` if both amounts represent the same asset and the same value. + * + * Incomparable amounts (different asset types) are never equal. + */ bool operator==(STAmount const& lhs, STAmount const& rhs) { @@ -1175,6 +1370,13 @@ operator==(STAmount const& lhs, STAmount const& rhs) lhs.exponent() == rhs.exponent() && lhs.mantissa() == rhs.mantissa(); } +/** Return `true` if `lhs` is strictly less than `rhs`. + * + * Ordering is by sign first, then exponent, then mantissa. Amounts of + * different assets cannot be ordered and throw. + * + * @throw std::runtime_error if the amounts are not comparable. + */ bool operator<(STAmount const& lhs, STAmount const& rhs) { @@ -1209,6 +1411,7 @@ operator<(STAmount const& lhs, STAmount const& rhs) return false; } +/** Negate an amount; zero is returned unchanged (no negative zero). */ STAmount operator-(STAmount const& value) { @@ -1229,8 +1432,15 @@ operator-(STAmount const& value) // //------------------------------------------------------------------------------ -// Calculate (a * b) / c when all three values are 64-bit -// without loss of precision: +/** Compute `(multiplier × multiplicand) / divisor` exactly using 128-bit + * intermediate precision. + * + * The 64-bit inputs are widened to `uint128_t` before multiplying, preventing + * overflow in the intermediate product. The final quotient is checked to fit + * in 64 bits before truncation. + * + * @throw std::overflow_error if the result exceeds `UINT64_MAX`. + */ static std::uint64_t muldiv(std::uint64_t multiplier, std::uint64_t multiplicand, std::uint64_t divisor) { @@ -1249,6 +1459,15 @@ muldiv(std::uint64_t multiplier, std::uint64_t multiplicand, std::uint64_t divis return static_cast(ret); } +/** Compute `(multiplier × multiplicand + rounding) / divisor` with 128-bit + * intermediate precision. + * + * Adds `rounding` to the product before dividing. Callers pass + * `divisor − 1` as `rounding` to implement round-up (away from zero), or + * `0` for truncation. + * + * @throw std::overflow_error if the result exceeds `UINT64_MAX`. + */ static std::uint64_t muldivRound( std::uint64_t multiplier, @@ -1272,6 +1491,20 @@ muldivRound( return static_cast(ret); } +/** Divide two amounts and produce a result with the given asset. + * + * Integral operands are first scaled up into the IOU canonical mantissa + * window [10^15, 10^16). The formula `muldiv(numVal, 10^17, denVal)` + * maintains full 15-digit precision; the `+5` bias provides a half-up + * rounding approximation in the legacy (non-strict) path. The combined + * exponent is `numOffset − denOffset − 17`. + * + * @param num Numerator amount. + * @param den Denominator amount. + * @param asset Asset identity for the result. + * @return The quotient as an `STAmount` with the given asset. + * @throw std::runtime_error on division by zero or result overflow. + */ STAmount divide(STAmount const& num, STAmount const& den, Asset const& asset) { @@ -1305,11 +1538,6 @@ divide(STAmount const& num, STAmount const& den, Asset const& asset) } } - // We divide the two mantissas (each is between 10^15 - // and 10^16). To maintain precision, we multiply the - // numerator by 10^17 (the product is in the range of - // 10^32 to 10^33) followed by a division, so the result - // is in the range of 10^16 to 10^15. return STAmount( asset, muldiv(numVal, kTEN_TO17, denVal) + 5, @@ -1317,6 +1545,23 @@ divide(STAmount const& num, STAmount const& den, Asset const& asset) num.negative() != den.negative()); } +/** Multiply two amounts and produce a result with the given asset. + * + * For all-native XRP or all-MPT, guards against overflow using factored + * comparisons against `sqrt(cMaxNative)` / `sqrt(maxMPTokenAmount)` before + * the 64-bit multiply, avoiding the 128-bit path for the common case. + * + * For mixed or IOU operands, each mantissa is scaled into [10^15, 10^16), + * the 128-bit product is divided by 10^14, and `+7` provides a rounding + * bias. The combined exponent is `offset1 + offset2 + 14`. + * When `getSTNumberSwitchover()` is active, delegates to `Number` arithmetic. + * + * @param v1 First factor. + * @param v2 Second factor. + * @param asset Asset identity for the result. + * @return The product as an `STAmount` with the given asset. + * @throw std::runtime_error on overflow. + */ STAmount multiply(STAmount const& v1, STAmount const& v2, Asset const& asset) { @@ -1379,10 +1624,6 @@ multiply(STAmount const& v1, STAmount const& v2, Asset const& asset) } } - // We multiply the two mantissas (each is between 10^15 - // and 10^16), so their product is in the 10^30 to 10^32 - // range. Dividing their product by 10^14 maintains the - // precision, by scaling the result to 10^16 to 10^18. return STAmount( asset, muldiv(value1, value2, kTEN_TO14) + 7, @@ -1390,26 +1631,22 @@ multiply(STAmount const& v1, STAmount const& v2, Asset const& asset) v1.negative() != v2.negative()); } -// This is the legacy version of canonicalizeRound. It's been in use -// for years, so it is deeply embedded in the behavior of cross-currency -// transactions. -// -// However, in 2022 it was noticed that the rounding characteristics were -// surprising. When the code converts from IOU-like to XRP-like there may -// be a fraction of the IOU-like representation that is too small to be -// represented in drops. `canonicalizeRound()` currently does some unusual -// rounding. -// -// 1. If the fractional part is greater than or equal to 0.1, then the -// number of drops is rounded up. -// -// 2. However, if the fractional part is less than 0.1 (for example, -// 0.099999), then the number of drops is rounded down. -// -// The XRP Ledger has this rounding behavior baked in. But there are -// situations where this rounding behavior led to undesirable outcomes. -// So an alternative rounding approach was introduced. You'll see that -// alternative below. +/** Bring a mantissa/exponent pair into the canonical range after multiply or + * divide, using the legacy rounding rule. + * + * For **integral** types: divides repeatedly until `offset >= −1`, adding 9 + * (or 10 when only one intermediate divide was needed) before the final + * divide to round up. The result is that fractions ≥ 0.1 round up while + * fractions < 0.1 round down — a historically baked-in XRP Ledger behavior. + * + * For **IOU**: if `value > kMAX_VALUE`, repeatedly divides until + * `value <= 10 * kMAX_VALUE`, then adds 9 before the last divide to bias + * toward ceiling. + * + * @note The `bool` fourth parameter is accepted for interface compatibility + * with `canonicalizeRoundStrict` but is ignored. + * @see canonicalizeRoundStrict for the corrected rounding variant. + */ static void canonicalizeRound(bool integral, std::uint64_t& value, int& offset, bool) { @@ -1445,10 +1682,20 @@ canonicalizeRound(bool integral, std::uint64_t& value, int& offset, bool) } } -// The original canonicalizeRound did not allow the rounding direction to -// be specified. It also ignored some of the bits that could contribute to -// rounding decisions. canonicalizeRoundStrict() tracks all of the bits in -// the value being rounded. +/** Bring a mantissa/exponent pair into the canonical range after multiply or + * divide, tracking all remainder bits to round correctly. + * + * Unlike `canonicalizeRound`, accumulates a `hadRemainder` flag across all + * intermediate divides. The final bias is 10 (round up) when a remainder + * was observed and `roundUp` is true, otherwise 9. This ensures that any + * truncated bits influence the rounding decision — not just the last + * fractional digit. + * + * @param integral `true` for XRP/MPT, `false` for IOU. + * @param value The mantissa, modified in place. + * @param offset The exponent, modified in place. + * @param roundUp `true` to round away from zero; `false` to truncate. + */ static void canonicalizeRoundStrict(bool integral, std::uint64_t& value, int& offset, bool roundUp) { @@ -1486,14 +1733,30 @@ canonicalizeRoundStrict(bool integral, std::uint64_t& value, int& offset, bool r } } +/** Round an IOU amount to the precision implied by `scale`. + * + * Constructs a reference value at `(kMIN_VALUE, scale)` and exploits IOU + * addition's exponent-alignment truncation: adding the reference forces the + * sum to be represented at the reference's precision, then subtracting the + * reference yields a result rounded to that precision. + * + * Integral types (XRP, MPT) and zero are returned unchanged — no rounding + * is needed. If `value.exponent() >= scale` the amount is already at or + * coarser than the target precision and is returned as-is to avoid losing + * information. + * + * @param value The IOU amount to round. + * @param scale Target exponent; `value.exponent()` must be less than this. + * @param rounding Rounding mode applied to the intermediate addition via + * `NumberRoundModeGuard`. + * @return The rounded `STAmount`. + */ STAmount roundToScale(STAmount const& value, std::int32_t scale, Number::RoundingMode rounding) { - // Nothing to do for integral types. if (value.integral()) return value; - // Nothing to do for zero. if (value == beast::kZERO) return value; @@ -1515,8 +1778,13 @@ roundToScale(STAmount const& value, std::int32_t scale, Number::RoundingMode rou namespace { -// We need a class that has an interface similar to NumberRoundModeGuard -// but does nothing. +/** No-op substitute for `NumberRoundModeGuard`. + * + * Used as the `MightSaveRound` template argument in `mulRoundImpl` and + * `divRoundImpl` when the caller does not want to propagate a rounding mode + * into the thread-local `Number` round mode (i.e. the legacy `mulRound` / + * `divRound` paths). + */ class DontAffectNumberRoundMode { public: @@ -1532,10 +1800,32 @@ public: } // anonymous namespace -// Pass the canonicalizeRound function pointer as a template parameter. -// -// We might need to use NumberRoundModeGuard. Allow the caller -// to pass either that or a replacement as a template parameter. +/** Shared implementation for `mulRound` and `mulRoundStrict`. + * + * Template parameters allow selecting between the legacy and strict rounding + * strategies without code duplication: + * - `CanonicalizeFunc`: either `canonicalizeRound` (legacy) or + * `canonicalizeRoundStrict` (remainder-tracking). + * - `MightSaveRound`: either `NumberRoundModeGuard` (strict — propagates + * rounding into `Number` during `canonicalize()`) or + * `DontAffectNumberRoundMode` (legacy — no-op). + * + * For all-native XRP and all-MPT, overflow is guarded via the same factored + * comparisons as `multiply()`. For IOU, operands are scaled into + * [10^15, 10^16), multiplied via `muldivRound`, and the canonicalize function + * trims back into the canonical window with the desired rounding direction. + * When the rounded result is zero but `roundUp && !resultNegative`, the + * smallest representable positive value is returned instead. + * + * @tparam CanonicalizeFunc Post-multiply rounding adjuster function. + * @tparam MightSaveRound RAII guard type for propagating rounding mode. + * @param v1 First factor. + * @param v2 Second factor. + * @param asset Asset for the result. + * @param roundUp `true` to round away from zero; `false` to truncate. + * @return The rounded product. + * @throw std::runtime_error on overflow. + */ template static STAmount mulRoundImpl(STAmount const& v1, STAmount const& v2, Asset const& asset, bool roundUp) @@ -1594,14 +1884,6 @@ mulRoundImpl(STAmount const& v1, STAmount const& v2, Asset const& asset, bool ro bool const resultNegative = v1.negative() != v2.negative(); - // We multiply the two mantissas (each is between 10^15 - // and 10^16), so their product is in the 10^30 to 10^32 - // range. Dividing their product by 10^14 maintains the - // precision, by scaling the result to 10^16 to 10^18. - // - // If we're rounding up, we want to round up away - // from zero, and if we're rounding down, truncation - // is implicit. std::uint64_t amount = muldivRound(value1, value2, kTEN_TO14, (resultNegative != roundUp) ? kTEN_TO14M1 : 0); @@ -1611,8 +1893,8 @@ mulRoundImpl(STAmount const& v1, STAmount const& v2, Asset const& asset, bool ro CanonicalizeFunc(asset.integral(), amount, offset, roundUp); } STAmount result = [&]() { - // If appropriate, tell Number to round down. This gives the desired - // result from STAmount::canonicalize. + // Tell Number to round toward zero so that STAmount::canonicalize + // does not re-round in the unexpected direction. MightSaveRound const savedRound(Number::RoundingMode::TowardsZero); return STAmount(asset, amount, offset, resultNegative); }(); @@ -1621,13 +1903,11 @@ mulRoundImpl(STAmount const& v1, STAmount const& v2, Asset const& asset, bool ro { if (asset.integral()) { - // return the smallest value above zero amount = 1; offset = 0; } else { - // return the smallest value above zero amount = STAmount::kMIN_VALUE; offset = STAmount::kMIN_OFFSET; } @@ -1636,20 +1916,63 @@ mulRoundImpl(STAmount const& v1, STAmount const& v2, Asset const& asset, bool ro return result; } +/** Multiply with legacy rounding: fractions ≥ 0.1 round up, < 0.1 round down. + * + * Uses `canonicalizeRound` (backward-compatible behavior baked into the + * XRP Ledger since inception) and does not propagate a rounding mode to the + * `Number` engine. + * + * @param v1 First factor. + * @param v2 Second factor. + * @param asset Asset for the result. + * @param roundUp `true` to round away from zero. + * @return The rounded product. + */ STAmount mulRound(STAmount const& v1, STAmount const& v2, Asset const& asset, bool roundUp) { return mulRoundImpl(v1, v2, asset, roundUp); } +/** Multiply with strict remainder-tracking rounding. + * + * Uses `canonicalizeRoundStrict` and propagates the rounding direction to + * the thread-local `Number` round mode via `NumberRoundModeGuard`, ensuring + * `STAmount::canonicalize()` rounds consistently. + * + * @param v1 First factor. + * @param v2 Second factor. + * @param asset Asset for the result. + * @param roundUp `true` to round away from zero. + * @return The rounded product. + */ STAmount mulRoundStrict(STAmount const& v1, STAmount const& v2, Asset const& asset, bool roundUp) { return mulRoundImpl(v1, v2, asset, roundUp); } -// We might need to use NumberRoundModeGuard. Allow the caller -// to pass either that or a replacement as a template parameter. +/** Shared implementation for `divRound` and `divRoundStrict`. + * + * Scales each integral operand into [10^15, 10^16) before dividing, then + * computes `muldivRound(numVal, 10^17, denVal, rounding)` where `rounding` + * is `denVal − 1` when rounding away from zero, or `0` to truncate. + * `canonicalizeRound` then trims the result back into the canonical window. + * When the rounded result is zero but `roundUp && !resultNegative`, the + * smallest representable positive value is returned. + * + * The `MightSaveRound` template parameter has the same semantics as in + * `mulRoundImpl`: use `NumberRoundModeGuard` for strict mode, or + * `DontAffectNumberRoundMode` for the legacy path. + * + * @tparam MightSaveRound RAII guard type for propagating rounding mode. + * @param num Numerator. + * @param den Denominator. + * @param asset Asset for the result. + * @param roundUp `true` to round away from zero; `false` to truncate. + * @return The rounded quotient. + * @throw std::runtime_error on division by zero or overflow. + */ template static STAmount divRoundImpl(STAmount const& num, STAmount const& den, Asset const& asset, bool roundUp) @@ -1683,14 +2006,6 @@ divRoundImpl(STAmount const& num, STAmount const& den, Asset const& asset, bool bool const resultNegative = (num.negative() != den.negative()); - // We divide the two mantissas (each is between 10^15 - // and 10^16). To maintain precision, we multiply the - // numerator by 10^17 (the product is in the range of - // 10^32 to 10^33) followed by a division, so the result - // is in the range of 10^16 to 10^15. - // - // We round away from zero if we're rounding up or - // truncate if we're rounding down. std::uint64_t amount = muldivRound(numVal, kTEN_TO17, denVal, (resultNegative != roundUp) ? denVal - 1 : 0); @@ -1712,13 +2027,11 @@ divRoundImpl(STAmount const& num, STAmount const& den, Asset const& asset, bool { if (asset.integral()) { - // return the smallest value above zero amount = 1; offset = 0; } else { - // return the smallest value above zero amount = STAmount::kMIN_VALUE; offset = STAmount::kMIN_OFFSET; } @@ -1727,12 +2040,34 @@ divRoundImpl(STAmount const& num, STAmount const& den, Asset const& asset, bool return result; } +/** Divide with legacy rounding (same ≥ 0.1 / < 0.1 threshold as `mulRound`). + * + * Does not propagate a rounding mode to the `Number` engine. + * + * @param num Numerator. + * @param den Denominator. + * @param asset Asset for the result. + * @param roundUp `true` to round away from zero. + * @return The rounded quotient. + */ STAmount divRound(STAmount const& num, STAmount const& den, Asset const& asset, bool roundUp) { return divRoundImpl(num, den, asset, roundUp); } +/** Divide with strict remainder-tracking rounding. + * + * Propagates the rounding direction to the thread-local `Number` round mode + * via `NumberRoundModeGuard`, so that `STAmount::canonicalize()` rounds + * consistently. + * + * @param num Numerator. + * @param den Denominator. + * @param asset Asset for the result. + * @param roundUp `true` to round away from zero. + * @return The rounded quotient. + */ STAmount divRoundStrict(STAmount const& num, STAmount const& den, Asset const& asset, bool roundUp) { diff --git a/src/libxrpl/protocol/STArray.cpp b/src/libxrpl/protocol/STArray.cpp index 6bfe9fe88e..1bfdd60c78 100644 --- a/src/libxrpl/protocol/STArray.cpp +++ b/src/libxrpl/protocol/STArray.cpp @@ -1,3 +1,11 @@ +/** @file + * Implements STArray — the protocol's typed container for sequences of STObject + * instances, used for fields like sfMemos, sfSigners, and sfNFTokens. + * + * Non-trivial method bodies live here; inline accessors and iterator plumbing + * are defined in STArray.h. + */ + #include #include @@ -15,10 +23,29 @@ namespace xrpl { +/** Move constructor. + * + * Explicitly copies the field name from @p other before moving the element + * vector, because STBase stores the field-name pointer separately from the + * data. Without the explicit `setFName` call the compiler-generated move + * would leave the field name association in an unspecified state, causing + * field-ID mismatches during serialization. + * + * @param other The STArray to move from; left in a valid but unspecified state. + */ STArray::STArray(STArray&& other) : STBase(other.getFName()), v_(std::move(other.v_)) { } +/** Move assignment operator. + * + * Same field-name transfer requirement as the move constructor: the field + * name must be copied explicitly because STBase does not participate in the + * compiler-generated move-assignment. + * + * @param other The STArray to move from; left in a valid but unspecified state. + * @return *this + */ STArray& STArray::operator=(STArray&& other) { @@ -27,20 +54,68 @@ STArray::operator=(STArray&& other) return *this; } +/** Construct an anonymous STArray with pre-allocated capacity. + * + * Creates an array with no associated SField but with storage reserved for + * @p n elements, avoiding early reallocations when the size is known up front. + * + * @param n Number of elements to reserve space for. + */ STArray::STArray(int n) { v_.reserve(n); } +/** Construct an empty STArray bound to the given field. + * + * @param f The SField that names this array in its parent object. + */ STArray::STArray(SField const& f) : STBase(f) { } +/** Construct an STArray bound to a field with pre-allocated capacity. + * + * @param f The SField that names this array in its parent object. + * @param n Number of elements to reserve space for. + */ STArray::STArray(SField const& f, std::size_t n) : STBase(f) { v_.reserve(n); } +/** Deserializing constructor — decodes a sentinel-terminated sequence of + * inner objects from a binary stream. + * + * The XRPL binary format does not prefix arrays with a length; instead the + * decoder loops over field-ID tokens until it sees the canonical end-of-array + * marker (`STI_ARRAY, field == 1`). Each element is validated and constructed + * in place before the next token is read. + * + * Four checks guard each loop iteration: + * - `(STI_ARRAY, 1)` — clean end of array; break. + * - `(STI_OBJECT, 1)` — misplaced end-of-object marker; the stream is + * structurally corrupt. Throws `std::runtime_error("Illegal terminator + * in array")`. + * - `fn.isInvalid()` — unrecognized `(type, field)` pair. Throws + * `std::runtime_error("Unknown field")`. + * - `fn.fieldType != STI_OBJECT` — every STArray element must be an + * STObject. Throws `std::runtime_error("Non-object in array")`. + * + * After successful construction, `applyTemplateFromSField(fn)` validates the + * new element against the schema registered for that inner-object field type + * (e.g. sfMemo, sfSigner). This call may also throw; any exception unwinds + * the partially built array entirely — there is no partial-recovery path. + * + * @param sit Forward cursor over the binary payload. Advanced in place; the + * caller retains ownership. + * @param f The SField naming this array in its parent object. + * @param depth Current nesting depth, threaded from the parent STObject. Each + * child STObject is constructed with `depth + 1`; STObject's own + * constructor enforces a maximum depth of 10 to prevent stack exhaustion + * from crafted payloads. + * @throws std::runtime_error on any structural violation in the stream. + */ STArray::STArray(SerialIter& sit, SField const& f, int depth) : STBase(f) { while (!sit.empty()) @@ -77,18 +152,46 @@ STArray::STArray(SerialIter& sit, SField const& f, int depth) : STBase(f) } } +/** Copy this object into a caller-supplied buffer using placement new. + * + * Delegates to `STBase::emplace()`, which places the copy into @p buf when + * it fits within @p n bytes, or heap-allocates otherwise. This supports the + * small-object optimization in `detail::STVar`. + * + * @param n Size of @p buf in bytes. + * @param buf Target buffer for placement new; must be suitably aligned. + * @return Pointer to the newly constructed copy (either @p buf or heap). + */ STBase* STArray::copy(std::size_t n, void* buf) const { return emplace(n, buf, *this); } +/** Move this object into a caller-supplied buffer using placement new. + * + * Delegates to `STBase::emplace()`. If this array fits within @p n bytes it + * is move-constructed into @p buf; otherwise it is move-constructed on the + * heap. + * + * @param n Size of @p buf in bytes. + * @param buf Target buffer for placement new; must be suitably aligned. + * @return Pointer to the newly constructed object (either @p buf or heap). + */ STBase* STArray::move(std::size_t n, void* buf) { return emplace(n, buf, std::move(*this)); } +/** Return a bracket-delimited, comma-separated string including field names. + * + * Each element is rendered via `STObject::getFullText()`, which includes + * field-name prefixes. Intended for debugging and logging. + * + * @return Human-readable representation of the array, e.g. `[fieldA = ..., + * fieldB = ...]`. + */ std::string STArray::getFullText() const { @@ -108,6 +211,13 @@ STArray::getFullText() const return r; } +/** Return a bracket-delimited, comma-separated string of element values only. + * + * Each element is rendered via `STObject::getText()`, which omits field-name + * prefixes. Intended for debugging. + * + * @return Human-readable value-only representation of the array. + */ std::string STArray::getText() const { @@ -127,6 +237,24 @@ STArray::getText() const return r; } +/** Serialize this array to a JSON array value. + * + * Each element that is not `STI_NOTPRESENT` is appended as a JSON object with + * a single key — the element's field name — mapping to the element's own JSON + * representation: + * + * @code + * [ { "Memo": { "MemoData": "..." } }, ... ] + * @endcode + * + * This outer-object wrapping preserves the named-field context of each inner + * object for round-trip fidelity with the XRPL JSON API. Elements with type + * `STI_NOTPRESENT` (absent optional fields in a template-bound context) are + * silently skipped. + * + * @param p JSON rendering options forwarded to each element. + * @return A `json::Value` of array type. + */ json::Value STArray::getJson(JsonOptions p) const { @@ -142,6 +270,16 @@ STArray::getJson(JsonOptions p) const return v; } +/** Append the binary encoding of every element to @p s. + * + * For each element the encoding is: field ID, element content, per-element + * object terminator (`STI_OBJECT, 1`). The outer array's own field ID and the + * end-of-array terminator (`STI_ARRAY, 1`) are written by the caller + * (`STObject::add()`), not here. This split is consistent across all + * `STBase` subclasses. + * + * @param s The serializer to append to. + */ void STArray::add(Serializer& s) const { @@ -153,12 +291,22 @@ STArray::add(Serializer& s) const } } +/** @return `STI_ARRAY` — the serialized type ID for this class. */ SerializedTypeID STArray::getSType() const { return STI_ARRAY; } +/** Test deep equality with another STBase. + * + * Performs a `dynamic_cast` to confirm @p t is also an STArray, then + * delegates to vector equality, which cascades through `STObject::operator==`. + * + * @param t The object to compare against. + * @return `true` if @p t is an STArray whose elements are pairwise equal + * to this array's elements. + */ bool STArray::isEquivalent(STBase const& t) const { @@ -166,12 +314,30 @@ STArray::isEquivalent(STBase const& t) const return v != nullptr && v_ == v->v_; } +/** @return `true` when the array is empty — an empty array need not be + * encoded on the wire. + */ bool STArray::isDefault() const { return v_.empty(); } +/** Sort elements in place using a caller-supplied comparator. + * + * Used to impose canonical ordering before serialization. Notable callers: + * - `TxMeta::addRaw()` — sorts `AffectedNodes` by `sfLedgerIndex` before + * writing metadata; deviation from canonical order is a consensus-fork risk. + * - `NFTokenHelpers` — sorts `sfNFTokens` entries by `sfNFTokenID` when + * managing NFT pages. + * + * @note Multi-sign transactions require `sfSigners` to be sorted in ascending + * `AccountID` order before submission. That sorting is expected to be + * performed by the signing client, not by this method. + * + * @param compare Function pointer returning `true` when the first argument + * should precede the second. Must satisfy strict-weak-ordering. + */ void STArray::sort(bool (*compare)(STObject const&, STObject const&)) { diff --git a/src/libxrpl/protocol/STBase.cpp b/src/libxrpl/protocol/STBase.cpp index ec6131482f..77b6640862 100644 --- a/src/libxrpl/protocol/STBase.cpp +++ b/src/libxrpl/protocol/STBase.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements the abstract root of the XRPL serialized-type hierarchy. + * + * Every field that appears in a transaction, ledger entry, validation, or + * metadata — integers, amounts, account IDs, path sets, objects, arrays — + * derives from `STBase`. This translation unit provides field-name + * management, comparison dispatch, text/JSON rendering, binary serialization + * plumbing, and the small-object placement interface consumed by + * `detail::STVar`. + */ #include #include @@ -21,6 +31,24 @@ STBase::STBase(SField const& n) : fName_(&n) XRPL_ASSERT(fName_, "xrpl::STBase::STBase : field is set"); } +/** Copy-assign value but preserve a meaningful field name. + * + * `fName_` is only overwritten when the current name is not useful (i.e., + * it is `sfGeneric` or another placeholder with `fieldCode <= 0`). This + * dual-purpose behaviour supports two distinct call sites: + * + * - **Slot initialisation:** a freshly-constructed `STBase` (holding only + * `sfGeneric`) inherits the source field name, so the new slot acquires + * the correct protocol identity. + * - **Element slide-down in `STObject`/`STArray`:** when an element is + * erased by sliding remaining elements down via copy assignment, the + * destination slot already holds a meaningful field name and keeps it, + * preventing the surviving neighbours from adopting each other's names. + * + * @note Do NOT store derived types in a plain `std::vector`. The slide-down + * semantics above are the *only* reason this operator does not + * unconditionally copy `fName_`. Use Boost pointer containers instead. + */ STBase& STBase::operator=(STBase const& t) { @@ -93,15 +121,32 @@ STBase::getJson(JsonOptions /*options*/) const return getText(); } +/** Serialize this field's value into @p s. + * + * The base implementation is a hard-unreachable stub: every concrete + * subclass must override this method. Reaching this body indicates a + * missing override in a derived type and is treated as a programming error. + * + * @param s The serializer to write into. + */ void STBase::add(Serializer& s) const { - // Should never be called // LCOV_EXCL_START UNREACHABLE("xrpl::STBase::add : not implemented"); // LCOV_EXCL_STOP } +/** Base-level value equivalence check, valid only for bare `STBase` objects. + * + * This implementation asserts that `this` has type `STI_NOTPRESENT` — the + * only situation in which the base body is legitimately reached. All + * concrete subclasses override this method to perform value comparison; any + * subclass that omits the override and reaches this body has a bug. + * + * @param t The other instance to compare against. + * @return `true` if @p t also has type `STI_NOTPRESENT`. + */ bool STBase::isEquivalent(STBase const& t) const { diff --git a/src/libxrpl/protocol/STBlob.cpp b/src/libxrpl/protocol/STBlob.cpp index 3f44c9b529..fe6b288063 100644 --- a/src/libxrpl/protocol/STBlob.cpp +++ b/src/libxrpl/protocol/STBlob.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements STBlob, the serialized-type class for variable-length binary + * fields (`STI_VL`) and account-identifier fields (`STI_ACCOUNT`). + * + * Both wire types share identical VL-prefixed binary encoding; the + * `fieldType` tag on the `SField` carries the semantic distinction used + * by higher layers (JSON formatting, validation) while this file handles + * the common binary I/O path. + */ + #include #include @@ -12,34 +22,92 @@ namespace xrpl { +/** Deserialize a variable-length blob from a byte stream. + * + * Reads a VL-prefixed byte sequence from @p st via `getVLBuffer()` and + * stores the resulting owned `Buffer`. The length prefix has already been + * consumed by `SerialIter` before this constructor runs. + * + * @param st Forward-only cursor over the serialized byte stream. + * @param name The `SField` descriptor that identifies this field. + */ STBlob::STBlob(SerialIter& st, SField const& name) : STBase(name), value_(st.getVLBuffer()) { } +/** Place-construct a copy of this blob into an STVar buffer. + * + * Used exclusively by `detail::STVar` to clone a blob field without + * knowing its concrete type. If `sizeof(STBlob) <= n`, the copy is + * constructed in-place at @p buf via `STBase::emplace`; otherwise it + * falls back to heap allocation. + * + * @param n Size of the inline buffer offered by `STVar`. + * @param buf Pointer to the inline buffer (valid for at least @p n bytes). + * @return Pointer to the newly constructed `STBlob` (may be @p buf or heap). + */ STBase* STBlob::copy(std::size_t n, void* buf) const { return emplace(n, buf, *this); } +/** Place-construct a moved instance of this blob into an STVar buffer. + * + * Like `copy()`, but transfers ownership of the internal `Buffer` via + * move, avoiding a heap allocation for the payload bytes. Used by + * `detail::STVar` when relocating an existing blob field. + * + * @param n Size of the inline buffer offered by `STVar`. + * @param buf Pointer to the inline buffer (valid for at least @p n bytes). + * @return Pointer to the newly constructed `STBlob` (may be @p buf or heap). + */ STBase* STBlob::move(std::size_t n, void* buf) { return emplace(n, buf, std::move(*this)); } +/** Return the wire type code for this field. + * + * Always returns `STI_VL` regardless of whether the `SField` is + * semantically `STI_ACCOUNT`. Both types share the same VL-prefixed + * binary encoding; the field-ID byte written by the containing + * `STObject` will embed `STI_VL` for both. + * + * @return `STI_VL` + */ SerializedTypeID STBlob::getSType() const { return STI_VL; } +/** Return the blob contents as an uppercase hex string. + * + * Used by `STBase::getFullText()` and surfaces in JSON output and log + * messages. + * + * @return Uppercase hex encoding of the raw bytes. + */ std::string STBlob::getText() const { return strHex(value_); } +/** Serialize this blob into @p s with a VL length prefix. + * + * Writes the byte count followed by the raw payload via `Serializer::addVL`, + * the exact inverse of the `getVLBuffer()` deserialization path. + * + * @param s Serializer accumulator to append to. + * @note Asserts that the associated `SField` is a binary field + * (`fieldValue < 256`) and that its `fieldType` is either `STI_VL` + * or `STI_ACCOUNT`. Violations indicate an `SField` of the wrong type + * was used to construct this `STBlob`, which would produce a malformed + * wire encoding. + */ void STBlob::add(Serializer& s) const { @@ -50,6 +118,17 @@ STBlob::add(Serializer& s) const s.addVL(value_.data(), value_.size()); } +/** Compare this blob to another serialized type by byte value. + * + * Returns `true` only when @p t is also an `STBlob` and its payload is + * byte-for-byte identical. The `dynamic_cast` guard ensures a field of a + * different ST type never compares equal even if their raw bytes happened + * to match. Used by `STBase::operator==` for transaction de-duplication + * and ledger comparison. + * + * @param t The other serialized type to compare against. + * @return `true` if @p t is an `STBlob` with identical contents. + */ bool STBlob::isEquivalent(STBase const& t) const { @@ -57,6 +136,13 @@ STBlob::isEquivalent(STBase const& t) const return (v != nullptr) && (value_ == v->value_); } +/** Return whether this blob holds no data. + * + * `STObject` serialization uses this to omit optional fields that have + * not been populated, keeping the wire representation compact. + * + * @return `true` if the internal buffer is empty. + */ bool STBlob::isDefault() const { diff --git a/src/libxrpl/protocol/STCurrency.cpp b/src/libxrpl/protocol/STCurrency.cpp index 9b761864d9..b4cf0992bc 100644 --- a/src/libxrpl/protocol/STCurrency.cpp +++ b/src/libxrpl/protocol/STCurrency.cpp @@ -1,3 +1,9 @@ +/** @file + * Implements STCurrency, the serialized-type wrapper for 160-bit XRPL + * currency identifiers, and the JSON deserialization helper + * `currencyFromJson`. + */ + #include #include @@ -15,44 +21,93 @@ namespace xrpl { +/** Construct a default (XRP) currency field for the given SField slot. + * + * Used when an STObject allocates a placeholder for a field that has not + * yet been populated. The stored currency is the all-zeroes value, which + * represents native XRP. + * + * @param name The SField descriptor that identifies this field within its + * parent STObject. + */ STCurrency::STCurrency(SField const& name) : STBase{name} { } +/** Deserialize a currency field from a binary stream. + * + * Reads exactly 160 bits from `sit` via `SerialIter::get160()` and stores + * the result verbatim. No validation is performed: binary ledger data + * originates from a consensus-validated stream and re-checking every field + * on ingestion would be prohibitively expensive. + * + * @param sit The forward-only cursor over the serialized byte buffer. + * @param name The SField descriptor for this field. + */ STCurrency::STCurrency(SerialIter& sit, SField const& name) : STBase{name} { currency_ = sit.get160(); } +/** Construct a currency field with a known Currency value. + * + * Used programmatically when the Currency is already available (e.g., when + * building a transaction in memory). + * + * @param name The SField descriptor for this field. + * @param currency The 160-bit currency identifier to store. + */ STCurrency::STCurrency(SField const& name, Currency const& currency) : STBase{name}, currency_{currency} { } +/** @return The `STI_CURRENCY` type tag for field-dispatch and wire encoding. */ SerializedTypeID STCurrency::getSType() const { return STI_CURRENCY; } +/** @return The human-readable currency string: empty for XRP (zero), the + * ISO-4217-style three-character ticker for well-known tokens, or a + * hex string for opaque custom currencies. + */ std::string STCurrency::getText() const { return to_string(currency_); } +/** @return A JSON string representation of the currency, identical to + * `getText()`. + */ json::Value STCurrency::getJson(JsonOptions) const { return to_string(currency_); } +/** Serialize the currency by appending its 160-bit value verbatim. + * + * @param s The Serializer accumulator to write into. + */ void STCurrency::add(Serializer& s) const { s.addBitString(currency_); } +/** Compare this field to another STBase for value equality. + * + * Uses `dynamic_cast` to confirm `t` is also an `STCurrency` before + * comparing the stored 160-bit values. Returns `false` for any other + * STBase subtype, consistent with the polymorphic comparison contract + * across the ST hierarchy. + * + * @param t The other serialized field to compare against. + * @return `true` if `t` is an `STCurrency` holding the same currency. + */ bool STCurrency::isEquivalent(STBase const& t) const { @@ -60,30 +115,89 @@ STCurrency::isEquivalent(STBase const& t) const return (v != nullptr) && (*v == *this); } +/** @return `true` when the stored currency is XRP (all-zeroes). + * + * In STBase semantics, "default" fields are omitted from canonical + * serialization, so a field whose currency is XRP need not carry an + * explicit currency code on the wire. + */ bool STCurrency::isDefault() const { return isXRP(currency_); } +/** Factory for the STVar deserialization registry. + * + * Called by `detail::STVar` when the wire type tag resolves to + * `STI_CURRENCY`. Delegates to the `SerialIter` constructor. + * + * @param sit The binary cursor to deserialize from. + * @param name The SField descriptor for this field. + * @return A heap-allocated STCurrency owning the deserialized value. + */ std::unique_ptr STCurrency::construct(SerialIter& sit, SField const& name) { return std::make_unique(sit, name); } +/** Copy this field into a caller-supplied buffer using the STVar small-buffer + * optimization. + * + * Delegates to `STBase::emplace`: if `sizeof(STCurrency)` fits within `n` + * bytes, the copy is placement-new'd into `buf`; otherwise a heap allocation + * is returned. + * + * @param n Capacity of `buf` in bytes. + * @param buf Pointer to inline storage offered by the enclosing STVar. + * @return Pointer to the newly constructed copy (either `buf` or heap). + */ STBase* STCurrency::copy(std::size_t n, void* buf) const { return emplace(n, buf, *this); } +/** Move this field into a caller-supplied buffer using the STVar small-buffer + * optimization. + * + * Delegates to `STBase::emplace` with move semantics. See `copy()` for the + * buffer-selection logic. + * + * @param n Capacity of `buf` in bytes. + * @param buf Pointer to inline storage offered by the enclosing STVar. + * @return Pointer to the newly constructed object (either `buf` or heap). + */ STBase* STCurrency::move(std::size_t n, void* buf) { return emplace(n, buf, std::move(*this)); } +/** Parse and validate a currency field from a JSON value. + * + * Accepts only string JSON values. The string is converted via + * `toCurrency()` and then checked against two sentinel values: + * + * - `noCurrency()` — returned by `toCurrency()` when the string is + * syntactically invalid. + * - `badCurrency()` — returned by `toCurrency()` for the three-letter + * string `"XRP"` used as a token identifier, which is explicitly + * prohibited to prevent confusion with native XRP. + * + * Both sentinels are rejected so callers receive a clean guarantee: if + * this function returns, the `STCurrency` holds a well-formed, + * non-reserved currency code. Unlike the binary deserialization path, + * this function validates strictly because JSON input arrives from + * untrusted API consumers. + * + * @param name The SField descriptor for the resulting field. + * @param v The JSON value to parse; must be a string. + * @return An STCurrency holding the validated currency. + * @throws std::runtime_error if `v` is not a string, or if the string + * resolves to `badCurrency()` or `noCurrency()`. + */ STCurrency currencyFromJson(SField const& name, json::Value const& v) { diff --git a/src/libxrpl/protocol/STInteger.cpp b/src/libxrpl/protocol/STInteger.cpp index 51af029011..bda6545f7c 100644 --- a/src/libxrpl/protocol/STInteger.cpp +++ b/src/libxrpl/protocol/STInteger.cpp @@ -1,3 +1,24 @@ +/** @file + * Explicit template specializations for STInteger covering the five + * instantiations used by the XRPL protocol: uint8, uint16, uint32, uint64, + * and int32. + * + * The generic template defined in STInteger.h supplies all methods that + * behave identically across integer widths (add, isDefault, isEquivalent, + * operator=, copy/move plumbing). This file provides the four virtual methods + * that must differ per instantiation: the SerialIter deserialization + * constructor, getSType(), getText(), and getJson(). Keeping these in a single + * translation unit prevents duplicate-symbol ODR violations and avoids + * implicitly instantiating all specializations in every TU that includes the + * header. + * + * @note getText() and getJson() are not naive integer-to-string converters: + * they inspect the field's SField identity and emit semantic strings for + * well-known protocol fields (sfTransactionResult, sfLedgerEntryType, + * sfTransactionType, sfPermissionValue). This file and STParsedJSON.cpp + * form a symmetric round-trip pair — STParsedJSON parses those semantic + * strings back to integers during JSON ingestion. + */ #include #include @@ -20,12 +41,16 @@ namespace xrpl { +// --- STUInt8 (unsigned char) --- + +/** Deserialize an STUInt8 from a wire byte stream. */ template <> STInteger::STInteger(SerialIter& sit, SField const& name) : STInteger(name, sit.get8()) { } +/** @return STI_UINT8 */ template <> SerializedTypeID STUInt8::getSType() const @@ -33,6 +58,17 @@ STUInt8::getSType() const return STI_UINT8; } +/** Return a human-readable representation of the value. + * + * For sfTransactionResult, resolves the raw byte to the long-form TER + * description (e.g., "The transaction was applied. Only final in a + * validated ledger.") via transResultInfo(). Unrecognized codes — which + * should be unreachable under correct operation — log an error and fall + * through to the raw decimal string. + * + * @return Human-readable TER description for sfTransactionResult fields; + * decimal string for all other fields. + */ template <> std::string STUInt8::getText() const @@ -52,6 +88,17 @@ STUInt8::getText() const return std::to_string(value_); } +/** Return a JSON representation suitable for API clients. + * + * For sfTransactionResult, resolves the raw byte to its short token + * string (e.g., "tesSUCCESS") via transResultInfo(). This token is the + * form consumed by JSON API clients and recorded in transaction metadata. + * Unrecognized codes — unreachable under correct operation — log an error + * and fall through to the raw integer. + * + * @return JSON string token for sfTransactionResult fields (e.g., + * "tesSUCCESS"); raw integer for all other fields. + */ template <> json::Value STUInt8::getJson(JsonOptions) const @@ -71,14 +118,16 @@ STUInt8::getJson(JsonOptions) const return value_; } -//------------------------------------------------------------------------------ +// --- STUInt16 (uint16_t) --- +/** Deserialize an STUInt16 from a wire byte stream. */ template <> STInteger::STInteger(SerialIter& sit, SField const& name) : STInteger(name, sit.get16()) { } +/** @return STI_UINT16 */ template <> SerializedTypeID STUInt16::getSType() const @@ -86,6 +135,17 @@ STUInt16::getSType() const return STI_UINT16; } +/** Return a human-readable representation of the value. + * + * For sfLedgerEntryType, looks up the name in the LedgerFormats singleton + * (e.g., value 0x61 → "AccountRoot"). For sfTransactionType, looks up the + * name in TxFormats (e.g., 0 → "Payment"). The cast through safeCast<> is + * deliberate — it documents the intentional enum reinterpretation of the + * raw wire integer. Falls back to decimal for unrecognized values. + * + * @return Registered format name for sfLedgerEntryType and + * sfTransactionType fields; decimal string for all other fields. + */ template <> std::string STUInt16::getText() const @@ -109,6 +169,16 @@ STUInt16::getText() const return std::to_string(value_); } +/** Return a JSON representation suitable for API clients. + * + * Semantics mirror getText(): sfLedgerEntryType yields a string like + * "Offer", sfTransactionType yields a string like "Payment". Falls back to + * the raw integer for unrecognized codes so that old ledger entries with + * deprecated types remain representable. + * + * @return JSON string name for sfLedgerEntryType and sfTransactionType + * fields; raw integer for all other fields. + */ template <> json::Value STUInt16::getJson(JsonOptions) const @@ -132,14 +202,16 @@ STUInt16::getJson(JsonOptions) const return value_; } -//------------------------------------------------------------------------------ +// --- STUInt32 (uint32_t) --- +/** Deserialize an STUInt32 from a wire byte stream. */ template <> STInteger::STInteger(SerialIter& sit, SField const& name) : STInteger(name, sit.get32()) { } +/** @return STI_UINT32 */ template <> SerializedTypeID STUInt32::getSType() const @@ -147,6 +219,16 @@ STUInt32::getSType() const return STI_UINT32; } +/** Return a human-readable representation of the value. + * + * For sfPermissionValue, delegates to Permission::getPermissionName(), + * which tries granular permission lookup first (values ≥65537), then falls + * back to transaction-type-based permission resolution. Falls back to the + * raw decimal string if the value is not recognized. + * + * @return Permission name string for sfPermissionValue fields; decimal + * string for all other fields. + */ template <> std::string STUInt32::getText() const @@ -160,6 +242,15 @@ STUInt32::getText() const return std::to_string(value_); } +/** Return a JSON representation suitable for API clients. + * + * Semantics mirror getText(): sfPermissionValue yields a string like + * "Payment" or "PaymentMint". Falls back to the raw integer for + * unrecognized values. + * + * @return JSON string name for sfPermissionValue fields; raw integer for + * all other fields. + */ template <> json::Value STUInt32::getJson(JsonOptions) const @@ -174,14 +265,16 @@ STUInt32::getJson(JsonOptions) const return value_; } -//------------------------------------------------------------------------------ +// --- STUInt64 (uint64_t) --- +/** Deserialize an STUInt64 from a wire byte stream. */ template <> STInteger::STInteger(SerialIter& sit, SField const& name) : STInteger(name, sit.get64()) { } +/** @return STI_UINT64 */ template <> SerializedTypeID STUInt64::getSType() const @@ -189,6 +282,11 @@ STUInt64::getSType() const return STI_UINT64; } +/** Return a decimal string for diagnostic and log use. + * + * Always decimal regardless of field identity. Use getJson() when + * producing output for API consumers. + */ template <> std::string STUInt64::getText() const @@ -196,13 +294,30 @@ STUInt64::getText() const return std::to_string(value_); } +/** Return a JSON string representation of the 64-bit value. + * + * Always returns a JSON string (never a JSON number) to avoid precision + * loss from IEEE 754 double, which cannot represent all uint64_t values + * exactly. The base is determined by the SField::kSMD_BASE_TEN metadata + * flag set on the field's SField at registration time: + * + * - Fields annotated sMD_BaseTen (e.g., sequence-like counters) render in + * decimal (e.g., sfMaximumAmount → "18446744073709551615"). + * - All other fields render in lowercase hexadecimal (e.g., quality or + * rate fields → "ffffffffffffffff"). + * + * Conversion uses std::to_chars — locale-independent and allocation-free. + * + * @return Decimal or hexadecimal string depending on the field's + * kSMD_BASE_TEN metadata flag. + */ template <> json::Value STUInt64::getJson(JsonOptions) const { auto convertToString = [](uint64_t const value, int const base) { XRPL_ASSERT(base == 10 || base == 16, "xrpl::STUInt64::getJson : base 10 or 16"); - std::string str(base == 10 ? 20 : 16, 0); // Allocate space depending on base + std::string str(base == 10 ? 20 : 16, 0); auto ret = std::to_chars(str.data(), str.data() + str.size(), value, base); XRPL_ASSERT(ret.ec == std::errc(), "xrpl::STUInt64::getJson : to_chars succeeded"); str.resize(std::distance(str.data(), ret.ptr)); @@ -210,21 +325,27 @@ STUInt64::getJson(JsonOptions) const }; if (auto const& fName = getFName(); fName.shouldMeta(SField::kSMD_BASE_TEN)) - { - return convertToString(value_, 10); // Convert to base 10 - } + return convertToString(value_, 10); - return convertToString(value_, 16); // Convert to base 16 + return convertToString(value_, 16); } -//------------------------------------------------------------------------------ +// --- STInt32 (int32_t) --- +/** Deserialize an STInt32 from a wire byte stream. + * + * @note Reads via sit.get32() (the same call as STUInt32) and relies on + * the implicit bit-pattern reinterpretation when the unsigned result is + * stored in value_ (int32_t). The wire format treats integer fields as + * type-agnostic bytes. + */ template <> STInteger::STInteger(SerialIter& sit, SField const& name) : STInteger(name, sit.get32()) { } +/** @return STI_INT32 */ template <> SerializedTypeID STInt32::getSType() const @@ -232,6 +353,7 @@ STInt32::getSType() const return STI_INT32; } +/** @return Decimal string representation of the signed value. */ template <> std::string STInt32::getText() const @@ -239,6 +361,7 @@ STInt32::getText() const return std::to_string(value_); } +/** @return Raw signed integer as a JSON number. */ template <> json::Value STInt32::getJson(JsonOptions) const diff --git a/src/libxrpl/protocol/STIssue.cpp b/src/libxrpl/protocol/STIssue.cpp index c0019c334f..dcb37c5653 100644 --- a/src/libxrpl/protocol/STIssue.cpp +++ b/src/libxrpl/protocol/STIssue.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements STIssue — the serialized-type wrapper that allows an Asset + * (XRP, IOU, or MPT issuance) to be stored as a named field in an STObject. + * + * The wire format multiplexes all three asset flavors through a fixed-width + * layout: a 160-bit currency/issuer slot, an optional 160-bit account slot, + * and an optional 32-bit sequence for MPT. The `noAccount()` sentinel in the + * second slot discriminates IOU from MPT without a separate type prefix byte. + */ + #include #include @@ -20,10 +30,32 @@ namespace xrpl { +/** Construct an STIssue with the given field name and the default asset (XRP). */ STIssue::STIssue(SField const& name) : STBase{name} { } +/** Deserialize an STIssue from a byte stream, detecting XRP, IOU, or MPT by + * inspecting the wire layout. + * + * The encoding is a compact, type-multiplexed fixed-width layout: + * - **XRP**: a single 160-bit all-zeros currency sentinel; no further bytes. + * - **IOU**: 160-bit currency, then 160-bit issuer AccountID. + * - **MPT**: 160-bit issuer AccountID (in the currency slot), then the 160-bit + * `noAccount()` sentinel to signal MPT, then a 32-bit sequence number. + * + * For MPT, the MPTID buffer (192 bits) is assembled with the sequence at + * offset 0 and the raw issuer bytes at offset 4, reversing the on-wire order. + * + * @param sit Forward cursor over the serialized byte buffer; advanced in place. + * @param name The SField that identifies this field within its parent STObject. + * @throws std::runtime_error if an IOU carries a native currency paired with a + * non-null issuer, or a non-native currency paired with the null issuer. + * + * @note `isConsistent()` is checked only for the IOU branch; MPT is inherently + * consistent by construction. The static_assert on `MPTID::size()` guards + * the memcpy layout assumption at compile time. + */ STIssue::STIssue(SerialIter& sit, SField const& name) : STBase{name} { auto const currencyOrAccount = sit.get160(); @@ -32,20 +64,16 @@ STIssue::STIssue(SerialIter& sit, SField const& name) : STBase{name} { asset_ = xrpIssue(); } - // Check if MPT else { - // MPT is serialized as: - // - 160 bits MPT issuer account - // - 160 bits black hole account - // - 32 bits sequence + // MPT wire layout: 160-bit issuer | 160-bit noAccount() sentinel | 32-bit sequence AccountID const account = static_cast(sit.get160()); - // MPT if (noAccount() == account) { MPTID mptID; std::uint32_t sequence = sit.get32(); static_assert(MPTID::size() == sizeof(sequence) + sizeof(currencyOrAccount)); + // MPTID layout: sequence (4 bytes) || issuer (20 bytes); wire order is reversed. memcpy(mptID.data(), &sequence, sizeof(sequence)); memcpy( mptID.data() + sizeof(sequence), @@ -66,18 +94,29 @@ STIssue::STIssue(SerialIter& sit, SField const& name) : STBase{name} } } +/** @return The serialized type identifier `STI_ISSUE` for generic field dispatch. */ SerializedTypeID STIssue::getSType() const { return STI_ISSUE; } +/** @return A human-readable representation of the asset (e.g., "XRP", + * "USD/r...", or the raw hex of an MPTID), forwarded from `Asset::getText()`. + */ std::string STIssue::getText() const { return asset_.getText(); } +/** Serialize the asset to a JSON value suitable for RPC responses. + * + * Delegates to `Asset::setJson()`, which formats the asset according to its + * active variant (XRP, IOU, or MPT). + * + * @return A `json::Value` representing the asset. + */ json::Value STIssue::getJson(JsonOptions) const { @@ -86,6 +125,16 @@ STIssue::getJson(JsonOptions) const return jv; } +/** Serialize the asset into a Serializer, inverting the deserializing constructor. + * + * - **XRP**: writes only the 160-bit zero currency sentinel (no account). + * - **IOU**: writes the 160-bit currency followed by the 160-bit issuer AccountID. + * - **MPT**: writes the 160-bit issuer AccountID (in the currency slot), then the + * 160-bit `noAccount()` sentinel, then the 32-bit sequence extracted from the + * front of the MPTID blob — reversing the MPTID memory layout back to wire order. + * + * @param s The Serializer to append bytes to. + */ void STIssue::add(Serializer& s) const { @@ -104,6 +153,11 @@ STIssue::add(Serializer& s) const }); } +/** Return true if `t` is an STIssue holding the same asset as this object. + * + * @param t The STBase to compare against; must be safely downcasted to STIssue. + * @return `true` if `t` is an STIssue and its asset compares equal to this one. + */ bool STIssue::isEquivalent(STBase const& t) const { @@ -111,6 +165,15 @@ STIssue::isEquivalent(STBase const& t) const return (v != nullptr) && (*v == *this); } +/** Return true if this field holds the default asset (XRP). + * + * Matches the member initializer `asset_{xrpIssue()}`. An STIssue field + * absent from a ledger object is implicitly XRP, consistent with the + * ledger's treatment of the native currency as the base case. MPT issuances + * are never considered default. + * + * @return `true` iff the held asset is `xrpIssue()`. + */ bool STIssue::isDefault() const { @@ -119,18 +182,44 @@ STIssue::isDefault() const [](MPTIssue const&) { return false; }); } +/** Placement-copy this STIssue into a caller-supplied buffer for STVar storage. + * + * Used by `detail::STVar` to store heterogeneous STBase subclasses inside + * STObject without a heap allocation per field (small-object optimization). + * + * @param n Size of the destination buffer in bytes. + * @param buf Pointer to the destination buffer. + * @return Pointer to the newly constructed object within `buf`. + */ STBase* STIssue::copy(std::size_t n, void* buf) const { return emplace(n, buf, *this); } +/** Placement-move this STIssue into a caller-supplied buffer for STVar storage. + * + * @param n Size of the destination buffer in bytes. + * @param buf Pointer to the destination buffer. + * @return Pointer to the newly constructed object within `buf`. + */ STBase* STIssue::move(std::size_t n, void* buf) { return emplace(n, buf, std::move(*this)); } +/** Construct an STIssue from a JSON value representing an asset. + * + * Delegates parsing to `assetFromJson()`, which resolves the JSON + * representation to the appropriate `Asset` variant. Consistency validation + * (native currency vs. null issuer) is assumed to have been enforced upstream + * by the JSON parsing layer rather than re-checked here. + * + * @param name The SField to attach to the resulting STIssue. + * @param v A JSON value encoding an asset (XRP object, IOU object, or MPT object). + * @return An STIssue bound to `name` holding the parsed asset. + */ STIssue issueFromJson(SField const& name, json::Value const& v) { diff --git a/src/libxrpl/protocol/STLedgerEntry.cpp b/src/libxrpl/protocol/STLedgerEntry.cpp index 8bec23d319..0056ad6a37 100644 --- a/src/libxrpl/protocol/STLedgerEntry.cpp +++ b/src/libxrpl/protocol/STLedgerEntry.cpp @@ -1,3 +1,14 @@ +/** @file + * Implementation of `STLedgerEntry`, the typed representation of a single + * object in the XRPL ledger state. + * + * Covers construction from a `Keylet` (new entry), deserialization from a + * `SerialIter` (wire bytes), and promotion from a generic `STObject`. Also + * implements JSON representation (including the synthetic `mpt_issuance_id` + * for `ltMPTOKEN_ISSUANCE` objects) and the transaction-threading mechanism + * (`sfPreviousTxnID` / `sfPreviousTxnLgrSeq`) that links each ledger entry + * to the transaction that last modified it. + */ #include #include @@ -30,6 +41,15 @@ namespace xrpl { +/** Construct a new, empty ledger entry for the given keylet. + * + * Looks up the `LedgerFormats` schema for `k.type`, applies the + * `SOTemplate` (populating all declared fields at their default values), + * and writes `sfLedgerEntryType` so the wire encoding is self-describing. + * + * @throws std::runtime_error if `k.type` is not registered in + * `LedgerFormats`. + */ STLedgerEntry::STLedgerEntry(Keylet const& k) : STObject(sfLedgerEntry), key_(k.key), type_(k.type) { auto const format = LedgerFormats::getInstance().findByType(type_); @@ -46,6 +66,17 @@ STLedgerEntry::STLedgerEntry(Keylet const& k) : STObject(sfLedgerEntry), key_(k. setFieldU16(sfLedgerEntryType, static_cast(type_)); } +/** Deserialize a ledger entry from a byte stream. + * + * Reads all fields from `sit` into the underlying `STObject`, then calls + * `setSLEType()` to resolve the `sfLedgerEntryType` field, apply the + * matching `SOTemplate`, and validate field conformance. + * + * @param sit Wire-format byte cursor; consumed in place. + * @param index SHAMap key that addresses this entry in the ledger state. + * @throws std::runtime_error if the deserialized type is unrecognized or + * the field set does not conform to the declared template. + */ STLedgerEntry::STLedgerEntry(SerialIter& sit, uint256 const& index) : STObject(sfLedgerEntry), key_(index), type_(ltANY) { @@ -53,12 +84,34 @@ STLedgerEntry::STLedgerEntry(SerialIter& sit, uint256 const& index) setSLEType(); } +/** Promote a pre-populated `STObject` to a typed ledger entry. + * + * Used when fields have already been parsed into a generic `STObject` and + * need to be re-interpreted with a concrete `LedgerEntryType`. Delegates + * to `setSLEType()` for type resolution and template conformance. + * + * @param object The source object, copied into this entry. + * @param index SHAMap key that addresses this entry in the ledger state. + * @throws std::runtime_error if `sfLedgerEntryType` is absent, unrecognized, + * or the field set does not conform to the declared template. + */ STLedgerEntry::STLedgerEntry(STObject const& object, uint256 const& index) : STObject(object), key_(index), type_(ltANY) { setSLEType(); } +/** Resolve `type_` from the embedded `sfLedgerEntryType` field and enforce + * template conformance on an already-populated object. + * + * This is the post-hoc counterpart to the `set(SOTemplate)` call in the + * `Keylet` constructor: instead of initializing an empty object, it + * validates and conforms an already-populated one. Called after wire + * deserialization and after `STObject` promotion. + * + * @throws std::runtime_error if `sfLedgerEntryType` names an unrecognized + * type, or if `applyTemplate()` rejects the field set. + */ void STLedgerEntry::setSLEType() { @@ -72,6 +125,15 @@ STLedgerEntry::setSLEType() applyTemplate(format->getSOTemplate()); // May throw } +/** Return a verbose diagnostic string including the type name, key, and all + * field values. + * + * Re-validates `type_` against `LedgerFormats` as a defensive invariant + * check before emitting output. + * + * @throws std::runtime_error if `type_` is no longer recognized (indicates + * in-memory corruption). + */ std::string STLedgerEntry::getFullText() const { @@ -90,30 +152,59 @@ STLedgerEntry::getFullText() const return ret; } +/** Placement-copy this entry into `buf` for `detail::STVar` small-buffer + * optimization. + * + * @param n Size of the destination buffer in bytes. + * @param buf Pointer to the destination buffer. + * @return Pointer to the newly constructed object within `buf`. + */ STBase* STLedgerEntry::copy(std::size_t n, void* buf) const { return emplace(n, buf, *this); } +/** Placement-move this entry into `buf` for `detail::STVar` small-buffer + * optimization. + * + * @param n Size of the destination buffer in bytes. + * @param buf Pointer to the destination buffer. + * @return Pointer to the newly constructed object within `buf`. + */ STBase* STLedgerEntry::move(std::size_t n, void* buf) { return emplace(n, buf, std::move(*this)); } +/** Return the serialized type identifier for ledger entries (`STI_LEDGERENTRY`). */ SerializedTypeID STLedgerEntry::getSType() const { return STI_LEDGERENTRY; } +/** Return a compact diagnostic string containing the hex key and field + * contents. + */ std::string STLedgerEntry::getText() const { return str(boost::format("{ %s, %s }") % to_string(key_) % STObject::getText()); } +/** Serialize this entry to JSON, augmenting the base `STObject` output. + * + * Injects `"index"` (the hex-encoded SHAMap key) because `key_` is not + * stored as a serialized field. For `ltMPTOKEN_ISSUANCE` objects, also + * injects `"mpt_issuance_id"` computed from `sfSequence` and `sfIssuer` + * — this derived identifier is not stored on-ledger; it is recomputed + * on every read to keep consensus-critical storage non-redundant. + * + * @param options Controls JSON formatting (e.g., binary vs. human-readable). + * @return A `Json::Value` object with all fields plus the injected keys. + */ json::Value STLedgerEntry::getJson(JsonOptions options) const { @@ -130,6 +221,23 @@ STLedgerEntry::getJson(JsonOptions options) const return ret; } +/** Determine whether this entry participates in transaction threading. + * + * Threading links each ledger entry to the transaction that last modified + * it via `sfPreviousTxnID` / `sfPreviousTxnLgrSeq`. Five types + * (`ltDIR_NODE`, `ltAMENDMENTS`, `ltFEE_SETTINGS`, `ltNEGATIVE_UNL`, + * `ltAMM`) only gained `sfPreviousTxnID` support when the + * `fixPreviousTxnID` amendment activated; before that, this method + * returns `false` for those types to avoid producing threading fields + * that pre-amendment validators would not expect. + * + * @param rules The active amendment rules for the current ledger. + * @return `true` if this entry carries `sfPreviousTxnID` and threading + * is permitted under the current rules. + * @note The amendment guard performs a linear scan over a five-element + * compile-time array — acceptable cost for transaction-application + * frequency. + */ bool STLedgerEntry::isThreadedType(Rules const& rules) const { @@ -143,6 +251,23 @@ STLedgerEntry::isThreadedType(Rules const& rules) const return !excludePrevTxnID && getFieldIndex(sfPreviousTxnID) != -1; } +/** Update the threading fields to record that `txID` last modified this + * entry, and capture the previous transaction link for metadata. + * + * Reads `sfPreviousTxnID`; if it already equals `txID`, the transaction + * has been applied before — asserts that `sfPreviousTxnLgrSeq` also + * matches and returns `false` to signal no-op (guards against + * double-application). Otherwise writes the new `txID` and `ledgerSeq` + * and captures the old values in the output parameters so that callers + * can reconstruct the modification chain. + * + * @param txID Hash of the transaction that is modifying this entry. + * @param ledgerSeq Sequence number of the ledger containing `txID`. + * @param prevTxID [out] The previous value of `sfPreviousTxnID`. + * @param prevLedgerID [out] The previous value of `sfPreviousTxnLgrSeq`. + * @return `true` if the fields were updated; `false` if `txID` was + * already threaded (entry unchanged). + */ bool STLedgerEntry::thread( uint256 const& txID, diff --git a/src/libxrpl/protocol/STNumber.cpp b/src/libxrpl/protocol/STNumber.cpp index 3bbd28e8f6..adfacd5d94 100644 --- a/src/libxrpl/protocol/STNumber.cpp +++ b/src/libxrpl/protocol/STNumber.cpp @@ -1,3 +1,14 @@ +/** @file + * Implements STNumber, the serializable precision-number field type for + * XRPL ledger objects that store asset amounts without redundant asset identity. + * + * STNumber stores only a mantissa+exponent pair (a `Number`) on the wire + * (12 bytes: int64 mantissa + int32 exponent). Asset binding is deferred to + * runtime via the `STTakesAsset` mixin so that ledger objects such as Vault, + * LoanBroker, and Loan — where many numeric fields all refer to the same asset + * — do not pay the storage cost of duplicating asset identity in every field. + */ + #include #include @@ -31,10 +42,20 @@ STNumber::STNumber(SField const& field, Number const& value) : STTakesAsset(fiel { } +/** Deserialize an STNumber from a byte stream. + * + * Reads a 64-bit signed mantissa followed by a 32-bit exponent from @p sit. + * The two reads are in separate statements to guarantee sequencing — C++ + * does not specify argument-evaluation order within a single call expression, + * so merging them into `Number{sit.geti64(), sit.geti32()}` would produce + * undefined behavior. + * + * @param sit Cursor positioned at the start of the 12-byte payload. + * @param field The SField that identifies this value in its containing object. + */ STNumber::STNumber(SerialIter& sit, SField const& field) : STTakesAsset(field) { - // We must call these methods in separate statements - // to guarantee their order of execution. + // Separate statements guarantee evaluation order (geti64 before geti32). auto mantissa = sit.geti64(); auto exponent = sit.geti32(); value_ = Number{mantissa, exponent}; @@ -52,6 +73,20 @@ STNumber::getText() const return to_string(value_); } +/** Bind an Asset to this field and round the stored value to its precision. + * + * Phase 1 of the two-phase rounding contract: stores the asset via + * `STTakesAsset::associateAsset` and immediately rounds `value_` to the + * asset's canonical precision via `roundToAsset`. For XRP and MPT this + * means truncating fractional drops; for IOU this means normalising to + * 15 significant decimal digits. After this call, `add()` will assert + * idempotency — calling `setValue()` without re-associating afterward is + * a programming error. + * + * @param a The asset whose precision governs rounding. + * @note The field must carry the `sMD_NeedsAsset` metadata flag; the + * assertion will fire in debug builds if it does not. + */ void STNumber::associateAsset(Asset const& a) { @@ -65,6 +100,26 @@ STNumber::associateAsset(Asset const& a) roundToAsset(a, value_); } +/** Serialize this value as 12 bytes: int64 mantissa followed by int32 exponent. + * + * For `sMD_NeedsAsset` fields this is Phase 2 of the two-phase rounding + * contract. When an asset is present the value is rounded again (via + * `roundToAsset`) and the result is asserted to equal `value_`, verifying + * idempotency — any mismatch means `setValue()` was called after + * `associateAsset()` without re-associating, which would produce incorrect + * ledger state. When no asset is present (e.g., an already-rounded value + * being re-serialized without going through a transactor), a debug-only + * assertion confirms that `MantissaRange::Large` is active, because + * serializing under `Small` scale would silently truncate precision. + * + * The mantissa range assertion guards the wire format: although + * `Number::mantissa()` returns `int64_t`, the internal representation uses an + * unsigned 64-bit value extended to 19 digits under `Large` scale, and the + * accessor divides by 10 when necessary; the assertion documents that the + * result must always fit in a signed 64-bit wire field. + * + * @param s Serializer accumulator to append to. + */ void STNumber::add(Serializer& s) const { @@ -78,23 +133,20 @@ STNumber::add(Serializer& s) const SField const& field = getFName(); if (field.shouldMeta(SField::kSMD_NEEDS_ASSET)) { - // asset is defined in the STTakesAsset base class if (asset_) { - // The number should be rounded to the asset's precision, but round - // it here if it has an asset assigned. + // Phase 2 idempotency check: re-round and assert the stored value + // was already rounded by associateAsset(). roundToAsset(*asset_, value); XRPL_ASSERT_PARTS(value_ == value, "xrpl::STNumber::add", "value is already rounded"); } else { #if !NDEBUG - // There are circumstances where an already-rounded Number is - // serialized without being touched by a transactor, and thus - // without an asset. We can't know if it's rounded, because it could - // represent _anything_, particularly when serializing user-provided - // Json. Regardless, the only time we should be serializing an - // STNumber is when the scale is large. + // Serializing without an asset (e.g., a pass-through re-serialization + // that bypassed a transactor). We cannot verify rounding, but we can + // assert the mantissa scale is Large — using Small scale here would + // silently truncate XRP/MPT integer values that exceed 15 digits. XRPL_ASSERT_PARTS( Number::getMantissaScale() == MantissaRange::MantissaScale::Large, "xrpl::STNumber::add", @@ -157,6 +209,26 @@ operator<<(std::ostream& out, STNumber const& rhs) return out << rhs.getText(); } +/** Parse a decimal string into raw mantissa/exponent/sign parts. + * + * Accepts optional sign, integer part (no leading zeroes unless the value is + * exactly `"0"`), optional fractional part, and optional `e`/`E` exponent. + * The fractional digits are concatenated with the integer digits to form the + * mantissa; the exponent is adjusted by the negative of the fractional digit + * count, then shifted by any explicit exponent. No normalization is applied — + * the caller receives the raw parsed representation. + * + * @param number Decimal string to parse (e.g., `"3.14e2"`, `"-42"`, `"0"`). + * @return Parsed `NumberParts` with unsigned mantissa, adjusted exponent, and + * sign flag. + * @throws std::runtime_error if the string does not match the expected decimal + * format (e.g., leading zeroes, dangling decimal point, bare `"e"`, + * empty string). + * @throws std::bad_cast (from `boost::lexical_cast`) if the digit string + * overflows `uint64_t` (e.g., a 200-digit integer literal). + * @note The regex is compiled once as a `static` local with the `optimize` + * flag to amortize construction cost across calls. + */ NumberParts partsFromString(std::string const& number) { @@ -174,36 +246,24 @@ partsFromString(std::string const& number) if (!boost::regex_match(number, match, kRE_NUMBER)) Throw("'" + number + "' is not a number"); - // Match fields: - // 0 = whole input - // 1 = sign - // 2 = integer portion - // 3 = whole fraction (with '.') - // 4 = fraction (without '.') - // 5 = whole exponent (with 'e') - // 6 = exponent sign - // 7 = exponent number - bool const negative = (match[1].matched && (match[1] == "-")); std::uint64_t mantissa = 0; int exponent = 0; - if (!match[4].matched) // integer only + if (!match[4].matched) { mantissa = boost::lexical_cast(std::string(match[2])); exponent = 0; } else { - // integer and fraction mantissa = boost::lexical_cast(match[2] + match[4]); exponent = -(match[4].length()); } if (match[5].matched) { - // we have an exponent if (match[6].matched && (match[6] == "-")) { exponent -= boost::lexical_cast(std::string(match[7])); @@ -217,6 +277,27 @@ partsFromString(std::string const& number) return {.mantissa = mantissa, .exponent = exponent, .negative = negative}; } +/** Construct an STNumber from a JSON value. + * + * Dispatches on the JSON value type: + * - **Integer** (`isInt`/`isUInt`): reads the native integer value directly, + * preserving sign for signed integers via `asAbsUInt()` + `negative` flag. + * - **String**: delegates to `partsFromString`; this path asserts that no + * active transaction rules are present (`getCurrentTransactionRules()` is + * null), because accepting user-supplied decimal strings inside a transactor + * would expose precision-sensitive parsing to untrusted input. + * - Anything else throws. + * + * @param field The SField that identifies the resulting STNumber. + * @param value JSON node containing the numeric value. + * @return A new STNumber holding the parsed value (not yet asset-rounded). + * @throws std::runtime_error if @p value is not an integer or string, or if + * the string fails to parse as a valid decimal. + * @throws std::bad_cast (via `boost::lexical_cast`) if a string mantissa + * overflows `uint64_t`. + * @note String input is forbidden during transaction processing; only numeric + * JSON types are accepted in that context. + */ STNumber numberFromJson(SField const& field, json::Value const& value) { diff --git a/src/libxrpl/protocol/STObject.cpp b/src/libxrpl/protocol/STObject.cpp index ec44250bed..1eea6f89df 100644 --- a/src/libxrpl/protocol/STObject.cpp +++ b/src/libxrpl/protocol/STObject.cpp @@ -1,3 +1,14 @@ +/** @file STObject.cpp + * Implementation of STObject — the heterogeneous, field-keyed container that + * underlies every XRPL transaction, ledger entry, and inner object. + * + * Fields are stored in `v_` (a `std::vector`) either in + * insertion order (free mode, `type_ == nullptr`) or in template order + * (templated mode, `type_` points to an `SOTemplate`). Templated mode + * provides O(1) field lookup and guarantees every slot is pre-populated with + * a sentinel (`STI_NOTPRESENT`) for optional/default fields. + */ + #include #include @@ -42,20 +53,42 @@ namespace xrpl { +/** Move constructor. Transfers field storage and template pointer from + * `other`, leaving it in a valid but empty state. + */ STObject::STObject(STObject&& other) : STBase(other.getFName()), v_(std::move(other.v_)), type_(other.type_) { } +/** Construct a free (schema-less) STObject with the given field name. */ STObject::STObject(SField const& name) : STBase(name) { } +/** Construct a templated STObject from a schema and field name. + * + * Calls `set(type)` to pre-populate every slot defined by `type`. + * + * @param type The SOTemplate that defines the layout of this object. + * @param name The SField identifying this object within its parent. + */ STObject::STObject(SOTemplate const& type, SField const& name) : STBase(name) { set(type); } +/** Construct a templated STObject by deserializing from a byte stream. + * + * Deserializes fields from `sit` in free mode, then calls `applyTemplate` + * to re-order and validate them against `type`. + * + * @param type The SOTemplate to enforce after deserialization. + * @param sit The byte stream to read from. + * @param name The SField identifying this object within its parent. + * @throws FieldErr if a required field is missing or an unexpected field + * is present. + */ STObject::STObject(SOTemplate const& type, SerialIter& sit, SField const& name) : STBase(name) { v_.reserve(type.size()); @@ -63,6 +96,18 @@ STObject::STObject(SOTemplate const& type, SerialIter& sit, SField const& name) applyTemplate(type); // May throw } +/** Construct an STObject by deserializing from a byte stream with a depth guard. + * + * The `depth` parameter prevents stack exhaustion when deserializing + * deeply nested `STObject`/`STArray` structures from untrusted input. + * Recursion beyond depth 10 throws `std::runtime_error`. + * + * @param sit The byte stream to read from. + * @param name The SField identifying this object within its parent. + * @param depth Current nesting depth; capped at 10. + * @throws std::runtime_error if `depth > 10` or if malformed data is + * encountered (e.g., duplicate fields, unknown field IDs). + */ STObject::STObject(SerialIter& sit, SField const& name, int depth) noexcept(false) : STBase(name) { if (depth > 10) @@ -70,16 +115,29 @@ STObject::STObject(SerialIter& sit, SField const& name, int depth) noexcept(fals set(sit, depth); } +/** Factory for inner objects that conditionally applies a schema template. + * + * Template application was introduced in two amendment phases to preserve + * replay compatibility with historical ledger entries serialized before + * schemas existed: + * - When no `Rules` are available (pre-consensus or unit-test context), + * the template is always applied. + * - `fixInnerObjTemplate` added templates to AMM inner objects + * (`sfAuctionSlot`, `sfVoteEntry`). + * - `fixInnerObjTemplate2` extended templates to all other inner objects. + * + * This method reads the ambient transaction rules via + * `getCurrentTransactionRules()`, making it implicitly context-sensitive. + * + * @param name The SField identifying the inner object type. + * @return A new STObject, bound to its SOTemplate when the active rules + * permit it. + */ STObject STObject::makeInnerObject(SField const& name) { STObject obj{name}; - // The if is complicated because inner object templates were added in - // two phases: - // 1. If there are no available Rules, then always apply the template. - // 2. fixInnerObjTemplate added templates to two AMM inner objects. - // 3. fixInnerObjTemplate2 added templates to all remaining inner objects. std::optional const& rules = getCurrentTransactionRules(); bool const isAMMObj = name == sfAuctionSlot || name == sfVoteEntry; if (!rules || (rules->enabled(fixInnerObjTemplate) && isAMMObj) || @@ -92,36 +150,47 @@ STObject::makeInnerObject(SField const& name) return obj; } +/** Copy this object into `buf` using placement-new via `STBase::emplace`. */ STBase* STObject::copy(std::size_t n, void* buf) const { return emplace(n, buf, *this); } +/** Move this object into `buf` using placement-new via `STBase::emplace`. */ STBase* STObject::move(std::size_t n, void* buf) { return emplace(n, buf, std::move(*this)); } +/** Returns `STI_OBJECT`, the serialized type identifier for this class. */ SerializedTypeID STObject::getSType() const { return STI_OBJECT; } +/** Returns `true` when the field storage vector is empty. + * + * An STObject is considered default (absent) only when it holds no fields + * at all. A templated object initialized via `set(SOTemplate)` is never + * empty because every slot is pre-populated. + */ bool STObject::isDefault() const { return v_.empty(); } +/** Serialize all fields (including signing fields) into `s`. */ void STObject::add(Serializer& s) const { add(s, WhichFields::WithAllFields); // just inner elements } +/** Move-assign from `other`, transferring field storage and template pointer. */ STObject& STObject::operator=(STObject&& other) { @@ -131,6 +200,15 @@ STObject::operator=(STObject&& other) return *this; } +/** Initialize this object from a template, pre-populating every slot. + * + * Clears `v_` and rebuilds it in template order. `soeREQUIRED` fields + * receive a type-correct default value; all other fields (`soeOPTIONAL`, + * `soeDEFAULT`) receive the `STI_NOTPRESENT` sentinel, which + * `isFieldPresent()` recognizes as absent. + * + * @param type The SOTemplate that defines the layout of this object. + */ void STObject::set(SOTemplate const& type) { @@ -151,6 +229,23 @@ STObject::set(SOTemplate const& type) } } +/** Validate and reorder fields against a schema after free-mode deserialization. + * + * Rebuilds `v_` in template order: + * - Each template slot is filled from the existing (insertion-ordered) `v_`, + * or populated with `STI_NOTPRESENT` when the field is absent. + * - A `soeDEFAULT` field whose serialized value equals the type's default + * is rejected — the ledger forbids explicitly encoding default values. + * - A `soeREQUIRED` field that is missing throws `FieldErr`. + * - Any fields left over in `v_` after template matching must be discardable + * (`SField::isDiscardable()`); non-discardable unknown fields throw. + * + * The final `v_.swap(v)` atomically replaces the old unordered data. + * + * @param type The SOTemplate to enforce. + * @throws FieldErr on required-missing, explicit-default, or unknown + * non-discardable field. + */ void STObject::applyTemplate(SOTemplate const& type) { @@ -189,17 +284,23 @@ STObject::applyTemplate(SOTemplate const& type) } for (auto const& e : v_) { - // Anything left over in the object must be discardable if (!e->getFName().isDiscardable()) { throwFieldErr(e->getFName().getName(), "found in disallowed location."); } } - // Swap the template matching data in for the old data, - // freeing any leftover junk v_.swap(v); } +/** Apply the schema registered for `sField` in `InnerObjectFormats`, if any. + * + * Looks up the global `InnerObjectFormats` singleton for a template keyed by + * `sField`. When found, delegates to `applyTemplate`. No-op if the field + * has no registered template (e.g., for inner objects without a known schema). + * + * @param sField The SField whose registered SOTemplate should be applied. + * @throws FieldErr (from `applyTemplate`) if the object does not conform. + */ void STObject::applyTemplateFromSField(SField const& sField) { @@ -208,7 +309,30 @@ STObject::applyTemplateFromSField(SField const& sField) applyTemplate(*elements); // May throw } -// return true = terminated with end-of-object +/** Deserialize fields from a byte stream into this object (free mode). + * + * Reads `(type, field)` ID pairs from `sit`, looks up each `SField`, and + * constructs a child `STVar` at `depth+1`. When a child is itself an + * `STObject`, `applyTemplateFromSField()` is called immediately to bind it + * to any known schema. + * + * Termination rules: + * - `STI_OBJECT / field==1` — end-of-object marker; consumed and returns + * `true`. + * - `STI_ARRAY / field==1` inside an object — malformed data; throws. + * - End of `sit` without a marker — returns `false` (top-level object). + * + * After all fields are read, the method enforces the no-duplicate-field + * invariant using `getSortedFields` + `std::adjacent_find`; duplicate fields + * throw `std::runtime_error("Duplicate field detected")`. + * + * @param sit The byte stream to read from. + * @param depth Current nesting depth (caller increments before passing). + * @return `true` if the object was terminated by an end-of-object marker, + * `false` if `sit` was exhausted without one. + * @throws std::runtime_error on unknown field ID, embedded end-of-array + * marker, or duplicate field. + */ bool STObject::set(SerialIter& sit, int depth) { @@ -216,17 +340,13 @@ STObject::set(SerialIter& sit, int depth) v_.clear(); - // Consume data in the pipe until we run out or reach the end while (!sit.empty()) { int type = 0; int field = 0; - // Get the metadata for the next field sit.getFieldID(type, field); - // The object termination marker has been found and the termination - // marker has been consumed. Done deserializing. if (type == STI_OBJECT && field == 1) { reachedEndOfObject = true; @@ -248,16 +368,12 @@ STObject::set(SerialIter& sit, int depth) Throw("Unknown field"); } - // Unflatten the field v_.emplace_back(sit, fn, depth + 1); - // If the object type has a known SOTemplate then set it. if (auto const obj = dynamic_cast(&(v_.back().get()))) obj->applyTemplateFromSField(fn); // May throw } - // We want to ensure that the deserialized object does not contain any - // duplicate fields. This is a key invariant: auto const sf = getSortedFields(*this, WhichFields::WithAllFields); auto const dup = std::ranges::adjacent_find(sf, [](STBase const* lhs, STBase const* rhs) { @@ -270,6 +386,13 @@ STObject::set(SerialIter& sit, int depth) return reachedEndOfObject; } +/** Returns `true` if this object contains a field equal to `t`. + * + * Looks up the field by name and compares via `STBase::operator==`. + * + * @param t The field to search for, identified by its `SField` name. + * @return `true` if a field with the same name and value exists. + */ bool STObject::hasMatchingEntry(STBase const& t) const { @@ -281,6 +404,11 @@ STObject::hasMatchingEntry(STBase const& t) const return t == *o; } +/** Returns a human-readable representation including the field name. + * + * Produces `" = { , , ... }"`, or `"{ ... }"` when + * the object has no name. `STI_NOTPRESENT` slots are skipped. + */ std::string STObject::getFullText() const { @@ -318,6 +446,9 @@ STObject::getFullText() const return ret; } +/** Returns a terse `"{ ... }"` representation of all fields (including absent + * sentinel slots). Primarily used for diagnostic logging. + */ std::string STObject::getText() const { @@ -337,6 +468,19 @@ STObject::getText() const return ret; } +/** Compare two STObjects for structural and value equivalence. + * + * Fast path: when both objects share the same `SOTemplate` pointer + * (`type_`), fields are compared positionally in `v_` order, which is + * O(n). This is sound because the same template guarantees identical slot + * layout. + * + * Slow path: when templates differ (or either is a free object), both + * objects are sorted by `fieldCode` and compared element-by-element. + * + * @param t The object to compare against; must also be an `STObject`. + * @return `true` if all present fields match in type and value. + */ bool STObject::isEquivalent(STBase const& t) const { @@ -361,6 +505,16 @@ STObject::isEquivalent(STBase const& t) const }); } +/** Compute a domain-separated SHA-512 half-hash over all fields. + * + * Prepends `prefix` (a 4-byte `HashPrefix` constant) to the serialization to + * prevent cross-domain hash collisions, then returns the first 256 bits of + * SHA-512. + * + * @param prefix The `HashPrefix` tag identifying the hash domain (e.g., + * `HashPrefix::transactionID`, `HashPrefix::leafNode`). + * @return The 256-bit digest of `prefix ‖ canonical_serialization`. + */ uint256 STObject::getHash(HashPrefix prefix) const { @@ -370,6 +524,16 @@ STObject::getHash(HashPrefix prefix) const return s.getSHA512Half(); } +/** Compute a domain-separated hash over signing fields only. + * + * Identical to `getHash` but excludes fields where `SField::shouldInclude` + * returns `false` for signing (e.g., `sfTxnSignature`, `sfSigners`). This + * is the digest that a private key signs and a verifier reconstructs. + * + * @param prefix The `HashPrefix` tag for this signing context (e.g., + * `HashPrefix::txSign`, `HashPrefix::txMultiSign`). + * @return The 256-bit signing digest. + */ uint256 STObject::getSigningHash(HashPrefix prefix) const { @@ -379,6 +543,14 @@ STObject::getSigningHash(HashPrefix prefix) const return s.getSHA512Half(); } +/** Return the index of `field` within `v_`, or -1 if not found. + * + * In templated mode delegates to `SOTemplate::getIndex()`, which is O(1). + * In free mode performs a linear scan through `v_`. + * + * @param field The SField to locate. + * @return Zero-based index into `v_`, or -1 when absent. + */ int STObject::getFieldIndex(SField const& field) const { @@ -395,6 +567,11 @@ STObject::getFieldIndex(SField const& field) const return -1; } +/** Return a const reference to `field`, throwing if absent. + * + * @param field The SField to retrieve. + * @throws std::runtime_error ("Field not found") if the field is not present. + */ STBase const& STObject::peekAtField(SField const& field) const { @@ -406,6 +583,11 @@ STObject::peekAtField(SField const& field) const return peekAtIndex(index); } +/** Return a mutable reference to `field`, throwing if absent. + * + * @param field The SField to retrieve. + * @throws std::runtime_error ("Field not found") if the field is not present. + */ STBase& STObject::getField(SField const& field) { @@ -417,12 +599,24 @@ STObject::getField(SField const& field) return getIndex(index); } +/** Return the `SField` associated with the slot at `index`. + * + * @param index Zero-based offset into `v_`. + */ SField const& STObject::getFieldSType(int index) const { return v_[index]->getFName(); } +/** Return a const pointer to `field`, or `nullptr` if absent. + * + * Callers that need a non-throwing presence check should prefer this over + * `peekAtField`. + * + * @param field The SField to look up. + * @return Pointer to the field's `STBase`, or `nullptr`. + */ STBase const* STObject::peekAtPField(SField const& field) const { @@ -434,6 +628,17 @@ STObject::peekAtPField(SField const& field) const return peekAtPIndex(index); } +/** Return a mutable pointer to `field`, optionally creating it in free mode. + * + * In templated mode the field must already exist; creation is never + * performed. In free mode, when `createOkay` is `true` and the field is + * absent, a new slot initialized to the default value is appended. + * + * @param field The SField to look up or create. + * @param createOkay When `true`, auto-creates the field in free objects. + * @return Mutable pointer to the field's `STBase`, or `nullptr` if absent + * and not created. + */ STBase* STObject::getPField(SField const& field, bool createOkay) { @@ -450,6 +655,14 @@ STObject::getPField(SField const& field, bool createOkay) return getPIndex(index); } +/** Returns `true` if `field` is present and not the `STI_NOTPRESENT` sentinel. + * + * In templated mode, optional and default slots always exist in `v_` but + * carry the sentinel when logically absent. This method distinguishes the + * two cases correctly. + * + * @param field The SField to test. + */ bool STObject::isFieldPresent(SField const& field) const { @@ -461,18 +674,38 @@ STObject::isFieldPresent(SField const& field) const return peekAtIndex(index).getSType() != STI_NOTPRESENT; } +/** Return a mutable reference to the inner `STObject` at `field`. + * + * @param field Must identify an `STObject`-typed field. + * @throws std::runtime_error if absent or wrong type. + */ STObject& STObject::peekFieldObject(SField const& field) { return peekField(field); } +/** Return a mutable reference to the inner `STArray` at `field`. + * + * @param field Must identify an `STArray`-typed field. + * @throws std::runtime_error if absent or wrong type. + */ STArray& STObject::peekFieldArray(SField const& field) { return peekField(field); } +/** Set one or more bits in `sfFlags`, creating the field in free objects. + * + * Uses `getPField(sfFlags, createOkay=true)` so that free objects have the + * field created on demand. Templated objects must already have `sfFlags` in + * their schema. + * + * @param f Bitmask of flags to set. + * @return `true` if the flags were set; `false` if `sfFlags` could not be + * found or created. + */ bool STObject::setFlag(std::uint32_t f) { @@ -485,6 +718,14 @@ STObject::setFlag(std::uint32_t f) return true; } +/** Clear one or more bits in `sfFlags`. + * + * Unlike `setFlag`, this method never creates the field — if `sfFlags` is + * absent the call is a no-op. + * + * @param f Bitmask of flags to clear. + * @return `true` if the flags were cleared; `false` if `sfFlags` is absent. + */ bool STObject::clearFlag(std::uint32_t f) { @@ -497,12 +738,17 @@ STObject::clearFlag(std::uint32_t f) return true; } +/** Returns `true` if all bits in `f` are set in `sfFlags`. + * + * @param f Bitmask to test. + */ bool STObject::isFlag(std::uint32_t f) const { return (getFlags() & f) == f; } +/** Return the raw value of `sfFlags`, or 0 if the field is absent. */ std::uint32_t STObject::getFlags(void) const { @@ -514,6 +760,19 @@ STObject::getFlags(void) const return t->value(); } +/** Transition a field from the `STI_NOTPRESENT` sentinel to a default-value + * instance, making it logically present. + * + * For templated objects the slot already exists; its `STVar` is replaced with + * a default-constructed instance of the appropriate ST type. For free + * objects the field is appended as a non-present sentinel (the sentinel is + * then replaced on first `set` or `setField*` call). + * + * @param field The SField to make present. + * @return Pointer to the now-present field slot. + * @throws std::runtime_error if the field does not exist in a templated + * object. + */ STBase* STObject::makeFieldPresent(SField const& field) { @@ -536,6 +795,14 @@ STObject::makeFieldPresent(SField const& field) return getPIndex(index); } +/** Transition a present field back to the `STI_NOTPRESENT` sentinel. + * + * Only meaningful in templated mode where optional/default slots keep their + * position in `v_`. If the field is already absent this is a no-op. + * + * @param field The SField to make absent. + * @throws std::runtime_error if `field` is not in the object at all. + */ void STObject::makeFieldAbsent(SField const& field) { @@ -551,6 +818,15 @@ STObject::makeFieldAbsent(SField const& field) v_[index] = detail::STVar(detail::gNonPresentObject, f.getFName()); } +/** Physically erase `field` from `v_` by SField. + * + * This permanently removes the slot (shifts subsequent indices). In + * templated mode this invalidates the positional index assumptions; it is + * intended only for free-mode objects or special-case cleanup. + * + * @param field The SField to remove. + * @return `true` if the field was found and removed; `false` if absent. + */ bool STObject::delField(SField const& field) { @@ -563,78 +839,131 @@ STObject::delField(SField const& field) return true; } +/** Physically erase the slot at `index` from `v_`. + * + * @param index Zero-based index into `v_`. + */ void STObject::delField(int index) { v_.erase(v_.begin() + index); } +/** Return the `SOEStyle` of `field` as declared in the active template. + * + * Returns `SoeInvalid` when the object is in free mode (no template). + * + * @param field The SField to query. + */ SOEStyle STObject::getStyle(SField const& field) const { return (type_ != nullptr) ? type_->style(field) : SoeInvalid; } +// --- Typed field getters --- +// All getters delegate to getFieldByValue (returns by value, throws if the +// field is absent and not optional) or getFieldByConstRef (returns a const +// reference to a function-local static empty value when the field is absent, +// allowing safe access to optional fields without a prior isFieldPresent check). + +/** @return Value of `field` as `unsigned char`. + * @throws std::runtime_error if absent or wrong type. + */ unsigned char STObject::getFieldU8(SField const& field) const { return getFieldByValue(field); } +/** @return Value of `field` as `uint16_t`. + * @throws std::runtime_error if absent or wrong type. + */ std::uint16_t STObject::getFieldU16(SField const& field) const { return getFieldByValue(field); } +/** @return Value of `field` as `uint32_t`. + * @throws std::runtime_error if absent or wrong type. + */ std::uint32_t STObject::getFieldU32(SField const& field) const { return getFieldByValue(field); } +/** @return Value of `field` as `uint64_t`. + * @throws std::runtime_error if absent or wrong type. + */ std::uint64_t STObject::getFieldU64(SField const& field) const { return getFieldByValue(field); } +/** @return Value of `field` as `uint128`. + * @throws std::runtime_error if absent or wrong type. + */ uint128 STObject::getFieldH128(SField const& field) const { return getFieldByValue(field); } +/** @return Value of `field` as `uint160`. + * @throws std::runtime_error if absent or wrong type. + */ uint160 STObject::getFieldH160(SField const& field) const { return getFieldByValue(field); } +/** @return Value of `field` as `uint192`. + * @throws std::runtime_error if absent or wrong type. + */ uint192 STObject::getFieldH192(SField const& field) const { return getFieldByValue(field); } +/** @return Value of `field` as `uint256`. + * @throws std::runtime_error if absent or wrong type. + */ uint256 STObject::getFieldH256(SField const& field) const { return getFieldByValue(field); } +/** @return Value of `field` as `int32_t`. + * @throws std::runtime_error if absent or wrong type. + */ std::int32_t STObject::getFieldI32(SField const& field) const { return getFieldByValue(field); } +/** @return The `AccountID` stored in `field`. + * @throws std::runtime_error if absent or wrong type. + */ AccountID STObject::getAccountID(SField const& field) const { return getFieldByValue(field); } +/** Return the variable-length blob in `field` as a new `Blob`. + * + * Returns an empty `Blob` when the optional field is absent. + * + * @param field Must be an `STBlob`-typed field. + * @throws std::runtime_error if wrong type. + */ Blob STObject::getFieldVL(SField const& field) const { @@ -643,6 +972,13 @@ STObject::getFieldVL(SField const& field) const return Blob(b.data(), b.data() + b.size()); } +/** Return a const reference to the `STAmount` in `field`. + * + * Returns a reference to a function-local static empty `STAmount` when the + * optional field is absent, allowing callers to skip a presence check. + * + * @param field Must be an `STAmount`-typed field. + */ STAmount const& STObject::getFieldAmount(SField const& field) const { @@ -650,6 +986,12 @@ STObject::getFieldAmount(SField const& field) const return getFieldByConstRef(field, kEMPTY); } +/** Return a const reference to the `STPathSet` in `field`. + * + * Returns an empty path set when the optional field is absent. + * + * @param field Must be an `STPathSet`-typed field. + */ STPathSet const& STObject::getFieldPathSet(SField const& field) const { @@ -657,6 +999,12 @@ STObject::getFieldPathSet(SField const& field) const return getFieldByConstRef(field, kEMPTY); } +/** Return a const reference to the `STVector256` in `field`. + * + * Returns an empty vector when the optional field is absent. + * + * @param field Must be an `STVector256`-typed field. + */ STVector256 const& STObject::getFieldV256(SField const& field) const { @@ -664,6 +1012,16 @@ STObject::getFieldV256(SField const& field) const return getFieldByConstRef(field, kEMPTY); } +/** Return a copy of the inner `STObject` at `field`, with template applied. + * + * Unlike the other const-ref getters, this returns by value. If the field + * is present, `applyTemplateFromSField` is called on the copy so the caller + * receives a template-bound object even when the source was deserialized in + * free mode. If absent, returns an empty `STObject` constructed with `field` + * as its name. + * + * @param field Must be an `STObject`-typed field. + */ STObject STObject::getFieldObject(SField const& field) const { @@ -674,6 +1032,12 @@ STObject::getFieldObject(SField const& field) const return ret; } +/** Return a const reference to the `STArray` in `field`. + * + * Returns an empty array when the optional field is absent. + * + * @param field Must be an `STArray`-typed field. + */ STArray const& STObject::getFieldArray(SField const& field) const { @@ -681,6 +1045,12 @@ STObject::getFieldArray(SField const& field) const return getFieldByConstRef(field, kEMPTY); } +/** Return a const reference to the `STCurrency` in `field`. + * + * Returns an empty currency when the optional field is absent. + * + * @param field Must be an `STCurrency`-typed field. + */ STCurrency const& STObject::getFieldCurrency(SField const& field) const { @@ -688,6 +1058,12 @@ STObject::getFieldCurrency(SField const& field) const return getFieldByConstRef(field, kEMPTY); } +/** Return a const reference to the `STNumber` in `field`. + * + * Returns an empty `STNumber` when the optional field is absent. + * + * @param field Must be an `STNumber`-typed field. + */ STNumber const& STObject::getFieldNumber(SField const& field) const { @@ -695,12 +1071,29 @@ STObject::getFieldNumber(SField const& field) const return getFieldByConstRef(field, kEMPTY); } +/** Set a field by transferring ownership from a heap-allocated `STBase`. + * + * Convenience overload that delegates to `set(STBase&&)`. + * + * @param v The field to set; must not be null. + */ void STObject::set(std::unique_ptr v) { set(std::move(*v.get())); } +/** Set or replace a field by value. + * + * If a slot for `v.getFName()` already exists in `v_`, it is replaced + * in-place. For free objects, if the field is not present it is appended. + * For templated objects, unknown fields are rejected with + * `std::runtime_error`. + * + * @param v The new field value; its `SField` identity determines the slot. + * @throws std::runtime_error when in templated mode and the field is not + * in the schema. + */ void STObject::set(STBase&& v) { @@ -717,120 +1110,156 @@ STObject::set(STBase&& v) } } +// --- Typed field setters --- +// Integer/bitstring setters delegate to setFieldUsingSetValue, which calls +// T::setValue(). Complex-type setters delegate to setFieldUsingAssignment, +// which uses T::operator=. All setters make the field present if it currently +// holds the STI_NOTPRESENT sentinel, and create it in free objects when absent. + +/** Set `field` to `v` (unsigned char). */ void STObject::setFieldU8(SField const& field, unsigned char v) { setFieldUsingSetValue(field, v); } +/** Set `field` to `v` (uint16_t). */ void STObject::setFieldU16(SField const& field, std::uint16_t v) { setFieldUsingSetValue(field, v); } +/** Set `field` to `v` (uint32_t). */ void STObject::setFieldU32(SField const& field, std::uint32_t v) { setFieldUsingSetValue(field, v); } +/** Set `field` to `v` (uint64_t). */ void STObject::setFieldU64(SField const& field, std::uint64_t v) { setFieldUsingSetValue(field, v); } +/** Set `field` to `v` (uint128). */ void STObject::setFieldH128(SField const& field, uint128 const& v) { setFieldUsingSetValue(field, v); } +/** Set `field` to `v` (uint192). */ void STObject::setFieldH192(SField const& field, uint192 const& v) { setFieldUsingSetValue(field, v); } +/** Set `field` to `v` (uint256). */ void STObject::setFieldH256(SField const& field, uint256 const& v) { setFieldUsingSetValue(field, v); } +/** Set `field` to `v` (int32_t). */ void STObject::setFieldI32(SField const& field, std::int32_t v) { setFieldUsingSetValue(field, v); } +/** Set `field` to `v` (STVector256). */ void STObject::setFieldV256(SField const& field, STVector256 const& v) { setFieldUsingSetValue(field, v); } +/** Set `field` to `v` (AccountID). */ void STObject::setAccountID(SField const& field, AccountID const& v) { setFieldUsingSetValue(field, v); } +/** Set `field` to the contents of `v` (variable-length blob from Blob). */ void STObject::setFieldVL(SField const& field, Blob const& v) { setFieldUsingSetValue(field, Buffer(v.data(), v.size())); } +/** Set `field` to the contents of `s` (variable-length blob from Slice). + * + * Copies the bytes from `s` into an owned `Buffer`. + */ void STObject::setFieldVL(SField const& field, Slice const& s) { setFieldUsingSetValue(field, Buffer(s.data(), s.size())); } +/** Set `field` to `v` (STAmount). */ void STObject::setFieldAmount(SField const& field, STAmount const& v) { setFieldUsingAssignment(field, v); } +/** Set `field` to `v` (STCurrency). */ void STObject::setFieldCurrency(SField const& field, STCurrency const& v) { setFieldUsingAssignment(field, v); } +/** Set `field` to `v` (STIssue). */ void STObject::setFieldIssue(SField const& field, STIssue const& v) { setFieldUsingAssignment(field, v); } +/** Set `field` to `v` (STNumber). */ void STObject::setFieldNumber(SField const& field, STNumber const& v) { setFieldUsingAssignment(field, v); } +/** Set `field` to `v` (STPathSet). */ void STObject::setFieldPathSet(SField const& field, STPathSet const& v) { setFieldUsingAssignment(field, v); } +/** Set `field` to `v` (STArray). */ void STObject::setFieldArray(SField const& field, STArray const& v) { setFieldUsingAssignment(field, v); } +/** Set `field` to `v` (STObject). */ void STObject::setFieldObject(SField const& field, STObject const& v) { setFieldUsingAssignment(field, v); } +/** Serialize this object to a JSON object. + * + * Iterates `v_`, skipping `STI_NOTPRESENT` slots, and adds each present + * field as `{ jsonName: field.getJson(options) }`. + * + * @param options Controls display format (e.g., human-readable amounts). + * @return A `json::Value` of object type. + */ json::Value STObject::getJson(JsonOptions options) const { @@ -844,17 +1273,28 @@ STObject::getJson(JsonOptions options) const return ret; } +/** Compare two STObjects for equality, considering only binary fields. + * + * Non-binary fields (metadata, computed values whose `SField::isBinary()` + * returns `false`) are excluded from comparison. The algorithm is O(n²) by + * design: for each binary field in `*this`, it searches all fields in `obj` + * for a match. The final check ensures the two sets have the same + * cardinality, preventing a subset from comparing equal to a superset. + * + * @note For a more efficient alternative when both objects share the same + * template, use `isEquivalent()`. + * + * @param obj The object to compare against. + * @return `true` if all binary fields match in both objects. + */ bool STObject::operator==(STObject const& obj) const { - // This is not particularly efficient, and only compares data elements - // with binary representations int matches = 0; for (auto const& t1 : v_) { if ((t1->getSType() != STI_NOTPRESENT) && t1->getFName().isBinary()) { - // each present field must have a matching field bool match = false; for (auto const& t2 : obj.v_) { @@ -884,14 +1324,28 @@ STObject::operator==(STObject const& obj) const return fields == matches; } +/** Serialize the object's fields into `s` in canonical field-code order. + * + * Obtains a `fieldCode`-sorted view of present fields via `getSortedFields`, + * then for each field: + * - writes the field-type/ID header (`addFieldID`), + * - writes the field value (`field->add(s)`), + * - appends an end-of-object or end-of-array termination marker for nested + * `STObject` and `STArray` fields. + * + * The sort is mandatory for canonical binary encoding; two logically + * identical objects must produce identical bytes regardless of insertion + * order. + * + * @param s The serializer to append to. + * @param whichFields `WithAllFields` includes signing fields; + * `OmitSigningFields` excludes them (used for signing hash computation). + */ void STObject::add(Serializer& s, WhichFields whichFields) const { - // Depending on whichFields, signing fields are either serialized or - // not. Then fields are added to the Serializer sorted by fieldCode. std::vector const fields{getSortedFields(*this, whichFields)}; - // insert sorted for (STBase const* const field : fields) { // When we serialize an object inside another object, @@ -908,13 +1362,27 @@ STObject::add(Serializer& s, WhichFields whichFields) const } } +/** Build a sorted, filtered view of the fields in `objToSort`. + * + * Collects pointers to all present (non-`STI_NOTPRESENT`) fields whose + * `SField::shouldInclude(bool)` returns `true` for the requested + * `whichFields` mode, then sorts them ascending by `SField::fieldCodeMem` + * (which encodes `(SerializedTypeID << 16) | fieldValue`). + * + * This ordering is the canonical XRPL binary sort order. Both + * serialization (`add`) and duplicate detection (`set(SerialIter)`) rely on + * it. + * + * @param objToSort The object whose fields are to be sorted. + * @param whichFields Controls whether signing-only fields are included. + * @return A vector of non-owning pointers in ascending `fieldCode` order. + */ std::vector STObject::getSortedFields(STObject const& objToSort, WhichFields whichFields) { std::vector sf; sf.reserve(objToSort.getCount()); - // Choose the fields that we need to sort. for (detail::STVar const& elem : objToSort.v_) { STBase const& base = elem.get(); @@ -925,7 +1393,6 @@ STObject::getSortedFields(STObject const& objToSort, WhichFields whichFields) } } - // Sort the fields by fieldCode. std::ranges::sort(sf, [](STBase const* lhs, STBase const* rhs) { return lhs->getFName().fieldCodeMem < rhs->getFName().fieldCodeMem; }); diff --git a/src/libxrpl/protocol/STParsedJSON.cpp b/src/libxrpl/protocol/STParsedJSON.cpp index 52d7b4d63e..1dcbfb7004 100644 --- a/src/libxrpl/protocol/STParsedJSON.cpp +++ b/src/libxrpl/protocol/STParsedJSON.cpp @@ -1,3 +1,22 @@ +/** @file + * JSON-to-protocol-object deserializer for the XRP Ledger. + * + * Converts untyped `Json::Value` trees into the strongly-typed Serialized + * Type (`ST*`) object graph required by transaction processing, ledger + * validation, and RPC handlers. Every RPC call that submits a transaction + * or queries ledger state passes through this code before any validation or + * execution. + * + * All implementation lives in the anonymous `STParsedJSONDetail` namespace. + * The three mutually-recursive entry points are `parseLeaf()` (primitives), + * `parseObject()` (JSON objects / nested ST objects), and `parseArray()` + * (JSON arrays / `STArray` values). Both recursive functions enforce a + * `kMAX_DEPTH = 64` nesting limit to prevent stack exhaustion from crafted + * inputs. + * + * No exceptions escape the public interface; all errors surface as a + * `Json::Value` carrying RPC error codes. + */ #include #include @@ -49,6 +68,19 @@ namespace xrpl { namespace STParsedJSONDetail { + +/** Safely narrow a signed integer to an unsigned type. + * + * Eliminates silent truncation: if the JSON library delivers a negative + * value for an unsigned field, the parse fails with an explicit error + * instead of wrapping to the unsigned maximum. + * + * @tparam U The target unsigned integer type. + * @tparam S The source signed integer type. + * @param value The signed value to convert. + * @return The value cast to `U`. + * @throws std::runtime_error if `value < 0` or `value > U::max`. + */ template constexpr std::enable_if_t && std::is_signed_v, U> toUnsigned(S value) @@ -58,6 +90,18 @@ toUnsigned(S value) return static_cast(value); } +/** Safely narrow an unsigned integer to a smaller unsigned type. + * + * Rejects values that exceed the representable range of the target type, + * preventing silent truncation when the JSON library returns a wider + * unsigned integer than the field's wire type. + * + * @tparam U1 The narrower target unsigned type. + * @tparam U2 The wider source unsigned type. + * @param value The value to convert. + * @return The value cast to `U1`. + * @throws std::runtime_error if `value > U1::max`. + */ template constexpr std::enable_if_t && std::is_unsigned_v, U1> toUnsigned(U2 value) @@ -68,6 +112,16 @@ toUnsigned(U2 value) } // LCOV_EXCL_START + +/** Build a dotted JSON path string for error messages. + * + * Concatenates `object` and `field` with a '.' separator. If `field` is + * empty, returns `object` unchanged. + * + * @param object The parent path component (e.g. `"tx_json"`). + * @param field The child field name, or empty if the error is at `object`. + * @return A path string suitable for embedding in an RPC error message. + */ static inline std::string makeName(std::string const& object, std::string const& field) { @@ -77,6 +131,12 @@ makeName(std::string const& object, std::string const& field) return object + "." + field; } +/** Build an `RpcInvalidParams` error for a field that is not a JSON object. + * + * @param object The parent path component. + * @param field The offending field name. + * @return A `Json::Value` error object ready to return as an RPC response. + */ static inline json::Value notAnObject(std::string const& object, std::string const& field) { @@ -84,24 +144,51 @@ notAnObject(std::string const& object, std::string const& field) RpcInvalidParams, "Field '" + makeName(object, field) + "' is not a JSON object."); } +/** Build an `RpcInvalidParams` error for a path that is not a JSON object. + * + * Overload for callers that have only the object path and no child field. + * + * @param object The full path that was expected to be an object. + * @return A `Json::Value` error object ready to return as an RPC response. + */ static inline json::Value notAnObject(std::string const& object) { return notAnObject(object, ""); } +/** Build an `RpcInvalidParams` error for a path that is not a JSON array. + * + * @param object The full path that was expected to be an array. + * @return A `Json::Value` error object ready to return as an RPC response. + */ static inline json::Value notAnArray(std::string const& object) { return RPC::makeError(RpcInvalidParams, "Field '" + object + "' is not a JSON array."); } +/** Build an `RpcInvalidParams` error for an unrecognized field name. + * + * Triggered when a JSON key does not correspond to any registered `SField`. + * + * @param object The parent path component. + * @param field The unrecognized field name. + * @return A `Json::Value` error object ready to return as an RPC response. + */ static inline json::Value unknownField(std::string const& object, std::string const& field) { return RPC::makeError(RpcInvalidParams, "Field '" + makeName(object, field) + "' is unknown."); } +/** Build an `RpcInvalidParams` error for a numeric value outside the + * target field's representable range. + * + * @param object The parent path component. + * @param field The offending field name. + * @return A `Json::Value` error object ready to return as an RPC response. + */ static inline json::Value outOfRange(std::string const& object, std::string const& field) { @@ -109,6 +196,15 @@ outOfRange(std::string const& object, std::string const& field) RpcInvalidParams, "Field '" + makeName(object, field) + "' is out of range."); } +/** Build an `RpcInvalidParams` error for a JSON value with the wrong type. + * + * Used when the JSON node kind (string, int, object, …) does not match + * what the field's `SerializedTypeID` requires. + * + * @param object The parent path component. + * @param field The offending field name. + * @return A `Json::Value` error object ready to return as an RPC response. + */ static inline json::Value badType(std::string const& object, std::string const& field) { @@ -116,6 +212,15 @@ badType(std::string const& object, std::string const& field) RpcInvalidParams, "Field '" + makeName(object, field) + "' has bad type."); } +/** Build an `RpcInvalidParams` error for a field with unparseable data. + * + * Used when the JSON value has the correct type but its content cannot + * be decoded (e.g. a hex string with invalid characters). + * + * @param object The parent path component. + * @param field The offending field name. + * @return A `Json::Value` error object ready to return as an RPC response. + */ static inline json::Value invalidData(std::string const& object, std::string const& field) { @@ -123,12 +228,25 @@ invalidData(std::string const& object, std::string const& field) RpcInvalidParams, "Field '" + makeName(object, field) + "' has invalid data."); } +/** Build an `RpcInvalidParams` error for a path with unparseable data. + * + * Overload for callers that have only the full path, not a separate field. + * + * @param object The full path that has invalid data. + * @return A `Json::Value` error object ready to return as an RPC response. + */ static inline json::Value invalidData(std::string const& object) { return invalidData(object, ""); } +/** Build an `RpcInvalidParams` error when a JSON array was required. + * + * @param object The parent path component. + * @param field The offending field name. + * @return A `Json::Value` error object ready to return as an RPC response. + */ static inline json::Value arrayExpected(std::string const& object, std::string const& field) { @@ -136,6 +254,12 @@ arrayExpected(std::string const& object, std::string const& field) RpcInvalidParams, "Field '" + makeName(object, field) + "' must be a JSON array."); } +/** Build an `RpcInvalidParams` error when a string value was required. + * + * @param object The parent path component. + * @param field The offending field name. + * @return A `Json::Value` error object ready to return as an RPC response. + */ static inline json::Value stringExpected(std::string const& object, std::string const& field) { @@ -143,12 +267,28 @@ stringExpected(std::string const& object, std::string const& field) RpcInvalidParams, "Field '" + makeName(object, field) + "' must be a string."); } +/** Build an `RpcInvalidParams` error when JSON nesting exceeds `kMAX_DEPTH`. + * + * @param object The full path at which the depth limit was reached. + * @return A `Json::Value` error object ready to return as an RPC response. + */ static inline json::Value tooDeep(std::string const& object) { return RPC::makeError(RpcInvalidParams, "Field '" + object + "' exceeds nesting depth limit."); } +/** Build an `RpcInvalidParams` error for an `STArray` element that is not + * a single-key JSON object. + * + * XRPL canonical convention requires every array element to be a JSON + * object with exactly one key (e.g. `[{"Memo": {...}}]`). Null elements + * and multi-keyed objects are both rejected with this error. + * + * @param object The array's JSON path. + * @param index The zero-based index of the offending element. + * @return A `Json::Value` error object ready to return as an RPC response. + */ static inline json::Value singletonExpected(std::string const& object, unsigned int index) { @@ -158,6 +298,17 @@ singletonExpected(std::string const& object, unsigned int index) "]' must be an object with a single key/object value."); } +/** Build an `RpcInvalidParams` error when parsed object contents do not + * satisfy the `SOTemplate` associated with the enclosing `SField`. + * + * This error is produced when `applyTemplateFromSField()` throws + * `STObject::FieldErr` — typically because a required field is missing, + * an unknown field is present, or a default-valued optional field was + * explicitly supplied. + * + * @param sField The field whose template was violated. + * @return A `Json::Value` error object ready to return as an RPC response. + */ static inline json::Value templateMismatch(SField const& sField) { @@ -166,6 +317,16 @@ templateMismatch(SField const& sField) "Object '" + sField.getName() + "' contents did not meet requirements for that type."); } +/** Build an `RpcInvalidParams` error when an element inside an `STArray` + * does not parse to an `STI_OBJECT`. + * + * Each element of a JSON-encoded `STArray` must have field type + * `STI_OBJECT` after parsing. Any other result triggers this error. + * + * @param item A descriptive string identifying the offending item. + * @param index The zero-based index within the array. + * @return A `Json::Value` error object ready to return as an RPC response. + */ static inline json::Value nonObjectInArray(std::string const& item, json::UInt index) { @@ -176,6 +337,24 @@ nonObjectInArray(std::string const& item, json::UInt index) } // LCOV_EXCL_STOP +/** Parse a JSON number into an unsigned integer `ST*` field. + * + * Accepts string (decimal, via `beast::lexicalCastThrow`), signed int, and + * unsigned int JSON nodes. Both signed and unsigned paths use `toUnsigned` + * to reject out-of-range values rather than silently truncating. + * + * @tparam STResult The target `STInteger` specialization (e.g. `STUInt32`). + * @tparam Integer The intermediate integer type used for string parsing. + * @param field The `SField` describing the target field. + * @param jsonName The parent JSON path, used in error messages. + * @param fieldName The field's JSON key, used in error messages. + * @param name Pointer to the current template sentinel `SField`; unused + * by this function but threaded through the call chain for consistency. + * @param value The JSON node to parse. + * @param error Output parameter set to an RPC error on failure. + * @return The constructed `STVar` on success, or `nullopt` on failure + * (with `error` populated). + */ template static std::optional parseUnsigned( @@ -222,6 +401,31 @@ parseUnsigned( return ret; } +/** Parse a JSON value into a `uint16` `ST*` field, with protocol-name support. + * + * Extends `parseUnsigned` with special handling for `sfTransactionType` and + * `sfLedgerEntryType`: when the JSON value is a non-numeric string (e.g. + * `"Payment"` or `"Offer"`), the name is resolved through `TxFormats` or + * `LedgerFormats` respectively. As a side effect, when parsing at the top + * level with `kSF_GENERIC` as the template sentinel, this function upgrades + * `*name` to `sfTransaction` or `sfLedgerEntry` so that the subsequent + * `applyTemplateFromSField()` call enforces the correct field schema. + * + * @tparam STResult The target `STInteger` specialization (typically + * `STUInt16`). + * @tparam Integer The intermediate numeric type; defaults to `uint16_t`. + * @param field The `SField` describing the target field. + * @param jsonName The parent JSON path, used in error messages. + * @param fieldName The field's JSON key, used in error messages. + * @param name In/out pointer to the template sentinel. May be + * upgraded to `&sfTransaction` or `&sfLedgerEntry` when the field is + * `sfTransactionType` / `sfLedgerEntryType` and the current sentinel is + * `kSF_GENERIC`. + * @param value The JSON node to parse. + * @param error Output parameter set to an RPC error on failure. + * @return The constructed `STVar` on success, or `nullopt` on failure + * (with `error` populated). + */ template static std::optional parseUInt16( @@ -283,6 +487,28 @@ parseUInt16( return ret; } +/** Parse a JSON value into a `uint32` `ST*` field, with permission-name support. + * + * Extends `parseUnsigned` with special handling for `sfPermissionValue`: + * when the JSON value is a string, it is first looked up as a granular + * permission name via `Permission::getInstance().getGranularValue()`, and + * if not found, treated as a transaction-type name resolved through + * `TxFormats` and then converted to a permission-type value. All other + * string-valued `uint32` fields are parsed as decimal integers. + * + * @tparam STResult The target `STInteger` specialization (typically + * `STUInt32`). + * @tparam Integer The intermediate numeric type; defaults to `uint32_t`. + * @param field The `SField` describing the target field. + * @param jsonName The parent JSON path, used in error messages. + * @param fieldName The field's JSON key, used in error messages. + * @param name Pointer to the current template sentinel `SField`; + * unused by this function but threaded through the call chain. + * @param value The JSON node to parse. + * @param error Output parameter set to an RPC error on failure. + * @return The constructed `STVar` on success, or `nullopt` on failure + * (with `error` populated). + */ template static std::optional parseUInt32( @@ -337,8 +563,44 @@ parseUInt32( return ret; } -// This function is used by parseObject to parse any JSON type that doesn't -// recurse. Everything represented here is a leaf-type. +/** Parse a single non-container JSON value into a typed `STVar`. + * + * Dispatches on `field.fieldType` across the full `SerializedTypeID` enum, + * covering every primitive wire type: integer widths (`STI_UINT8` through + * `STI_UINT256`, `STI_INT32`), variable-length blobs (`STI_VL`), amounts + * (`STI_AMOUNT`, `STI_NUMBER`), accounts (`STI_ACCOUNT`), asset descriptors + * (`STI_ISSUE`, `STI_CURRENCY`), paths (`STI_PATHSET`), and cross-chain + * bridge descriptors (`STI_XCHAIN_BRIDGE`). + * + * Type-specific protocol knowledge embedded here: + * - `STI_UINT8` (`sfTransactionResult`): accepts TER result-code strings + * (e.g. `"tesSUCCESS"`) and validates that the numeric value fits in 8 + * bits. + * - `STI_UINT16`: delegates to `parseUInt16`, which resolves + * `sfTransactionType` / `sfLedgerEntryType` human names and may upgrade + * `*name` to enforce the matching `SOTemplate`. + * - `STI_UINT32`: delegates to `parseUInt32`, which resolves + * `sfPermissionValue` human names via the `Permission` registry. + * - `STI_UINT64`: uses `std::from_chars` with base 16 by default, or base + * 10 when `field.shouldMeta(SField::kSMD_BASE_TEN)` is set. + * - `STI_PATHSET`: handles both IOU paths (`currency`, `issuer`) and MPT + * paths (`mpt_issuance_id`). Rejects simultaneous presence of both asset + * kinds, and validates that any explicit `issuer` matches the issuer + * embedded in the MPTID. + * + * @param jsonName The parent JSON path, used in error messages. + * @param fieldName The field's JSON key used both for registry lookup + * and in error messages. + * @param name In/out pointer to the template sentinel; may be + * modified by `parseUInt16` for type-upgrade logic. + * @param value The JSON node to parse. + * @param error Output parameter set to an RPC error on failure. + * @return The constructed `STVar` on success, or `nullopt` on failure + * (with `error` populated). + * @note `kSF_INVALID` lookup is checked by `parseObject` before calling + * this function; the guard inside `parseLeaf` is dead code in normal + * operation (guarded by `LCOV_EXCL` markers). + */ static std::optional parseLeaf( std::string const& jsonName, @@ -946,6 +1208,12 @@ parseLeaf( return ret; } +/** Maximum JSON nesting depth accepted by `parseObject` and `parseArray`. + * + * Any structure deeper than 64 levels is rejected as `too_deep`. This + * is the JSON-layer counterpart to the binary `STArray` depth cap (10), + * and prevents runaway recursion from crafted inputs. + */ static int const kMAX_DEPTH = 64; // Forward declaration since parseObject() and parseArray() call each other. @@ -957,6 +1225,31 @@ parseArray( int depth, json::Value& error); +/** Parse a JSON object into an `STObject`, recursing as needed. + * + * Iterates over every member of `json`, looks each key up in the global + * `SField` registry, and dispatches: + * - Object-style containers (`STI_OBJECT`, `STI_TRANSACTION`, + * `STI_LEDGERENTRY`, `STI_VALIDATION`) → recursion into `parseObject`. + * - Array containers (`STI_ARRAY`) → recursion into `parseArray`. + * - Everything else → `parseLeaf`. + * + * After all fields are parsed, calls `data.applyTemplateFromSField(inName)` + * to retroactively enforce the `SOTemplate` for the enclosing field. A + * schema mismatch throws `STObject::FieldErr`, which is caught and + * translated into a `template_mismatch` RPC error. + * + * @param jsonName The JSON path to this object, prepended to child paths + * in error messages. + * @param json The JSON object node to parse. + * @param inName The `SField` whose `SOTemplate` governs accepted fields. + * Pass `kSF_GENERIC` at the top level to accept any known field. + * @param depth Current recursion depth; the call is rejected with + * `too_deep` when `depth > kMAX_DEPTH`. + * @param error Output parameter set to an RPC error on failure. + * @return The populated `STObject` on success, or `nullopt` on failure + * (with `error` populated). + */ static std::optional parseObject( std::string const& jsonName, @@ -1071,6 +1364,28 @@ parseObject( return std::nullopt; } +/** Parse a JSON array into an `STArray` wrapped in an `STVar`. + * + * Enforces the XRPL canonical convention that every element of a + * JSON-encoded `STArray` is a single-key JSON object, e.g. + * `[{"Memo": {...}}, {"Memo": {...}}]`. Null or multi-keyed elements are + * rejected with `singleton_expected`. Each element's inner object is + * parsed via `parseObject`, and the resulting `STObject` must have field + * type `STI_OBJECT`; any other type produces `non_object_in_array`. + * + * On `parseObject` failure, the error message is enriched with the full + * path to the failing element before being returned to the caller. + * + * @param jsonName The JSON path to this array, prepended to element paths + * in error messages. + * @param json The JSON array node to parse. + * @param inName The `SField` that labels the array being built. + * @param depth Current recursion depth; the call is rejected with + * `too_deep` when `depth > kMAX_DEPTH`. + * @param error Output parameter set to an RPC error on failure. + * @return An `STVar` holding the completed `STArray` on success, or + * `nullopt` on failure (with `error` populated). + */ static std::optional parseArray( std::string const& jsonName, diff --git a/src/libxrpl/protocol/STPathSet.cpp b/src/libxrpl/protocol/STPathSet.cpp index 9c0cc20e96..79f5ecf48a 100644 --- a/src/libxrpl/protocol/STPathSet.cpp +++ b/src/libxrpl/protocol/STPathSet.cpp @@ -1,3 +1,14 @@ +/** + * @file STPathSet.cpp + * @brief Binary serialization, deserialization, hashing, cycle-detection, and + * JSON rendering for STPathSet, STPath, and STPathElement. + * + * Cross-currency payments carry candidate routes in the `Paths` field of a + * Payment transaction as an `STPathSet`. Each `STPath` is an ordered sequence + * of `STPathElement` hop descriptors that guide the payment engine through + * trust-line rippling nodes and order-book offer nodes. + */ + #include #include @@ -20,6 +31,24 @@ namespace xrpl { +/** + * Compute a non-cryptographic hash over a path element's account, asset, and + * issuer fields for fast equality short-circuiting. + * + * Uses the pattern `hash = hash * prime ^ byte` with distinct small primes + * (257, 509, 911) per field to reduce collisions, then XORs the three + * sub-hashes together. The result is stored eagerly in `hash_value_` at + * construction time so that `operator==` can reject unequal elements without + * a full field comparison. + * + * @note The hash is derived from the actual `PathAsset` variant contents + * (via `getPathAsset().visit()`), not from the `type_` bitmask, because + * `Pathfinder::addLink()` may set `TypeAccount` while the asset slot still + * holds a currency or MPT value. + * @note This hash need not be cryptographically secure; speed dominates. + * @param element The element to hash. + * @return Non-cryptographic hash combining account, asset, and issuer bytes. + */ std::size_t STPathElement::getHash(STPathElement const& element) { @@ -27,16 +56,12 @@ STPathElement::getHash(STPathElement const& element) std::size_t hashCurrency = 2654435761; std::size_t hashIssuer = 2654435761; - // NIKB NOTE: This doesn't have to be a secure hash as speed is more - // important. We don't even really need to fully hash the whole - // base_uint here, as a few bytes would do for our use. - for (auto const x : element.getAccountID()) hashAccount += (hashAccount * 257) ^ x; - // Check pathAsset type instead of element's type_ - // In some cases type_ might be account but the asset - // is still set to either MPT or currency (see Pathfinder::addLink()) + // Dispatch on actual PathAsset contents rather than type_ because type_ + // may carry TypeAccount while the asset slot is still populated + // (e.g. from Pathfinder::addLink()). element.getPathAsset().visit( [&](MPTID const& mpt) { hashCurrency += beast::Uhash<>{}(mpt); }, [&](Currency const& currency) { @@ -50,6 +75,27 @@ STPathElement::getHash(STPathElement const& element) return (hashAccount ^ hashCurrency ^ hashIssuer); } +/** + * Deserialize an STPathSet from a binary stream. + * + * Parses the wire format produced by `add()`: a sequence of one-byte type + * tags followed by the optional account (20 bytes), currency (20 bytes), + * MPT ID (24 bytes), and/or issuer (20 bytes) payloads for each hop. + * Paths are delimited by `TypeBoundary` (0xFF) bytes and the set is + * terminated by a `TypeNone` (0x00) byte. + * + * @param sit Binary cursor positioned at the first type byte of the + * path set; advanced past the terminating `TypeNone` on return. + * @param name SField that names this path set field in the enclosing object. + * @throws std::runtime_error with message "empty path" if a `TypeBoundary` + * or `TypeNone` marker is encountered before any hop has been accumulated, + * indicating corrupt or malformed input. + * @throws std::runtime_error with message "bad path element" if a type byte + * contains bits outside `TypeAll` (0x71), indicating an unknown element + * kind. + * @note Setting both `TypeCurrency` and `TypeMpt` in a single hop's type byte + * is a protocol invariant violation; an assertion fires in debug builds. + */ STPathSet::STPathSet(SerialIter& sit, SField const& name) : STBase(name) { std::vector path; @@ -106,21 +152,61 @@ STPathSet::STPathSet(SerialIter& sit, SField const& name) : STBase(name) } } +/** + * Copy-construct this STPathSet into an external buffer via placement-new. + * + * Supports the `detail::STVar` small-object storage used by `STObject` to + * keep serialized fields compact without per-field heap allocation. + * + * @param n Size in bytes of the buffer at `buf`; must be at least + * `sizeof(STPathSet)`. + * @param buf Aligned storage into which the copy is placement-constructed. + * @return Pointer to the newly constructed object within `buf`. + */ STBase* STPathSet::copy(std::size_t n, void* buf) const { return emplace(n, buf, *this); } +/** + * Move-construct this STPathSet into an external buffer via placement-new. + * + * Supports the `detail::STVar` small-object storage used by `STObject`. + * + * @param n Size in bytes of the buffer at `buf`; must be at least + * `sizeof(STPathSet)`. + * @param buf Aligned storage into which the object is placement-constructed. + * @return Pointer to the newly constructed object within `buf`. + */ STBase* STPathSet::move(std::size_t n, void* buf) { return emplace(n, buf, std::move(*this)); } +/** + * Append `base` extended by `tail` to the set, unless an identical path is + * already present. + * + * Used by the pathfinder to build candidate paths incrementally. The candidate + * is formed by pushing `base` into `value_` and then appending `tail`. The + * existing paths are then scanned in reverse order — newest-first — to detect + * a duplicate; if one is found the candidate is popped and `false` is + * returned. Reverse iteration is a micro-optimisation because duplicates are + * most likely to be the most recently added paths. + * + * Deduplication at insertion time bounds the set size during pathfinding, + * preventing the payment engine from simulating redundant paths. + * + * @param base Prefix path to extend. + * @param tail Single hop to append to `base` before adding to this set. + * @return `true` if the path was added; `false` if it was a duplicate and + * discarded. + */ bool STPathSet::assembleAdd(STPath const& base, STPathElement const& tail) -{ // assemble base+tail and add it to the set if it's not a duplicate +{ value_.push_back(base); std::vector::reverse_iterator it = value_.rbegin(); @@ -139,6 +225,12 @@ STPathSet::assembleAdd(STPath const& base, STPathElement const& tail) return true; } +/** + * Return `true` if `t` is an STPathSet with identical path contents. + * + * @param t Object to compare; returns `false` immediately if it is not an + * STPathSet. + */ bool STPathSet::isEquivalent(STBase const& t) const { @@ -146,12 +238,31 @@ STPathSet::isEquivalent(STBase const& t) const return (v != nullptr) && (value_ == v->value_); } +/** + * Return `true` when the path set contains no paths (default/empty state). + * + * Used by the serialization layer to elide the field from a transaction when + * no paths have been supplied. + */ bool STPathSet::isDefault() const { return value_.empty(); } +/** + * Return `true` if the path already contains a hop with the given account, + * asset, and issuer combination. + * + * Used by the pathfinder for cycle detection: before extending a path with a + * new hop, calling `hasSeen()` prevents the payment engine from being handed + * a route that would loop back through a node it has already visited. + * + * @param account AccountID of the hop to look for. + * @param asset PathAsset (Currency or MPTID) of the hop. + * @param issuer Issuer AccountID of the hop. + * @return `true` if any element in the path matches all three fields exactly. + */ bool STPath::hasSeen(AccountID const& account, PathAsset const& asset, AccountID const& issuer) const { @@ -164,6 +275,21 @@ STPath::hasSeen(AccountID const& account, PathAsset const& asset, AccountID cons return false; } +/** + * Serialize the path to a JSON array of hop objects. + * + * Each hop object always includes a numeric `type` field so consumers can + * identify the hop kind without re-parsing optional keys. Additional fields + * are present only when their corresponding type bit is set: + * - `TypeAccount` → `"account"` (base58-encoded AccountID) + * - `TypeCurrency` → `"currency"` (ISO currency string) + * - `TypeMpt` → `"mpt_issuance_id"` (hex-encoded MPTID) + * - `TypeIssuer` → `"issuer"` (base58-encoded AccountID) + * + * @note `TypeCurrency` and `TypeMpt` are mutually exclusive per protocol; + * an assertion fires in debug builds if both bits are set. + * @return JSON array containing one object per hop. + */ json::Value STPath::getJson(JsonOptions) const { @@ -198,6 +324,16 @@ STPath::getJson(JsonOptions) const return ret; } +/** + * Serialize the path set to a JSON array of path arrays. + * + * Delegates to `STPath::getJson()` for each path, producing a nested + * array structure: `[[hop, ...], [hop, ...], ...]`. + * + * @param options JSON rendering options forwarded to each path. + * @return JSON array where each element is the JSON representation of one + * candidate payment path. + */ json::Value STPathSet::getJson(JsonOptions options) const { @@ -208,12 +344,35 @@ STPathSet::getJson(JsonOptions options) const return ret; } +/** + * Return the serialized type identifier for this field (`STI_PATHSET`). + * + * Used by the generic serialization infrastructure to select the correct + * codec when encoding or decoding an `STPathSet` field. + */ SerializedTypeID STPathSet::getSType() const { return STI_PATHSET; } +/** + * Serialize the path set to its canonical binary wire format. + * + * Emits each path as a sequence of (type-byte, payload…) hop records. + * Consecutive paths are separated by a `TypeBoundary` (0xFF) byte, and the + * entire set is terminated by a `TypeNone` (0x00) byte. This is the inverse + * of the deserialization constructor. + * + * Wire layout per hop: + * - 1-byte type bitmask (`TypeAccount | TypeCurrency | TypeIssuer | TypeMpt`) + * - 20-byte AccountID, if `TypeAccount` is set + * - 24-byte MPTID, if `TypeMpt` is set + * - 20-byte Currency, if `TypeCurrency` is set + * - 20-byte issuer AccountID, if `TypeIssuer` is set + * + * @param s Serializer accumulator to which the encoded bytes are appended. + */ void STPathSet::add(Serializer& s) const { diff --git a/src/libxrpl/protocol/STTakesAsset.cpp b/src/libxrpl/protocol/STTakesAsset.cpp index 1fe01b1e84..0383c69bdd 100644 --- a/src/libxrpl/protocol/STTakesAsset.cpp +++ b/src/libxrpl/protocol/STTakesAsset.cpp @@ -1,3 +1,14 @@ +/** @file + * Implements `associateAsset()`, which injects asset-type context into + * `sMD_NeedsAsset`-flagged fields of a ledger entry so that `STNumber` + * can round to the correct asset precision before serialization. + * + * @note `STTakesAsset.h` must be included before `STLedgerEntry.h`. + * The header contains only a forward declaration of `STLedgerEntry` + * (to avoid a circular dependency), while this translation unit needs + * the full definition to call `getIndex()`, `getStyle()`, and + * `makeFieldAbsent()`. + */ #include #include @@ -12,7 +23,8 @@ namespace xrpl { void associateAsset(SLE& sle, Asset const& asset) { - // Iterating by offset is the only way to get non-const references + // Offset-based iteration is the only path in STObject that yields + // mutable STBase& references; iterator-based traversal is const-only. for (int i = 0; i < sle.getCount(); ++i) { STBase& entry = sle.getIndex(i); @@ -20,27 +32,29 @@ associateAsset(SLE& sle, Asset const& asset) if (field.shouldMeta(SField::kSMD_NEEDS_ASSET)) { auto const type = entry.getSType(); - // If the field is not set or present, skip it. if (type == STI_NOTPRESENT) continue; - // If the type doesn't downcast, then the flag shouldn't be on the - // SField + // A failed downcast means kSMD_NEEDS_ASSET is set on a field + // whose type does not derive from STTakesAsset — a programming + // error in the field schema; downcast() throws to catch it early. auto& ta = entry.downcast(); auto const style = sle.getStyle(ta.getFName()); XRPL_ASSERT_PARTS( style != SoeInvalid, "xrpl::associateAsset", "valid template element style"); + // soeDEFAULT fields are removed from the SLE when they equal + // their default value, so a present soeDEFAULT field must be + // non-default before association runs. XRPL_ASSERT_PARTS( style != SoeDefault || !ta.isDefault(), "xrpl::associateAsset", "non-default value"); ta.associateAsset(asset); - // associateAsset in derived classes may change the underlying - // value, but it won't know anything about how the value relates to - // the SLE. If the template element is soeDEFAULT, and the value - // changed to the default value, remove the field. + // Precision rounding inside associateAsset() may reduce the + // value to zero. A soeDEFAULT field at its default must not be + // persisted in the ledger, so remove it if that happened. if (style == SoeDefault && ta.isDefault()) sle.makeFieldAbsent(field); } diff --git a/src/libxrpl/protocol/STTx.cpp b/src/libxrpl/protocol/STTx.cpp index 06731e9072..59ecabf210 100644 --- a/src/libxrpl/protocol/STTx.cpp +++ b/src/libxrpl/protocol/STTx.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements `STTx` — the canonical in-memory representation of an XRP + * Ledger transaction. + * + * Contains the three construction paths (wire deserialization, object + * promotion, and programmatic assembly), all four signing modes (single, + * multi, batch single, batch multi), counterparty signature support, + * local pre-submission validation (`passesLocalChecks`), and SQL + * persistence helpers for the `Transactions` database table. + */ #include #include @@ -52,6 +62,12 @@ namespace xrpl { +/** Look up the registered format for a transaction type. + * + * @param type The transaction type to look up. + * @return A pointer to the `KnownFormat` entry for `type`. + * @throws std::runtime_error if `type` is not registered in `TxFormats`. + */ static auto getTxFormat(TxType type) { @@ -67,6 +83,19 @@ getTxFormat(TxType type) return format; } +/** Promote an already-parsed `STObject` into a fully validated `STTx`. + * + * Used when a transaction has been reconstructed from JSON or another + * in-memory representation. No wire-size checks are performed because + * the object is already in memory; `applyTemplate` enforces field + * conformance against the registered `SOTemplate` for the transaction + * type. The transaction ID is computed and cached on exit. + * + * @param object An rvalue `STObject` that must already contain + * `sfTransactionType`. The object is consumed by the move. + * @throws std::runtime_error if the transaction type is not registered + * or if `applyTemplate` rejects the field layout. + */ STTx::STTx(STObject&& object) : STObject(std::move(object)) { tx_type_ = safeCast(getFieldU16(sfTransactionType)); @@ -74,6 +103,21 @@ STTx::STTx(STObject&& object) : STObject(std::move(object)) tid_ = getHash(HashPrefix::TransactionId); } +/** Deserialize a transaction from a wire-format byte stream. + * + * This is the hottest construction path: every inbound transaction and + * every transaction loaded from disk passes through it. Size bounds are + * checked before field parsing to reject pathological input early. + * After parsing, an object-terminator byte found at the top level + * (`set()` returning `true`) indicates a structurally invalid stream. + * + * @param sit A `SerialIter` positioned at the first byte of the + * serialized transaction. The iterator is advanced by the call. + * @throws std::runtime_error if the byte count is outside + * [`kTX_MIN_SIZE_BYTES`, `kTX_MAX_SIZE_BYTES`], if an object + * terminator is encountered at the top level, if the transaction + * type is unregistered, or if field layout fails `applyTemplate`. + */ STTx::STTx(SerialIter& sit) : STObject(sfTransaction) { int const length = sit.getBytesLeft(); @@ -90,6 +134,24 @@ STTx::STTx(SerialIter& sit) : STObject(sfTransaction) tid_ = getHash(HashPrefix::TransactionId); } +/** Programmatically construct a transaction of the given type. + * + * Installs the `SOTemplate` for `type` first so the object has the + * correct field scaffolding, then invokes `assembler` to populate + * fields. After the assembler returns, `sfTransactionType` is read + * back; if the assembler mutated it, `logicError` fires rather than + * `std::runtime_error` because that is a programming mistake, not a + * data error. + * + * @param type The transaction type, which must be registered in + * `TxFormats`. + * @param assembler A callable invoked with a mutable reference to the + * newly constructed `STObject`. It must not change + * `sfTransactionType`. + * @throws std::runtime_error if `type` is not registered. + * @note Fires `logicError` (not an exception) if `assembler` mutates + * `sfTransactionType`. + */ STTx::STTx(TxType type, std::function assembler) : STObject(sfTransaction) { auto format = getTxFormat(type); @@ -137,6 +199,17 @@ STTx::getFullText() const return ret; } +/** Collect every `AccountID` referenced by this transaction. + * + * Walks the top-level fields of the transaction and adds each + * non-default `STAccount` value and each non-XRP `STAmount` issuer + * to the result set. Used to determine which accounts are touched by + * a transaction for indexing and fee purposes. + * + * @return A flat, sorted set of all referenced account IDs. + * @note Only top-level fields are examined; nested objects (e.g., + * inner multi-sign signers) are not descended into. + */ boost::container::flat_set STTx::getMentionedAccounts() const { @@ -161,6 +234,16 @@ STTx::getMentionedAccounts() const return list; } +/** Produce the canonical single-sign payload for a transaction. + * + * Prepends `HashPrefix::TxSign` to the transaction serialized without + * its signature fields (`addWithoutSigningFields`). This is the exact + * byte sequence that is signed and verified for single-signed + * transactions. + * + * @param that The transaction to serialize. + * @return The signing payload as a byte blob. + */ static Blob getSigningData(STTx const& that) { @@ -176,6 +259,13 @@ STTx::getSigningHash() const return STObject::getSigningHash(HashPrefix::TxSign); } +/** Extract the raw `sfTxnSignature` bytes from an object. + * + * @param sigObject The object to read the signature field from; + * typically `*this` or a multi-sign signer sub-object. + * @return The signature bytes, or an empty `Blob` if the field is + * absent or an exception is thrown during access. + */ Blob STTx::getSignature(STObject const& sigObject) { @@ -189,6 +279,18 @@ STTx::getSignature(STObject const& sigObject) } } +/** Return a unified sequence proxy for this transaction. + * + * When `sfSequence` is non-zero the transaction uses classic sequence + * ordering. When `sfSequence` is zero and `sfTicketSequence` is + * present, the transaction consumes a ticket. In either case the + * returned `SeqProxy` comparison operators guarantee that + * sequence-type values sort before ticket-type values, preserving the + * correct processing order between ticket-creating and + * ticket-consuming transactions. + * + * @return A `SeqProxy` of type `Sequence` or `Ticket` as appropriate. + */ SeqProxy STTx::getSeqProxy() const { @@ -212,20 +314,44 @@ STTx::getSeqValue() const return getSeqProxy().value(); } +/** Resolve the account whose balance pays the transaction fee. + * + * When `sfDelegate` is present the delegate account bears the fee; + * otherwise the transaction's `sfAccount` pays. This method performs + * no authorization — the delegate's right to act on behalf of the + * account owner is enforced in `Transactor::checkPermission`, and the + * cryptographic validity of the delegate's signature is verified in + * `Transactor::checkSign`. + * + * @return The `AccountID` of the fee-paying account. + */ AccountID STTx::getFeePayer() const { - // If sfDelegate is present, the delegate account is the payer - // note: if a delegate is specified, its authorization to act on behalf of the account is - // enforced in `Transactor::checkPermission` - // cryptographic signature validity is checked separately (e.g., in `Transactor::checkSign`) if (isFieldPresent(sfDelegate)) return getAccountID(sfDelegate); - // Default payer return getAccountID(sfAccount); } +/** Sign this transaction with the given key pair. + * + * Computes the single-sign payload via `getSigningData`, signs it, + * and writes the resulting signature into `sfTxnSignature`. If + * `signatureTarget` is supplied the signature is written into that + * field's nested object instead of the top-level transaction — used + * for counterparty signing (e.g., `LoanSet`). The cached transaction + * ID is recomputed after the signature is stored because the content + * has changed. + * + * @param publicKey The signer's public key, written to + * `sfSigningPubKey`. + * @param secretKey The corresponding secret key used to produce + * the signature. + * @param signatureTarget If set, names the sub-object field (e.g., + * `sfCounterpartySignature`) into which the signature is written + * instead of the transaction root. + */ void STTx::sign( PublicKey const& publicKey, @@ -248,6 +374,18 @@ STTx::sign( tid_ = getHash(HashPrefix::TransactionId); } +/** Dispatch a signature check to the single- or multi-sign verifier. + * + * Inspects `sfSigningPubKey` in `sigObject` to determine the signing + * mode: an empty key indicates multi-sign, a non-empty key indicates + * single-sign. + * + * @param rules The current ledger rules. + * @param sigObject The object that carries the signature fields; + * usually `*this` but may be a counterparty sub-object. + * @return An empty `Expected` on success, or an `Expected` holding + * an error string describing the failure. + */ Expected STTx::checkSign(Rules const& rules, STObject const& sigObject) const { @@ -267,6 +405,18 @@ STTx::checkSign(Rules const& rules, STObject const& sigObject) const } } +/** Verify the primary signature and, if present, the counterparty signature. + * + * Checks the primary (single- or multi-) signature on the transaction. + * If `sfCounterpartySignature` is present, its signature is verified + * with the same dispatch; errors from the counterparty check are + * prefixed with `"Counterparty: "` so callers can distinguish which + * party failed. + * + * @param rules The current ledger rules. + * @return An empty `Expected` on success, or an `Expected` holding an + * error description. + */ Expected STTx::checkSign(Rules const& rules) const { @@ -282,6 +432,21 @@ STTx::checkSign(Rules const& rules) const return {}; } +/** Verify all batch-signing signatures on a `ttBATCH` transaction. + * + * Iterates over `sfBatchSigners`, dispatching each entry to + * `checkBatchSingleSign` or `checkBatchMultiSign` based on whether + * `sfSigningPubKey` is empty. The signed payload for batch signers is + * the output of `serializeBatch()` — a batch-specific hash prefix, + * the outer transaction flags, and the IDs of the inner transactions + * — rather than the standard transaction signing payload. + * + * @param rules The current ledger rules. + * @return An empty `Expected` on success, or an `Expected` holding an + * error description. + * @note Asserts (and returns an error) if called on a non-batch + * transaction. + */ Expected STTx::checkBatchSign(Rules const& rules) const { @@ -349,6 +514,15 @@ STTx::getJson(JsonOptions options, bool binary) const return ret; } +/** Return the static SQL header for inserting a transaction row. + * + * The returned string is the `INSERT OR REPLACE INTO Transactions` + * prefix used by `getMetaSQL`. Callers append one or more + * parenthesized value tuples produced by `getMetaSQL`. + * + * @return A reference to a process-lifetime static string containing + * the SQL header. + */ std::string const& STTx::getMetaSQLInsertReplaceHeader() { @@ -361,6 +535,18 @@ STTx::getMetaSQLInsertReplaceHeader() return kSQL; } +/** Produce a SQL value tuple for this validated transaction. + * + * Serializes the transaction, then delegates to the full overload with + * `TxnSql::Validated` status. + * + * @param inLedger The ledger sequence number that contains this + * transaction. + * @param escapedMetaData Pre-escaped binary metadata string for the + * `TxnMeta` column. + * @return A SQL value tuple string suitable for appending to + * `getMetaSQLInsertReplaceHeader()`. + */ std::string STTx::getMetaSQL(std::uint32_t inLedger, std::string const& escapedMetaData) const { @@ -370,6 +556,22 @@ STTx::getMetaSQL(std::uint32_t inLedger, std::string const& escapedMetaData) con } // VFALCO This could be a free function elsewhere +/** Produce a SQL value tuple with explicit status and raw transaction bytes. + * + * Formats a parenthesized row for the `Transactions` table containing + * the transaction ID, type name, source account (Base58), sequence + * number, ledger sequence, a single-character status code, the raw + * serialized transaction blob, and pre-escaped metadata. + * + * @param rawTxn The serialized transaction bytes (by value; + * the caller may pass the result of `STObject::getSerializer()`). + * @param inLedger The ledger sequence number containing this + * transaction. + * @param status The persistence status code for the + * `Status` column. + * @param escapedMetaData Pre-escaped binary metadata for `TxnMeta`. + * @return A SQL value tuple string. + */ std::string STTx::getMetaSQL( Serializer rawTxn, @@ -389,6 +591,20 @@ STTx::getMetaSQL( safeCast(status) % rTxn % escapedMetaData); } +/** Verify a single signature against a pre-built signing payload. + * + * Rejects the signature immediately if `sfSigners` is also present, + * which would mean the object is signed two ways simultaneously. + * Validates the public key type before attempting `verify()`; + * any exception during field access or verification is treated as an + * invalid signature. + * + * @param sigObject The object carrying `sfSigningPubKey` and + * `sfTxnSignature`. + * @param data The exact bytes that were signed (hash-prefixed + * payload). + * @return An empty `Expected` on success, or an error string. + */ static Expected singleSignHelper(STObject const& sigObject, Slice const& data) { @@ -419,6 +635,15 @@ singleSignHelper(STObject const& sigObject, Slice const& data) return {}; } +/** Verify a single-sign signature on this transaction. + * + * Builds the signing payload from the transaction content and + * delegates to `singleSignHelper`. + * + * @param sigObject The object containing the signature fields. Usually + * `*this`, but may be a counterparty sub-object. + * @return An empty `Expected` on success, or an error string. + */ Expected STTx::checkSingleSign(STObject const& sigObject) const { @@ -426,6 +651,16 @@ STTx::checkSingleSign(STObject const& sigObject) const return singleSignHelper(sigObject, makeSlice(data)); } +/** Verify a single-sign batch signature for one entry in `sfBatchSigners`. + * + * The signing payload is the batch-specific serialization produced by + * `serializeBatch` (hash prefix, outer flags, inner transaction IDs) + * rather than the standard transaction payload. This ties the + * signature to a specific set of inner transactions. + * + * @param batchSigner The signer sub-object from `sfBatchSigners`. + * @return An empty `Expected` on success, or an error string. + */ Expected STTx::checkBatchSingleSign(STObject const& batchSigner) const { @@ -434,6 +669,30 @@ STTx::checkBatchSingleSign(STObject const& batchSigner) const return singleSignHelper(batchSigner, msg.slice()); } +/** Core multi-sign verification loop shared by regular and batch paths. + * + * Enforces the multi-sign invariants: + * - `sfSigners` must be present and `sfTxnSignature` must be absent + * (prevents dual-signing). + * - Signer count must be in [`kMIN_MULTI_SIGNERS`, `kMAX_MULTI_SIGNERS`]. + * - Signers must appear in strictly ascending `AccountID` order (no + * duplicates). + * - The transaction owner (`txnAccountID`) may not appear as one of + * their own multi-signers; pass `std::nullopt` to skip this check + * (used for batch signing where the owner constraint differs). + * - Each signer's verification message is built per-signer via + * `makeMsg(accountID)`, which appends the signer's `AccountID` to + * a shared prefix — preventing cross-account signature replay. + * + * @param sigObject The object containing `sfSigners`. + * @param txnAccountID The account that submitted the transaction, or + * `std::nullopt` to bypass the self-multisign check. + * @param makeMsg Callable that constructs the per-signer signing + * payload given the signer's `AccountID`. + * @param rules The current ledger rules. + * @return An empty `Expected` on success, or an error string + * identifying the failing signer. + */ Expected multiSignHelper( STObject const& sigObject, @@ -511,6 +770,18 @@ multiSignHelper( return {}; } +/** Verify a multi-sign batch signature for one entry in `sfBatchSigners`. + * + * Pre-builds the shared batch payload once via `serializeBatch`, then + * per-signer appends the signer's `AccountID` via + * `finishMultiSigningData`. The owner-cannot-multisign constraint is + * skipped (`std::nullopt`) because batch signers are authorizing the + * set of inner transactions, not the outer account's own operations. + * + * @param batchSigner The signer sub-object from `sfBatchSigners`. + * @param rules The current ledger rules. + * @return An empty `Expected` on success, or an error string. + */ Expected STTx::checkBatchMultiSign(STObject const& batchSigner, Rules const& rules) const { @@ -530,6 +801,22 @@ STTx::checkBatchMultiSign(STObject const& batchSigner, Rules const& rules) const rules); } +/** Verify multi-sign signatures on this transaction or a sub-object. + * + * Pre-builds the shared signing prefix once via + * `startMultiSigningData`, then per-signer appends the signer's + * `AccountID` via `finishMultiSigningData`. When `sigObject` is + * `*this`, the transaction owner's `AccountID` is passed to + * `multiSignHelper` to enforce the constraint that the account may not + * multisign for themselves; when `sigObject` is a counterparty + * sub-object, `std::nullopt` is passed to skip that check. + * + * @param rules The current ledger rules. + * @param sigObject The object containing `sfSigners`. Pass `*this` + * for the primary multi-sign check, or a sub-object for a + * counterparty check. + * @return An empty `Expected` on success, or an error string. + */ Expected STTx::checkMultiSign(Rules const& rules, STObject const& sigObject) const { @@ -553,20 +840,18 @@ STTx::checkMultiSign(Rules const& rules, STObject const& sigObject) const rules); } -/** - * @brief Retrieves a batch of transaction IDs from the STTx. +/** Return the cached IDs of the inner transactions in a batch. * - * This function returns a vector of transaction IDs by extracting them from - * the field array `sfRawTransactions` within the STTx. If the batch - * transaction IDs have already been computed and cached in `batchTxnIds_`, - * it returns the cached vector. Otherwise, it computes the transaction IDs, - * caches them, and then returns the vector. + * On the first call the IDs are computed by hashing each entry in + * `sfRawTransactions` and stored in `batchTxnIds_`. Subsequent calls + * return the cached vector directly. An assertion on every call + * verifies that the cache size still matches `sfRawTransactions`, + * enforcing the invariant that inner transactions may not be modified + * after the IDs have been observed. * - * @return A vector of `uint256` containing the batch transaction IDs. - * - * @note The function asserts that the `sfRawTransactions` field array is not - * empty and that the size of the computed batch transaction IDs matches the - * size of the `sfRawTransactions` field array. + * @return A reference to the cached vector of inner transaction IDs. + * @note Must only be called on a `ttBATCH` transaction with a + * non-empty `sfRawTransactions` array. */ std::vector const& STTx::getBatchTransactionIDs() const @@ -593,6 +878,22 @@ STTx::getBatchTransactionIDs() const //------------------------------------------------------------------------------ +/** Validate the `sfMemos` field of a transaction object. + * + * Enforces a 1024-byte total serialized memo size and validates that + * each `MemoType` and `MemoFormat` field decodes from hex and contains + * only RFC 3986 URL-safe characters. `MemoData` is validated for + * hex encoding but its decoded content is unrestricted. + * + * The character whitelist is a `constexpr`-initialized 256-element + * lookup table, giving O(1) per-character validation without branching + * at runtime. + * + * @param st The transaction object to inspect. + * @param reason Populated with a human-readable failure message when + * the function returns `false`. + * @return `true` if memos are absent or valid; `false` otherwise. + */ static bool isMemoOkay(STObject const& st, std::string& reason) { @@ -683,7 +984,14 @@ isMemoOkay(STObject const& st, std::string& reason) return true; } -// Ensure all account fields are 160-bits +/** Verify that no `STAccount` field holds a default (zero) value. + * + * A zero account ID represents an uninitialized field. Any transaction + * that contains one must be rejected before submission. + * + * @param st The transaction object to inspect. + * @return `true` if all account fields carry non-zero values. + */ static bool isAccountFieldOkay(STObject const& st) { @@ -697,6 +1005,17 @@ isAccountFieldOkay(STObject const& st) return true; } +/** Detect an `MPTIssue` in a field that does not declare MPT support. + * + * Consults the `SOTemplate` for each field's `soeMPTSupported` flag. + * Returns `true` if any `STAmount` or `STIssue` field holds an + * `MPTIssue` value while the template marks that field as + * `SoeMptNone` (not MPT-capable). This catches transactions that try + * to use MPT in contexts where it has not been enabled. + * + * @param tx The transaction object to inspect. + * @return `true` if an invalid MPT amount is found; `false` otherwise. + */ static bool invalidMPTAmountInTx(STObject const& tx) { @@ -724,6 +1043,23 @@ invalidMPTAmountInTx(STObject const& tx) return false; } +/** Validate the `sfRawTransactions` array of a batch transaction. + * + * Enforces three constraints: + * - The `sfBatchSigners` array (if present) must not exceed + * `kMAX_BATCH_TX_COUNT` entries. + * - `sfRawTransactions` must not exceed `kMAX_BATCH_TX_COUNT` (8) + * entries. + * - No inner transaction may itself be of type `ttBATCH` (no + * batch-of-batches), and each inner transaction must pass + * `applyTemplate` against its registered `SOTemplate`. + * + * @param st The transaction object to inspect. + * @param reason Populated with a human-readable failure message when + * the function returns `false`. + * @return `true` if `sfRawTransactions` is absent or all entries are + * valid; `false` otherwise. + */ static bool isRawTransactionOkay(STObject const& st, std::string& reason) { @@ -765,6 +1101,20 @@ isRawTransactionOkay(STObject const& st, std::string& reason) return true; } +/** Run all local pre-submission validity checks on a transaction object. + * + * Gate-keeps local relay and submission by enforcing memo constraints, + * non-zero account fields, prohibition of pseudo-transactions, MPT + * amount field compatibility, and batch inner-transaction validity. + * This is a free function rather than an `STTx` method because it + * operates on any `STObject` — it may run before the object is + * promoted to a full `STTx`. + * + * @param st The transaction object to validate. + * @param reason Populated with a human-readable failure description + * when the function returns `false`. + * @return `true` if all checks pass; `false` on the first failure. + */ bool passesLocalChecks(STObject const& st, std::string& reason) { @@ -795,6 +1145,20 @@ passesLocalChecks(STObject const& st, std::string& reason) return true; } +/** Canonicalize a transaction via a serialize-then-deserialize round-trip. + * + * Serializes `stx` to bytes via `add()`, then constructs a fresh + * `STTx` from those bytes via `SerialIter`. The result is a + * wire-canonical transaction where all equivalent in-memory + * representations collapse to the same byte sequence, including a + * freshly computed transaction ID. Used when a transaction arrives in + * a non-canonical form (e.g., built from JSON) and must be stored or + * compared against wire-format transactions. + * + * @param stx The source transaction to sterilize. + * @return A `shared_ptr` to the newly constructed canonical `STTx`. + * @throws std::runtime_error if the round-trip deserialization fails. + */ std::shared_ptr sterilize(STTx const& stx) { @@ -804,6 +1168,17 @@ sterilize(STTx const& stx) return std::make_shared(std::ref(sit)); } +/** Determine whether a transaction object is a ledger-generated pseudo-transaction. + * + * Pseudo-transactions (`ttAMENDMENT`, `ttFEE`, `ttUNL_MODIFY`) are + * synthesized internally by the ledger and must never be submitted by + * external clients. `passesLocalChecks` rejects any object for which + * this returns `true`. + * + * @param tx The transaction object to test; need not be a fully + * constructed `STTx`. + * @return `true` if the object carries a pseudo-transaction type. + */ bool isPseudoTx(STObject const& tx) { diff --git a/src/libxrpl/protocol/STValidation.cpp b/src/libxrpl/protocol/STValidation.cpp index dd4b8a0fee..5a5c86bdf0 100644 --- a/src/libxrpl/protocol/STValidation.cpp +++ b/src/libxrpl/protocol/STValidation.cpp @@ -1,3 +1,12 @@ +/** @file + * Implements the non-template methods of STValidation — a signed ledger + * agreement broadcast by a validator during each consensus round. + * + * The template constructors and hot-path inlines are defined in + * STValidation.h. This file owns the field schema, cryptographic + * verification, and serialization helpers. + */ + #include #include @@ -19,24 +28,65 @@ namespace xrpl { +/** Place-construct a copy of this object into a caller-supplied buffer. + * + * Implements the `STBase` polymorphic copy protocol used by `STVar`'s + * small-object storage. The caller guarantees `buf` is at least `n` bytes. + * + * @param n Size of the buffer in bytes. + * @param buf Destination buffer; receives a placement-new'd copy. + * @return Pointer to the constructed object inside `buf`. + */ STBase* STValidation::copy(std::size_t n, void* buf) const { return emplace(n, buf, *this); } +/** Place-construct a moved instance of this object into a caller-supplied buffer. + * + * Implements the `STBase` polymorphic move protocol used by `STVar`'s + * small-object storage. The caller guarantees `buf` is at least `n` bytes. + * + * @param n Size of the buffer in bytes. + * @param buf Destination buffer; receives a placement-new'd move-constructed instance. + * @return Pointer to the constructed object inside `buf`. + */ STBase* STValidation::move(std::size_t n, void* buf) { return emplace(n, buf, std::move(*this)); } +/** Return the field schema for STValidation objects. + * + * Defines every field that may appear in a serialized validation, along + * with its presence rule (`SoeRequired`, `SoeOptional`, or `SoeDefault`). + * Required fields form the non-negotiable nucleus: `sfFlags`, + * `sfLedgerHash`, `sfLedgerSequence`, `sfSigningTime`, `sfSigningPubKey`, + * and `sfSignature`. Optional fields carry advisory network-state data + * (fee schedule, pending amendments) that validators may publish. + * + * The three `sfBaseFeeDrops` / `sfReserveBaseDrops` / + * `sfReserveIncrementDrops` entries are added by the XRPFees amendment + * and coexist with the legacy `sfBaseFee` / `sfReserveBase` / + * `sfReserveIncrement` fields to allow gradual network adoption. + * + * `sfCookie` is `SoeDefault`: always present in the serialized form but + * zero-valued unless explicitly set, which prevents fingerprinting + * validators that do not populate it. + * + * @note The template is a function-local static rather than a + * namespace-scope global. This defers initialization to first call, + * by which time all `SField` singletons are guaranteed to be alive — + * C++ provides no cross-translation-unit initialization order for + * namespace-scope statics. + * + * @return A reference to the single immutable `SOTemplate` instance. + */ SOTemplate const& STValidation::validationFormat() { - // We can't have this be a magic static at namespace scope because - // it relies on the SField's below being initialized, and we can't - // guarantee the initialization order. // clang-format off static SOTemplate const kFORMAT{ {sfFlags, SoeRequired}, @@ -65,6 +115,16 @@ STValidation::validationFormat() return kFORMAT; }; +/** Compute the domain-separated hash that was (or will be) signed. + * + * Prepends `HashPrefix::Validation` (the 4-byte big-endian encoding of + * `'V','A','L',0x00`) to the canonical serialization of all signed fields + * before hashing with SHA-512-Half. The prefix ensures a validation hash + * can never collide with a transaction or any other signed payload type. + * + * @return The 256-bit digest over the validation's signed content. + * @see HashPrefix::Validation + */ uint256 STValidation::getSigningHash() const { @@ -83,18 +143,60 @@ STValidation::getConsensusHash() const return getFieldH256(sfConsensusHash); } +/** Reconstruct the NetClock time at which the validator signed this message. + * + * Reads `sfSigningTime` from the serialized object. This is the validator's + * own claim about when it signed; it is part of the signed payload and + * therefore cannot be forged without invalidating the signature. + * + * @note This is distinct from `getSeenTime()`, which records when the + * *local* node received the message and is never serialized. Staleness + * checks use both: sign time to detect a validator drifting far from + * network time, seen time to detect messages that arrived too late + * regardless of the sender's clock. + * + * @return The signing instant as a `NetClock::time_point`. + */ NetClock::time_point STValidation::getSignTime() const { return NetClock::time_point{NetClock::duration{getFieldU32(sfSigningTime)}}; } +/** Return the local receive time recorded by this node for this validation. + * + * `seenTime_` is set via `setSeen()` when the message arrives over the + * network; it is never serialized or signed. For self-issued validations + * the constructor initializes it to the signing time. + * + * @return The instant this node observed the validation. + */ NetClock::time_point STValidation::getSeenTime() const noexcept { return seenTime_; } +/** Verify the cryptographic signature, caching the result. + * + * On the first call, performs ECDSA signature verification via + * `verifyDigest()` using the pre-computed `getSigningHash()` digest. + * The `kVF_FULLY_CANONICAL_SIG` flag is passed to `verifyDigest()` to + * enforce low-S canonicality, preventing ECDSA signature malleability. + * The result is stored in `valid_` and returned on all subsequent calls + * without re-verifying. + * + * For self-issued validations the signing constructor sets `valid_ = true` + * immediately after calling `signDigest()`, so this method never performs + * cryptographic work for locally created validations. + * + * @note An assertion guards that the signing public key is `secp256k1`. + * If it fires, a non-ECDSA key was stored in `signingPubKey_`, which + * indicates a logic error in the deserialization path — the + * constructor is supposed to throw before that can happen. + * + * @return `true` if the signature is valid; `false` otherwise. + */ bool STValidation::isValid() const noexcept { @@ -126,6 +228,15 @@ STValidation::getSignature() const return getFieldVL(sfSignature); } +/** Serialize this validation to its canonical binary wire format. + * + * The returned bytes include all fields (including the signature) and are + * suitable for network transmission or for computing a deduplication hash. + * Callers that need to suppress duplicate relays typically hash this output + * with `sha512Half`. + * + * @return A `Blob` containing the complete serialized validation. + */ Blob STValidation::getSerialized() const { diff --git a/src/libxrpl/protocol/STVar.cpp b/src/libxrpl/protocol/STVar.cpp index 8e45d3c75b..d5de90ddbf 100644 --- a/src/libxrpl/protocol/STVar.cpp +++ b/src/libxrpl/protocol/STVar.cpp @@ -1,3 +1,17 @@ +/** @file + * Type-erased variant storage for XRPL serialized types. + * + * Provides the out-of-line definitions for `STVar`: constructors, assignment + * operators, destructor, and the central `constructST` type-dispatch function. + * `STVar` uses a small-object optimization — objects up to 72 bytes are stored + * in an aligned stack buffer (`d_`) rather than the heap, eliminating the + * majority of allocations during transaction processing and ledger deserialization. + * + * @see xrpl::detail::STVar + * @see xrpl::STObject + * @see xrpl::STArray + */ + #include #include @@ -25,22 +39,52 @@ namespace xrpl::detail { +/** Global sentinel for default-valued object construction. + * + * Passed to `STVar(DefaultObjectT, SField)` to construct a field with its + * type's default value. The tag disambiguates from other construction paths. + */ DefaultObjectT gDefaultObject; + +/** Global sentinel for absent-field object construction. + * + * Passed to `STVar(NonPresentObjectT, SField)` to construct a bare `STBase` + * with `STI_NOTPRESENT`, representing a schema field that is absent from a + * specific object instance. + */ NonPresentObjectT gNonPresentObject; //------------------------------------------------------------------------------ +/** Destroy the contained object, releasing any heap allocation. */ STVar::~STVar() { destroy(); } +/** Copy-construct from another STVar. + * + * Delegates to the virtual `STBase::copy()` on the source object, which + * re-applies the small-object size check independently for the destination + * buffer. The source is left unchanged. + * + * @param other The STVar to copy from; may be empty (`p_ == nullptr`). + */ STVar::STVar(STVar const& other) { if (other.p_ != nullptr) p_ = other.p_->copy(kMAX_SIZE, &d_); } +/** Move-construct from another STVar. + * + * If the source object lives on the heap, ownership is transferred by pointer + * swap — an O(1) zero-copy operation. If it lives in the source's stack buffer + * the object is move-constructed into this object's buffer via the virtual + * `STBase::move()`, since stack buffer addresses are non-transferable. + * + * @param other The STVar to move from; left in an empty (`p_ == nullptr`) state. + */ STVar::STVar(STVar&& other) { if (other.onHeap()) @@ -54,6 +98,14 @@ STVar::STVar(STVar&& other) } } +/** Copy-assign from another STVar. + * + * Destroys the current contents before copying the source. Self-assignment is + * a no-op. Delegates to `STBase::copy()` for the actual object duplication. + * + * @param rhs The STVar to copy from. + * @return `*this` + */ STVar& STVar::operator=(STVar const& rhs) { @@ -73,6 +125,15 @@ STVar::operator=(STVar const& rhs) return *this; } +/** Move-assign from another STVar. + * + * Destroys the current contents before taking ownership of `rhs`. Applies the + * same heap-steal vs. buffer-move logic as the move constructor. Self-assignment + * is a no-op. + * + * @param rhs The STVar to move from; left in an empty state on success. + * @return `*this` + */ STVar& STVar::operator=(STVar&& rhs) { @@ -93,14 +154,41 @@ STVar::operator=(STVar&& rhs) return *this; } +/** Construct a default-valued object for the given field. + * + * Tag-dispatch wrapper that delegates to `STVar(SerializedTypeID, SField)` + * using `name.fieldType` as the type selector. + * + * @param name The field whose type determines which `ST*` subtype is created. + */ STVar::STVar(DefaultObjectT, SField const& name) : STVar(name.fieldType, name) { } +/** Construct an absent-field sentinel for the given field. + * + * Creates a bare `STBase` with type `STI_NOTPRESENT`, representing a schema + * slot that exists in the object template but carries no value in this + * particular instance. + * + * @param name The field that is absent. + */ STVar::STVar(NonPresentObjectT, SField const& name) : STVar(STI_NOTPRESENT, name) { } +/** Deserialize an `STVar` from a wire-format byte stream. + * + * Dispatches to the appropriate `ST*` deserialization constructor based on + * `name.fieldType`. Enforces a hard nesting-depth limit to prevent stack + * exhaustion from maliciously crafted or corrupt data containing deeply nested + * `STObject` or `STArray` fields. + * + * @param sit Iterator positioned at the start of the serialized field value. + * @param name The field descriptor; its `fieldType` selects the concrete type. + * @param depth Current nesting depth, incremented by each recursive container. + * @throws std::runtime_error if `depth` exceeds 10. + */ STVar::STVar(SerialIter& sit, SField const& name, int depth) { if (depth > 10) @@ -108,6 +196,16 @@ STVar::STVar(SerialIter& sit, SField const& name, int depth) constructST(name.fieldType, depth, sit, name); } +/** Construct a default-valued object of a specific serialized type. + * + * Used internally when constructing absent-field sentinels (`STI_NOTPRESENT`) + * or default-valued fields. The `id` must equal `name.fieldType` unless + * `id == STI_NOTPRESENT`. + * + * @param id The serialized type to instantiate; must match `name.fieldType` + * or be `STI_NOTPRESENT`. + * @param name The associated field descriptor. + */ STVar::STVar(SerializedTypeID id, SField const& name) { XRPL_ASSERT( @@ -116,6 +214,14 @@ STVar::STVar(SerializedTypeID id, SField const& name) constructST(id, 0, name); } +/** Destroy the contained object and reset to the empty state. + * + * Uses `delete` for heap-allocated objects and an explicit destructor call for + * objects that were placement-new'd into the stack buffer. Calling `delete` on + * a pointer into `d_` would attempt to free stack memory and is undefined + * behaviour — this asymmetry is intentional. Resets `p_` to `nullptr` in both + * cases. + */ void STVar::destroy() { @@ -131,6 +237,25 @@ STVar::destroy() p_ = nullptr; } +/** Dispatch construction of the concrete `ST*` type identified by `id`. + * + * The `ValidConstructSTArgs` concept restricts `args` to exactly one of two + * forms: `(SField)` for default construction, or `(SerialIter, SField)` for + * deserialization. The `constructWithDepth` local lambda uses `if constexpr` + * to select the appropriate calling convention at compile time for container + * types (`STObject`, `STArray`) that require `depth` to propagate the nesting + * limit into their own recursive `STVar` construction. + * + * @note `STI_NOTPRESENT` always constructs a bare `STBase` regardless of + * `args`, since the field is absent and carries no value. + * + * @tparam Args Constrained to `(SField)` or `(SerialIter, SField)`. + * @param id The serialized type ID selecting the concrete `ST*` subtype. + * @param depth Current nesting depth; forwarded to `STObject` and `STArray` + * constructors only — all other types ignore it. + * @param args Construction arguments forwarded to the selected `ST*` type. + * @throws std::runtime_error if `id` is not a recognised `SerializedTypeID`. + */ template requires ValidConstructSTArgs void diff --git a/src/libxrpl/protocol/STVector256.cpp b/src/libxrpl/protocol/STVector256.cpp index 75b833dd7a..dd9ba8e8eb 100644 --- a/src/libxrpl/protocol/STVector256.cpp +++ b/src/libxrpl/protocol/STVector256.cpp @@ -1,3 +1,11 @@ +/** @file + * Implements STVector256, the serialized type for ordered lists of uint256 values. + * + * On the wire the array is encoded as a single VL-prefixed blob of concatenated + * 32-byte hashes (type identifier STI_VECTOR256, code 19). Common ledger fields + * that use this type include sfAmendments, sfIndexes, and sfHashes. + */ + #include #include @@ -15,6 +23,20 @@ namespace xrpl { +/** Deserialize an STVector256 from a wire-format stream. + * + * Reads a single VL-prefixed blob from @p sit and partitions it into + * consecutive 32-byte chunks, each becoming one `uint256` entry. The + * resulting vector retains the original wire order. + * + * @param sit Forward-only iterator positioned at the VL length prefix of + * the field. Consumed by exactly one `getVLDataLength` + `getSlice` pair. + * @param name The `SField` that identifies this field within its parent + * `STObject`. + * @throws std::runtime_error if the decoded blob length is not an exact + * multiple of 32 bytes, indicating corrupt or truncated data. Thrown + * (not asserted) because this path processes untrusted network data. + */ STVector256::STVector256(SerialIter& sit, SField const& name) : STBase(name) { auto const slice = sit.getSlice(sit.getVLDataLength()); @@ -33,30 +55,72 @@ STVector256::STVector256(SerialIter& sit, SField const& name) : STBase(name) value_.emplace_back(slice.substr(i * uint256::size(), uint256::size())); } +/** Copy this object into a caller-supplied buffer using the STVar placement protocol. + * + * Delegates to `STBase::emplace`, which constructs in-place when @p buf is + * large enough, or heap-allocates otherwise. + * + * @param n Size of the buffer at @p buf, in bytes. + * @param buf Destination buffer for placement construction. + * @return Pointer to the newly constructed `STVector256`. + */ STBase* STVector256::copy(std::size_t n, void* buf) const { return emplace(n, buf, *this); } +/** Move this object into a caller-supplied buffer using the STVar placement protocol. + * + * Delegates to `STBase::emplace`, which constructs in-place when @p buf is + * large enough, or heap-allocates otherwise. The source object is left in a + * valid but unspecified state after the move. + * + * @param n Size of the buffer at @p buf, in bytes. + * @param buf Destination buffer for placement construction. + * @return Pointer to the newly constructed `STVector256`. + */ STBase* STVector256::move(std::size_t n, void* buf) { return emplace(n, buf, std::move(*this)); } +/** Return the serialized type identifier for this field. + * + * @return `STI_VECTOR256` (code 19). + */ SerializedTypeID STVector256::getSType() const { return STI_VECTOR256; } +/** Return whether this object holds no entries. + * + * An empty `STVector256` is the canonical default value. Per XRPL + * serialization rules, default-valued fields are omitted from the wire + * encoding and contribute nothing to a transaction or ledger hash. + * + * @return `true` if the internal vector is empty. + */ bool STVector256::isDefault() const { return value_.empty(); } +/** Serialize this array into @p s as a VL-prefixed blob. + * + * Writes a length prefix followed by the concatenated raw bytes of each + * `uint256` entry. The two assertions guard against programmer errors such + * as assigning an `STVector256` value to a field of the wrong type before + * serialization reaches the wire. + * + * @param s Accumulator to append the encoded field into. + * @note Asserts (debug builds only) that the associated `SField` is marked + * binary and carries type `STI_VECTOR256`. + */ void STVector256::add(Serializer& s) const { @@ -65,6 +129,15 @@ STVector256::add(Serializer& s) const s.addVL(value_.begin(), value_.end(), value_.size() * (256 / 8)); } +/** Test deep equality with another `STBase` instance. + * + * Two `STVector256` objects are equivalent when they contain the same + * sequence of `uint256` values in the same order. + * + * @param t The object to compare against. Must be dynamically of type + * `STVector256`; otherwise the result is `false`. + * @return `true` if @p t is an `STVector256` with identical contents. + */ bool STVector256::isEquivalent(STBase const& t) const { @@ -72,6 +145,16 @@ STVector256::isEquivalent(STBase const& t) const return (v != nullptr) && (value_ == v->value_); } +/** Produce a JSON array where each element is the hex string of a `uint256`. + * + * The `JsonOptions` parameter is accepted for interface conformance but is + * not consumed; `STVector256` has no output variants that depend on API + * version or format flags. + * + * @return A `json::arrayValue` containing one lowercase hex string per entry, + * as produced by `to_string(uint256)`. This is the representation seen + * in RPC responses for fields such as `sfAmendments`. + */ json::Value STVector256::getJson(JsonOptions) const { diff --git a/src/libxrpl/protocol/STXChainBridge.cpp b/src/libxrpl/protocol/STXChainBridge.cpp index 97b46852fd..86c7c68576 100644 --- a/src/libxrpl/protocol/STXChainBridge.cpp +++ b/src/libxrpl/protocol/STXChainBridge.cpp @@ -1,3 +1,17 @@ +/** @file + * Implements STXChainBridge: the serialized type encoding a cross-chain + * bridge specification (locking-chain door + asset, issuing-chain door + + * wrapped asset) as a first-class STBase-derived wire-format field. + * + * Construction paths range from trusted internal use (no validation) through + * binary deserialization (SerialIter) to the fully defensive JSON path, which + * rejects unknown fields via a key-set comparison against a default-constructed + * instance before parsing any values. + * + * The static @c construct() factory is the hook registered in STVar.cpp's + * @c constructST() switch under @c STI_XCHAIN_BRIDGE, allowing the generic + * binary deserializer to materialize this type by type-ID alone. + */ #include #include @@ -63,6 +77,11 @@ STXChainBridge::STXChainBridge(SField const& name, json::Value const& v) : STBas "STXChainBridge can only be specified with a 'object' Json value"); } + // Guard against unknown or misspelled field names by comparing the input + // key set against the canonical JSON produced by a default-constructed + // bridge. The reference set is computed once (static) and reused on every + // call. Any key absent from that set is rejected immediately, preventing + // silent discard of typo'd or future fields. auto checkExtra = [](json::Value const& v) { static auto const kBRIDGE_JSON = xrpl::STXChainBridge().getJson(xrpl::JsonOptions::Values::None); @@ -179,18 +198,47 @@ STXChainBridge::isDefault() const issuingChainDoor_.isDefault() && issuingChainIssue_.isDefault(); } +/** Factory called by the STVar dispatch table (STVar.cpp, case STI_XCHAIN_BRIDGE) + * to materialize an STXChainBridge from a binary stream by type-ID alone. + * + * @param sit Forward-only cursor over the incoming byte stream; consumed + * in declaration order: locking door, locking issue, issuing door, + * issuing issue. + * @param name The SField tag to associate with the constructed object. + * @return Heap-allocated STXChainBridge ready for ownership transfer into + * the enclosing STVar or STObject. + */ std::unique_ptr STXChainBridge::construct(SerialIter& sit, SField const& name) { return std::make_unique(sit, name); } +/** Satisfy the STBase small-buffer-optimization interface for copy construction. + * + * STVar maintains an aligned inline buffer; if sizeof(*this) fits within @p n + * bytes the object is placement-new'd there, otherwise it is heap-allocated. + * @c emplace() (inherited from STBase) encapsulates that decision. + * + * @param n Size of the inline buffer offered by the enclosing STVar. + * @param buf Pointer to that inline buffer (may be ignored if too small). + * @return Pointer to the newly constructed copy, either at @p buf or on the heap. + */ STBase* STXChainBridge::copy(std::size_t n, void* buf) const { return emplace(n, buf, *this); } +/** Satisfy the STBase small-buffer-optimization interface for move construction. + * + * Identical contract to @c copy(), but invokes the move constructor, allowing + * STVar to steal heap-allocated members when the object does not fit inline. + * + * @param n Size of the inline buffer offered by the enclosing STVar. + * @param buf Pointer to that inline buffer (may be ignored if too small). + * @return Pointer to the newly constructed object, either at @p buf or on the heap. + */ STBase* STXChainBridge::move(std::size_t n, void* buf) { diff --git a/src/libxrpl/protocol/SecretKey.cpp b/src/libxrpl/protocol/SecretKey.cpp index f33b1871e1..ed8ca1c6de 100644 --- a/src/libxrpl/protocol/SecretKey.cpp +++ b/src/libxrpl/protocol/SecretKey.cpp @@ -1,3 +1,15 @@ +/** @file + * @brief Cryptographic key generation, derivation, and signing for XRPL. + * + * Implements secret-key construction, deterministic key derivation for both + * secp256k1 (XRPL's custom BIP-32-predating algorithm via `detail::Generator`) + * and Ed25519, public-key derivation, message and digest signing, and + * Base58-encoded key parsing. + * + * All intermediate key-material buffers are unconditionally `secureErase`d + * on both the success and failure paths to prevent secret bytes from + * lingering in freed memory. + */ #include #include @@ -55,6 +67,14 @@ SecretKey::toString() const namespace detail { +/** Write a 32-bit unsigned integer into a byte buffer in big-endian order. + * + * Used to append the sequence/counter word when building the hash input + * for both `deriveDeterministicRootKey` and `Generator::calculateTweak`. + * + * @param out Pointer to the first of four consecutive bytes to write. + * @param v Value to encode. + */ void copyUInt32(std::uint8_t* out, std::uint32_t v) { @@ -64,24 +84,32 @@ copyUInt32(std::uint8_t* out, std::uint32_t v) *out = v & 0xff; } +/** Derive the root secp256k1 secret key from a 128-bit seed. + * + * Hashes `seed || seq` (big-endian 32-bit counter) with SHA-512/Half and + * validates the result as a secp256k1 scalar (nonzero and less than the + * group order). The counter loop retries on the negligible chance of an + * invalid scalar; after 128 failed attempts a `std::runtime_error` is thrown. + * + * Buffer layout (20 bytes): + * @code + * 0 16 20 + * |---- seed ------|seq-| + * @endcode + * + * The intermediate buffer is always `secureErase`d before returning, + * regardless of success or failure. + * + * @param seed The 128-bit XRPL seed to derive from. + * @return The root secret key scalar as a `uint256`. + * @throws std::runtime_error if no valid scalar is found within 128 attempts. + */ uint256 deriveDeterministicRootKey(Seed const& seed) { - // We fill this buffer with the seed and append a 32-bit "counter" - // that counts how many attempts we've had to make to generate a - // non-zero key that's less than the curve's order: - // - // 1 2 - // 0 6 0 - // buf |----------------|----| - // | seed | seq| - std::array buf{}; std::ranges::copy(seed, buf.begin()); - // The odds that this loop executes more than once are negligible - // but *just* in case someone managed to generate a key that required - // more iterations loop a few times. for (std::uint32_t seq = 0; seq != 128; ++seq) { copyUInt32(buf.data() + 16, seq); @@ -98,7 +126,6 @@ deriveDeterministicRootKey(Seed const& seed) Throw("Unable to derive generator from seed"); } -//------------------------------------------------------------------------------ /** Produces a sequence of secp256k1 key pairs. The reference implementation of the XRP Ledger uses a custom derivation @@ -123,24 +150,31 @@ private: uint256 root_; std::array generator_{}; + /** Compute the scalar tweak for the given key-family ordinal. + * + * Hashes `generator || seq || subseq` (both counters big-endian 32-bit) + * with SHA-512/Half and validates the result as a secp256k1 scalar. + * `subseq` retries on the negligible chance of an invalid scalar. + * + * Buffer layout (41 bytes): + * @code + * 0 33 37 41 + * |--generator--|seq-|cnt-| + * @endcode + * + * The intermediate buffer is always `secureErase`d before returning. + * + * @param seq Key-family ordinal (0 for the standard single-key case). + * @return The tweak scalar as a `uint256`. + * @throws std::runtime_error if no valid scalar is found within 128 attempts. + */ [[nodiscard]] uint256 calculateTweak(std::uint32_t seq) const { - // We fill the buffer with the generator, the provided sequence - // and a 32-bit counter tracking the number of attempts we have - // already made looking for a non-zero key that's less than the - // curve's order: - // 3 3 4 - // 0 pubGen 3 7 1 - // buf |---------------------------------|----|----| - // | generator | seq| cnt| - std::array buf{}; std::ranges::copy(generator_, buf.begin()); copyUInt32(buf.data() + 33, seq); - // The odds that this loop executes more than once are negligible - // but we impose a maximum limit just in case. for (std::uint32_t subseq = 0; subseq != 128; ++subseq) { copyUInt32(buf.data() + 37, subseq); @@ -158,6 +192,16 @@ private: } public: + /** Construct a Generator from a seed. + * + * Derives the root secret key via `deriveDeterministicRootKey`, then + * computes and stores its compressed 33-byte public key as the generator + * point used by `calculateTweak` for all subsequent child derivations. + * + * @param seed The 128-bit XRPL seed. + * @throws std::runtime_error propagated from `deriveDeterministicRootKey` + * if key derivation fails (statistically negligible). + */ explicit Generator(Seed const& seed) : root_(deriveDeterministicRootKey(seed)) { secp256k1_pubkey pubkey; @@ -171,17 +215,29 @@ public: logicError("derivePublicKey: secp256k1_ec_pubkey_serialize failed"); } + /** Securely erases all key material on destruction. */ ~Generator() { secureErase(root_.data(), root_.size()); secureErase(generator_.data(), generator_.size()); } - /** Generate the nth key pair. */ + /** Generate the nth key pair in this family. + * + * Computes `child = root + tweak(ordinal) mod n` via + * `secp256k1_ec_seckey_tweak_add`, then derives the corresponding + * compressed public key. Ordinal 0 is the standard single-key case + * used by `generateKeyPair`. + * + * The intermediate copy of the root scalar is `secureErase`d immediately + * after the child secret key is constructed. + * + * @param ordinal Key-family index (0-based). + * @return `{PublicKey, SecretKey}` pair for the requested ordinal. + */ std::pair operator()(std::size_t ordinal) const { - // Generates Nth secret key: auto gsk = [this, tweak = calculateTweak(ordinal)]() { auto rpk = root_; @@ -201,6 +257,19 @@ public: } // namespace detail +/** Sign a pre-computed SHA-512/Half digest with a secp256k1 key. + * + * Uses RFC 6979 deterministic nonce generation (`secp256k1_nonce_function_rfc6979`) + * to eliminate nonce-reuse risk and make signatures reproducible. The result + * is DER-encoded and at most 72 bytes. + * + * @note This overload is secp256k1-only. Ed25519 signs the raw message + * internally and does not support pre-hashed input. + * @param pk Public key (must be secp256k1; used to determine key type). + * @param sk Corresponding secret key. + * @param digest The 32-byte SHA-512/Half digest to sign. + * @return DER-encoded signature in a `Buffer` (up to 72 bytes). + */ Buffer signDigest(PublicKey const& pk, SecretKey const& sk, uint256 const& digest) { @@ -226,6 +295,22 @@ signDigest(PublicKey const& pk, SecretKey const& sk, uint256 const& digest) return Buffer{sig, len}; } +/** Sign an arbitrary message with a key of the detected type. + * + * Dispatches on the key type embedded in `pk`: + * - **Ed25519**: calls `ed25519_sign` directly on the raw message bytes. + * Ed25519's security model requires that the library itself hash the + * message; bypassing that hash (as `signDigest` does) is not supported. + * Returns a fixed 64-byte signature. + * - **secp256k1**: applies SHA-512/Half to `m` then signs the resulting + * digest with RFC 6979 deterministic nonces. Returns a DER-encoded + * signature of up to 72 bytes. + * + * @param pk Public key; its type determines the signing algorithm. + * @param sk Corresponding secret key. + * @param m Raw message bytes to sign. + * @return Signature in a `Buffer` (64 bytes for Ed25519, ≤72 for secp256k1). + */ Buffer sign(PublicKey const& pk, SecretKey const& sk, Slice const& m) { @@ -267,6 +352,16 @@ sign(PublicKey const& pk, SecretKey const& sk, Slice const& m) } } +/** Generate a secret key from the platform CSPRNG. + * + * Fills a 32-byte stack buffer via `beast::rngfill` / `cryptoPrng()`, wraps + * it in a `SecretKey`, then immediately `secureErase`s the stack buffer. + * The result is not tied to any seed and cannot be deterministically + * reproduced — use `generateKeyPair` when wallet recovery is required. + * + * @return A freshly generated `SecretKey` backed by cryptographically + * secure random bytes. + */ SecretKey randomSecretKey() { @@ -277,6 +372,22 @@ randomSecretKey() return sk; } +/** Derive a secret key deterministically from a seed. + * + * - **Ed25519**: computes `sha512HalfS(seed)` directly — no counter loop is + * needed because Ed25519's scalar space is large enough to accept any + * 256-bit hash output. + * - **secp256k1**: delegates to `detail::deriveDeterministicRootKey`, which + * hashes `seed || counter` and validates against the curve order. + * + * In both cases the intermediate key buffer is `secureErase`d before return. + * + * @param type Algorithm (`KeyType::Ed25519` or `KeyType::Secp256k1`). + * @param seed The 128-bit XRPL seed. + * @return The derived `SecretKey`. + * @throws std::runtime_error (secp256k1 only) if derivation fails after 128 + * attempts, propagated from `deriveDeterministicRootKey`. + */ SecretKey generateSecretKey(KeyType type, Seed const& seed) { @@ -299,6 +410,19 @@ generateSecretKey(KeyType type, Seed const& seed) logicError("generateSecretKey: unknown key type"); } +/** Derive the public key from a secret key. + * + * - **secp256k1**: produces a compressed 33-byte point via + * `secp256k1_ec_pubkey_create` + `secp256k1_ec_pubkey_serialize`. + * - **Ed25519**: produces a 33-byte key where `buf[0] == 0xED` and + * `buf[1..32]` is the 32-byte Ed25519 public key. The `0xED` prefix is + * the XRPL convention for distinguishing Ed25519 keys on the wire from + * secp256k1 keys (which start with `0x02` or `0x03`). + * + * @param type Algorithm (`KeyType::Ed25519` or `KeyType::Secp256k1`). + * @param sk The secret key to derive from. + * @return The corresponding `PublicKey`. + */ PublicKey derivePublicKey(KeyType type, SecretKey const& sk) { @@ -331,6 +455,21 @@ derivePublicKey(KeyType type, SecretKey const& sk) }; } +/** Generate a key pair deterministically from a seed. + * + * - **secp256k1**: constructs a `detail::Generator` from the seed and + * returns the key pair at ordinal 0 (the standard single-key derivation). + * The generator-based design supports key families but ordinal 0 is used + * in the vast majority of cases. + * - **Ed25519**: calls `generateSecretKey` then `derivePublicKey` directly; + * no generator indirection is needed. + * + * @param type Algorithm (`KeyType::Ed25519` or `KeyType::Secp256k1`). + * @param seed The 128-bit XRPL seed. + * @return `{PublicKey, SecretKey}` pair. + * @throws std::runtime_error propagated if secp256k1 root-key derivation + * fails (statistically negligible). + */ std::pair generateKeyPair(KeyType type, Seed const& seed) { @@ -348,6 +487,15 @@ generateKeyPair(KeyType type, Seed const& seed) } } +/** Generate a key pair from the CSPRNG (non-deterministic). + * + * Combines `randomSecretKey` with `derivePublicKey`. Unlike + * `generateKeyPair`, the result cannot be reproduced from any seed, making + * it suitable for one-off key generation where wallet recovery is not needed. + * + * @param type Algorithm (`KeyType::Ed25519` or `KeyType::Secp256k1`). + * @return `{PublicKey, SecretKey}` pair backed by random key material. + */ std::pair randomKeyPair(KeyType type) { @@ -355,6 +503,17 @@ randomKeyPair(KeyType type) return {derivePublicKey(type, sk), sk}; } +/** Decode a Base58Check-encoded secret key. + * + * Decodes the token via `decodeBase58Token` and validates that the payload + * is exactly 32 bytes. Returns `std::nullopt` on decoding failure or if + * the payload length is wrong. + * + * @tparam SecretKey Explicit template specialization — not a generic helper. + * @param type The expected `TokenType` prefix (e.g. `TokenType::FamilySeed`). + * @param s Base58Check-encoded string to decode. + * @return The decoded `SecretKey`, or `std::nullopt` on any error. + */ template <> std::optional parseBase58(TokenType type, std::string const& s) diff --git a/src/libxrpl/protocol/Seed.cpp b/src/libxrpl/protocol/Seed.cpp index f15d4dcff0..f2acbe9ce7 100644 --- a/src/libxrpl/protocol/Seed.cpp +++ b/src/libxrpl/protocol/Seed.cpp @@ -1,3 +1,14 @@ +/** @file + * Implements the Seed class and the factory/parsing functions that feed + * XRPL's deterministic key-derivation pipeline. + * + * A seed is the 128-bit root secret from which both secp256k1 and ed25519 + * key pairs are derived via @c generateSecretKey / @c generateKeyPair. + * Every XRPL account or validator node traces back to one of these 16-byte + * values, making this the most security-sensitive path in the key-management + * subsystem. Secure erasure is applied to every transient buffer that + * touches raw seed material. + */ #include #include @@ -23,11 +34,25 @@ namespace xrpl { +/** Securely wipe the seed buffer on destruction. + * + * Uses @c secureErase (backed by @c OPENSSL_cleanse) rather than a plain + * @c memset or zeroing loop. The OpenSSL variant uses strategies specifically + * designed to resist dead-store elimination by optimizing compilers, ensuring + * the key material is actually overwritten before the storage is reclaimed. + */ Seed::~Seed() { secureErase(buf_.data(), buf_.size()); } +/** Construct from a raw byte span. + * + * @param slice A non-owning view of exactly 16 bytes of seed material. + * @throw LogicError if @p slice is not exactly 16 bytes. A size mismatch + * here is a programmer error — callers must validate size before + * constructing a Seed from an already-typed value. + */ Seed::Seed(Slice const& slice) { if (slice.size() != buf_.size()) @@ -35,6 +60,11 @@ Seed::Seed(Slice const& slice) std::memcpy(buf_.data(), slice.data(), buf_.size()); } +/** Construct from a 128-bit integer. + * + * @param seed A @c uint128 whose raw bytes are copied into the seed buffer. + * @throw LogicError if the size of @p seed is not exactly 16 bytes. + */ Seed::Seed(uint128 const& seed) { if (seed.size() != buf_.size()) @@ -44,6 +74,15 @@ Seed::Seed(uint128 const& seed) //------------------------------------------------------------------------------ +/** Generate a seed from 16 bytes of cryptographically secure random data. + * + * The raw entropy is written into a stack-allocated buffer, used to + * construct the @c Seed, and then immediately wiped with @c secureErase + * before the function returns, preventing the raw bytes from lingering in + * the stack frame after the call. + * + * @return A freshly generated, cryptographically random @c Seed. + */ Seed randomSeed() { @@ -54,6 +93,23 @@ randomSeed() return seed; } +/** Derive a seed deterministically from a passphrase. + * + * Computes the SHA-512 half (first 256 bits of SHA-512) of @p passPhrase + * and uses the first 16 bytes of the resulting digest as the seed. The + * passphrase bytes are hashed without normalization, null terminator, or + * length prefix. + * + * @param passPhrase The passphrase to hash. Any non-empty string is + * accepted; no format detection is performed. + * @return The deterministically derived @c Seed. + * + * @note Uses @c sha512_half_hasher_s (the secure variant) so that the + * internal SHA-512 state is zeroed in the hasher's destructor, + * preventing the passphrase's hash state from lingering in stack memory. + * The passphrase @c "masterpassphrase" always produces the seed encoded + * as @c "snoPBrXtMeMyMHUVTgbuqAfg1SUTb". + */ Seed generateSeed(std::string const& passPhrase) { @@ -63,6 +119,15 @@ generateSeed(std::string const& passPhrase) return Seed({digest.data(), 16}); } +/** Decode a Base58Check-encoded seed string tagged with @c TokenType::FamilySeed. + * + * @param s A Base58Check string in XRPL's @c sXXXX family-seed format. + * @return The decoded @c Seed on success, or @c std::nullopt if @p s fails + * Base58Check decoding, carries the wrong token type, or decodes to a + * payload that is not exactly 16 bytes. A size mismatch here is treated + * as an input error (returning @c std::nullopt) rather than a logic error, + * because the string originates from external input. + */ template <> std::optional parseBase58(std::string const& s) @@ -75,6 +140,34 @@ parseBase58(std::string const& s) return Seed(makeSlice(result)); } +/** Attempt to parse a string as a seed using cascading format detection. + * + * Tries each representation in order and returns the first match: + * 1. **Rejection guard** — returns @c std::nullopt immediately if @p str + * decodes as an @c AccountID, node/account public key, or node/account + * secret key. This prevents key-type confusion: without this guard, a + * valid public-key string could be silently reparsed as a seed via the + * passphrase fallback below. + * 2. **Hex** — attempts to parse as a 128-bit hexadecimal value. + * 3. **Base58** — attempts @c parseBase58 for the standard @c sXXXX + * family-seed format. + * 4. **RFC1751** (when @p rfc1751 is @c true) — decodes a mnemonic English + * word sequence; the decoded bytes are reversed to match the reversal + * applied by @c seedAs1751 during encoding. + * 5. **Passphrase fallback** — any non-empty string that passes none of the + * above is hashed via @c generateSeed. + * + * @param str The string to parse. + * @param rfc1751 When @c true, RFC1751 mnemonic decoding is attempted. + * Defaults to @c true but is deprecated; prefer @c parseBase58 + * for strict parsing. + * @return The parsed @c Seed, or @c std::nullopt if @p str is empty or + * decodes as a recognized key type other than a seed. + * + * @note The passphrase fallback means this function never returns + * @c std::nullopt for a non-empty string that is not another key type. + * Callers requiring strict validation should use @c parseBase58. + */ std::optional parseGenericSeed(std::string const& str, bool rfc1751) { @@ -112,6 +205,17 @@ parseGenericSeed(std::string const& str, bool rfc1751) return generateSeed(str); } +/** Encode a seed as an RFC1751 mnemonic word sequence. + * + * The 16 seed bytes are reversed before being passed to + * @c RFC1751::getEnglishFromKey. This reversal is symmetric with the + * reversal applied by @c parseGenericSeed when decoding an RFC1751 string, + * and reflects a historical endianness convention in XRPL's adoption of the + * RFC. + * + * @param seed The seed to encode. + * @return The RFC1751 mnemonic string for @p seed. + */ std::string seedAs1751(Seed const& seed) { diff --git a/src/libxrpl/protocol/Serializer.cpp b/src/libxrpl/protocol/Serializer.cpp index d500919df9..aca88efb39 100644 --- a/src/libxrpl/protocol/Serializer.cpp +++ b/src/libxrpl/protocol/Serializer.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements the binary serialization backbone of the XRP Ledger protocol. + * + * `Serializer` (write side) appends typed values to a growing byte buffer; + * every `add*` method returns the byte offset at which writing began so + * callers can patch previously written slots. `SerialIter` (read side) is a + * non-owning forward cursor that throws `std::runtime_error` on underrun. + * Together they encode and decode the canonical wire format used by every + * transaction, ledger object, and cryptographic hash in the system. + */ #include #include @@ -21,6 +31,11 @@ namespace xrpl { +/** Append a 16-bit unsigned integer in big-endian byte order. + * + * @param i Value to append. + * @return Byte offset at which the value was written. + */ int Serializer::add16(std::uint16_t i) { @@ -30,6 +45,17 @@ Serializer::add16(std::uint16_t i) return ret; } +/** Append a `HashPrefix` domain-separator as a big-endian 32-bit value. + * + * `HashPrefix` is an `enum class : std::uint32_t` whose values are + * prepended to data before signing or hashing to prevent cross-domain hash + * collisions (e.g., `TXN` for transaction IDs, `STX` for signing payloads). + * A `static_assert` guards against a future accidental change to the enum's + * underlying type, which would silently corrupt the wire format. + * + * @param p The domain-separation prefix to append. + * @return Byte offset at which the prefix was written. + */ int Serializer::add32(HashPrefix p) { @@ -71,6 +97,11 @@ Serializer::addInteger(std::int32_t i) return add32(i); } +/** Append a raw byte sequence without any length prefix. + * + * @param vector Bytes to append. + * @return Byte offset at which the data was written. + */ int Serializer::addRaw(Blob const& vector) { @@ -79,6 +110,11 @@ Serializer::addRaw(Blob const& vector) return ret; } +/** Append the bytes referenced by a `Slice` without any length prefix. + * + * @param slice Non-owning view of bytes to append. + * @return Byte offset at which the data was written. + */ int Serializer::addRaw(Slice slice) { @@ -87,6 +123,11 @@ Serializer::addRaw(Slice slice) return ret; } +/** Append all bytes accumulated in another `Serializer` without a length prefix. + * + * @param s Source serializer whose buffer is appended in full. + * @return Byte offset at which the data was written. + */ int Serializer::addRaw(Serializer const& s) { @@ -95,6 +136,12 @@ Serializer::addRaw(Serializer const& s) return ret; } +/** Append a raw memory region without any length prefix. + * + * @param ptr Pointer to the first byte to append. + * @param len Number of bytes to copy from `ptr`. + * @return Byte offset at which the data was written. + */ int Serializer::addRaw(void const* ptr, int len) { @@ -103,6 +150,28 @@ Serializer::addRaw(void const* ptr, int len) return ret; } +/** Append a compact TLV field tag used by `STObject` serialization. + * + * Encodes the (type, name) pair into 1, 2, or 3 bytes according to the + * XRPL field-ID packing scheme: + * + * - Both < 16 (common/common): one byte `(type << 4) | name`. + * - Type < 16, name ≥ 16 (common type, uncommon name): `type << 4` then + * `name`. + * - Type ≥ 16, name < 16 (uncommon type, common name): `name` then `type` + * — note the reversed order. + * - Both ≥ 16 (uncommon/uncommon): leading `0x00` sentinel, then `type`, + * then `name`. + * + * The leading zero in the three-byte form signals the decoder that the + * subsequent two bytes are a full type+name pair. + * + * @param type Serialized-type family code (1–255). + * @param name Per-type field index (1–255). + * @return Byte offset at which the tag was written. + * @note Both `type` and `name` must be in [1, 255]; the assertion fires in + * debug builds if either is out of range. + */ int Serializer::addFieldID(int type, int name) { @@ -142,6 +211,11 @@ Serializer::addFieldID(int type, int name) return ret; } +/** Append a single byte. + * + * @param byte Value to append. + * @return Byte offset at which the value was written. + */ int Serializer::add8(unsigned char byte) { @@ -150,6 +224,12 @@ Serializer::add8(unsigned char byte) return ret; } +/** Read a single byte at the given byte offset without consuming it. + * + * @param byte Output parameter; set to the byte value on success. + * @param offset Zero-based byte offset into the internal buffer. + * @return `true` if `offset` is within bounds; `false` otherwise. + */ bool Serializer::get8(int& byte, int offset) const { @@ -160,6 +240,12 @@ Serializer::get8(int& byte, int offset) const return true; } +/** Remove bytes from the end of the buffer. + * + * @param bytes Number of bytes to remove. + * @return `true` on success; `false` if `bytes` exceeds the current size, + * leaving the buffer unchanged. + */ bool Serializer::chop(int bytes) { @@ -170,12 +256,29 @@ Serializer::chop(int bytes) return true; } +/** Compute SHA-512 over the buffer and return the first 256 bits. + * + * This is XRPL's standard hashing primitive (`sha512Half`), used for + * transaction IDs, SHAMap inner-node hashes, and signing digests. + * + * @return The first 256 bits of SHA-512 applied to the accumulated bytes. + */ uint256 Serializer::getSHA512Half() const { return sha512Half(makeSlice(data_)); } +/** Append a variable-length-prefixed blob using XRPL's three-tier encoding. + * + * Writes a compact length header (1–3 bytes) followed by the raw bytes. + * An assertion verifies the buffer grew by exactly `data_size + header_size`. + * + * @param vector Data to append. + * @return Byte offset at which the length header was written. + * @throws std::overflow_error if the data exceeds 918,744 bytes. + * @see addEncoded + */ int Serializer::addVL(Blob const& vector) { @@ -187,6 +290,15 @@ Serializer::addVL(Blob const& vector) return ret; } +/** Append a variable-length-prefixed blob from a `Slice`. + * + * Writes a compact length header then the referenced bytes. An empty slice + * writes the header only (length 0). + * + * @param slice Non-owning view of the data to append. + * @return Byte offset at which the length header was written. + * @throws std::overflow_error if the slice exceeds 918,744 bytes. + */ int Serializer::addVL(Slice const& slice) { @@ -196,6 +308,16 @@ Serializer::addVL(Slice const& slice) return ret; } +/** Append a variable-length-prefixed blob from a raw pointer. + * + * Writes a compact length header then `len` bytes from `ptr`. When + * `len == 0` only the header is written; `ptr` is not dereferenced. + * + * @param ptr Pointer to the data to append. May be null when `len == 0`. + * @param len Number of bytes to copy. + * @return Byte offset at which the length header was written. + * @throws std::overflow_error if `len` exceeds 918,744. + */ int Serializer::addVL(void const* ptr, int len) { @@ -207,6 +329,17 @@ Serializer::addVL(void const* ptr, int len) return ret; } +/** Write the XRPL variable-length header for a payload of the given size. + * + * Encoding tiers (identical to `decodeLengthLength` / `decodeVLLength`): + * - 0–192 → 1 byte (direct value). + * - 193–12,480 → 2 bytes, bias-193 big-endian. + * - 12,481–918,744 → 3 bytes, bias-12,481 big-endian. + * + * @param length Number of data bytes that will follow. + * @return Byte offset at which the header was written. + * @throws std::overflow_error if `length` exceeds 918,744. + */ int Serializer::addEncoded(int length) { @@ -241,6 +374,15 @@ Serializer::addEncoded(int length) return addRaw(&bytes[0], numBytes); } +/** Return the number of header bytes required to encode a VL prefix of the + * given data length. + * + * Used by `addVL` to assert that the buffer grew by the correct amount. + * + * @param length Data payload size in bytes. + * @return 1, 2, or 3. + * @throws std::overflow_error if `length` is negative or exceeds 918,744. + */ int Serializer::encodeLengthLength(int length) { @@ -260,6 +402,16 @@ Serializer::encodeLengthLength(int length) return 0; // Silence compiler warning. } +/** Return the total number of length-header bytes given the first byte. + * + * Dispatches by first-byte range: ≤192 → 1 byte; 193–240 → 2 bytes; + * 241–254 → 3 bytes. Used by `SerialIter::getVLDataLength` to know how + * many additional bytes to consume before assembling the final length. + * + * @param b1 First byte of the VL header (0–254). + * @return 1, 2, or 3. + * @throws std::overflow_error if `b1` is negative or equals 255 (reserved). + */ int Serializer::decodeLengthLength(int b1) { @@ -279,6 +431,12 @@ Serializer::decodeLengthLength(int b1) return 0; // Silence compiler warning. } +/** Decode a one-byte VL length (0–192 range). + * + * @param b1 The sole header byte (must be 0–254). + * @return The decoded data length. + * @throws std::overflow_error if `b1` is negative or > 254. + */ int Serializer::decodeVLLength(int b1) { @@ -291,6 +449,15 @@ Serializer::decodeVLLength(int b1) return b1; } +/** Decode a two-byte VL length (193–12,480 range). + * + * Formula: `193 + (b1 - 193) * 256 + b2`. + * + * @param b1 First header byte (193–240). + * @param b2 Second header byte. + * @return The decoded data length. + * @throws std::overflow_error if `b1` is outside [193, 240]. + */ int Serializer::decodeVLLength(int b1, int b2) { @@ -303,6 +470,16 @@ Serializer::decodeVLLength(int b1, int b2) return 193 + ((b1 - 193) * 256) + b2; } +/** Decode a three-byte VL length (12,481–918,744 range). + * + * Formula: `12481 + (b1 - 241) * 65536 + b2 * 256 + b3`. + * + * @param b1 First header byte (241–254). + * @param b2 Second header byte. + * @param b3 Third header byte. + * @return The decoded data length. + * @throws std::overflow_error if `b1` is outside [241, 254]. + */ int Serializer::decodeVLLength(int b1, int b2, int b3) { @@ -317,11 +494,25 @@ Serializer::decodeVLLength(int b1, int b2, int b3) //------------------------------------------------------------------------------ +/** Construct a cursor over an existing byte buffer. + * + * The iterator does not take ownership of the buffer; the caller must ensure + * that `data` remains valid for the lifetime of this object. + * + * @param data Pointer to the first byte of the buffer. + * @param size Total number of bytes available. + */ SerialIter::SerialIter(void const* data, std::size_t size) noexcept : p_(reinterpret_cast(data)), remain_(size) { } +/** Rewind the cursor to the beginning of the buffer. + * + * Restores `p_`, `remain_`, and `used_` to their construction-time values. + * Cheap O(1) operation: uses `used_` as the rewind offset rather than + * storing a separate copy of the original pointer. + */ void SerialIter::reset() noexcept { @@ -330,6 +521,11 @@ SerialIter::reset() noexcept used_ = 0; } +/** Advance the cursor by `length` bytes without reading the data. + * + * @param length Number of bytes to skip. + * @throws std::runtime_error if `length` exceeds the remaining bytes. + */ void SerialIter::skip(int length) { @@ -340,6 +536,11 @@ SerialIter::skip(int length) remain_ -= length; } +/** Consume and return the next byte. + * + * @return The byte at the current cursor position. + * @throws std::runtime_error if the buffer is exhausted. + */ unsigned char SerialIter::get8() { @@ -352,6 +553,11 @@ SerialIter::get8() return t; } +/** Consume and decode the next 2 bytes as a big-endian unsigned 16-bit integer. + * + * @return Decoded value. + * @throws std::runtime_error if fewer than 2 bytes remain. + */ std::uint16_t SerialIter::get16() { @@ -364,6 +570,14 @@ SerialIter::get16() return (std::uint64_t(t[0]) << 8) + std::uint64_t(t[1]); } +/** Consume and decode the next 4 bytes as a big-endian unsigned 32-bit integer. + * + * Uses explicit bit-shift assembly; does not perform sign extension. + * Use `geti32` for signed values. + * + * @return Decoded value. + * @throws std::runtime_error if fewer than 4 bytes remain. + */ std::uint32_t SerialIter::get32() { @@ -377,6 +591,14 @@ SerialIter::get32() std::uint64_t(t[3]); } +/** Consume and decode the next 8 bytes as a big-endian unsigned 64-bit integer. + * + * Uses explicit bit-shift assembly; does not perform sign extension. + * Use `geti64` for signed values. + * + * @return Decoded value. + * @throws std::runtime_error if fewer than 8 bytes remain. + */ std::uint64_t SerialIter::get64() { @@ -391,6 +613,14 @@ SerialIter::get64() (std::uint64_t(t[6]) << 8) + std::uint64_t(t[7]); } +/** Consume and decode the next 4 bytes as a big-endian signed 32-bit integer. + * + * Uses `boost::endian::load_big_s32` rather than manual shifts to ensure + * correct two's-complement sign extension for values with the high bit set. + * + * @return Decoded value. + * @throws std::runtime_error if fewer than 4 bytes remain. + */ std::int32_t SerialIter::geti32() { @@ -403,6 +633,14 @@ SerialIter::geti32() return boost::endian::load_big_s32(t); } +/** Consume and decode the next 8 bytes as a big-endian signed 64-bit integer. + * + * Uses `boost::endian::load_big_s64` rather than manual shifts to ensure + * correct two's-complement sign extension for values with the high bit set. + * + * @return Decoded value. + * @throws std::runtime_error if fewer than 8 bytes remain. + */ std::int64_t SerialIter::geti64() { @@ -415,6 +653,18 @@ SerialIter::geti64() return boost::endian::load_big_s64(t); } +/** Decode and consume the next field-ID tag, inverse of `Serializer::addFieldID`. + * + * Reads 1–3 bytes depending on the packing scheme: a high nibble of zero in + * the first byte signals an uncommon type and causes an additional byte to be + * read; a low nibble of zero signals an uncommon name and likewise. + * + * @param type Output: decoded type family code (≥ 1). + * @param name Output: decoded per-type field index (≥ 1). + * @throws std::runtime_error if the buffer is exhausted or an uncommon + * code is decoded as < 16 (which would be ambiguous with the common + * encoding). + */ void SerialIter::getFieldID(int& type, int& name) { @@ -439,7 +689,18 @@ SerialIter::getFieldID(int& type, int& name) } } -// getRaw for blob or buffer +/** Copy `size` bytes from the current cursor position into a new `Blob` or + * `Buffer`. + * + * The `size == 0` guard skips `memcpy` entirely: an empty `Blob`/`Buffer` + * may have a null `data()` pointer, and passing null to `memcpy` even with + * a zero count is undefined behavior in C++ (C99 §7.21.1/2 notwithstanding). + * + * @tparam T Either `Blob` or `Buffer`. + * @param size Number of bytes to copy. + * @return A freshly allocated container holding the copied bytes. + * @throws std::runtime_error if `size` exceeds the remaining bytes. + */ template T SerialIter::getRawHelper(int size) @@ -461,13 +722,32 @@ SerialIter::getRawHelper(int size) return result; } -// VFALCO DEPRECATED Returns a copy +/** @deprecated Prefer `getSlice` to avoid allocation. + * + * Copy `size` bytes from the current position and advance the cursor. + * + * @param size Number of bytes to copy. + * @return A `Blob` containing the copied bytes. + * @throws std::runtime_error if `size` exceeds the remaining bytes. + */ Blob SerialIter::getRaw(int size) { return getRawHelper(size); } +/** Decode and consume the variable-length header, returning the payload size. + * + * Reads 1–3 bytes by first calling `get8()` on the leading byte, then + * using `Serializer::decodeLengthLength` to determine whether 1 or 2 + * additional bytes are needed, and finally calling the matching + * `Serializer::decodeVLLength` overload to assemble the final value. + * After this call the cursor sits at the first byte of the payload. + * + * @return Decoded payload length in bytes. + * @throws std::runtime_error if the buffer is exhausted mid-header. + * @throws std::overflow_error if the first byte is out of the valid range. + */ int SerialIter::getVLDataLength() { @@ -493,6 +773,16 @@ SerialIter::getVLDataLength() return datLen; } +/** Return a zero-copy view of the next `bytes` bytes and advance the cursor. + * + * The returned `Slice` points directly into the underlying buffer; it is + * valid only as long as the buffer outlives the slice. Preferred over + * `getRaw` when an allocation can be avoided. + * + * @param bytes Number of bytes to expose. + * @return A `Slice` referencing the requested region. + * @throws std::runtime_error if `bytes` exceeds the remaining bytes. + */ Slice SerialIter::getSlice(std::size_t bytes) { @@ -505,13 +795,29 @@ SerialIter::getSlice(std::size_t bytes) return s; } -// VFALCO DEPRECATED Returns a copy +/** @deprecated Prefer `getVLBuffer` or read the length with `getVLDataLength` + * then call `getSlice` to avoid allocation. + * + * Decode the VL header and return a copy of the payload as a `Blob`. + * + * @return A `Blob` containing the VL payload. + * @throws std::runtime_error if the buffer is exhausted. + */ Blob SerialIter::getVL() { return getRaw(getVLDataLength()); } +/** Decode the VL header and return the payload as a `Buffer`. + * + * Equivalent to `getVL` but returns a `Buffer` (move-only, no SSO overhead) + * instead of a `Blob`. Prefer this over `getVL` for new callers that do not + * need `Blob` compatibility. + * + * @return A `Buffer` containing the VL payload. + * @throws std::runtime_error if the buffer is exhausted. + */ Buffer SerialIter::getVLBuffer() { diff --git a/src/libxrpl/protocol/Sign.cpp b/src/libxrpl/protocol/Sign.cpp index 9e7ef7f999..6ccb5166d9 100644 --- a/src/libxrpl/protocol/Sign.cpp +++ b/src/libxrpl/protocol/Sign.cpp @@ -1,3 +1,15 @@ +/** @file + * Protocol-level signing and verification for XRPL serialized objects. + * + * Every function here follows the same three-step composition: prepend the + * domain-separation `HashPrefix`, serialize the object via + * `addWithoutSigningFields()` (which excludes signature-carrying fields to + * break circularity), then delegate to the raw cryptographic primitives in + * `SecretKey.h`. The `HashPrefix` guarantees that a valid signature in one + * protocol context (e.g., a single-signed transaction) cannot be replayed as + * a valid signature in another (e.g., a ledger validation). + */ + #include #include @@ -38,37 +50,33 @@ verify(STObject const& st, HashPrefix const& prefix, PublicKey const& pk, SF_VL return verify(pk, Slice(ss.data(), ss.size()), Slice(sig->data(), sig->size())); } -// Questions regarding buildMultiSigningData: -// -// Why do we include the Signer.Account in the blob to be signed? -// -// Unless you include the Account which is signing in the signing blob, -// you could swap out any Signer.Account for any other, which may also -// be on the SignerList and have a RegularKey matching the -// Signer.SigningPubKey. -// -// That RegularKey may be set to allow some 3rd party to sign transactions -// on the account's behalf, and that RegularKey could be common amongst all -// users of the 3rd party. That's just one example of sharing the same -// RegularKey amongst various accounts and just one vulnerability. -// -// "When you have something that's easy to do that makes entire classes of -// attacks clearly and obviously impossible, you need a damn good reason -// not to do it." -- David Schwartz -// -// Why would we include the signingFor account in the blob to be signed? -// -// In the current signing scheme, the account that a signer is `signing -// for/on behalf of` is the tx_json.Account. -// -// Later we might support more levels of signing. Suppose Bob is a signer -// for Alice, and Carol is a signer for Bob, so Carol can sign for Bob who -// signs for Alice. But suppose Alice has two signers: Bob and Dave. If -// Carol is a signer for both Bob and Dave, then the signature needs to -// distinguish between Carol signing for Bob and Carol signing for Dave. -// -// So, if we support multiple levels of signing, then we'll need to -// incorporate the "signing for" accounts into the signing data as well. +/** Build the full multi-signing payload for a single signer. + * + * Serializes `obj` under `HashPrefix::TxMultiSign` (excluding signature + * fields), then appends `signingID` as a raw 160-bit bit-string. The + * result is equivalent to calling `startMultiSigningData` followed by + * `finishMultiSigningData`. + * + * The `signingID` (the signer's `AccountID`) **must** be included in the + * blob. Without it an attacker could substitute any other `SignerList` + * entry whose `RegularKey` happens to match the same third-party key — + * a realistic threat when custodial services share a single signing key + * across many accounts. Including the account ID makes each authorization + * cryptographically specific to that signer slot. + * + * @note If XRPL ever adds nested multi-signing (Carol signs for Bob who + * signs for Alice), each intermediate "signing-for" account ID would + * also need to be incorporated here. The current scheme already + * carries the transaction's `Account` field implicitly via the + * serialized `STObject`, with the signer's own identity appended by + * this function. + * + * @param obj The transaction or object being authorized. + * @param signingID The `AccountID` of the signer authorizing `obj`. + * @return A `Serializer` containing the complete signing payload ready + * for hashing and signing. + * @see startMultiSigningData, finishMultiSigningData + */ Serializer buildMultiSigningData(STObject const& obj, AccountID const& signingID) { @@ -77,6 +85,20 @@ buildMultiSigningData(STObject const& obj, AccountID const& signingID) return s; } +/** Build the shared prefix of a multi-signing payload. + * + * Serializes `obj` under `HashPrefix::TxMultiSign`, omitting all signing + * fields. The returned `Serializer` is identical for every signer of the + * same transaction; callers pass it to `finishMultiSigningData` once per + * signer to append only the small, signer-specific tail. This split + * avoids re-serializing the (potentially large) transaction body for each + * signer during batch verification. + * + * @param obj The transaction or object being authorized. + * @return A `Serializer` containing the shared signing prefix; must be + * completed with `finishMultiSigningData` before use. + * @see finishMultiSigningData, buildMultiSigningData + */ Serializer startMultiSigningData(STObject const& obj) { diff --git a/src/libxrpl/protocol/TER.cpp b/src/libxrpl/protocol/TER.cpp index 3654547dd2..b2f66f6bc0 100644 --- a/src/libxrpl/protocol/TER.cpp +++ b/src/libxrpl/protocol/TER.cpp @@ -1,3 +1,13 @@ +/** @file + * Single authoritative registry mapping every TER code to its symbolic token + * and human-readable description. + * + * This file is a runtime-lookup companion to the compile-time enum + * definitions in TER.h. Its data is consumed by logging, RPC responses, and + * reverse-parsing tools that need to convert between integer codes and their + * string representations. + */ + #include #include @@ -10,13 +20,30 @@ namespace xrpl { +/** Return the complete map of every known TER code to its token and description. + * + * The map is keyed by the underlying integer value of each TER code and maps + * to a pair of C-string literals: the symbolic token (e.g., `"tecNO_DST"`) + * and the English description. Token strings are derived directly from each + * enum identifier via preprocessor stringification (`#code`), so they cannot + * diverge from the actual enum name. + * + * Implemented as a Meyers singleton (function-local static): initialized + * exactly once on first call, thread-safe per the C++11 standard, and + * immutable thereafter. Both the map and its string values are `const`. + * + * @return A `const` reference to the singleton registry map. The reference + * remains valid for the lifetime of the process. + * @note `temUNCERTAIN` and `temUNKNOWN` are registered here as sentinel + * codes that represent intermediate engine states; they should never be + * returned to callers, but their presence ensures readable output if they + * appear in logs. + */ std::unordered_map> const& transResults() { // clang-format off - // Macros are generally ugly, but they can help make code readable to - // humans without affecting the compiler. #define MAKE_ERROR(code, desc) { code, { #code, desc } } static @@ -226,6 +253,19 @@ transResults() return kRESULTS; } +/** Look up the token and human-readable description for a TER code. + * + * Extracts the underlying integer from `code`, performs a hash-map lookup + * against the registry returned by `transResults()`, and populates the + * out-parameters on success. + * + * @param code The TER result code to look up. + * @param token On success, set to the symbolic token string (e.g., + * `"tecNO_DST"`). Unchanged on failure. + * @param text On success, set to the English description string. Unchanged + * on failure. + * @return `true` if `code` is a known registered code; `false` otherwise. + */ bool transResultInfo(TER code, std::string& token, std::string& text) { @@ -241,6 +281,12 @@ transResultInfo(TER code, std::string& token, std::string& text) return true; } +/** Return the symbolic token string for a TER code. + * + * @param code The TER result code to look up. + * @return The token string (e.g., `"tecNO_DST"`) for known codes, or `"-"` + * if `code` is not in the registry. + */ std::string transToken(TER code) { @@ -250,6 +296,12 @@ transToken(TER code) return transResultInfo(code, token, text) ? token : "-"; } +/** Return the human-readable description for a TER code. + * + * @param code The TER result code to look up. + * @return The English description string for known codes, or `"-"` if `code` + * is not in the registry. + */ std::string transHuman(TER code) { @@ -259,6 +311,20 @@ transHuman(TER code) return transResultInfo(code, token, text) ? text : "-"; } +/** Convert a symbolic token string to the corresponding TER code. + * + * Provides the reverse direction of `transToken()`. The reverse map is built + * lazily as a function-local static on the first call by inverting the + * primary registry from `transResults()` using a Boost range adaptor; the + * two maps are therefore guaranteed to stay in sync. + * + * @param token The symbolic token string to look up (e.g., `"tecNO_DST"`). + * @return The corresponding `TER` wrapped in `std::optional`, or + * `std::nullopt` if `token` is not a recognized code name. + * @note The returned `TER` is reconstructed via `TER::fromInt()`, bypassing + * the type-checking constructors, because the integer originates from the + * validated primary registry. + */ std::optional transCode(std::string const& token) { diff --git a/src/libxrpl/protocol/TxFormats.cpp b/src/libxrpl/protocol/TxFormats.cpp index 083889622c..7dcfb70fbe 100644 --- a/src/libxrpl/protocol/TxFormats.cpp +++ b/src/libxrpl/protocol/TxFormats.cpp @@ -1,3 +1,13 @@ +/** @file + * Central registry materializing the field schema for every XRPL transaction + * type. + * + * Each transaction type is registered once in `TxFormats::TxFormats()` by + * expanding `transactions.macro` with a bespoke `TRANSACTION` macro. The + * resulting `SOTemplate` objects govern wire parsing, JSON validation, and + * programmatic construction for the lifetime of the process. + */ + #include #include // IWYU pragma: keep @@ -9,6 +19,33 @@ namespace xrpl { +/** Return the fields shared by every XRPL transaction, regardless of type. + * + * The returned vector is merged with each transaction's unique fields inside + * `KnownFormats::add()` to produce the complete `SOTemplate` for that type. + * Callers must not mutate the returned reference. + * + * Required fields — `sfTransactionType`, `sfAccount`, `sfSequence`, + * `sfFee`, and `sfSigningPubKey` — form the minimum viable transaction + * skeleton; a serialized object missing any of them fails template validation. + * + * Notable optional fields and their protocol roles: + * - `sfPreviousTxnID` — retained for backward compatibility with the pre-027 + * wire format; kept optional rather than removed to avoid invalidating + * older transaction blobs. + * - `sfSigners` — the multi-signature array; coexists with `sfSigningPubKey` + * because single-sig and multi-sig are orthogonal modes at the format + * level. + * - `sfTicketSequence` — allows a transaction to consume a ticket instead of + * the account's current sequence number, enabling out-of-order submission. + * - `sfNetworkID` — lets sidechain networks distinguish their transactions + * from mainnet ones at the wire level. + * - `sfDelegate` — supports the delegation feature, allowing an account to + * authorize another account to act on its behalf. + * + * @return A stable reference to the static common-field list. The vector is + * initialized on the first call and lives for the lifetime of the process. + */ std::vector const& TxFormats::getCommonFields() { @@ -18,7 +55,7 @@ TxFormats::getCommonFields() {sfSourceTag, SoeOptional}, {sfAccount, SoeRequired}, {sfSequence, SoeRequired}, - {sfPreviousTxnID, SoeOptional}, // emulate027 + {sfPreviousTxnID, SoeOptional}, {sfLastLedgerSequence, SoeOptional}, {sfAccountTxnID, SoeOptional}, {sfFee, SoeRequired}, @@ -27,13 +64,32 @@ TxFormats::getCommonFields() {sfSigningPubKey, SoeRequired}, {sfTicketSequence, SoeOptional}, {sfTxnSignature, SoeOptional}, - {sfSigners, SoeOptional}, // submit_multisigned + {sfSigners, SoeOptional}, {sfNetworkID, SoeOptional}, {sfDelegate, SoeOptional}, }; return kCOMMON_FIELDS; } +/** Populate the registry with every known transaction format. + * + * Iterates `transactions.macro` via X-macro expansion, calling + * `KnownFormats::add()` once per transaction type. Each call merges the + * type-specific `SOElement` list with `getCommonFields()` into an + * `SOTemplate` and indexes the result by both `TxType` and name. + * + * The `UNWRAP(...)` helper strips the extra parentheses that surround each + * field list in the macro file — those parens prevent the comma-separated + * field entries from being interpreted as separate macro arguments. + * + * The `push_macro` / `pop_macro` sandwich preserves any pre-existing + * definitions of `TRANSACTION` or `UNWRAP` in the translation unit, avoiding + * hard-to-diagnose macro collisions with platform or third-party headers. + * + * @note A duplicate `TxType` value triggers `logicError()` (process abort) + * inside `KnownFormats::add()`, making type-ID collisions a hard crash + * at static-initialization time rather than a silent runtime bug. + */ TxFormats::TxFormats() { #pragma push_macro("UNWRAP") @@ -53,6 +109,15 @@ TxFormats::TxFormats() #pragma pop_macro("UNWRAP") } +/** Return the process-wide singleton registry of all transaction formats. + * + * The registry is constructed on the first call via a function-local static, + * guaranteeing both lazy initialization and thread-safe construction under + * the C++11 rules for local statics. The same reference is returned on + * every subsequent call. + * + * @return A stable `const` reference to the singleton `TxFormats` instance. + */ TxFormats const& TxFormats::getInstance() { diff --git a/src/libxrpl/protocol/TxMeta.cpp b/src/libxrpl/protocol/TxMeta.cpp index 0373706e84..4b58aa2854 100644 --- a/src/libxrpl/protocol/TxMeta.cpp +++ b/src/libxrpl/protocol/TxMeta.cpp @@ -1,3 +1,10 @@ +/** @file + * Implementation of TxMeta: construction, node accumulation, account + * extraction, and deterministic serialization of per-transaction metadata. + * + * @see TxMeta.h for the public interface. + * @see ApplyStateTable.cpp for the primary caller that drives accumulation. + */ #include #include @@ -22,6 +29,15 @@ namespace xrpl { +/** Construct from an already-parsed STObject (e.g. during ledger replay). + * + * The member initializer list seeds `nodes_` via `getFieldArray`, but the + * constructor body immediately overwrites it using `peekAtPField` + a + * `dynamic_cast`. The redundant first extraction is a mild inefficiency + * retained from earlier refactoring; the authoritative assignment is the + * body `nodes_ = *affectedNodes`. The assertion guards against corrupted + * or malformed ledger data where the runtime type does not match. + */ TxMeta::TxMeta(uint256 const& txid, std::uint32_t ledger, STObject const& obj) : transactionID_(txid), ledgerSeq_(ledger), nodes_(obj.getFieldArray(sfAffectedNodes)) { @@ -36,6 +52,15 @@ TxMeta::TxMeta(uint256 const& txid, std::uint32_t ledger, STObject const& obj) setAdditionalFields(obj); } +/** Deserialize metadata from a raw byte blob (e.g. loaded from the ledger + * database or received over the wire). + * + * Wraps `vec` in a `SerialIter`, materializes a full `STObject` tagged + * `sfMetadata`, then extracts the three required fields. `nodes_` is + * pre-constructed with a capacity of 32 to match the reservation used in + * the empty constructor; the actual contents are populated by + * `getFieldArray`. + */ TxMeta::TxMeta(uint256 const& txid, std::uint32_t ledger, Blob const& vec) : transactionID_(txid), ledgerSeq_(ledger), nodes_(sfAffectedNodes, 32) { @@ -49,6 +74,15 @@ TxMeta::TxMeta(uint256 const& txid, std::uint32_t ledger, Blob const& vec) setAdditionalFields(obj); } +/** Construct an empty metadata object for a transaction being applied. + * + * `result_` is initialized to 255 and `index_` to `UINT32_MAX` as + * sentinels marking the object as not yet finalized. `getAsObject()` + * asserts `result_ != 255`; callers must invoke `addRaw()` before + * serializing. The `nodes_` array pre-reserves 32 slots to avoid + * reallocation during accumulation for all but the most complex + * transactions. + */ TxMeta::TxMeta(uint256 const& transactionID, std::uint32_t ledger) : transactionID_(transactionID) , ledgerSeq_(ledger) @@ -59,10 +93,22 @@ TxMeta::TxMeta(uint256 const& transactionID, std::uint32_t ledger) nodes_.reserve(32); } +/** Register or update a ledger entry in the affected-nodes list. + * + * If an entry with the same `sfLedgerIndex` already exists, its `type` + * and `sfLedgerEntryType` fields are updated in place. Otherwise a new + * `STObject` is appended to `nodes_`. The linear scan is deliberate: + * the affected-node count per transaction is small (typically well under + * 32) and a map would add overhead without benefit. + * + * @param node The ledger-entry key (`sfLedgerIndex`) being affected. + * @param type The node category — `sfCreatedNode`, `sfModifiedNode`, or + * `sfDeletedNode`. + * @param nodeType The `sfLedgerEntryType` value (e.g. `ltACCOUNT_ROOT`). + */ void TxMeta::setAffectedNode(uint256 const& node, SField const& type, std::uint16_t nodeType) { - // make sure the node exists and force its type for (auto& n : nodes_) { if (n.getFieldH256(sfLedgerIndex) == node) @@ -81,14 +127,33 @@ TxMeta::setAffectedNode(uint256 const& node, SField const& type, std::uint16_t n obj.setFieldU16(sfLedgerEntryType, nodeType); } +/** Collect all AccountIDs whose ledger state was touched by this transaction. + * + * Scans `sfNewFields` for `sfCreatedNode` entries and `sfFinalFields` for + * modified and deleted nodes — the asymmetry reflects the metadata schema, + * where newly created objects record state in `sfNewFields` while + * modified/deleted objects record their last-known state in `sfFinalFields`. + * + * Three distinct field shapes are handled: + * - `STAccount` fields — account IDs stored directly (e.g. `sfAccount`, + * `sfDestination`). + * - `STAmount` fields `sfLowLimit`, `sfHighLimit`, `sfTakerPays`, + * `sfTakerGets` — trust-line and order-book amounts that embed an + * issuer `AccountID`. + * - `sfMPTokenIssuanceID` — a 192-bit `STBitString` from which the issuer + * `AccountID` is recovered via `MPTIssue`. + * + * @note The behavior of this method must remain identical to that of the + * JavaScript `Meta#getAffectedAccounts` method to preserve + * cross-platform consistency. + * @return Sorted, deduplicated set of affected `AccountID` values. + */ boost::container::flat_set TxMeta::getAffectedAccounts() const { boost::container::flat_set list; list.reserve(10); - // This code should match the behavior of the JS method: - // Meta#getAffectedAccounts for (auto const& node : nodes_) { int const index = @@ -145,6 +210,19 @@ TxMeta::getAffectedAccounts() const return list; } +/** Retrieve the metadata node for a ledger entry, creating it if absent. + * + * Used by `ApplyStateTable` in the second accumulation phase, after + * `setAffectedNode` has registered the entry, to attach `sfPreviousFields`, + * `sfFinalFields`, or `sfNewFields` sub-objects describing field-level + * deltas. Unlike the `uint256` overload this variant silently creates a new + * entry when the node has not been previously registered. + * + * @param node The ledger entry whose metadata node to retrieve or create. + * @param type The node category field (`sfCreatedNode`, `sfModifiedNode`, + * or `sfDeletedNode`) used when creating a new entry. + * @return Reference to the `STObject` node in `nodes_`. + */ STObject& TxMeta::getAffectedNode(SLE::ref node, SField const& type) { @@ -165,6 +243,18 @@ TxMeta::getAffectedNode(SLE::ref node, SField const& type) return obj; } +/** Retrieve the metadata node for a ledger-entry key, asserting it exists. + * + * Internal variant called only after `setAffectedNode` has guaranteed + * registration of the given key. If the node is absent the call is a + * programming error: `UNREACHABLE` fires in debug builds and + * `std::runtime_error` is thrown as a last-resort guard in release. + * + * @param node The `sfLedgerIndex` key to look up. + * @return Reference to the matching `STObject` node in `nodes_`. + * @throws std::runtime_error If the node is not present (should never + * occur in correct callers; the error path is excluded from coverage). + */ STObject& TxMeta::getAffectedNode(uint256 const& node) { @@ -180,6 +270,17 @@ TxMeta::getAffectedNode(uint256 const& node) // LCOV_EXCL_STOP } +/** Assemble the complete metadata as an STObject. + * + * Includes the three required fields (`sfTransactionResult`, + * `sfTransactionIndex`, `sfAffectedNodes`) and, when present, the optional + * `sfDeliveredAmount` and `sfParentBatchID` fields. + * + * @note Asserts `result_ != 255` to catch calls made before `addRaw()` + * has finalized the result code. Emitting metadata with the sentinel + * value would silently corrupt the ledger record. + * @return A fully populated `STObject` tagged `sfTransactionMetaData`. + */ STObject TxMeta::getAsObject() const { @@ -197,6 +298,20 @@ TxMeta::getAsObject() const return metaData; } +/** Finalize and serialize metadata into a `Serializer`. + * + * Stamps `result_` and `index_` with the actual `TER` outcome and ledger + * position, then sorts `nodes_` by `sfLedgerIndex` before delegating to + * `getAsObject().add(s)`. The sort is critical for consensus: two + * validators applying the same transaction must produce byte-identical + * metadata blobs, and map-iteration order over `ApplyStateTable::items_` + * is not guaranteed stable across implementations. + * + * @param s Serializer to append the metadata bytes to. + * @param result The `TER` outcome of the transaction. + * @param index The transaction's position within the closed ledger + * (from `OpenView::txCount()`). + */ void TxMeta::addRaw(Serializer& s, TER result, std::uint32_t index) { diff --git a/src/libxrpl/protocol/UintTypes.cpp b/src/libxrpl/protocol/UintTypes.cpp index adef5e2c14..6b954c7d52 100644 --- a/src/libxrpl/protocol/UintTypes.cpp +++ b/src/libxrpl/protocol/UintTypes.cpp @@ -1,3 +1,13 @@ +/** @file + * Currency code serialization for the XRPL protocol. + * + * Implements the canonical mapping between 160-bit `Currency` values and + * human-readable strings, and defines the three protocol sentinel currencies + * (`xrpCurrency`, `noCurrency`, `badCurrency`). + * + * @see https://xrpl.org/serialization.html#currency-codes + */ + #include #include @@ -11,27 +21,48 @@ namespace xrpl { -// For details on the protocol-level serialization please visit -// https://xrpl.org/serialization.html#currency-codes - namespace detail { -// Characters we are willing to allow in the ASCII representation of a -// three-letter currency code. +/** Characters accepted in a three-character ISO-style currency code. + * + * This set is intentionally broader than strict ISO 4217 (which allows only + * `[A-Z]`), accommodating XRPL's extended custom-currency ecosystem. + * Any 3-byte sequence composed entirely of these characters is treated as an + * ISO-style code during both parsing and serialization. + */ constexpr std::string_view kISO_CHAR_SET = "abcdefghijklmnopqrstuvwxyz" "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "0123456789" "<>(){}[]|?!@#$%^&*"; -// The location (in bytes) of the 3 digit currency inside a 160-bit value +/** Byte offset of the 3-character currency code within a 160-bit Currency value. + * + * Per the XRPL serialization spec, bytes 0–11 and 15–19 of an ISO-style + * currency must be zero; the ASCII code occupies bytes 12–14. + */ constexpr std::size_t kISO_CODE_OFFSET = 12; -// The length of an ISO-4217 like code +/** Length in bytes of an ISO-style currency code. */ constexpr std::size_t kISO_CODE_LENGTH = 3; } // namespace detail +/** Convert a 160-bit Currency value to its canonical string representation. + * + * Applies a priority-ordered decision tree: + * 1. Zero value → `"XRP"` (the native asset). + * 2. `noCurrency()` → `"1"`. + * 3. All non-ISO bytes zero and three-character code in `kISO_CHAR_SET` + * and not equal to `"XRP"` → the three-character string. + * 4. Everything else → a 40-character uppercase hex string. + * + * @param currency The currency value to convert. + * @return A string in one of the four forms described above. + * @note A currency whose ISO position contains `"XRP"` but whose + * surrounding bytes are not all zero is returned as hex, making the + * anomaly visible rather than silently aliasing to the native currency. + */ std::string to_string(Currency const& currency) { @@ -61,6 +92,22 @@ to_string(Currency const& currency) return strHex(currency); } +/** Parse a string into a Currency value, reporting success via return value. + * + * Accepted forms: + * - Empty string or `"XRP"` → sets `currency` to zero (`xrpCurrency()`). + * - Exactly 3 characters from `kISO_CHAR_SET` → zero-pads and writes the + * code at byte offset 12 of `currency`. + * - 40-character hex string → parsed directly into `currency`. + * - Anything else → returns `false` and leaves `currency` unmodified. + * + * @param currency Output parameter set on success. + * @param code The string to parse. + * @return `true` if parsing succeeded, `false` otherwise. + * @note Returns `true` even when the result is `badCurrency()`. This + * legacy behaviour is preserved to avoid breaking existing callers; + * use the return value (not the currency value) to detect failure. + */ bool toCurrency(Currency& currency, std::string const& code) { @@ -86,6 +133,18 @@ toCurrency(Currency& currency, std::string const& code) return currency.parseHex(code); } +/** Parse a string into a Currency value, returning `noCurrency()` on failure. + * + * Convenience wrapper around `toCurrency(Currency&, std::string const&)`. + * Prefer the two-argument overload when the caller must distinguish a parse + * failure from a legitimately absent currency. + * + * @param code The string to parse. + * @return The parsed `Currency`, or `noCurrency()` if the string is invalid. + * @note Can return `badCurrency()` for hex input that encodes that sentinel. + * This legacy behaviour is preserved; callers should validate the result + * if they need to exclude `badCurrency()`. + */ Currency toCurrency(std::string const& code) { @@ -95,6 +154,15 @@ toCurrency(std::string const& code) return currency; } +/** The canonical representation of XRP, the ledger's native asset. + * + * Returns the all-zeros `Currency` value (`beast::kZERO`). This is the + * protocol-defined encoding for XRP; `isXRP()` tests against this value. + * The returned reference is to a function-local static and is valid for + * the lifetime of the process. + * + * @return A reference to the singleton zero `Currency`. + */ Currency const& xrpCurrency() { @@ -102,6 +170,14 @@ xrpCurrency() return kCURRENCY; } +/** A placeholder currency used when no currency is specified. + * + * Holds the value `1`. Data structures that require a `Currency` slot but + * have no meaningful currency to store use this sentinel. The returned + * reference is to a function-local static valid for the process lifetime. + * + * @return A reference to the singleton `Currency{1}`. + */ Currency const& noCurrency() { @@ -109,6 +185,18 @@ noCurrency() return kCURRENCY; } +/** A poisoned sentinel that marks the "XRP as ISO code" misuse. + * + * Early adopters sometimes encoded XRP as the three-letter ISO code + * `"XRP"` (yielding value `0x5852500000000000`) rather than using the + * correct all-zeros form. This sentinel exists so that code paths that + * encounter such values can detect and reject them explicitly rather than + * silently treating them as valid currency. `to_string()` on this value + * produces a hex string, not `"XRP"`. The returned reference is to a + * function-local static valid for the process lifetime. + * + * @return A reference to the singleton `Currency{0x5852500000000000}`. + */ Currency const& badCurrency() { diff --git a/src/libxrpl/protocol/XChainAttestations.cpp b/src/libxrpl/protocol/XChainAttestations.cpp index f89ad6a52c..0bbf33c020 100644 --- a/src/libxrpl/protocol/XChainAttestations.cpp +++ b/src/libxrpl/protocol/XChainAttestations.cpp @@ -1,3 +1,19 @@ +/** @file + * Implements the attestation type system for XRPL's cross-chain bridge + * protocol. + * + * Two parallel hierarchies exist: `Attestations::AttestationClaim` and + * `Attestations::AttestationCreateAccount` carry full witness-submitted + * proofs including raw signatures; `XChainClaimAttestation` and + * `XChainCreateAccountAttestation` are the stripped, ledger-stored variants + * that retain only the key identity and event fields. Conversion from the + * signing side to the storage side is a one-step projection in the + * `TSignedAttestation` constructors. + * + * Template bodies for `XChainAttestationsBase` are kept here + * (not in the header) and explicitly instantiated at the bottom of the file + * for the two concrete types, limiting compile-time overhead. + */ #include #include @@ -26,6 +42,7 @@ namespace xrpl { namespace Attestations { +/** Construct from individual field values supplied by the witness server. */ AttestationBase::AttestationBase( AccountID attestationSignerAccount, PublicKey const& publicKey, @@ -44,6 +61,17 @@ AttestationBase::AttestationBase( { } +/** Compare all fields of two `AttestationBase` instances, including signer + * identity and raw signature bytes. + * + * Used by subclass `operator==` to test whether two attestations are + * identical in every respect. Compare with `sameEventHelper`, which + * intentionally excludes the signer fields. + * + * @param lhs Left-hand attestation. + * @param rhs Right-hand attestation. + * @return `true` if every base field matches. + */ bool AttestationBase::equalHelper(AttestationBase const& lhs, AttestationBase const& rhs) { @@ -65,6 +93,19 @@ AttestationBase::equalHelper(AttestationBase const& lhs, AttestationBase const& rhs.wasLockingChainSend); } +/** Check whether two attestations witness the same cross-chain event, + * ignoring signer identity. + * + * Two attestations from different witnesses for the same transfer share + * identical `sendingAccount`, `sendingAmount`, and `wasLockingChainSend` + * values. Signer fields (`attestationSignerAccount`, `publicKey`, + * `signature`) are deliberately excluded so that distinct witnesses + * corroborating the same event can be aggregated toward quorum. + * + * @param lhs Left-hand attestation. + * @param rhs Right-hand attestation. + * @return `true` if the event-identity fields match. + */ bool AttestationBase::sameEventHelper(AttestationBase const& lhs, AttestationBase const& rhs) { @@ -72,6 +113,18 @@ AttestationBase::sameEventHelper(AttestationBase const& lhs, AttestationBase con std::tie(rhs.sendingAccount, rhs.sendingAmount, rhs.wasLockingChainSend); } +/** Cryptographically verify the witness signature against the stored fields. + * + * Re-derives the canonical message bytes via the virtual `message()` call, + * then checks them against `publicKey` and `signature`. Called during + * transaction preflight (`attestationPreflight` in `XChainBridge.cpp`); a + * failure here returns `temXCHAIN_BAD_PROOF` before any ledger state is + * modified. + * + * @param bridge The bridge the attestation relates to; included in the + * signed payload to scope the proof to a specific bridge instance. + * @return `true` if the signature is valid. + */ bool AttestationBase::verify(STXChainBridge const& bridge) const { @@ -79,6 +132,7 @@ AttestationBase::verify(STXChainBridge const& bridge) const return xrpl::verify(publicKey, makeSlice(msg), signature); } +/** Deserialize from a ledger `STObject`. */ AttestationBase::AttestationBase(STObject const& o) : attestationSignerAccount{o[sfAttestationSignerAccount]} , publicKey{o[sfPublicKey]} @@ -90,6 +144,11 @@ AttestationBase::AttestationBase(STObject const& o) { } +/** Deserialize from a JSON value. + * + * @throws std::runtime_error if any required field is missing or has the + * wrong type (via `json::getOrThrow`). + */ AttestationBase::AttestationBase(json::Value const& v) : attestationSignerAccount{json::getOrThrow(v, sfAttestationSignerAccount)} , publicKey{json::getOrThrow(v, sfPublicKey)} @@ -101,6 +160,13 @@ AttestationBase::AttestationBase(json::Value const& v) { } +/** Populate `o` with the base attestation fields shared by both claim types. + * + * Subclass `toSTObject()` implementations call this before setting their + * own type-specific fields. + * + * @param o The `STObject` to populate. + */ void AttestationBase::addHelper(STObject& o) const { @@ -113,6 +179,7 @@ AttestationBase::addHelper(STObject& o) const o[sfWasLockingChainSend] = wasLockingChainSend; } +/** Construct from individual fields with a pre-computed signature. */ AttestationClaim::AttestationClaim( AccountID attestationSignerAccount, PublicKey const& publicKey, @@ -136,6 +203,16 @@ AttestationClaim::AttestationClaim( { } +/** Construct and immediately sign. + * + * Derives the canonical message bytes from the supplied fields and `bridge`, + * then signs them with `secretKey`. Intended for witness servers and test + * harnesses that generate attestations from scratch. + * + * @param bridge Bridge context included in the signed payload. + * @param secretKey Signing key; the resulting signature is stored in + * `AttestationBase::signature`. + */ AttestationClaim::AttestationClaim( STXChainBridge const& bridge, AccountID attestationSignerAccount, @@ -162,11 +239,17 @@ AttestationClaim::AttestationClaim( signature = sign(publicKey, secretKey, makeSlice(toSign)); } +/** Deserialize from a ledger `STObject`. */ AttestationClaim::AttestationClaim(STObject const& o) : AttestationBase(o), claimID{o[sfXChainClaimID]}, dst{o[~sfDestination]} { } +/** Deserialize from a JSON value. + * + * @throws std::runtime_error if any required field is missing or has the + * wrong type (via `json::getOrThrow`). + */ AttestationClaim::AttestationClaim(json::Value const& v) : AttestationBase{v}, claimID{json::getOrThrow(v, sfXChainClaimID)} { @@ -174,6 +257,12 @@ AttestationClaim::AttestationClaim(json::Value const& v) dst = json::getOrThrow(v, sfDestination); } +/** Serialize this attestation to an `STObject` for inclusion in a transaction + * or `STArray`. + * + * @return An inner object tagged `sfXChainClaimAttestationCollectionElement` + * containing all claim attestation fields. + */ STObject AttestationClaim::toSTObject() const { @@ -185,6 +274,23 @@ AttestationClaim::toSTObject() const return o; } +/** Produce the canonical bytes that a witness signs for a claim attestation. + * + * Builds an `STObject{sfGeneric}` populated with all claim fields and + * serializes it via `Serializer::add()`. Fields are inserted in `SField` + * sort order to ensure independent serializers (e.g., Python witness + * implementations) produce byte-for-byte identical output. + * + * @param bridge Bridge context scoping the proof. + * @param sendingAccount Source account on the sending chain. + * @param sendingAmount Amount transferred on the sending chain. + * @param rewardAccount Destination-chain account receiving the reward share. + * @param wasLockingChainSend `true` if the transfer originated on the + * locking chain. + * @param claimID Monotonic counter from the bridge that prevents replay. + * @param dst Optional destination override on the issuing chain. + * @return Serialized bytes suitable for signing or verification. + */ std::vector AttestationClaim::message( STXChainBridge const& bridge, @@ -212,6 +318,14 @@ AttestationClaim::message( return std::move(s.modData()); } +/** Instance overload delegating to the static form using stored field values. + * + * Called by `AttestationBase::verify()` to regenerate the signed payload + * for signature checking. + * + * @param bridge Bridge context scoping the proof. + * @return Serialized bytes identical to those that were originally signed. + */ std::vector AttestationClaim::message(STXChainBridge const& bridge) const { @@ -219,12 +333,27 @@ AttestationClaim::message(STXChainBridge const& bridge) const bridge, sendingAccount, sendingAmount, rewardAccount, wasLockingChainSend, claimID, dst); } +/** Check that `sendingAmount` is a legal network amount. + * + * @return `true` if the amount is valid for wire transmission. + */ bool AttestationClaim::validAmounts() const { return isLegalNet(sendingAmount); } +/** Check whether `rhs` witnesses the same cross-chain claim event, ignoring + * signer identity fields. + * + * Two attestations for the same event may differ only in their + * `attestationSignerAccount`, `publicKey`, and `signature` (i.e., they + * come from different witnesses). Both the base event fields and the + * claim-specific `claimID` and `dst` must agree. + * + * @param rhs The attestation to compare against. + * @return `true` if both attestations describe the same claim event. + */ bool AttestationClaim::sameEvent(AttestationClaim const& rhs) const { @@ -232,6 +361,9 @@ AttestationClaim::sameEvent(AttestationClaim const& rhs) const tie(claimID, dst) == tie(rhs.claimID, rhs.dst); } +/** Test full equality of two `AttestationClaim` values, including signer + * identity and raw signature. + */ bool operator==(AttestationClaim const& lhs, AttestationClaim const& rhs) { @@ -239,6 +371,7 @@ operator==(AttestationClaim const& lhs, AttestationClaim const& rhs) tie(lhs.claimID, lhs.dst) == tie(rhs.claimID, rhs.dst); } +/** Deserialize from a ledger `STObject`. */ AttestationCreateAccount::AttestationCreateAccount(STObject const& o) : AttestationBase(o) , createCount{o[sfXChainAccountCreateCount]} @@ -247,6 +380,11 @@ AttestationCreateAccount::AttestationCreateAccount(STObject const& o) { } +/** Deserialize from a JSON value. + * + * @throws std::runtime_error if any required field is missing or has the + * wrong type (via `json::getOrThrow`). + */ AttestationCreateAccount::AttestationCreateAccount(json::Value const& v) : AttestationBase{v} , createCount{json::getOrThrow(v, sfXChainAccountCreateCount)} @@ -255,6 +393,7 @@ AttestationCreateAccount::AttestationCreateAccount(json::Value const& v) { } +/** Construct from individual fields with a pre-computed signature. */ AttestationCreateAccount::AttestationCreateAccount( AccountID attestationSignerAccount, PublicKey const& publicKey, @@ -280,6 +419,16 @@ AttestationCreateAccount::AttestationCreateAccount( { } +/** Construct and immediately sign. + * + * Derives the canonical message bytes from the supplied fields and `bridge`, + * then signs them with `secretKey`. Intended for witness servers and test + * harnesses that generate account-creation attestations from scratch. + * + * @param bridge Bridge context included in the signed payload. + * @param secretKey Signing key; the resulting signature is stored in + * `AttestationBase::signature`. + */ AttestationCreateAccount::AttestationCreateAccount( STXChainBridge const& bridge, AccountID attestationSignerAccount, @@ -308,6 +457,13 @@ AttestationCreateAccount::AttestationCreateAccount( signature = sign(publicKey, secretKey, makeSlice(toSign)); } +/** Serialize this attestation to an `STObject` for inclusion in a transaction + * or `STArray`. + * + * @return An inner object tagged + * `sfXChainCreateAccountAttestationCollectionElement` containing all + * account-creation attestation fields. + */ STObject AttestationCreateAccount::toSTObject() const { @@ -321,6 +477,27 @@ AttestationCreateAccount::toSTObject() const return o; } +/** Produce the canonical bytes that a witness signs for an account-creation + * attestation. + * + * Builds an `STObject{sfGeneric}` with all account-creation fields and + * serializes it via `Serializer::add()`. Fields are inserted in `SField` + * sort order to ensure cross-language serializers produce byte-for-byte + * identical output. + * + * @param bridge Bridge context scoping the proof. + * @param sendingAccount Source account on the sending chain. + * @param sendingAmount Amount transferred on the sending chain. + * @param rewardAmount Total size of the witness-reward pool for this event. + * @param rewardAccount Destination-chain account receiving this witness's + * reward share. + * @param wasLockingChainSend `true` if the transfer originated on the + * locking chain. + * @param createCount Value of `XChainAccountCreateCount` on the sending- + * chain bridge at the time of the event; prevents replay. + * @param dst Account to create on the destination chain. + * @return Serialized bytes suitable for signing or verification. + */ std::vector AttestationCreateAccount::message( STXChainBridge const& bridge, @@ -349,6 +526,14 @@ AttestationCreateAccount::message( return std::move(s.modData()); } +/** Instance overload delegating to the static form using stored field values. + * + * Called by `AttestationBase::verify()` to regenerate the signed payload + * for signature checking. + * + * @param bridge Bridge context scoping the proof. + * @return Serialized bytes identical to those that were originally signed. + */ std::vector AttestationCreateAccount::message(STXChainBridge const& bridge) const { @@ -363,12 +548,25 @@ AttestationCreateAccount::message(STXChainBridge const& bridge) const toCreate); } +/** Check that both `sendingAmount` and `rewardAmount` are legal network amounts. + * + * @return `true` if both amounts are valid for wire transmission. + */ bool AttestationCreateAccount::validAmounts() const { return isLegalNet(rewardAmount) && isLegalNet(sendingAmount); } +/** Check whether `rhs` witnesses the same cross-chain account-creation event, + * ignoring signer identity fields. + * + * The base event fields plus the create-specific `createCount`, `toCreate`, + * and `rewardAmount` must all agree. + * + * @param rhs The attestation to compare against. + * @return `true` if both attestations describe the same account-creation event. + */ bool AttestationCreateAccount::sameEvent(AttestationCreateAccount const& rhs) const { @@ -377,6 +575,9 @@ AttestationCreateAccount::sameEvent(AttestationCreateAccount const& rhs) const std::tie(rhs.createCount, rhs.toCreate, rhs.rewardAmount); } +/** Test full equality of two `AttestationCreateAccount` values, including + * signer identity and raw signature. + */ bool operator==(AttestationCreateAccount const& lhs, AttestationCreateAccount const& rhs) { @@ -387,9 +588,19 @@ operator==(AttestationCreateAccount const& lhs, AttestationCreateAccount const& } // namespace Attestations +/** `SField` used to name the `STArray` containing claim attestations in + * ledger objects. + */ SField const& XChainClaimAttestation::arrayFieldName{sfXChainClaimAttestations}; + +/** `SField` used to name the `STArray` containing account-creation + * attestations in ledger objects. + */ SField const& XChainCreateAccountAttestation::arrayFieldName{sfXChainCreateAccountAttestations}; +/** Construct from individual field values, used when promoting a ledger-stored + * entry or creating a test fixture. + */ XChainClaimAttestation::XChainClaimAttestation( AccountID const& keyAccount, PublicKey const& publicKey, @@ -406,6 +617,11 @@ XChainClaimAttestation::XChainClaimAttestation( { } +/** Construct from `STAccount` fields, unwrapping the `AccountID` values. + * + * Convenience overload used when deserializing from an `STObject` whose + * account fields are already wrapped in `STAccount`. + */ XChainClaimAttestation::XChainClaimAttestation( STAccount const& keyAccount, PublicKey const& publicKey, @@ -423,6 +639,7 @@ XChainClaimAttestation::XChainClaimAttestation( { } +/** Deserialize from a ledger `STObject`. */ XChainClaimAttestation::XChainClaimAttestation(STObject const& o) : XChainClaimAttestation{ o[sfAttestationSignerAccount], @@ -432,6 +649,11 @@ XChainClaimAttestation::XChainClaimAttestation(STObject const& o) o[sfWasLockingChainSend] != 0, o[~sfDestination]} {}; +/** Deserialize from a JSON value. + * + * @throws std::runtime_error if any required field is missing or has the + * wrong type (via `json::getOrThrow`). + */ XChainClaimAttestation::XChainClaimAttestation(json::Value const& v) : XChainClaimAttestation{ json::getOrThrow(v, sfAttestationSignerAccount), @@ -445,6 +667,15 @@ XChainClaimAttestation::XChainClaimAttestation(json::Value const& v) dst = json::getOrThrow(v, sfDestination); }; +/** Project a signing-side `AttestationClaim` into its ledger-storage form, + * dropping the raw signature. + * + * The raw `signature` and signer-account fields are stripped; only the + * event-identity and key-account fields that need to persist on-ledger are + * retained. + * + * @param claimAtt The full witness-submitted attestation to convert. + */ XChainClaimAttestation::XChainClaimAttestation( XChainClaimAttestation::TSignedAttestation const& claimAtt) : XChainClaimAttestation{ @@ -457,6 +688,11 @@ XChainClaimAttestation::XChainClaimAttestation( { } +/** Serialize this ledger-stored attestation to an `STObject`. + * + * @return An inner object tagged `sfXChainClaimProofSig` containing the + * ledger-side claim attestation fields (no raw signature). + */ STObject XChainClaimAttestation::toSTObject() const { @@ -471,6 +707,7 @@ XChainClaimAttestation::toSTObject() const return o; } +/** Test equality of two ledger-stored claim attestations. */ bool operator==(XChainClaimAttestation const& lhs, XChainClaimAttestation const& rhs) { @@ -490,12 +727,30 @@ operator==(XChainClaimAttestation const& lhs, XChainClaimAttestation const& rhs) rhs.dst); } +/** Construct `MatchFields` from the signing-side representation, projecting + * only the fields used for quorum matching. + * + * @param att The full witness-submitted attestation to extract match fields from. + */ XChainClaimAttestation::MatchFields::MatchFields( XChainClaimAttestation::TSignedAttestation const& att) : amount{att.sendingAmount}, wasLockingChainSend{att.wasLockingChainSend}, dst{att.dst} { } +/** Determine how closely this stored attestation matches the supplied fields. + * + * The three-state result lets callers distinguish a fully matching + * attestation from one that matches except for the destination: + * + * - `XChainAddClaimAttestation` transactions require `Match` — all witnesses + * must agree on the destination. + * - `XChainClaim` transactions specify their own destination, so + * `MatchExceptDst` is also accepted (the user overrides the dst). + * + * @param rhs The fields from the incoming attestation or claim request. + * @return `Match`, `MatchExceptDst`, or `NonDstMismatch`. + */ AttestationMatch XChainClaimAttestation::match(XChainClaimAttestation::MatchFields const& rhs) const { @@ -508,6 +763,7 @@ XChainClaimAttestation::match(XChainClaimAttestation::MatchFields const& rhs) co //------------------------------------------------------------------------------ +/** Construct from individual field values. */ XChainCreateAccountAttestation::XChainCreateAccountAttestation( AccountID const& keyAccount, PublicKey const& publicKey, @@ -526,6 +782,7 @@ XChainCreateAccountAttestation::XChainCreateAccountAttestation( { } +/** Deserialize from a ledger `STObject`. */ XChainCreateAccountAttestation::XChainCreateAccountAttestation(STObject const& o) : XChainCreateAccountAttestation{ o[sfAttestationSignerAccount], @@ -536,6 +793,11 @@ XChainCreateAccountAttestation::XChainCreateAccountAttestation(STObject const& o o[sfWasLockingChainSend] != 0, o[sfDestination]} {}; +/** Deserialize from a JSON value. + * + * @throws std::runtime_error if any required field is missing or has the + * wrong type (via `json::getOrThrow`). + */ XChainCreateAccountAttestation ::XChainCreateAccountAttestation(json::Value const& v) : XChainCreateAccountAttestation{ json::getOrThrow(v, sfAttestationSignerAccount), @@ -548,6 +810,11 @@ XChainCreateAccountAttestation ::XChainCreateAccountAttestation(json::Value cons { } +/** Project a signing-side `AttestationCreateAccount` into its ledger-storage + * form, dropping the raw signature. + * + * @param createAtt The full witness-submitted attestation to convert. + */ XChainCreateAccountAttestation::XChainCreateAccountAttestation( XChainCreateAccountAttestation::TSignedAttestation const& createAtt) : XChainCreateAccountAttestation{ @@ -561,6 +828,11 @@ XChainCreateAccountAttestation::XChainCreateAccountAttestation( { } +/** Serialize this ledger-stored attestation to an `STObject`. + * + * @return An inner object tagged `sfXChainCreateAccountProofSig` containing + * the ledger-side account-creation attestation fields (no raw signature). + */ STObject XChainCreateAccountAttestation::toSTObject() const { @@ -577,6 +849,11 @@ XChainCreateAccountAttestation::toSTObject() const return o; } +/** Construct `MatchFields` from the signing-side representation, projecting + * only the fields used for quorum matching. + * + * @param att The full witness-submitted attestation to extract match fields from. + */ XChainCreateAccountAttestation::MatchFields::MatchFields( XChainCreateAccountAttestation::TSignedAttestation const& att) : amount{att.sendingAmount} @@ -586,6 +863,15 @@ XChainCreateAccountAttestation::MatchFields::MatchFields( { } +/** Determine how closely this stored attestation matches the supplied fields. + * + * Returns the same three-state `AttestationMatch` as the claim variant; + * for account-creation, `amount`, `rewardAmount`, and `wasLockingChainSend` + * must all agree before the destination is considered. + * + * @param rhs The fields from the incoming attestation or claim request. + * @return `Match`, `MatchExceptDst`, or `NonDstMismatch`. + */ AttestationMatch XChainCreateAccountAttestation::match(XChainCreateAccountAttestation::MatchFields const& rhs) const { @@ -597,6 +883,7 @@ XChainCreateAccountAttestation::match(XChainCreateAccountAttestation::MatchField return AttestationMatch::Match; } +/** Test equality of two ledger-stored account-creation attestations. */ bool operator==(XChainCreateAccountAttestation const& lhs, XChainCreateAccountAttestation const& rhs) { @@ -619,7 +906,11 @@ operator==(XChainCreateAccountAttestation const& lhs, XChainCreateAccountAttesta } //------------------------------------------------------------------------------ -// + +/** Construct from a pre-built collection. + * + * @param atts Attestation vector to take ownership of. + */ template XChainAttestationsBase::XChainAttestationsBase( XChainAttestationsBase::AttCollection&& atts) @@ -655,6 +946,12 @@ XChainAttestationsBase::end() return attestations_.end(); } +/** Deserialize from a JSON value containing an `"attestations"` array. + * + * @throws std::runtime_error if `v` is not a JSON object, if the array + * exceeds `kMAX_ATTESTATIONS` (256), or if any element fails to + * deserialize. + */ template XChainAttestationsBase::XChainAttestationsBase(json::Value const& v) { @@ -679,6 +976,11 @@ XChainAttestationsBase::XChainAttestationsBase(json::Value const& }(); } +/** Deserialize from an `STArray` read out of a ledger object. + * + * @throws std::runtime_error if `arr` contains more than `kMAX_ATTESTATIONS` + * (256) elements. + */ template XChainAttestationsBase::XChainAttestationsBase(STArray const& arr) { @@ -690,6 +992,11 @@ XChainAttestationsBase::XChainAttestationsBase(STArray const& arr) attestations_.emplace_back(o); } +/** Serialize the collection to an `STArray` for storage in a ledger object. + * + * @return An `STArray` tagged with `TAttestation::arrayFieldName` whose + * elements are the `STObject` representations of each attestation. + */ template STArray XChainAttestationsBase::toSTArray() const @@ -700,6 +1007,8 @@ XChainAttestationsBase::toSTArray() const return r; } +// Explicit instantiations keep template bodies in this translation unit, +// avoiding recompilation of the full implementation in every consumer. template class XChainAttestationsBase; template class XChainAttestationsBase; diff --git a/src/libxrpl/tx/paths/BookStep.cpp b/src/libxrpl/tx/paths/BookStep.cpp index e483252e7f..69b33b4f1d 100644 --- a/src/libxrpl/tx/paths/BookStep.cpp +++ b/src/libxrpl/tx/paths/BookStep.cpp @@ -353,11 +353,20 @@ private: [[nodiscard]] bool equal(Step const& rhs) const override; - // Iterate through the offers at the best quality in a book. - // Unfunded offers and bad offers are skipped (and returned). - // callback is called with the offer SLE, taker pays, taker gets. - // If callback returns false, don't process any more offers. - // Return the unfunded, bad offers and the number of offers consumed. + /** Iterates offers at the best available quality, invoking `callback` for + * each. Skips (and schedules removal of) unfunded, expired, and + * authorization-failing offers. Stops when `callback` returns false or + * when `kMAX_OFFERS_TO_CONSUME` offers have been visited, in which case + * `inactive_` is set. Also tries the AMM once per iteration when its + * quality exceeds the CLOB tip. + * + * @param sb Mutable payment sandbox. + * @param afView Apply view for the offer stream. + * @param prevStepDebtDir Debt direction of the preceding step. + * @param callback Callable receiving `(offer, ofrAmt, stpAmt, + * ownerGives, trIn, trOut)`. + * @return `{offersToRemove, offersConsumed}`. + */ template std::pair, std::uint32_t> forEachOffer( @@ -366,7 +375,18 @@ private: DebtDirection prevStepDebtDir, Callback& callback) const; - // Offer is either TOffer or AMMOffer + /** Executes the fund transfer for one CLOB or AMM offer: sends `ofrAmt.in` + * from the book-in issuer to the offer owner, then sends `ownerGives` + * from the offer owner to the book-out issuer, and marks the offer as + * consumed. Throws `FlowException` on any transfer error or if the AMM + * pool product invariant is violated. + * + * @param sb Mutable payment sandbox. + * @param offer The offer being consumed (CLOB `TOffer` or `AMMOffer`). + * @param ofrAmt Raw offer amounts (before transfer fee). + * @param stepAmt Step amounts (after transfer fee applied to `in`). + * @param ownerGives Amount the offer owner actually sends (out minus fee). + */ template