diff --git a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp index 13c6a9b235..338a41aad4 100644 --- a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp +++ b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp @@ -1,3 +1,15 @@ +/** @file + * Implementation of the PaymentChannelClaim transactor. + * + * Settles an off-chain XRP micropayment channel on the ledger by advancing + * the channel's cumulative `sfBalance`, closing the channel on expiry or + * request, or clearing a pending close via `tfRenew`. This is the only + * transactor in the payment-channel family that transfers XRP to the + * destination; `PaymentChannelCreate` and `PaymentChannelFund` only lock + * or add XRP into the channel's escrow. + * + * @see PaymentChannelClaim.h for the full class and method contracts. + */ #include #include @@ -150,10 +162,7 @@ PaymentChannelClaim::doApply() return tecUNFUNDED_PAYMENT; if (reqBalance <= chanBalance) - { - // nothing requested return tecUNFUNDED_PAYMENT; - } auto const sled = ctx_.view().peek(keylet::account(dst)); if (!sled) diff --git a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelCreate.cpp b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelCreate.cpp index bbc8d9f13a..c6b139e25e 100644 --- a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelCreate.cpp +++ b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelCreate.cpp @@ -25,25 +25,24 @@ namespace xrpl { -/* - PaymentChannel - - Payment channels permit off-ledger checkpoints of XRP payments flowing - in a single direction. A channel sequesters the owner's XRP in its own - ledger entry. The owner can authorize the recipient to claim up to a - given balance by giving the receiver a signed message (off-ledger). The - recipient can use this signed message to claim any unpaid balance while - the channel remains open. The owner can top off the line as needed. If - the channel has not paid out all its funds, the owner must wait out a - delay to close the channel to give the recipient a chance to supply any - claims. The recipient can close the channel at any time. Any transaction - that touches the channel after the expiration time will close the - channel. The total amount paid increases monotonically as newer claims - are issued. When the channel is closed any remaining balance is returned - to the owner. Channels are intended to permit intermittent off-ledger - settlement of ILP trust lines as balances get substantial. For - bidirectional channels, a payment channel can be used in each direction. -*/ +/** @file + * Implements `PaymentChannelCreate` — the transactor that opens a + * unidirectional XRP payment channel. + * + * Payment channels sequesters the owner's XRP in a dedicated `PayChannel` + * SLE so the owner can issue signed off-ledger claims to a recipient without + * touching the ledger for every payment. The recipient presents claims + * on-chain to settle; the paid total increases monotonically. When the channel + * closes, any remaining balance is returned to the owner. For bidirectional + * flow, two channels (one in each direction) are used. + * + * This file covers only channel construction. Funding additions, claim + * settlement, and closure are handled by `PaymentChannelFund.cpp` and + * `PaymentChannelClaim.cpp` respectively. Crucially, `PaymentChannelCreate` + * is the only transactor that allocates the `PayChannel` SLE and inserts + * both directory entries; the sibling transactors always assume those + * structures already exist. + */ //------------------------------------------------------------------------------ @@ -76,7 +75,11 @@ PaymentChannelCreate::preclaim(PreclaimContext const& ctx) if (!sle) return terNO_ACCOUNT; - // Check reserve and funds availability + // Two-step reserve check: first ensure the account can hold one more owned + // object (tecINSUFFICIENT_RESERVE), then that it can also fund the channel + // at the requested level (tecUNFUNDED). The distinction is intentional — + // callers can use it to tell whether the problem is reserve capacity or + // channel size. { auto const balance = (*sle)[sfBalance]; auto const reserve = ctx.view.fees().accountReserve((*sle)[sfOwnerCount] + 1); @@ -91,26 +94,25 @@ PaymentChannelCreate::preclaim(PreclaimContext const& ctx) auto const dst = ctx.tx[sfDestination]; { - // Check destination account auto const sled = ctx.view.read(keylet::account(dst)); if (!sled) + // Deliberate design choice: channels to non-existent accounts are + // rejected even though XRP would be held in escrow. Allowing it + // would reserve funds to an address that may never be activated, + // effectively burning the sender's XRP in a dead-end channel. return tecNO_DST; auto const flags = sled->getFlags(); - // Check if they have disallowed incoming payment channels if ((flags & lsfDisallowIncomingPayChan) != 0u) return tecNO_PERMISSION; if (((flags & lsfRequireDestTag) != 0u) && !ctx.tx[~sfDestinationTag]) return tecDST_TAG_NEEDED; - // Pseudo-accounts cannot receive payment channels, other than native - // to their underlying ledger object - implemented in their respective - // transaction types. Note, this is not amendment-gated because all - // writes to pseudo-account discriminator fields **are** amendment - // gated, hence the behaviour of this check will always match the - // currently active amendments. + // Not amendment-gated: pseudo-account discriminator fields are only + // written under their own amendment guards, so this check automatically + // tracks the active amendment set without additional gating here. if (isPseudoAccount(sled)) return tecNO_PERMISSION; } @@ -135,16 +137,19 @@ PaymentChannelCreate::doApply() auto const dst = ctx_.tx[sfDestination]; - // Create PayChan in ledger. - // - // Note that we use the value from the sequence or ticket as the - // payChan sequence. For more explanation see comments in SeqProxy.h. + // The keylet hashes account, destination, and sequence (or ticket) number. + // Including the sequence ensures that two channels between the same pair of + // accounts are always addressable distinctly — each channel open has a + // unique sequence value, so their keylets never collide. getSeqValue() + // transparently returns either the transaction sequence or the ticket + // number, so the keylet is deterministic for both regular and ticket-based + // transactions. See SeqProxy.h for details. Keylet const payChanKeylet = keylet::payChan(account, dst, ctx_.tx.getSeqValue()); auto const slep = std::make_shared(payChanKeylet); - // Funds held in this channel (*slep)[sfAmount] = ctx_.tx[sfAmount]; - // Amount channel has already paid + // zeroed() preserves the XRPAmount type, keeping sfBalance type-consistent + // with sfAmount from the start (as opposed to a bare integer zero). (*slep)[sfBalance] = ctx_.tx[sfAmount].zeroed(); (*slep)[sfAccount] = account; (*slep)[sfDestination] = dst; @@ -153,6 +158,11 @@ PaymentChannelCreate::doApply() (*slep)[~sfCancelAfter] = ctx_.tx[~sfCancelAfter]; (*slep)[~sfSourceTag] = ctx_.tx[~sfSourceTag]; (*slep)[~sfDestinationTag] = ctx_.tx[~sfDestinationTag]; + // fixIncludeKeyletFields is a bug-fix amendment: channels created before it + // was activated lack sfSequence in the SLE, so callers must fetch the + // originating transaction to recompute the keylet. When active, storing + // sfSequence directly in the SLE lets any tool reconstruct the channel's + // keylet from the object alone, without additional transaction lookup. if (ctx_.view().rules().enabled(fixIncludeKeyletFields)) { (*slep)[sfSequence] = ctx_.tx.getSeqValue(); @@ -160,7 +170,8 @@ PaymentChannelCreate::doApply() ctx_.view().insert(slep); - // Add PayChan to owner directory + // Dual directory insertion: both owner and recipient can enumerate this + // channel, and the stored page indices make O(1) removal possible on close. { auto const page = ctx_.view().dirInsert( keylet::ownerDir(account), payChanKeylet, describeOwnerDir(account)); @@ -169,7 +180,6 @@ PaymentChannelCreate::doApply() (*slep)[sfOwnerNode] = *page; } - // Add PayChan to the recipient's owner directory { auto const page = ctx_.view().dirInsert(keylet::ownerDir(dst), payChanKeylet, describeOwnerDir(dst)); @@ -178,7 +188,6 @@ PaymentChannelCreate::doApply() (*slep)[sfDestinationNode] = *page; } - // Deduct owner's balance, increment owner count (*sle)[sfBalance] = (*sle)[sfBalance] - ctx_.tx[sfAmount]; adjustOwnerCount(ctx_.view(), sle, 1, ctx_.journal); ctx_.view().update(sle); diff --git a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelFund.cpp b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelFund.cpp index e392df213a..8350201f04 100644 --- a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelFund.cpp +++ b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelFund.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements `PaymentChannelFund`, the "refill" transactor for XRPL payment + * channels. A channel owner calls this to deposit additional XRP into an + * existing channel or to extend its voluntary expiration deadline without + * closing and reopening the channel. + * + * The apply phase enforces a specific guard order: expiry-triggered cleanup + * runs before the ownership check, so any account touching a stale channel + * will garbage-collect it regardless of whether they own it. + */ #include #include @@ -22,12 +32,33 @@ namespace xrpl { +/** Construct `TxConsequences` that reflect the full XRP deposit, not just the fee. + * + * The transaction queue uses this to account for the XRP locked in flight. + * Reporting only the fee would undercount resource consumption and allow + * conflicting transactions to be queued that assume the deposited XRP is + * still available. + * + * @param ctx Preflight context providing the transaction fields. + * @return `TxConsequences` with consumed XRP set to `sfAmount.xrp()`. + */ TxConsequences PaymentChannelFund::makeTxConsequences(PreflightContext const& ctx) { return TxConsequences{ctx.tx, ctx.tx[sfAmount].xrp()}; } +/** Stateless validation: `sfAmount` must be a positive XRP value. + * + * The `sfAmount` field is polymorphic at the protocol level and could carry + * a non-XRP (IOU) amount from a malformed transaction; the `isXRP()` guard + * rejects those. All ledger-state checks (channel existence, permissions, + * reserve) are deferred to `doApply`. + * + * @param ctx Preflight context providing the transaction and current rules. + * @return `tesSUCCESS`, or `temBAD_AMOUNT` if `sfAmount` is non-XRP or + * non-positive. + */ NotTEC PaymentChannelFund::preflight(PreflightContext const& ctx) { @@ -37,6 +68,47 @@ PaymentChannelFund::preflight(PreflightContext const& ctx) return tesSUCCESS; } +/** Apply the funding operation: validate channel state then mutate it. + * + * Guards are evaluated in this deliberate order: + * + * 1. **Channel existence** — looks up the `ltPAYCHAN` SLE; `tecNO_ENTRY` if + * absent. + * 2. **Expiry short-circuit** — if `parentCloseTime` has reached either + * `sfCancelAfter` (immutable hard deadline) or `sfExpiration` (mutable soft + * deadline), calls `closeChannel()` and returns immediately, *before* the + * ownership check. Any account touching an expired channel thus triggers + * cleanup — this is how XRPL garbage-collects stale channels without + * requiring owner action. + * 3. **Ownership** — only the original channel creator (`sfAccount` on the + * channel SLE) may fund or extend; others receive `tecNO_PERMISSION`. + * 4. **Expiration extension** — if the transaction carries `sfExpiration`, the + * new value must be ≥ `parentCloseTime + sfSettleDelay` (floored to the + * existing expiration when it is earlier). This prevents the owner from + * sneaking in a very short expiration that deprives the recipient of their + * full settle window; violation returns `temBAD_EXPIRATION`. + * 5. **Reserve check** — owner balance must cover `accountReserve(ownerCount)`; + * shortfall returns `tecINSUFFICIENT_RESERVE`. Checked separately from the + * balance check to give callers a distinct error code when the account is + * already underwater before considering the deposit. + * 6. **Balance check** — owner balance must also cover reserve + `sfAmount`; + * shortfall returns `tecUNFUNDED`. + * 7. **Destination existence** — destination account must still exist; if it + * was deleted after channel creation, adding funds would lock XRP into an + * unclaimable channel; returns `tecNO_DST`. + * + * On success, `sfAmount` on the channel SLE (total funded capacity) is + * incremented and the owner's account `sfBalance` is decremented by the same + * value. Both SLEs are committed via `ctx_.view().update()`. + * + * @return `tesSUCCESS`, or one of `tecNO_ENTRY`, `tecNO_PERMISSION`, + * `temBAD_EXPIRATION`, `tecINSUFFICIENT_RESERVE`, `tecUNFUNDED`, + * `tecNO_DST`, or `tefINTERNAL` (unreachable in practice — see below). + * + * @note The `tefINTERNAL` path (source account SLE not found) is marked + * `LCOV_EXCL_LINE` because a signed, accepted transaction implies the + * submitting account must exist; the check is purely defensive. + */ TER PaymentChannelFund::doApply() { @@ -58,7 +130,6 @@ PaymentChannelFund::doApply() if (src != txAccount) { - // only the owner can add funds or extend return tecNO_PERMISSION; } @@ -80,7 +151,6 @@ PaymentChannelFund::doApply() return tefINTERNAL; // LCOV_EXCL_LINE { - // Check reserve and funds availability auto const balance = (*sle)[sfBalance]; auto const reserve = ctx_.view().fees().accountReserve((*sle)[sfOwnerCount]); @@ -91,7 +161,6 @@ PaymentChannelFund::doApply() return tecUNFUNDED; } - // do not allow adding funds if dst does not exist if (AccountID const dst = (*slep)[sfDestination]; !ctx_.view().read(keylet::account(dst))) { return tecNO_DST; @@ -106,6 +175,7 @@ PaymentChannelFund::doApply() return tesSUCCESS; } +/** No-op invariant visitor; no per-entry checks are defined for this transaction type yet. */ void PaymentChannelFund::visitInvariantEntry( bool, @@ -115,6 +185,9 @@ PaymentChannelFund::visitInvariantEntry( // No transaction-specific invariants yet (future work). } +/** No-op invariant finalizer; always returns `true` until transaction-specific + * invariants are defined. + */ bool PaymentChannelFund::finalizeInvariants( STTx const&, diff --git a/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainDelete.cpp b/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainDelete.cpp index 0f62c5d28f..60b95fd894 100644 --- a/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainDelete.cpp +++ b/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainDelete.cpp @@ -1,3 +1,12 @@ +/** @file + * Implements the `PermissionedDomainDelete` transactor, which removes a + * permissioned domain SLE from the XRPL ledger and recovers the owner + * reserve locked at creation. + * + * The three-phase pipeline is: `preflight` (structural check on `sfDomainID`) + * → `preclaim` (existence + ownership, read-only) → `doApply` (directory + * removal, owner-count decrement, SLE erasure). + */ #include #include @@ -16,6 +25,16 @@ namespace xrpl { +/** Reject structurally invalid transactions before consulting ledger state. + * + * A zero-valued `sfDomainID` is analogous to a null pointer — it cannot + * reference any real domain object. All ledger-state checks (existence, + * ownership) are deferred to `preclaim` so that they can influence + * fee-claiming behaviour. + * + * @param ctx The preflight context containing the raw transaction. + * @return `temMALFORMED` if `sfDomainID` is zero; `tesSUCCESS` otherwise. + */ NotTEC PermissionedDomainDelete::preflight(PreflightContext const& ctx) { @@ -26,6 +45,19 @@ PermissionedDomainDelete::preflight(PreflightContext const& ctx) return tesSUCCESS; } +/** Verify domain existence and submitter ownership against live ledger state. + * + * Resolves `sfDomainID` to a `PermissionedDomain` SLE via + * `keylet::permissionedDomain`. Only the account that created the domain + * (`sfOwner`) may delete it — there is no admin override or co-ownership + * model. Placing these checks here (rather than in `doApply`) ensures that + * an unauthorized or nonexistent-domain attempt still charges the fee. + * + * @param ctx The preclaim context with read-only ledger view. + * @return `tecNO_ENTRY` if the domain SLE does not exist in the current + * ledger; `tecNO_PERMISSION` if the submitter is not the domain owner; + * `tesSUCCESS` otherwise. + */ TER PermissionedDomainDelete::preclaim(PreclaimContext const& ctx) { @@ -44,7 +76,22 @@ PermissionedDomainDelete::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } -/** Attempt to delete the Permissioned Domain. */ +/** Remove the permissioned domain object and release the owner reserve. + * + * Performs three mutations in strict order: + * 1. `view().dirRemove()` — removes the SLE's back-reference from the + * account's owner directory using the page index stored in `sfOwnerNode`. + * Passing `true` also cleans up the directory page if it becomes empty. + * Failure is unreachable for a well-formed ledger and is marked + * `LCOV_EXCL`; it returns `tefBAD_LEDGER` if somehow triggered. + * 2. `adjustOwnerCount(..., -1, j)` — decrements `sfOwnerCount`, recovering + * the base reserve that was locked when the domain was created. + * 3. `view().erase(slePd)` — removes the `PermissionedDomain` SLE from the + * ledger view. This is the final, irreversible step. + * + * @return `tefBAD_LEDGER` on owner-directory corruption (unreachable under + * normal operation); `tesSUCCESS` otherwise. + */ TER PermissionedDomainDelete::doApply() { @@ -73,6 +120,10 @@ PermissionedDomainDelete::doApply() return tesSUCCESS; } +/** Per-entry invariant visitor — no domain-deletion-specific checks yet. + * + * @note This is a placeholder for future per-entry invariant enforcement. + */ void PermissionedDomainDelete::visitInvariantEntry( bool, @@ -82,6 +133,12 @@ PermissionedDomainDelete::visitInvariantEntry( // No transaction-specific invariants yet (future work). } +/** Post-transaction invariant finalization — always passes. + * + * @note This is a placeholder for future transaction-level invariant checks; + * no domain-deletion-specific invariants are enforced yet. + * @return `true` unconditionally. + */ bool PermissionedDomainDelete::finalizeInvariants( STTx const&, diff --git a/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.cpp b/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.cpp index f794844a57..a3b60e3204 100644 --- a/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.cpp +++ b/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.cpp @@ -1,3 +1,11 @@ +/** @file + * Implementation of the `PermissionedDomainSet` transactor. + * + * Handles both creation and in-place update of `PermissionedDomain` ledger + * objects. The presence of `sfDomainID` in the transaction selects the + * update path; its absence triggers creation. See + * `PermissionedDomainSet.h` for the public interface contract. + */ #include #include @@ -73,7 +81,30 @@ PermissionedDomainSet::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } -/** Attempt to create the Permissioned Domain. */ +/** Create or update a `PermissionedDomain` SLE. + * + * Canonicalises `sfAcceptedCredentials` via `credentials::makeSorted` before + * writing, ensuring the stored array is in deterministic order regardless of + * the order in which credentials were submitted. + * + * **Update path** (`sfDomainID` present): replaces the existing domain SLE's + * `sfAcceptedCredentials` array in-place and calls `view().update()`. No + * reserve change occurs because the object already exists. + * + * **Create path** (`sfDomainID` absent): verifies the account has sufficient + * XRP to cover `accountReserve(ownerCount + 1)`, then allocates a new SLE + * keyed by `keylet::permissionedDomain(account_, sfSequence)`. Using the + * transaction sequence as the key differentiator gives each domain a globally + * unique, collision-resistant identifier without a separate ID-generation + * step. The SLE is inserted into the owner directory and the owner count is + * incremented by one. + * + * @return `tesSUCCESS` on success; `tecINSUFFICIENT_RESERVE` if the account + * cannot cover the new reserve (create path only); `tecDIR_FULL` if the + * owner directory is full (create path, unreachable in practice — + * LCOV_EXCL); `tefINTERNAL` if the owner SLE or, on the update path, the + * domain SLE is unexpectedly absent (unreachable — LCOV_EXCL). + */ TER PermissionedDomainSet::doApply() { @@ -94,7 +125,6 @@ PermissionedDomainSet::doApply() if (ctx_.tx.isFieldPresent(sfDomainID)) { - // Modify existing permissioned domain. auto slePd = view().peek(keylet::permissionedDomain(ctx_.tx.getFieldH256(sfDomainID))); if (!slePd) return tefINTERNAL; // LCOV_EXCL_LINE @@ -103,8 +133,6 @@ PermissionedDomainSet::doApply() } else { - // Create new permissioned domain. - // Check reserve availability for new object creation auto const balance = STAmount((*ownerSle)[sfBalance]).xrp(); auto const reserve = ctx_.view().fees().accountReserve((*ownerSle)[sfOwnerCount] + 1); if (balance < reserve) @@ -123,7 +151,6 @@ PermissionedDomainSet::doApply() return tecDIR_FULL; // LCOV_EXCL_LINE slePd->setFieldU64(sfOwnerNode, *page); - // If we succeeded, the new entry counts against the creator's reserve. adjustOwnerCount(view(), ownerSle, 1, ctx_.journal); view().insert(slePd); } diff --git a/src/libxrpl/tx/transactors/system/Batch.cpp b/src/libxrpl/tx/transactors/system/Batch.cpp index a7c9ef52a0..48589bd5c3 100644 --- a/src/libxrpl/tx/transactors/system/Batch.cpp +++ b/src/libxrpl/tx/transactors/system/Batch.cpp @@ -30,32 +30,29 @@ namespace xrpl { -/** - * @brief Calculates the total base fee for a batch transaction. +/** Compute the total base fee for a Batch transaction. * - * This function computes the required base fee for a batch transaction, - * including the base fee for the batch itself, the sum of base fees for - * all inner transactions, and additional fees for each batch signer. - * It performs overflow checks and validates the structure of the batch - * and its signers. + * Fee = `view.fees().base` (batch overhead) + `Transactor::calculateBaseFee` + * (normal outer-tx base) + sum(inner tx fees) + signerCount × `view.fees().base`. + * Each `sfBatchSigners` entry is expanded to its actual key count: a + * single-sig entry contributes 1, a multi-sig entry contributes the number of + * sub-signers in `sfSigners`. * - * @param view The ledger view providing fee and state information. - * @param tx The batch transaction to calculate the fee for. - * @return XRPAmount The total base fee required for the batch transaction. + * Every accumulation is overflow-checked. On overflow or any structural error + * that should have been caught by `preflight`, `kINITIAL_XRP` is returned as + * a sentinel and the error is logged. These paths are unreachable in practice + * (covered by LCOV exclusions) because `preflight` enforces all size limits + * and rejects nested Batch transactions. * - * @throws std::overflow_error If any fee calculation would overflow the - * XRPAmount type. - * @throws std::length_error If the number of inner transactions or signers - * exceeds the allowed maximum. - * @throws std::invalid_argument If an inner transaction is itself a batch - * transaction. + * @param view The ledger view, used to obtain the network base fee. + * @param tx The outer Batch transaction. + * @return Total fee in drops, or `kINITIAL_XRP` if an overflow is detected. */ XRPAmount Batch::calculateBaseFee(ReadView const& view, STTx const& tx) { XRPAmount const maxAmount{std::numeric_limits::max()}; - // batchBase: view.fees().base for batch processing + default base fee XRPAmount const baseFee = Transactor::calculateBaseFee(view, tx); // LCOV_EXCL_START @@ -68,7 +65,6 @@ Batch::calculateBaseFee(ReadView const& view, STTx const& tx) XRPAmount const batchBase = view.fees().base + baseFee; - // Calculate the Inner Txn Fees XRPAmount txnFees{0}; if (tx.isFieldPresent(sfRawTransactions)) { @@ -107,7 +103,6 @@ Batch::calculateBaseFee(ReadView const& view, STTx const& tx) } } - // Calculate the Signers/BatchSigners Fees std::int32_t signerCount = 0; if (tx.isFieldPresent(sfBatchSigners)) { @@ -157,7 +152,6 @@ Batch::calculateBaseFee(ReadView const& view, STTx const& tx) } // LCOV_EXCL_STOP - // 10 drops per batch signature + sum of inner tx fees + batchBase return signerFees + txnFees + batchBase; } @@ -167,38 +161,26 @@ Batch::getFlagsMask(PreflightContext const& ctx) return tfBatchMask; } -/** - * @brief Performs preflight validation checks for a Batch transaction. +/** Validate the structural integrity of the Batch transaction. * - * This function validates the structure and contents of a Batch transaction - * before it is processed. It ensures that the Batch feature is enabled, - * checks for valid flags, validates the number and uniqueness of inner - * transactions, and enforces correct signing and fee requirements. + * `std::popcount` is used on the policy-flag bits to guarantee exactly one + * execution policy is active — a simple range or switch check would silently + * pass flag combinations. * - * The following validations are performed: - * - The Batch feature must be enabled in the current rules. - * - Only one of the mutually exclusive batch flags must be set. - * - The batch must contain at least two and no more than the maximum allowed - * inner transactions. - * - Each inner transaction must: - * - Be unique within the batch. - * - Not itself be a Batch transaction. - * - Have the tfInnerBatchTxn flag set. - * - Not include a TxnSignature or Signers field. - * - Have an empty SigningPubKey. - * - Pass its own preflight checks. - * - Have a fee of zero. - * - Have either Sequence or TicketSequence set, but not both or neither. - * - Not duplicate Sequence or TicketSequence values for the same account (for - * certain flags). - * - Validates that all required inner transaction accounts are present in the - * batch signers array, and that all batch signers are unique and not the outer - * account. - * - Verifies the batch signature if batch signers are present. + * Duplicate-sequence detection (via `accountSeqTicket`) is enforced only for + * `tfAllOrNothing` and `tfUntilFailure`. Those modes commit or abort as a + * unit, so two inner transactions from the same account consuming the same + * sequence slot would be structurally incoherent. `tfIndependent` and + * `tfOnlyOne` relax this: only one of a pair may actually execute, so the + * collision is not guaranteed to matter. * - * @param ctx The PreflightContext containing the transaction and environment. - * @return NotTEC Returns tesSUCCESS if all checks pass, or an appropriate error - * code otherwise. + * Signer reconciliation (building `requiredSigners` and comparing against + * `sfBatchSigners`) is deferred to `preflightSigValidated` so it runs only + * after the outer account's own signature is confirmed valid by the framework. + * + * @param ctx Preflight context for the outer Batch transaction. + * @return `tesSUCCESS` on success, or a `tem*` code describing the first + * structural violation found. */ NotTEC Batch::preflight(PreflightContext const& ctx) @@ -228,7 +210,6 @@ Batch::preflight(PreflightContext const& ctx) return temARRAY_TOO_LARGE; } - // Validation Inner Batch Txns std::unordered_set uniqueHashes; std::unordered_map> accountSeqTicket; auto checkSignatureFields = @@ -310,7 +291,6 @@ Batch::preflight(PreflightContext const& ctx) } } - // Check that the Fee is native asset (XRP) and zero if (auto const fee = stx.getFieldAmount(sfFee); !fee.native() || fee.xrp() != beast::kZERO) { JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " @@ -331,7 +311,6 @@ Batch::preflight(PreflightContext const& ctx) return temINVALID_INNER_BATCH; } - // Check that Sequence and TicketSequence are not both present if (stx.isFieldPresent(sfTicketSequence) && stx.getFieldU32(sfSequence) != 0) { JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " @@ -341,7 +320,6 @@ Batch::preflight(PreflightContext const& ctx) return temSEQ_AND_TICKET; } - // Verify that either Sequence or TicketSequence is present if (!stx.isFieldPresent(sfTicketSequence) && stx.getFieldU32(sfSequence) == 0) { JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " @@ -351,7 +329,6 @@ Batch::preflight(PreflightContext const& ctx) return temSEQ_AND_TICKET; } - // Duplicate sequence and ticket checks if ((flags & (tfAllOrNothing | tfUntilFailure)) != 0u) { if (auto const seq = stx.getFieldU32(sfSequence); seq != 0) @@ -382,6 +359,25 @@ Batch::preflight(PreflightContext const& ctx) return tesSUCCESS; } +/** Validate batch-signer authorization after the outer signature is verified. + * + * Builds `requiredSigners` from every inner-transaction account and every + * `sfCounterparty` field that differs from the outer account, then performs a + * bidirectional reconciliation against `sfBatchSigners`: each batch signer is + * removed from `requiredSigners` as matched; a signer absent from + * `requiredSigners` is extraneous (`temBAD_SIGNER`). After the loop, + * any non-empty `requiredSigners` means a required party did not provide a + * batch signature. Finally, `ctx.tx.checkBatchSign()` verifies the + * cryptographic payload produced by `serializeBatch()`. + * + * Called by the framework only after the outer account's own signature is + * confirmed valid, ensuring the submitter is authenticated before evaluating + * whether other parties have also signed. + * + * @param ctx Preflight context, post outer-signature verification. + * @return `tesSUCCESS` if all required parties have valid batch signatures; + * a `tem*` code otherwise. + */ NotTEC Batch::preflightSigValidated(PreflightContext const& ctx) { @@ -389,7 +385,6 @@ Batch::preflightSigValidated(PreflightContext const& ctx) auto const outerAccount = ctx.tx.getAccountID(sfAccount); auto const& rawTxns = ctx.tx.getFieldArray(sfRawTransactions); - // Build the signers list std::unordered_set requiredSigners; for (STObject const& rb : rawTxns) { @@ -406,13 +401,11 @@ Batch::preflightSigValidated(PreflightContext const& ctx) requiredSigners.insert(*counterparty); } - // Validation Batch Signers std::unordered_set batchSigners; if (ctx.tx.isFieldPresent(sfBatchSigners)) { STArray const& signers = ctx.tx.getFieldArray(sfBatchSigners); - // Check that the batch signers array is not too large. if (signers.size() > kMAX_BATCH_TX_COUNT) { JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " @@ -420,11 +413,6 @@ Batch::preflightSigValidated(PreflightContext const& ctx) return temARRAY_TOO_LARGE; } - // Add batch signers to the set to ensure all signer accounts are - // unique. Meanwhile, remove signer accounts from the set of inner - // transaction accounts (`requiredSigners`). By the end of the loop, - // `requiredSigners` should be empty, indicating that all inner - // accounts are matched with signers. for (auto const& signer : signers) { AccountID const signerAccount = signer.getAccountID(sfAccount); @@ -442,8 +430,6 @@ Batch::preflightSigValidated(PreflightContext const& ctx) return temREDUNDANT; } - // Check that the batch signer is in the required signers set. - // Remove it if it does, as it can be crossed off the list. if (requiredSigners.erase(signerAccount) == 0) { JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " @@ -452,7 +438,6 @@ Batch::preflightSigValidated(PreflightContext const& ctx) } } - // Check the batch signers signatures. auto const sigResult = ctx.tx.checkBatchSign(ctx.rules); if (!sigResult) @@ -472,22 +457,15 @@ Batch::preflightSigValidated(PreflightContext const& ctx) return tesSUCCESS; } -/** - * @brief Checks the validity of signatures for a batch transaction. +/** Verify the outer account's signature and the batch-signers' signatures. * - * This method first verifies the standard transaction signature by calling - * Transactor::checkSign. If the signature is not valid it returns the - * corresponding error code. + * Chains `Transactor::checkSign` (outer account: regular key, master key, or + * signer list) and `Transactor::checkBatchSign` (on-ledger credentials for + * each entry in `sfBatchSigners`). Both must pass; failure at the first check + * short-circuits. * - * Next, it verifies the batch-specific signature requirements by calling - * Transactor::checkBatchSign. If this check fails, it also returns the - * corresponding error code. - * - * If both checks succeed, the function returns tesSUCCESS. - * - * @param ctx The PreclaimContext containing transaction and environment data. - * @return NotTEC Returns tesSUCCESS if all signature checks pass, or an error - * code otherwise. + * @param ctx Preclaim context providing a read-only ledger view. + * @return `tesSUCCESS` if both checks pass; a `tef*` code otherwise. */ NotTEC Batch::checkSign(PreclaimContext const& ctx) @@ -501,15 +479,15 @@ Batch::checkSign(PreclaimContext const& ctx) return tesSUCCESS; } -/** - * @brief Applies the outer batch transaction. +/** Apply the outer Batch transaction. * - * This method is responsible for applying the outer batch transaction. - * The inner transactions within the batch are applied separately in the - * `applyBatchTransactions` method after the outer transaction is processed. + * Returns `tesSUCCESS` immediately. The outer Batch does not directly mutate + * any ledger objects — fee deduction and sequence consumption are handled by + * the base-class `apply()` method. Inner transaction execution is performed + * by `applyBatchTransactions()` in `apply.cpp`, called by `applyTransaction()` + * after this method returns. * - * @return TER Returns tesSUCCESS to indicate successful application of the - * outer batch transaction. + * @return Always `tesSUCCESS`. */ TER Batch::doApply() @@ -517,19 +495,33 @@ Batch::doApply() return tesSUCCESS; } +/** Invariant visitor for the outer Batch transaction — currently a no-op. + * + * The outer Batch does not directly create or modify ledger objects beyond + * fee and sequence handling, so there are no Batch-specific per-entry + * invariants to enforce. Inner-transaction invariant checks are performed by + * each inner transactor's own apply context. + */ void Batch::visitInvariantEntry( bool, std::shared_ptr const&, std::shared_ptr const&) { - // No transaction-specific invariants yet (future work). } +/** Finalize invariant checks for the outer Batch transaction — currently a no-op. + * + * Returns `true` unconditionally. No outer-Batch invariant violations are + * possible: the outer transaction only deducts a fee and consumes a sequence, + * both of which are covered by shared invariant checkers. Inner-transaction + * invariants are finalized within each inner transactor's own apply context. + * + * @return Always `true`. + */ bool Batch::finalizeInvariants(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) { - // No transaction-specific invariants yet (future work). return true; } diff --git a/src/libxrpl/tx/transactors/system/Change.cpp b/src/libxrpl/tx/transactors/system/Change.cpp index 13c09e8187..ed7d8debe5 100644 --- a/src/libxrpl/tx/transactors/system/Change.cpp +++ b/src/libxrpl/tx/transactors/system/Change.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements the `Change` transactor, which processes the three + * consensus-synthesized pseudo-transaction types: `ttAMENDMENT` (amendment + * lifecycle events), `ttFEE` (fee-schedule updates), and `ttUNL_MODIFY` + * (Negative UNL validator staging). None of these are submitted by users; + * they are injected directly into ledger proposals by the consensus machinery. + * + * @see Change.h for class-level documentation and the `EnableAmendment`, + * `SetFee`, and `UNLModify` semantic aliases. + */ #include #include @@ -29,14 +39,27 @@ namespace xrpl { +/** Full specialization of `Transactor::invokePreflight` for pseudo-transactions. + * + * Pseudo-transactions violate nearly every rule enforced by the generic + * pipeline: they carry no signature, no signing public key, no multisig + * signers list, a zero fee, a zero sequence, and the zero account ID as + * their source. This specialization validates exactly those invariants + * instead of delegating to `preflight1` / `preflight2`. + * + * Flag-mask enforcement (`tfEnableAmendmentMask`) is gated behind + * `featureLendingProtocol`. That feature introduced the flag parameter; a + * dedicated amendment purely to enable this guard would add protocol + * complexity for no meaningful gain, so the existing feature acts as a proxy. + * + * @param ctx Preflight context carrying the transaction and current rules. + * @return `tesSUCCESS`, `temBAD_SRC_ACCOUNT`, `temBAD_FEE`, + * `temBAD_SIGNATURE`, or `temBAD_SEQUENCE`. + */ template <> NotTEC Transactor::invokePreflight(PreflightContext const& ctx) { - // 0 means "Allow any flags" - // The check for tfEnableAmendmentMask is gated by LendingProtocol because - // that feature introduced this parameter, and it's not worth adding another - // amendment just for this. if (auto const ret = preflight0(ctx, ctx.rules.enabled(featureLendingProtocol) ? tfEnableAmendmentMask : 0)) return ret; @@ -48,7 +71,6 @@ Transactor::invokePreflight(PreflightContext const& ctx) return temBAD_SRC_ACCOUNT; } - // No point in going any further if the transaction fee is malformed. auto const fee = ctx.tx.getFieldAmount(sfFee); if (!fee.native() || fee != beast::kZERO) { @@ -201,7 +223,6 @@ Change::applyAmendment() } else { - // pass through newMajorities.pushBack(majority); } } @@ -212,7 +233,6 @@ Change::applyAmendment() if (gotMajority) { - // This amendment now has a majority newMajorities.pushBack(STObject::makeInnerObject(sfMajority)); auto& entry = newMajorities.back(); entry[sfAmendment] = amendment; @@ -225,7 +245,6 @@ Change::applyAmendment() } else if (!lostMajority) { - // No flags, enable amendment amendments.pushBack(amendment); amendmentObject->setFieldV256(sfAmendments, amendments); @@ -351,14 +370,12 @@ Change::applyUNLModify() if (disabling) { - // cannot have more than one toDisable if (negUnlObject->isFieldPresent(sfValidatorToDisable)) { JLOG(j_.warn()) << "N-UNL: applyUNLModify, already has ToDisable"; return tefFAILURE; } - // cannot be the same as toReEnable if (negUnlObject->isFieldPresent(sfValidatorToReEnable)) { if (negUnlObject->getFieldVL(sfValidatorToReEnable) == validator) @@ -368,7 +385,6 @@ Change::applyUNLModify() } } - // cannot be in negative UNL already if (found) { JLOG(j_.warn()) << "N-UNL: applyUNLModify, ToDisable already in negative UNL"; @@ -379,14 +395,12 @@ Change::applyUNLModify() } else { - // cannot have more than one toReEnable if (negUnlObject->isFieldPresent(sfValidatorToReEnable)) { JLOG(j_.warn()) << "N-UNL: applyUNLModify, already has ToReEnable"; return tefFAILURE; } - // cannot be the same as toDisable if (negUnlObject->isFieldPresent(sfValidatorToDisable)) { if (negUnlObject->getFieldVL(sfValidatorToDisable) == validator) @@ -396,7 +410,6 @@ Change::applyUNLModify() } } - // must be in negative UNL if (!found) { JLOG(j_.warn()) << "N-UNL: applyUNLModify, ToReEnable is not in negative UNL"; @@ -416,13 +429,11 @@ Change::visitInvariantEntry( std::shared_ptr const&, std::shared_ptr const&) { - // No transaction-specific invariants yet (future work). } bool Change::finalizeInvariants(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) { - // No transaction-specific invariants yet (future work). return true; } diff --git a/src/libxrpl/tx/transactors/system/LedgerStateFix.cpp b/src/libxrpl/tx/transactors/system/LedgerStateFix.cpp index 2f30311e6f..0f2bb25c2f 100644 --- a/src/libxrpl/tx/transactors/system/LedgerStateFix.cpp +++ b/src/libxrpl/tx/transactors/system/LedgerStateFix.cpp @@ -1,3 +1,16 @@ +/** @file + * Implements the LedgerStateFix transactor (`ttLEDGER_STATE_FIX`), a + * privileged maintenance mechanism for correcting corrupted ledger state. + * + * Currently supports one repair operation (`NfTokenPageLink`) that heals + * broken doubly-linked page chains in an account's NFToken directory. + * New repair types can be added by extending `FixType` and adding matching + * cases to each pipeline phase. + * + * Gated on the `fixNFTokenPageLinks` amendment; the framework rejects the + * transaction type entirely before preflight when the amendment is inactive. + */ + #include #include @@ -33,11 +46,18 @@ LedgerStateFix::preflight(PreflightContext const& ctx) return tesSUCCESS; } +/** Override that prices the transaction at one owner reserve rather than the + * standard base fee. + * + * The elevated fee (identical to `AccountDelete`'s pricing) deters speculative + * repair submissions: an operator pays a full reserve increment whether or not + * `nft::repairNFTokenDirectoryLinks` finds anything to fix. This signals that + * the transaction should only be submitted when there is strong reason to + * believe the target account's NFToken directory is actually corrupt. + */ XRPAmount LedgerStateFix::calculateBaseFee(ReadView const& view, STTx const& tx) { - // The fee required for LedgerStateFix is one owner reserve, just like - // the fee for AccountDelete. return calculateOwnerReserveFee(view, tx); } @@ -57,6 +77,13 @@ LedgerStateFix::preclaim(PreclaimContext const& ctx) return tecINTERNAL; // LCOV_EXCL_LINE } +/** @note A `false` return from `nft::repairNFTokenDirectoryLinks` means the + * directory was already consistent — no corrections were applied. `doApply` + * maps this to `tecFAILED_PROCESSING` so the submitter is charged the owner + * reserve fee with no ledger state change; submitting a repair for a healthy + * account is not an internal error, but is a failed operation from the + * transaction's point of view. + */ TER LedgerStateFix::doApply() { diff --git a/src/libxrpl/tx/transactors/system/TicketCreate.cpp b/src/libxrpl/tx/transactors/system/TicketCreate.cpp index f690243d86..694f66307b 100644 --- a/src/libxrpl/tx/transactors/system/TicketCreate.cpp +++ b/src/libxrpl/tx/transactors/system/TicketCreate.cpp @@ -21,13 +21,36 @@ namespace xrpl { +/** Build a `TxConsequences` that reports the true sequence consumption. + * + * A normal transaction consumes exactly one sequence number. `TicketCreate` + * burns `sfTicketCount` sequence numbers in a single shot, so the default + * `Normal` factory would cause the transaction queue to undercount the + * consumed sequence space and misjudge ordering for subsequent same-account + * transactions. Passing `sfTicketCount` as `sequencesConsumed` gives the + * queue the correct value. + * + * @param ctx Preflight context providing access to the transaction fields. + * @return `TxConsequences` encoding the multi-sequence claim. + */ TxConsequences TicketCreate::makeTxConsequences(PreflightContext const& ctx) { - // Create TxConsequences identifying the number of sequences consumed. return TxConsequences{ctx.tx, ctx.tx[sfTicketCount]}; } +/** Validate `sfTicketCount` without accessing ledger state. + * + * Rejects counts outside `[kMIN_VALID_COUNT, kMAX_VALID_COUNT]`. The upper + * bound of 250 is a CPU-budget ceiling: benchmarking showed that creating + * 250 tickets in one transaction takes roughly the same validator time as a + * compute-intensive three-path `Payment`, keeping per-transaction cost + * predictable across node hardware. + * + * @param ctx Preflight context providing access to the transaction fields. + * @return `tesSUCCESS` if the count is within range; `temINVALID_COUNT` + * otherwise. + */ NotTEC TicketCreate::preflight(PreflightContext const& ctx) { @@ -38,6 +61,23 @@ TicketCreate::preflight(PreflightContext const& ctx) return tesSUCCESS; } +/** Enforce the per-account ticket inventory ceiling before any mutations. + * + * Computes the net ticket delta: `curTicketCount + addedTickets − + * consumedTickets`. The `consumedTickets` term is 1 when this transaction + * was itself submitted via a ticket (i.e. `getSeqProxy().isTicket()` is + * true), otherwise 0. The subtraction prevents a false rejection when an + * account exactly at the 250-ticket limit submits a `TicketCreate` via a + * ticket to add one more — the net change is zero and the transaction should + * succeed. Unsigned underflow cannot occur because `addedTickets >= 1` and + * `consumedTickets <= 1`, so the worst case is `addedTickets == + * consumedTickets`, yielding `curTicketCount` unchanged. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return `tesSUCCESS` if the net delta stays within `kMAX_TICKET_THRESHOLD`; + * `tecDIR_FULL` if the threshold would be exceeded; `terNO_ACCOUNT` if + * the submitting account does not exist. + */ TER TicketCreate::preclaim(PreclaimContext const& ctx) { @@ -46,24 +86,56 @@ TicketCreate::preclaim(PreclaimContext const& ctx) if (!sleAccountRoot) return terNO_ACCOUNT; - // Make sure the TicketCreate would not cause the account to own - // too many tickets. std::uint32_t const curTicketCount = (*sleAccountRoot)[~sfTicketCount].value_or(0u); std::uint32_t const addedTickets = ctx.tx[sfTicketCount]; std::uint32_t const consumedTickets = ctx.tx.getSeqProxy().isTicket() ? 1u : 0u; - // Note that unsigned integer underflow can't currently happen because - // o curTicketCount >= 0 - // o addedTickets >= 1 - // o consumedTickets <= 1 - // So in the worst case addedTickets == consumedTickets and the - // computation yields curTicketCount. if (curTicketCount + addedTickets - consumedTickets > kMAX_TICKET_THRESHOLD) return tecDIR_FULL; return tesSUCCESS; } +/** Create the requested tickets and update all associated account-root fields. + * + * Execution proceeds in four stages: + * + * 1. **Reserve check.** The reserve for `ticketCount` additional owned objects + * is computed and compared against `preFeeBalance_` (the balance *before* + * the transaction fee was deducted). Using the pre-fee snapshot deliberately + * permits the account to consume reserve XRP to cover the fee, while still + * requiring that the full reserve for the new tickets exists before fee + * payment is considered. + * + * 2. **Sequence anchor.** `firstTicketSeq` is read from `sfSequence` on the + * account root, which the transaction machinery has already incremented + * before `doApply` runs. A defensive guard checks that the transaction's + * own `sfSequence` equals `firstTicketSeq − 1` (or is 0 for + * ticket-submitted transactions). This invariant is guaranteed by the + * framework; the guard is `LCOV_EXCL_LINE` because it should never fire + * outside of a framework bug. + * + * 3. **Ticket creation loop.** For each of the `ticketCount` tickets, an + * `ltTICKET` SLE is allocated with a contiguous sequence number + * (`firstTicketSeq + i`), inserted into the ledger, and linked into the + * account's owner directory via `dirInsert`. The returned directory page + * number is stored in `sfOwnerNode` to enable O(1) deletion later. + * + * 4. **Account-root update.** After the loop: `sfTicketCount` is incremented + * by `ticketCount` (the field `preclaim` will read on future + * `TicketCreate` calls); `adjustOwnerCount` raises `sfOwnerCount` by + * `ticketCount`; and `sfSequence` is advanced to `firstTicketSeq + + * ticketCount`. This final sequence advance — by more than one — is + * unique to `TicketCreate` in the XRPL protocol and is the mechanism by + * which those sequence numbers are reserved as tickets rather than left as + * gaps. + * + * @return `tesSUCCESS` on success; `tecINSUFFICIENT_RESERVE` if `preFeeBalance_` + * cannot cover the new reserve obligation; `tecDIR_FULL` if the owner + * directory is exhausted (practically unreachable); `tefINTERNAL` if the + * account SLE is missing or the sequence invariant is violated (both + * indicate framework bugs and are excluded from coverage analysis). + */ TER TicketCreate::doApply() { @@ -71,9 +143,6 @@ TicketCreate::doApply() if (!sleAccountRoot) return tefINTERNAL; // LCOV_EXCL_LINE - // Each ticket counts against the reserve of the issuing account, but we - // check the starting balance because we want to allow dipping into the - // reserve to pay fees. std::uint32_t const ticketCount = ctx_.tx[sfTicketCount]; { XRPAmount const reserve = @@ -85,14 +154,8 @@ TicketCreate::doApply() beast::Journal const viewJ{ctx_.registry.get().getJournal("View")}; - // The starting ticket sequence is the same as the current account - // root sequence. Before we got here to doApply(), the transaction - // machinery already incremented the account root sequence if that - // was appropriate. std::uint32_t const firstTicketSeq = (*sleAccountRoot)[sfSequence]; - // Sanity check that the transaction machinery really did already - // increment the account root Sequence. if (std::uint32_t const txSeq = ctx_.tx[sfSequence]; txSeq != 0 && txSeq != (firstTicketSeq - 1)) return tefINTERNAL; // LCOV_EXCL_LINE @@ -119,21 +182,26 @@ TicketCreate::doApply() sleTicket->setFieldU64(sfOwnerNode, *page); } - // Update the record of the number of Tickets this account owns. std::uint32_t const oldTicketCount = (*(sleAccountRoot))[~sfTicketCount].valueOr(0u); - sleAccountRoot->setFieldU32(sfTicketCount, oldTicketCount + ticketCount); - // Every added Ticket counts against the creator's reserve. adjustOwnerCount(view(), sleAccountRoot, ticketCount, viewJ); - // TicketCreate is the only transaction that can cause an account root's - // Sequence field to increase by more than one. October 2018. + // TicketCreate is the only transaction that can advance sfSequence by + // more than one — this is the mechanism that reserves those sequence + // numbers as tickets rather than leaving gaps. sleAccountRoot->setFieldU32(sfSequence, firstTicketSeq + ticketCount); return tesSUCCESS; } +/** Invariant visitor stub — no per-entry checks implemented yet. + * + * Ticket creation and deletion are already validated by the generic + * `AccountRootsDeletedClean` and `XRPNotCreated` framework invariants. + * This override exists as a placeholder for any future `ltTICKET`-specific + * checks. + */ void TicketCreate::visitInvariantEntry( bool, @@ -143,6 +211,14 @@ TicketCreate::visitInvariantEntry( // No transaction-specific invariants yet (future work). } +/** Invariant finalization stub — unconditionally passes. + * + * No transaction-specific finalization logic is currently required. + * Always returns `true` so the invariant framework treats this transactor + * as passing its own checks. + * + * @return `true` unconditionally. + */ bool TicketCreate::finalizeInvariants( STTx const&, diff --git a/src/libxrpl/tx/transactors/token/Clawback.cpp b/src/libxrpl/tx/transactors/token/Clawback.cpp index 0a524ac6d0..43d237b77a 100644 --- a/src/libxrpl/tx/transactors/token/Clawback.cpp +++ b/src/libxrpl/tx/transactors/token/Clawback.cpp @@ -33,6 +33,24 @@ template static NotTEC preflightHelper(PreflightContext const& ctx); +/** Validate IOU clawback transaction fields before any ledger access. + * + * The IOU encoding packs the token holder's account into the `issuer` + * sub-field of `sfAmount` rather than a separate `sfHolder` field. + * Consequently, `sfHolder` must be absent — its presence is treated as a + * malformed transaction to enforce mutual exclusivity between the two + * encoding conventions. + * + * Three structural constraints are checked: + * - `sfHolder` absent (IOU encoding uses `sfAmount.issuer` for the holder) + * - `sfAmount` is not XRP and is strictly positive + * - The holder encoded in `sfAmount` differs from `sfAccount` (the issuer) + * + * @param ctx Stateless preflight context. + * @return `tesSUCCESS` if valid; `temMALFORMED` if `sfHolder` is present; + * `temBAD_AMOUNT` if the amount is XRP, non-positive, or names the + * issuer as the holder. + */ template <> NotTEC preflightHelper(PreflightContext const& ctx) @@ -52,6 +70,20 @@ preflightHelper(PreflightContext const& ctx) return tesSUCCESS; } +/** Validate MPT clawback transaction fields before any ledger access. + * + * MPT clawback uses the conventional `sfHolder` field rather than the IOU + * encoding trick. The function checks: + * - `featureMPTokensV1` is enabled (otherwise the transaction type itself + * does not exist on the network) + * - `sfHolder` is present and names a different account from `sfAccount` + * - `sfAmount` is strictly positive and within `kMAX_MP_TOKEN_AMOUNT` + * + * @param ctx Stateless preflight context. + * @return `tesSUCCESS` if valid; `temDISABLED` if the MPT amendment is off; + * `temMALFORMED` if `sfHolder` is absent or equals `sfAccount`; + * `temBAD_AMOUNT` if the amount is out of range or non-positive. + */ template <> NotTEC preflightHelper(PreflightContext const& ctx) @@ -75,6 +107,16 @@ preflightHelper(PreflightContext const& ctx) return tesSUCCESS; } +/** Dispatch to the asset-type-specific preflight helper. + * + * Resolves the runtime `std::variant` inside `sfAmount` + * to a compile-time template parameter and forwards to + * `preflightHelper` or `preflightHelper`. No ledger state + * is accessed; all checks are purely structural. + * + * @param ctx Stateless preflight context. + * @return The result of the appropriate `preflightHelper` specialisation. + */ NotTEC Clawback::preflight(PreflightContext const& ctx) { @@ -96,6 +138,41 @@ preclaimHelper( AccountID const& holder, STAmount const& clawAmount); +/** Validate ledger preconditions for an IOU clawback. + * + * Checks are applied in this order: + * + * 1. **Flag constraints on the issuer**: `lsfAllowTrustLineClawback` must + * be set and `lsfNoFreeze` must not be set. These flags are mutually + * exclusive by design — an issuer that permanently waived freeze authority + * also loses clawback authority because clawback is a strictly stronger + * power. + * + * 2. **Trust-line existence**: the trust line between `holder` and `issuer` + * for the given currency must exist; missing → `tecNO_LINE`. + * + * 3. **Balance sign/address ordering invariant**: XRPL stores trust-line + * balances with a sign convention keyed to account address ordering. A + * positive raw `sfBalance` means the account with the higher address is + * the net holder; a negative value means the lower address is the holder. + * If the raw balance contradicts the issuer/holder address order, the + * transaction is rejected with `tecNO_PERMISSION` to prevent targeting + * the wrong side of the trust line. + * + * 4. **Non-zero spendable balance**: `accountHolds` with `fhIGNORE_FREEZE` + * is used rather than the raw `sfBalance` from the SLE, because the + * available balance is subject to additional constraints (e.g., XLS-34 + * lock-ups) that the raw field does not reflect. + * + * @param ctx Read-only preclaim context. + * @param sleIssuer The issuer's `AccountRoot` SLE. + * @param issuer The issuer's `AccountID`. + * @param holder The holder's `AccountID`. + * @param clawAmount The amount specified in the transaction (IOU variant). + * @return `tesSUCCESS` on success; `tecNO_PERMISSION` if flag constraints or + * balance-sign ordering fail; `tecNO_LINE` if the trust line is absent; + * `tecINSUFFICIENT_FUNDS` if the spendable balance is zero. + */ template <> TER preclaimHelper( @@ -128,15 +205,9 @@ preclaimHelper( if (balance < beast::kZERO && issuer > holder) return tecNO_PERMISSION; - // At this point, we know that issuer and holder accounts - // are correct and a trustline exists between them. - // - // Must now explicitly check the balance to make sure - // available balance is non-zero. - // - // We can't directly check the balance of trustline because - // the available balance of a trustline is prone to new changes (eg. - // XLS-34). So we must use `accountHolds`. + // Must use `accountHolds` rather than reading the raw trust-line balance + // directly: the spendable balance can be further constrained by features + // such as XLS-34 that impose holds on top of the nominal sfBalance. if (accountHolds( ctx.view, holder, @@ -149,6 +220,39 @@ preclaimHelper( return tesSUCCESS; } +/** Validate ledger preconditions for an MPT clawback. + * + * Checks are applied in this order: + * + * 1. **MPT issuance object exists**: the `MPTIssuance` SLE identified by + * the `MPTIssue` in `sfAmount` must be present in the ledger. + * + * 2. **`lsfMPTCanClawback` flag**: the flag is immutable after issuance + * creation. If it was not set at creation time the issuer permanently + * lacks clawback authority. + * + * 3. **Issuer ownership**: the `sfIssuer` field on the issuance object must + * match the transaction submitter. This prevents a third party from + * constructing a clawback against an issuance they do not own. + * + * 4. **Holder `MPToken` object exists**: `keylet::mptoken` must resolve to + * an existing ledger object; missing → `tecOBJECT_NOT_FOUND`. + * + * 5. **Non-zero spendable balance**: `accountHolds` with both + * `fhIGNORE_FREEZE` and `ahIGNORE_AUTH` is used. Authorization status + * is irrelevant for a forced reclaim by the issuer. + * + * @param ctx Read-only preclaim context. + * @param sleIssuer The issuer's `AccountRoot` SLE (unused; kept for uniform + * signature with the `Issue` specialisation). + * @param issuer The issuer's `AccountID`. + * @param holder The holder's `AccountID`. + * @param clawAmount The amount specified in the transaction (MPT variant). + * @return `tesSUCCESS` on success; `tecOBJECT_NOT_FOUND` if the issuance or + * holder token object is missing; `tecNO_PERMISSION` if the clawback flag + * is unset or the caller is not the issuer; `tecINSUFFICIENT_FUNDS` if + * the spendable balance is zero. + */ template <> TER preclaimHelper( @@ -184,6 +288,28 @@ preclaimHelper( return tesSUCCESS; } +/** Validate ledger preconditions for a clawback transaction. + * + * Resolves the holder account ID from the asset-type-dependent encoding + * (IOU: from `sfAmount.issuer`; MPT: from `sfHolder`), then applies + * protocol-level guards before dispatching to the asset-specific helper. + * + * Order of checks: + * 1. Both issuer and holder `AccountRoot` SLEs must exist. + * 2. When `featureSingleAssetVault` is active, the holder must not be a + * pseudo-account (`isPseudoAccount` → `tecPSEUDO_ACCOUNT`). When SAV is + * active this check subsumes the AMM check that follows. + * 3. The holder must not be an AMM liquidity-pool account (`sfAMMID` present + * → `tecAMM_ACCOUNT`). AMM pool balances are managed via `AMMClawback` + * rather than the standard clawback path. + * 4. Asset-type-specific preconditions via `preclaimHelper`. + * + * @param ctx Read-only preclaim context. + * @return `tesSUCCESS` on success; `terNO_ACCOUNT` if either account is + * missing; `tecPSEUDO_ACCOUNT` or `tecAMM_ACCOUNT` if the holder is a + * protocol-internal account; or any code returned by the type-specific + * `preclaimHelper` specialisation. + */ TER Clawback::preclaim(PreclaimContext const& ctx) { @@ -196,8 +322,9 @@ Clawback::preclaim(PreclaimContext const& ctx) if (!sleIssuer || !sleHolder) return terNO_ACCOUNT; - // Note the order of checks - when SAV is active, this check here will make - // the one which follows `sleHolder->isFieldPresent(sfAMMID)` redundant. + // When SAV is active, isPseudoAccount() also catches AMM accounts, making + // the sfAMMID check below redundant. Both are kept for defence-in-depth + // and to preserve the pre-SAV code path without amendment branching. if (ctx.view.rules().enabled(featureSingleAssetVault) && isPseudoAccount(sleHolder)) { return tecPSEUDO_ACCOUNT; @@ -218,6 +345,30 @@ template static TER applyHelper(ApplyContext& ctx); +/** Execute an IOU clawback by transferring tokens from holder to issuer. + * + * The `sfAmount` field as received encodes the holder account in its + * `issuer` sub-field (the IOU wire-protocol trick used throughout the + * clawback path). Before invoking the transfer, this function corrects the + * field by writing the real issuer account ID into `clawAmount.get().account`, + * so that `directSendNoFee` sees a properly formed amount where `issuer` + * identifies the issuer, not the holder. + * + * The amount transferred is `min(spendableAmount, clawAmount)`. The + * spendable amount is re-queried via `accountHolds` (with `fhIGNORE_FREEZE`) + * at apply time rather than relying on the preclaim result, because ledger + * state may have changed between the two phases. This also correctly handles + * XLS-34-style holds that reduce the available balance below the nominal + * trust-line balance. + * + * Passing `/*checkIssuer*‌/ true` to `directSendNoFee` causes it to verify + * issuer involvement in the trust-line accounting, which is appropriate for + * the IOU model. + * + * @param ctx Mutable apply context. + * @return `tesSUCCESS` on success; `tecINTERNAL` if holder equals issuer + * (should never occur after preclaim; LCOV-excluded). + */ template <> TER applyHelper(ApplyContext& ctx) @@ -231,7 +382,9 @@ applyHelper(ApplyContext& ctx) if (holder == issuer) return tecINTERNAL; // LCOV_EXCL_LINE - // Get the spendable balance. Must use `accountHolds`. + // Re-query spendable balance at apply time: ledger state may have + // changed since preclaim, and accountHolds accounts for XLS-34 holds + // that the raw sfBalance does not reflect. STAmount const spendableAmount = accountHolds( ctx.view(), holder, @@ -244,6 +397,26 @@ applyHelper(ApplyContext& ctx) ctx.view(), holder, issuer, std::min(spendableAmount, clawAmount), true, ctx.journal); } +/** Execute an MPT clawback by transferring tokens from holder to issuer. + * + * For MPTs the holder account comes from the conventional `sfHolder` field + * (not the `sfAmount.issuer` encoding used by IOUs). No field correction is + * required before calling the transfer helper. + * + * As with the IOU path, the transfer amount is capped at + * `min(spendableAmount, clawAmount)`. The spendable balance is re-queried + * at apply time via `accountHolds` with both `fhIGNORE_FREEZE` and + * `ahIGNORE_AUTH` — authorization status does not gate a forced issuer + * reclaim. + * + * `/*checkIssuer*‌/ false` is passed to `directSendNoFee` because MPT + * issuance ownership is already established by the issuance object itself + * (verified in preclaim); the IOU-specific issuer-involvement check in + * `directSendNoFee` is not applicable to the MPT model. + * + * @param ctx Mutable apply context. + * @return The result of `directSendNoFee` — `tesSUCCESS` on success. + */ template <> TER applyHelper(ApplyContext& ctx) @@ -252,7 +425,9 @@ applyHelper(ApplyContext& ctx) auto clawAmount = ctx.tx[sfAmount]; AccountID const holder = ctx.tx[sfHolder]; - // Get the spendable balance. Must use `accountHolds`. + // Re-query spendable balance at apply time with both freeze and auth + // handling suppressed: authorization is irrelevant for a forced issuer + // reclaim, and the issuer may have frozen the holder's token. STAmount const spendableAmount = accountHolds( ctx.view(), holder, @@ -270,6 +445,16 @@ applyHelper(ApplyContext& ctx) ctx.journal); } +/** Dispatch to the asset-type-specific apply helper and commit mutations. + * + * Resolves the runtime `std::variant` in `sfAmount` to a + * compile-time template parameter and forwards to `applyHelper` or + * `applyHelper`. The chosen helper transfers tokens from the + * holder to the issuer via `directSendNoFee`, capped at the holder's + * current spendable balance. + * + * @return The result of the appropriate `applyHelper` specialisation. + */ TER Clawback::doApply() { @@ -278,6 +463,13 @@ Clawback::doApply() ctx_.tx[sfAmount].asset().value()); } +/** Per-SLE invariant visitor hook — currently a no-op. + * + * Reserved for future transaction-specific invariant checks. The global + * `ValidClawback` invariant checker (in `InvariantCheck.cpp`) verifies that + * at most one trust line or MPT object is modified and that the holder's + * balance remains non-negative; no per-transactor check is needed here yet. + */ void Clawback::visitInvariantEntry( bool, @@ -287,6 +479,14 @@ Clawback::visitInvariantEntry( // No transaction-specific invariants yet (future work). } +/** Invariant finalisation hook — currently a no-op that always passes. + * + * Reserved for future transaction-specific invariant checks. Global invariant + * enforcement (e.g., `XRPNotCreated`, `ValidClawback`) is performed by the + * framework independently of this hook. + * + * @return Always `true`; no violations are currently checked. + */ bool Clawback::finalizeInvariants(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) { diff --git a/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp b/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp index dea65bd5a0..c1e44538eb 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp @@ -1,3 +1,14 @@ +/** @file + * Implements the `MPTokenAuthorize` transactor, which handles all + * authorization state transitions for Multi-Purpose Tokens (MPTs). + * + * A single transaction type covers four operations differentiated by the + * presence of the optional `sfHolder` field and the `tfMPTUnauthorize` flag: + * holder opt-in (create `MPToken` SLE), holder opt-out (delete `MPToken` SLE), + * issuer allowlist grant (`lsfMPTAuthorized` set), and issuer allowlist revoke + * (`lsfMPTAuthorized` cleared). All ledger mutations are delegated to + * `authorizeMPToken()` in `MPTokenHelpers.cpp`. + */ #include #include @@ -19,12 +30,29 @@ namespace xrpl { +/** Return `tfMPTokenAuthorizeMask` — the complete set of valid flag bits for + * this transaction type. + * + * Called by the `preflight1()` framework before field validation; any flag bit + * not present in the mask causes immediate rejection with `temINVALID_FLAG`. + */ std::uint32_t MPTokenAuthorize::getFlagsMask(PreflightContext const& ctx) { return tfMPTokenAuthorizeMask; } +/** Stateless preflight guard — rejects self-authorization attempts. + * + * The only check needed here is that `sfAccount != sfHolder`. An account + * naming itself as the holder it wants to authorize would be degenerate: the + * holder and issuer paths in `preclaim` both assume these two identities are + * distinct, so catching this early avoids undefined branching downstream. + * Amendment gating and fee/sequence checks are handled by the surrounding + * `invokePreflight()` machinery; this method need not repeat them. + * + * @return `temMALFORMED` if `sfAccount == sfHolder`; `tesSUCCESS` otherwise. + */ NotTEC MPTokenAuthorize::preflight(PreflightContext const& ctx) { @@ -34,30 +62,63 @@ MPTokenAuthorize::preflight(PreflightContext const& ctx) return tesSUCCESS; } +/** Validate ledger state for all four authorization operations. + * + * Branches on whether `sfHolder` is present in the transaction: + * + * **Holder path** (`sfHolder` absent — submitter is the holder): + * + * The `tfMPTUnauthorize` check is performed before reading the issuance SLE. + * This ordering is intentional: when all holders reach a zero balance before + * the issuer destroys the issuance, the outstanding `MPToken` objects must + * still be cleanable after the issuance is gone. Checking the unauthorize flag + * first avoids a spurious `tecOBJECT_NOT_FOUND` in that cleanup scenario. + * + * - With `tfMPTUnauthorize` (delete): + * - `MPToken` SLE must exist (`tecOBJECT_NOT_FOUND`). + * - `sfMPTAmount` must be zero (`tecHAS_OBLIGATIONS`). + * - `sfLockedAmount` (optional) must also be zero (`tecHAS_OBLIGATIONS`); + * a zero net balance with non-zero locked amount indicates active escrow + * or vault operations that have not yet settled. + * - When `featureSingleAssetVault` is active, `lsfMPTLocked` must be clear + * on the `MPToken` SLE (`tecNO_PERMISSION`); a vault hold prevents opt-out. + * + * - Without `tfMPTUnauthorize` (create / opt-in): + * - `MPTokenIssuance` must exist (`tecOBJECT_NOT_FOUND`). + * - Submitter must not be the issuer (`tecNO_PERMISSION`). + * - `MPToken` SLE must not already exist (`tecDUPLICATE`). + * + * **Issuer path** (`sfHolder` present — submitter is the issuer): + * + * - Named holder account must exist on the ledger (`tecNO_DST`). + * - `MPTokenIssuance` must exist (`tecOBJECT_NOT_FOUND`). + * - Submitter must be `sfIssuer` on the issuance (`tecNO_PERMISSION`). + * - Issuance must carry `lsfMPTRequireAuth` (`tecNO_AUTH`); managing an + * allowlist on a non-auth issuance is meaningless. + * - Holder must have already created their `MPToken` SLE (`tecOBJECT_NOT_FOUND`); + * the protocol enforces a holder-first, issuer-second handshake. + * - Holder must not be a pseudo-account (`tecNO_PERMISSION`); vault and loan + * broker pseudo-accounts (identified by `sfVaultID` / `sfLoanBrokerID`) are + * implicitly always authorized by the protocol and cannot be managed via this + * path. No amendment gate is needed: such accounts only exist when + * `featureSingleAssetVault` is already active. + * + * @return `tesSUCCESS`, or one of the `tec`/`tef` error codes described above. + */ TER MPTokenAuthorize::preclaim(PreclaimContext const& ctx) { auto const accountID = ctx.tx[sfAccount]; auto const holderID = ctx.tx[~sfHolder]; - // if non-issuer account submits this tx, then they are trying either: - // 1. Unauthorize/delete MPToken - // 2. Use/create MPToken - // - // Note: `accountID` is holder's account - // `holderID` is NOT used if (!holderID) { std::shared_ptr const sleMpt = ctx.view.read(keylet::mptoken(ctx.tx[sfMPTokenIssuanceID], accountID)); - // There is an edge case where all holders have zero balance, issuance - // is legally destroyed, then outstanding MPT(s) are deleted afterwards. - // Thus, there is no need to check for the existence of the issuance if - // the MPT is being deleted with a zero balance. Check for unauthorize - // before fetching the MPTIssuance object. - - // if holder wants to delete/unauthorize a mpt + // Check for unauthorize before fetching the MPTIssuance object: a + // holder may delete a zero-balance MPToken even after the issuance has + // been destroyed (post-destruction cleanup path). if ((ctx.tx.getFlags() & tfMPTUnauthorize) != 0u) { if (!sleMpt) @@ -73,6 +134,8 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) return tecHAS_OBLIGATIONS; } + // A zero net balance with non-zero locked amount indicates tokens + // still held in escrow or vault; must settle before opt-out. if ((*sleMpt)[~sfLockedAmount].value_or(0) != 0) { auto const sleMptIssuance = @@ -88,7 +151,6 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } - // Now test when the holder wants to hold/create/authorize a new MPT auto const sleMptIssuance = ctx.view.read(keylet::mptIssuance(ctx.tx[sfMPTokenIssuanceID])); if (!sleMptIssuance) @@ -97,7 +159,6 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) if (accountID == (*sleMptIssuance)[sfIssuer]) return tecNO_PERMISSION; - // if holder wants to use and create a mpt if (sleMpt) return tecDUPLICATE; @@ -114,22 +175,16 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) std::uint32_t const mptIssuanceFlags = sleMptIssuance->getFieldU32(sfFlags); - // If tx is submitted by issuer, they would either try to do the following - // for allowlisting: - // 1. authorize an account - // 2. unauthorize an account - // - // Note: `accountID` is issuer's account - // `holderID` is holder's account if (accountID != (*sleMptIssuance)[sfIssuer]) return tecNO_PERMISSION; - // If tx is submitted by issuer, it only applies for MPT with - // lsfMPTRequireAuth set + // Allowlist management only applies when the issuance requires auth; + // granting/revoking on an open issuance is meaningless. if ((mptIssuanceFlags & lsfMPTRequireAuth) == 0u) return tecNO_AUTH; - // The holder must create the MPT before the issuer can authorize it. + // The holder must opt in before the issuer can authorize them + // (holder-first, issuer-second handshake). if (!ctx.view.exists(keylet::mptoken(ctx.tx[sfMPTokenIssuanceID], *holderID))) return tecOBJECT_NOT_FOUND; @@ -142,6 +197,21 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } +/** Apply the authorization change to the mutable ledger view. + * + * Delegates entirely to `authorizeMPToken()`, passing `preFeeBalance_` — the + * XRP balance captured by the base `Transactor` class before the transaction + * fee was deducted — as the reserve baseline for new `MPToken` SLE creation. + * All ledger mutations (SLE create/delete, owner directory linking, flag + * toggling) happen inside that helper. Because `preclaim` has already + * established all preconditions, any SLE-lookup failure inside + * `authorizeMPToken` is treated as `tecINTERNAL` and annotated + * `LCOV_EXCL_LINE`. + * + * @return `tesSUCCESS`, `tecINSUFFICIENT_RESERVE` when a new `MPToken` SLE + * cannot be created due to insufficient XRP reserve, or a `tef` code on + * internal invariant violations in `authorizeMPToken`. + */ TER MPTokenAuthorize::doApply() { @@ -156,6 +226,13 @@ MPTokenAuthorize::doApply() tx[~sfHolder]); } +/** No-op stub satisfying the `Transactor` pure-virtual interface. + * + * No transaction-specific invariants are defined for `MPTokenAuthorize` yet. + * Global invariants in `InvariantCheck.cpp` (e.g., `ValidMPTIssuance`, + * `ValidMPTPayment`) cover the relevant MPT ledger state; this method is + * reserved for any future per-transactor checks. + */ void MPTokenAuthorize::visitInvariantEntry( bool, @@ -165,6 +242,15 @@ MPTokenAuthorize::visitInvariantEntry( // No transaction-specific invariants yet (future work). } +/** No-op stub satisfying the `Transactor` pure-virtual interface. + * + * Always returns `true`. No transaction-specific finalization invariants are + * defined for `MPTokenAuthorize`; the global checker suite handles MPT + * issuance and holder-count accounting. This method is the extension point + * for future per-transactor post-apply assertions. + * + * @return Always `true`. + */ bool MPTokenAuthorize::finalizeInvariants( STTx const&, diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp index 567f5c3480..cfc35913ca 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp @@ -25,6 +25,23 @@ namespace xrpl { +/** Gate optional field categories on the amendments that enable them. + * + * Runs before the main flag-mask check in `invokePreflight`, so rejections + * here are cheaper than a full preflight. Two independent gates are applied: + * + * - `sfDomainID` is only meaningful when both `featurePermissionedDomains` + * and `featureSingleAssetVault` are active. Requiring both ensures the + * domain registry and the vault subsystem are available before any + * domain-scoped issuance can be created. + * + * - `sfMutableFlags` requires `featureDynamicMPT`. Without that amendment + * the ledger has no mechanism to honor mutability requests, so a non-null + * value would be silently dropped — returning false here prevents that. + * + * @return `true` if all present gated fields are covered by active amendments; + * `false` causes `invokePreflight` to return `temDISABLED`. + */ bool MPTokenIssuanceCreate::checkExtraFeatures(PreflightContext const& ctx) { @@ -39,18 +56,59 @@ MPTokenIssuanceCreate::checkExtraFeatures(PreflightContext const& ctx) return true; } +/** Return the valid-flags mask for this transaction type. + * + * The framework passes this value to `preflight1`, which rejects any + * transaction whose `sfFlags` field has bits set outside the mask. The + * mask constant `tfMPTokenIssuanceCreateMask` is defined in `TxFlags.h` and + * covers all flags meaningful to MPT issuance creation (e.g. + * `tfMPTCanTransfer`, `tfMPTRequireAuth`, `tfMPTCanEscrow`, etc.). + * + * The `ctx` parameter is accepted for interface uniformity but unused; the + * mask is unconditional for this transaction type. + */ std::uint32_t MPTokenIssuanceCreate::getFlagsMask(PreflightContext const& ctx) { - // This mask is only compared against sfFlags return tfMPTokenIssuanceCreateMask; } +/** Validate the semantic field-level constraints of an MPT issuance creation. + * + * This runs after `checkExtraFeatures` and `preflight1` have passed, so + * amendment gates and unknown flag bits are already ruled out. The checks + * are ordered from cheapest to most likely to fail in practice: + * + * 1. **`sfMutableFlags`**: If present, must be non-zero (at least one mutable + * flag declared) and must not have bits set outside + * `tmfMPTokenIssuanceCreateMutableMask`. The condition + * `(*mutableFlags & mask) != 0` is true when reserved bits ARE set — a + * defensive pattern in this codebase where the mask captures allowed bits, + * not disallowed ones. Returns `temINVALID_FLAG`. + * + * 2. **`sfTransferFee`**: Must not exceed `kMAX_TRANSFER_FEE` (50,000 basis + * points = 50%). A non-zero fee without `tfMPTCanTransfer` is incoherent + * by protocol design — the fee would never be applied — so that + * combination returns `temMALFORMED`. + * + * 3. **`sfDomainID`**: An all-zeros value (`beast::kZERO`) is rejected as a + * sentinel for "no domain". A valid domain ID mandates `tfMPTRequireAuth` + * because a domain-scoped issuance restricts which holders are eligible; + * without authorization the domain constraint cannot be enforced. Returns + * `temMALFORMED` for either violation. + * + * 4. **`sfMPTokenMetadata`**: Must be non-empty and at most + * `kMAX_MP_TOKEN_METADATA_LENGTH` (1,024) bytes. Returns `temMALFORMED`. + * + * 5. **`sfMaximumAmount`**: Must be positive and ≤ `kMAX_MP_TOKEN_AMOUNT` + * (0x7FFF_FFFF_FFFF_FFFF). The ceiling ensures amounts fit within the + * XRPL `Number` type's representable range. Returns `temMALFORMED`. + * + * @return `tesSUCCESS` if all checks pass; a `tem*` error code otherwise. + */ NotTEC MPTokenIssuanceCreate::preflight(PreflightContext const& ctx) { - // If the mutable flags field is included, at least one flag must be - // specified. if (auto const mutableFlags = ctx.tx[~sfMutableFlags]; mutableFlags && ((*mutableFlags == 0u) || ((*mutableFlags & tmfMPTokenIssuanceCreateMutableMask) != 0u))) return temINVALID_FLAG; @@ -82,7 +140,6 @@ MPTokenIssuanceCreate::preflight(PreflightContext const& ctx) return temMALFORMED; } - // Check if maximumAmount is within unsigned 63 bit range if (auto const maxAmt = ctx.tx[~sfMaximumAmount]) { if (maxAmt == 0) @@ -94,6 +151,56 @@ MPTokenIssuanceCreate::preflight(PreflightContext const& ctx) return tesSUCCESS; } +/** Create a new `MPTokenIssuance` SLE and insert it into the ledger. + * + * This is the single authoritative path for all MPT issuance creation. + * `doApply()` calls it directly; `VaultCreate` calls it to mint the + * vault's share token from a pseudo-account. Keeping the logic here rather + * than inline in `doApply()` allows other transactors to reuse it without a + * full `ApplyContext`. + * + * **Execution sequence:** + * + * 1. Peek the issuer's `AccountRoot` SLE (write access required for the + * subsequent `adjustOwnerCount`). A missing account returns + * `tecINTERNAL`; this branch is excluded from coverage because a valid + * transaction reaching apply phase always has its account in the ledger. + * + * 2. If `args.priorBalance` is provided, verify the issuer can cover the + * reserve for `ownerCount + 1` new objects. Callers that manage the + * reserve externally — most notably `VaultCreate`, which operates on a + * freshly-created pseudo-account — pass `std::nullopt` to skip this gate. + * + * 3. Compute the deterministic `MPTID` via `makeMptID(args.sequence, + * args.account)`. The 192-bit identifier is derived from the issuer's + * `AccountID` and the transaction sequence number; monotonically + * increasing sequences guarantee uniqueness under normal ledger operation. + * + * 4. Insert the issuance into the issuer's owner directory via + * `view.dirInsert()`. A full directory returns `tecDIR_FULL`; also + * excluded from coverage as a theoretical-only edge case. + * + * 5. Construct the `MPTokenIssuance` SLE. Mandatory fields written + * unconditionally: `sfFlags` (with universal bits stripped via + * `~tfUniversal`), `sfIssuer`, `sfOutstandingAmount` (initialized to 0), + * `sfOwnerNode`, and `sfSequence`. All optional fields + * (`sfMaximumAmount`, `sfAssetScale`, `sfTransferFee`, + * `sfMPTokenMetadata`, `sfDomainID`, `sfMutableFlags`) are written only + * when present in `args`, keeping the SLE sparse. `sfOutstandingAmount` + * starting at 0 is the exact condition that `MPTokenIssuanceDestroy` + * checks — the ledger enforces that an issuance with circulating supply + * cannot be destroyed. + * + * 6. Increment `sfOwnerCount` via `adjustOwnerCount(+1)`. This raises the + * issuer's reserve threshold and protects the issuance from + * garbage-collection until it is explicitly destroyed. + * + * @param view Mutable ledger view to write into. + * @param journal Logging sink. + * @param args Aggregate of all creation parameters; see `MPTCreateArgs`. + * @return On success, the newly minted `MPTID`. On failure, an `Unexpected` + * wrapping `tecINTERNAL`, `tecINSUFFICIENT_RESERVE`, or `tecDIR_FULL`. + */ Expected MPTokenIssuanceCreate::create(ApplyView& view, beast::Journal journal, MPTCreateArgs const& args) { @@ -108,7 +215,6 @@ MPTokenIssuanceCreate::create(ApplyView& view, beast::Journal journal, MPTCreate auto const mptId = makeMptID(args.sequence, args.account); auto const mptIssuanceKeylet = keylet::mptIssuance(mptId); - // create the MPTokenIssuance { auto const ownerNode = view.dirInsert( keylet::ownerDir(args.account), mptIssuanceKeylet, describeOwnerDir(args.account)); @@ -144,12 +250,27 @@ MPTokenIssuanceCreate::create(ApplyView& view, beast::Journal journal, MPTCreate view.insert(mptIssuance); } - // Update owner count. adjustOwnerCount(view, acct, 1, journal); return mptId; } +/** Apply the transaction by packaging its fields into `MPTCreateArgs` and + * delegating all ledger mutation to `create()`. + * + * `preFeeBalance_` is passed as `priorBalance` so the reserve check inside + * `create()` uses the pre-fee snapshot — consistent with the codebase + * convention that reserve adequacy is measured before the fee is deducted + * (see "Reserve Check Convention" in the transactors skill). + * + * All optional transaction fields are forwarded unchanged; absent fields + * become `std::nullopt` in `MPTCreateArgs` and are omitted from the SLE, + * keeping the ledger entry sparse. + * + * @return `tesSUCCESS` if `create()` succeeds; otherwise the `TER` embedded + * in the `Unexpected` result (one of `tecINTERNAL`, + * `tecINSUFFICIENT_RESERVE`, or `tecDIR_FULL`). + */ TER MPTokenIssuanceCreate::doApply() { @@ -172,6 +293,15 @@ MPTokenIssuanceCreate::doApply() return result ? tesSUCCESS : result.error(); } +/** No-op stub satisfying the `Transactor` invariant-entry interface. + * + * No transaction-specific invariants are defined for + * `MPTokenIssuanceCreate` yet. The global `ValidMPTIssuance` and + * `ValidMPTPayment` checkers in `InvariantCheck.cpp` cover the structural + * properties of the newly inserted SLE. This method exists as an extension + * point for future per-transaction checks (e.g., verifying that exactly one + * new `ltMPTOKEN_ISSUANCE` entry appears in the diff). + */ void MPTokenIssuanceCreate::visitInvariantEntry( bool, @@ -181,6 +311,14 @@ MPTokenIssuanceCreate::visitInvariantEntry( // No transaction-specific invariants yet (future work). } +/** No-op stub satisfying the `Transactor` invariant-finalize interface. + * + * Always returns `true`. No transaction-specific invariants are defined for + * `MPTokenIssuanceCreate` yet; the global invariant framework handles all + * structural checks. Reserved as an extension point for future checks. + * + * @return `true` unconditionally. + */ bool MPTokenIssuanceCreate::finalizeInvariants( STTx const&, diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceDestroy.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceDestroy.cpp index be03d78c64..83101fe809 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceDestroy.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceDestroy.cpp @@ -13,25 +13,59 @@ namespace xrpl { +/** Trivial preflight: unconditionally succeeds. + * + * A destroy transaction carries only `sfMPTokenIssuanceID` — no flags, + * amounts, or complex parameters that can be validated without ledger + * state. The framework's `preflight0`/`preflight1` layers have already + * checked fee, sequence, and signature format by the time this runs. + * All substantive validation is deferred to `preclaim`, where a read-only + * ledger view is available. + * + * @param ctx Preflight context (unused beyond framework consumption). + * @return `tesSUCCESS` always. + */ NotTEC MPTokenIssuanceDestroy::preflight(PreflightContext const& ctx) { return tesSUCCESS; } +/** Validate destruction preconditions against a read-only ledger view. + * + * Performs three ordered checks against the `MPTokenIssuance` SLE: + * + * 1. **Existence** — looks up the SLE via `keylet::mptIssuance(sfMPTokenIssuanceID)`. + * An absent entry returns `tecOBJECT_NOT_FOUND`; the keylet lookup also + * implicitly validates the ID's format. + * + * 2. **Ownership** — `sfIssuer` on the SLE must match the submitting account. + * No other account may destroy an issuance, regardless of administrative + * authority. Returns `tecNO_PERMISSION` on mismatch. + * + * 3. **Zero supply** — both `sfOutstandingAmount` (circulating tokens) and the + * optional `sfLockedAmount` (tokens held in escrow or protocol positions) + * must be exactly zero, checked separately because they represent distinct + * accounting concepts. Either non-zero returns `tecHAS_OBLIGATIONS`. + * Destroying while holders exist would orphan their `MPToken` SLEs, + * corrupting the ledger. The `sfLockedAmount` branch is `LCOV_EXCL_LINE` + * because reaching it with zero `sfOutstandingAmount` would indicate + * already-corrupted ledger state not reachable through normal flow. + * + * @param ctx Preclaim context providing the read-only ledger view. + * @return `tesSUCCESS` if all guards pass; `tecOBJECT_NOT_FOUND`, + * `tecNO_PERMISSION`, or `tecHAS_OBLIGATIONS` otherwise. + */ TER MPTokenIssuanceDestroy::preclaim(PreclaimContext const& ctx) { - // ensure that issuance exists auto const sleMPT = ctx.view.read(keylet::mptIssuance(ctx.tx[sfMPTokenIssuanceID])); if (!sleMPT) return tecOBJECT_NOT_FOUND; - // ensure it is issued by the tx submitter if ((*sleMPT)[sfIssuer] != ctx.tx[sfAccount]) return tecNO_PERMISSION; - // ensure it has no outstanding balances if ((*sleMPT)[sfOutstandingAmount] != 0) return tecHAS_OBLIGATIONS; @@ -41,6 +75,45 @@ MPTokenIssuanceDestroy::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } +/** Apply the destruction: remove the issuance SLE and unwind its accounting. + * + * Executes the symmetric inverse of `MPTokenIssuanceCreate::create()` in + * four steps: + * + * 1. **Defensive re-verification** — re-checks that `account_` equals the + * SLE's `sfIssuer`. This guard is redundant with `preclaim` by design: + * `doApply` runs against a mutable view that is logically distinct from + * the read-only snapshot used by `preclaim`, and the framework could, in + * theory, present a different ledger state here. `tecINTERNAL` on + * mismatch signals a framework-level bug, not user error; the branch is + * `LCOV_EXCL_LINE` because it cannot be reached through correct + * transaction processing. + * + * 2. **Owner-directory removal** — calls `view().dirRemove(keylet::ownerDir(account_), + * (*mpt)[sfOwnerNode], mpt->key(), false)`. The `sfOwnerNode` back-pointer + * stored in the SLE at creation time gives the exact directory page, making + * this an O(1) lookup rather than a linear scan. The `false` argument + * preserves the owner directory itself even if it becomes empty. Failure + * means the directory entry was never recorded or has already been removed — + * ledger corruption — so `tefBAD_LEDGER` is returned (also `LCOV_EXCL_LINE`). + * + * 3. **SLE erasure** — `view().erase(mpt)` removes the `MPTokenIssuance` + * object from the ledger. This is safe only after the directory entry is + * gone; erasing first would lose the `sfOwnerNode` page index needed for + * step 2. + * + * 4. **Owner-count adjustment** — `adjustOwnerCount(..., -1, j_)` decrements + * the issuer's reserve-tracked owner count, freeing the one reserve slot + * that `MPTokenIssuanceCreate` claimed. + * + * All four mutations are applied to the `ApplyContext` view and are committed + * atomically if `tesSUCCESS` is returned, or discarded entirely on any non-`tes` + * result per the standard transactor rollback contract. + * + * @return `tesSUCCESS` on success; `tecINTERNAL` if the issuer mismatch guard + * fires (framework bug); `tefBAD_LEDGER` if the owner-directory entry is + * missing (ledger corruption). + */ TER MPTokenIssuanceDestroy::doApply() { @@ -58,6 +131,15 @@ MPTokenIssuanceDestroy::doApply() return tesSUCCESS; } +/** No-op stub satisfying the invariant-visitor interface. + * + * No transaction-specific per-entry invariants are defined for + * `MPTokenIssuanceDestroy` yet. Global invariant checkers (e.g., + * `ValidMPTIssuance`, `NoZeroEscrow`) cover the relevant ledger + * state transitions without needing a per-transactor hook here. + * This method is the designated extension point for any future + * per-transaction invariants. + */ void MPTokenIssuanceDestroy::visitInvariantEntry( bool, @@ -67,6 +149,16 @@ MPTokenIssuanceDestroy::visitInvariantEntry( // No transaction-specific invariants yet (future work). } +/** No-op stub satisfying the invariant-finalizer interface. + * + * Always returns `true` because no transaction-specific finalization + * invariants exist for `MPTokenIssuanceDestroy`. The global invariant + * framework (e.g., `ValidMPTIssuance`, `XRPNotCreated`) handles all + * post-apply consistency checks for this transaction type. Reserved + * for future per-transaction invariants. + * + * @return `true` always — no invariants to fail. + */ bool MPTokenIssuanceDestroy::finalizeInvariants( STTx const&, diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp index 05089621c6..bbc330a9a9 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp @@ -42,16 +42,32 @@ MPTokenIssuanceSet::getFlagsMask(PreflightContext const& ctx) return tfMPTokenIssuanceSetMask; } -// Maps set/clear mutable flags in an MPTokenIssuanceSet transaction to the -// corresponding ledger mutable flags that control whether the change is -// allowed. +/** Mapping between the set/clear bits in a transaction's `sfMutableFlags` + * field and the corresponding `lsmfMPTCanMutate*` gate stored on the + * `MPTokenIssuance` SLE. + * + * The three fields implement a two-level flag design: `setFlag` and + * `clearFlag` are the transaction-level request bits; `canMutateFlag` is + * the issuance-level permission bit that must already be set before the + * corresponding change is allowed. The same struct drives three phases: + * `preflight` (conflict detection), `preclaim` (permission gating), and + * `doApply` (flag mutation) — ensuring all three always reason about the + * same six properties with no possibility of a phase mismatch. + */ struct MPTMutabilityFlags { - std::uint32_t setFlag; - std::uint32_t clearFlag; - std::uint32_t canMutateFlag; + std::uint32_t setFlag; /**< Transaction bit requesting the feature be enabled. */ + std::uint32_t clearFlag; /**< Transaction bit requesting the feature be disabled. */ + std::uint32_t canMutateFlag; /**< Issuance SLE bit that gates whether this change is permitted. */ }; +/** Table-driven mapping of the six mutable properties of an MPTokenIssuance. + * + * Each entry covers one logical capability (lock, require-auth, escrow, + * trade, transfer, clawback). Using a compile-time array rather than + * ad-hoc flag checks ensures that `preflight`, `preclaim`, and `doApply` + * always agree on which properties exist and how they map to ledger bits. + */ static constexpr std::array kMPT_MUTABILITY_FLAGS = { {{.setFlag = tmfMPTSetCanLock, .clearFlag = tmfMPTClearCanLock, @@ -72,6 +88,31 @@ static constexpr std::array kMPT_MUTABILITY_FLAGS = { .clearFlag = tmfMPTClearCanClawback, .canMutateFlag = lsmfMPTCanMutateCanClawback}}}; +/** Stateless validation for `MPTokenIssuanceSet` transactions. + * + * Operates in two modes depending on which fields are present: + * + * **Lock/unlock mode** (no `sfMutableFlags`, `sfMPTokenMetadata`, or + * `sfTransferFee`): validates flag exclusivity (`tfMPTLock` and + * `tfMPTUnlock` cannot both be set) and that the submitter is not also + * the named holder. The no-op check under `featureSingleAssetVault` or + * `featureDynamicMPT` rejects a transaction that changes nothing at all + * (zero flags, no domain, no mutation fields), preventing fee-burning + * submissions. + * + * **Mutation mode** (any of `sfMutableFlags`, `sfMPTokenMetadata`, + * `sfTransferFee` present): requires `featureDynamicMPT`; `sfHolder` and + * non-universal tx flags are both forbidden because mutation targets the + * issuance object rather than any individual holder slot. The check for a + * non-zero `sfTransferFee` alongside `tmfMPTClearCanTransfer` in + * `sfMutableFlags` catches a single-transaction contradiction (setting a + * fee while simultaneously removing transfer capability) before it reaches + * the ledger. + * + * The mutual exclusion of `sfDomainID` and `sfHolder` is validated here + * because domain assignment operates on the issuance object while holder + * operations target an individual `MPToken` slot — the two cannot coexist. + */ NotTEC MPTokenIssuanceSet::preflight(PreflightContext const& ctx) { @@ -88,7 +129,6 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx) auto const txFlags = ctx.tx.getFlags(); - // fails if both flags are set if (((txFlags & tfMPTLock) != 0u) && ((txFlags & tfMPTUnlock) != 0u)) return temINVALID_FLAG; @@ -99,18 +139,15 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx) if (ctx.rules.enabled(featureSingleAssetVault) || ctx.rules.enabled(featureDynamicMPT)) { - // Is this transaction actually changing anything ? if (txFlags == 0 && !ctx.tx.isFieldPresent(sfDomainID) && !isMutate) return temMALFORMED; } if (ctx.rules.enabled(featureDynamicMPT)) { - // Holder field is not allowed when mutating MPTokenIssuance if (isMutate && holderID) return temMALFORMED; - // Can not set flags when mutating MPTokenIssuance if (isMutate && ((txFlags & tfUniversalMask) != 0u)) return temMALFORMED; @@ -125,14 +162,11 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx) if ((*mutableFlags == 0u) || ((*mutableFlags & tmfMPTokenIssuanceSetMutableMask) != 0u)) return temINVALID_FLAG; - // Can not set and clear the same flag if (std::ranges::any_of(kMPT_MUTABILITY_FLAGS, [mutableFlags](auto const& f) { return (*mutableFlags & f.setFlag) && (*mutableFlags & f.clearFlag); })) return temINVALID_FLAG; - // Trying to set a non-zero TransferFee and clear MPTCanTransfer - // in the same transaction is not allowed. if ((transferFee.value_or(0) != 0u) && ((*mutableFlags & tmfMPTClearCanTransfer) != 0u)) return temMALFORMED; } @@ -141,6 +175,23 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx) return tesSUCCESS; } +/** Two-tier delegate authorization check for lock/unlock operations. + * + * If `sfDelegate` is absent the issuer signed directly and is + * unconditionally permitted (early return `tesSUCCESS`). + * + * When a delegate is present, authorization is evaluated in order: + * 1. A broad transaction-type permission via `checkTxPermission()` — if + * the delegate holds blanket `ttMPTOKEN_ISSUANCE_SET` authority, no + * further checks are needed. + * 2. A flag-level guard is applied first (currently unreachable because no + * transaction-level flags beyond lock/unlock exist, but retained as + * forward-compatibility infrastructure — see `LCOV_EXCL_LINE`). + * 3. Granular fall-back: `MPTokenIssuanceLock` is required for `tfMPTLock` + * and `MPTokenIssuanceUnlock` is required for `tfMPTUnlock`. The + * `loadGranularPermission()` helper populates the set from the delegate + * SLE's permission list. + */ NotTEC MPTokenIssuanceSet::checkPermission(ReadView const& view, STTx const& tx) { @@ -176,17 +227,55 @@ MPTokenIssuanceSet::checkPermission(ReadView const& view, STTx const& tx) return tesSUCCESS; } +/** Read-only ledger-state validation for `MPTokenIssuanceSet`. + * + * Checks are ordered from cheap/general to expensive/specific: + * + * 1. **Issuance existence and ownership**: the `MPTokenIssuance` SLE must + * exist and its `sfIssuer` must match the submitting account. + * + * 2. **`lsfMPTCanLock` gate** (asymmetric): under the original rules the + * flag must be set for _any_ transaction. Under `featureSingleAssetVault` + * or `featureDynamicMPT`, the flag is only required when the transaction + * actually requests a lock or unlock — mutation-only transactions on + * issuances that lack locking capability are still permitted. Two + * separate `if` blocks are used rather than `||` to preserve this + * readable asymmetric logic. + * + * 3. **Holder checks** (when `sfHolder` present): the holder account must + * exist and the holder's `MPToken` slot for this issuance must exist. + * + * 4. **Domain checks** (when `sfDomainID` present): the issuance must have + * `lsfMPTRequireAuth` set (binding a domain to an unauthorized issuance + * is meaningless); a non-zero domain ID must reference an existing + * `PermissionedDomain` object. + * + * 5. **Mutation permission checks** (`featureDynamicMPT`): each flag the + * transaction sets or clears via `sfMutableFlags` must have the + * corresponding `lsmfMPTCanMutate*` bit already set on the issuance SLE + * (table-driven via `kMPT_MUTABILITY_FLAGS`). Clearing + * `lsfMPTRequireAuth` while a `DomainID` is set on the issuance is + * blocked here because it would produce an internally inconsistent + * ledger state. `sfMPTokenMetadata` and `sfTransferFee` mutations + * require `lsmfMPTCanMutateMetadata` and `lsmfMPTCanMutateTransferFee` + * respectively. + * + * 6. **Transfer fee pre-existence requirement**: a non-zero `sfTransferFee` + * requires `lsfMPTCanTransfer` to already be set on the _current_ ledger + * object. Enabling `tmfMPTSetCanTransfer` in the same transaction does + * not satisfy this requirement — `preclaim` is the first phase where the + * current ledger value of the flag is visible, so this check belongs here + * rather than in `preflight`. + */ TER MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) { - // ensure that issuance exists auto const sleMptIssuance = ctx.view.read(keylet::mptIssuance(ctx.tx[sfMPTokenIssuanceID])); if (!sleMptIssuance) return tecOBJECT_NOT_FOUND; if (!sleMptIssuance->isFlag(lsfMPTCanLock)) { - // For readability two separate `if` rather than `||` of two conditions if (!ctx.view.rules().enabled(featureSingleAssetVault) && !ctx.view.rules().enabled(featureDynamicMPT)) { @@ -198,17 +287,14 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) } } - // ensure it is issued by the tx submitter if ((*sleMptIssuance)[sfIssuer] != ctx.tx[sfAccount]) return tecNO_PERMISSION; if (auto const holderID = ctx.tx[~sfHolder]) { - // make sure holder account exists if (!ctx.view.exists(keylet::account(*holderID))) return tecNO_DST; - // the mptoken must exist if (!ctx.view.exists(keylet::mptoken(ctx.tx[sfMPTokenIssuanceID], *holderID))) return tecOBJECT_NOT_FOUND; } @@ -226,8 +312,7 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) } } - // sfMutableFlags is soeDEFAULT, defaulting to 0 if not specified on - // the ledger. + // sfMutableFlags defaults to 0 when absent (soeDEFAULT field). auto const currentMutableFlags = sleMptIssuance->getFieldU32(sfMutableFlags); auto isMutableFlag = [&](std::uint32_t mutableFlag) -> bool { @@ -255,10 +340,6 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) if (auto const fee = ctx.tx[~sfTransferFee]) { - // A non-zero TransferFee is only valid if the lsfMPTCanTransfer flag - // was previously enabled (at issuance or via a prior mutation). Setting - // it by tmfMPTSetCanTransfer in the current transaction does not meet - // this requirement. if (fee > 0u && !sleMptIssuance->isFlag(lsfMPTCanTransfer)) return tecNO_PERMISSION; @@ -269,6 +350,40 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } +/** Apply the mutations described by the transaction to the ledger. + * + * **SLE selection**: if `sfHolder` is present the holder's `MPToken` SLE is + * used; otherwise the `MPTokenIssuance` SLE is used. This single branch + * lets both issuance-wide and per-holder locking share the same lock/unlock + * code path. A missing SLE at this point is a ledger-corruption sentinel + * (`tecINTERNAL`) since `preclaim` already verified existence. + * + * **Lock/unlock**: `lsfMPTLocked` is set or cleared directly on whichever + * SLE was selected. The two flags are mutually exclusive (enforced in + * `preflight`), so a simple if/else-if suffices. + * + * **Mutable-flag iteration**: `kMPT_MUTABILITY_FLAGS` is iterated to + * translate each `tmfMPTSet*`/`tmfMPTClear*` bit in `sfMutableFlags` into + * the corresponding `lsmfMPTCanMutate*` bit change on the issuance `sfFlags`. + * Clearing `tmfMPTClearCanTransfer` atomically removes `sfTransferFee` from + * the same SLE — you cannot have a fee-bearing issuance with transfer + * capability disabled, and removing the fee in the same write keeps the + * ledger internally consistent. + * + * **`soeDEFAULT` semantics for `sfTransferFee`**: a zero fee value removes + * the field entirely rather than storing zero, matching the convention that + * an absent field is interpreted as zero by readers. + * + * **`soeDEFAULT` semantics for `sfMPTokenMetadata`**: an empty blob removes + * the field entirely. + * + * **`beast::kZERO` sentinel for `sfDomainID`**: the zero 256-bit value + * signals "remove the existing domain link"; any other value sets or + * replaces the domain. This mirrors the sentinel-clear pattern used for + * similar optional-reference fields elsewhere in the protocol. + * + * All changes are written in a single `view().update(sle)` at the end. + */ TER MPTokenIssuanceSet::doApply() { @@ -317,11 +432,7 @@ MPTokenIssuanceSet::doApply() } if ((mutableFlags & tmfMPTClearCanTransfer) != 0u) - { - // If the lsfMPTCanTransfer flag is being cleared, then also clear - // the TransferFee field. sle->makeFieldAbsent(sfTransferFee); - } } if (flagsIn != flagsOut) @@ -329,10 +440,6 @@ MPTokenIssuanceSet::doApply() if (auto const transferFee = ctx_.tx[~sfTransferFee]) { - // TransferFee uses soeDEFAULT style: - // - If the field is absent, it is interpreted as 0. - // - If the field is present, it must be non-zero. - // Therefore, when TransferFee is 0, the field should be removed. if (transferFee == 0) { sle->makeFieldAbsent(sfTransferFee); @@ -357,7 +464,6 @@ MPTokenIssuanceSet::doApply() if (domainID) { - // This is enforced in preflight. XRPL_ASSERT( sle->getType() == ltMPTOKEN_ISSUANCE, "MPTokenIssuanceSet::doApply : modifying MPTokenIssuance"); diff --git a/src/libxrpl/tx/transactors/token/TrustSet.cpp b/src/libxrpl/tx/transactors/token/TrustSet.cpp index 3c0d9ed7ae..7cbd94937f 100644 --- a/src/libxrpl/tx/transactors/token/TrustSet.cpp +++ b/src/libxrpl/tx/transactors/token/TrustSet.cpp @@ -32,6 +32,37 @@ namespace { +/** Apply normal-freeze and deep-freeze flag changes to a trust-line flag word. + * + * This helper is the single authoritative place that translates the four + * transaction flags (`tfSetFreeze`, `tfClearFreeze`, `tfSetDeepFreeze`, + * `tfClearDeepFreeze`) into mutations of the per-side `lsfLow*`/`lsfHigh*` + * flag bits stored in the `RippleState` SLE. It is called identically from + * both `preclaim` (to simulate the post-transaction flag state for invariant + * validation) and `doApply` (to compute the value actually written to the + * ledger). Sharing a single implementation guarantees the two phases always + * agree on the resulting flag state, preventing silent divergence bugs. + * + * Semantics: + * - Set-freeze wins over clear-freeze when both are present (the pair is + * treated as a no-op). `bNoFreeze` additionally suppresses setting. + * - Deep-freeze follows the same mutual-exclusion rule independently of + * normal freeze, but `preclaim` enforces the invariant that deep-freeze + * requires normal freeze to be set. + * + * @param uFlags Current flag word from the `RippleState` SLE (or + * zero when the line does not yet exist). + * @param bHigh `true` if the transacting account is the high side + * of the trust line (account ID > counterparty ID). + * Selects `lsfHigh*` vs `lsfLow*` flag constants. + * @param bNoFreeze `true` if the transacting account has `lsfNoFreeze` + * set, permanently waiving freeze authority. + * @param bSetFreeze `true` if `tfSetFreeze` is present in the tx flags. + * @param bClearFreeze `true` if `tfClearFreeze` is present in the tx flags. + * @param bSetDeepFreeze `true` if `tfSetDeepFreeze` is present in the tx flags. + * @param bClearDeepFreeze `true` if `tfClearDeepFreeze` is present in the tx flags. + * @return Updated flag word with freeze bits adjusted. + */ uint32_t computeFreezeFlags( uint32_t uFlags, @@ -144,9 +175,6 @@ TrustSet::checkPermission(ReadView const& view, STTx const& tx) std::uint32_t const txFlags = tx.getFlags(); - // Currently we only support TrustlineAuthorize, TrustlineFreeze and - // TrustlineUnfreeze granular permission. Setting other flags returns - // error. if ((txFlags & tfTrustSetPermissionMask) != 0u) return terNO_DELEGATE_PERMISSION; @@ -188,6 +216,40 @@ TrustSet::checkPermission(ReadView const& view, STTx const& tx) return tesSUCCESS; } +/** Read-only ledger checks for `TrustSet`. + * + * Implementation notes for each check (in execution order): + * + * **`tfSetfAuth` guard**: the flag is meaningful only when the issuer account + * has `lsfRequireAuth` enabled; attempting it otherwise returns + * `tefNO_AUTH_REQUIRED` (no fee charged). + * + * **`lsfDisallowIncomingTrustline` softening**: the original `featureDisallowIncoming` + * implementation blocked ALL trust-set operations when the destination had + * opted out, including modifications to lines that already existed. The + * `fixDisallowIncomingV1` amendment corrects this by allowing the transaction + * to proceed when a line already exists between the two parties — only new + * line creation is gated by the opt-out preference. + * + * **Pseudo-account allow-listing**: trust lines to pseudo-accounts are + * generally prohibited. The block is not amendment-gated because the + * pseudo-account discriminator fields (`sfAMMID`, `sfVaultID`, + * `sfLoanBrokerID`) are only populated when the corresponding amendment is + * active, so the guard is implicitly gated. Three narrow exceptions apply: + * - AMM pseudo-accounts: a new trust line is allowed only when the currency + * matches the pool's LP token and the AMM holds non-zero liquidity + * (`lpTokenBalance > 0`); modifying an existing line is always permitted. + * - Vault and loan-broker pseudo-accounts: only modification of an existing + * line passes; new line creation returns `tecNO_PERMISSION`. + * - Any other pseudo-account type returns `tecPSEUDO_ACCOUNT`. + * + * **Deep-freeze invariant simulation**: when `featureDeepFreeze` is active, + * `computeFreezeFlags()` is called with the current flag word (zero if the + * line does not yet exist) to predict the post-transaction flag state. The + * invariant `deepFrozen → frozen` is then checked on that simulated state. + * Rejecting the violation here — before any writes — is cheaper and keeps + * `doApply` free of conditional rollback logic. + */ TER TrustSet::preclaim(PreclaimContext const& ctx) { @@ -247,8 +309,6 @@ TrustSet::preclaim(PreclaimContext const& ctx) // field populated, unless the appropriate amendment was already enabled. if (sleDst && isPseudoAccount(sleDst)) { - // If destination is AMM and the trustline doesn't exist then only allow - // TrustSet if the asset is AMM LP token and AMM is not in empty state. if (sleDst->isFieldPresent(sfAMMID)) { if (ctx.view.exists(keylet::line(id, uDstAccountID, currency))) @@ -284,7 +344,6 @@ TrustSet::preclaim(PreclaimContext const& ctx) } } - // Checking all freeze/deep freeze flag invariants. if (ctx.view.rules().enabled(featureDeepFreeze)) { bool const bNoFreeze = sle->isFlag(lsfNoFreeze); @@ -307,19 +366,16 @@ TrustSet::preclaim(PreclaimContext const& ctx) } bool const bHigh = id > uDstAccountID; - // Fetching current state of trust line auto const sleRippleState = ctx.view.read(keylet::line(id, uDstAccountID, currency)); std::uint32_t uFlags = sleRippleState ? sleRippleState->getFieldU32(sfFlags) : 0u; - // Computing expected trust line state uFlags = computeFreezeFlags( uFlags, bHigh, bNoFreeze, bSetFreeze, bClearFreeze, bSetDeepFreeze, bClearDeepFreeze); auto const frozen = uFlags & (bHigh ? lsfHighFreeze : lsfLowFreeze); auto const deepFrozen = uFlags & (bHigh ? lsfHighDeepFreeze : lsfLowDeepFreeze); - // Trying to set deep freeze on not already frozen trust line must - // fail. This also checks that clearing normal freeze while deep - // frozen must not work + // Enforce: deepFrozen → frozen. Covers both "set deep without + // normal freeze" and "clear normal freeze while deep-frozen". if ((deepFrozen != 0u) && (frozen == 0u)) { return tecNO_PERMISSION; @@ -329,6 +385,52 @@ TrustSet::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } +/** Apply the `TrustSet` transaction to the mutable ledger view. + * + * **`bHigh` convention**: a `RippleState` SLE stores both sides of a trust + * relationship in one shared entry. Per-side fields come in symmetric pairs + * (`sfLowLimit`/`sfHighLimit`, `sfLowQualityIn`/`sfHighQualityIn`, + * `lsfLowFreeze`/`lsfHighFreeze`, etc.). `bHigh = (account_ > uDstAccountID)` + * selects the correct field of each pair throughout the function without + * duplicating logic. Two concurrent `TrustSet` transactions by each party + * both write to the same SLE but modify disjoint fields. + * + * **Reserve exemption for onboarding**: the incremental reserve for creating a + * new trust line is waived entirely when the submitter owns fewer than two + * objects (`uOwnerCount < 2`). Without this exemption a gateway funding a + * brand-new user would need to deposit not just the base account reserve but + * also the trust-line reserve — surplus XRP the user could pocket without ever + * using the gateway. The exemption caps the minimum viable onboarding cost at + * the account reserve alone. + * + * **Quality normalization**: a quality value of exactly `QUALITY_ONE` + * (1 000 000 000) is the canonical "no adjustment" value and is stored as zero + * (field made absent via `makeFieldAbsent`). `uQualityOut` is normalized to + * zero immediately after reading from the transaction; `uQualityIn` and the + * values read back from the existing line are normalized before the reserve + * decision is made. This prevents callers from writing an explicit + * `QUALITY_ONE` and wasting 4 bytes of ledger storage per side. + * + * **Reserve recomputation**: after updating all fields, the need for a reserve + * on each side is derived from scratch by testing whether any per-side state + * deviates from defaults (non-zero quality, non-zero limit, freeze flag, positive + * balance, or a `noRipple` preference that disagrees with the account's + * `lsfDefaultRipple`). If the computed need differs from `lsfLowReserve`/ + * `lsfHighReserve`, `adjustOwnerCount` is called with ±1 to keep the owner + * count accurate. + * + * **Auto-deletion (`bDefault`)**: when both sides reach fully default state + * (`bLowReserveClear && bHighReserveClear`) or the currency is the + * `badCurrency()` sentinel, `trustDelete` removes the `RippleState` SLE from + * the ledger and both accounts' owner directories, preventing stale + * zero-balance objects from accumulating. + * + * **Delegation to `RippleStateHelpers`**: `trustCreate` and `trustDelete` + * handle the low-level SLE construction, directory insertion/removal, and + * initial `adjustOwnerCount` for the creating account. This keeps mutation + * logic centralised and reusable by other transactors that implicitly create + * trust lines (e.g., `issueIOU`). + */ TER TrustSet::doApply() { @@ -341,7 +443,6 @@ TrustSet::doApply() Currency const currency(saLimitAmount.get().currency); AccountID const uDstAccountID(saLimitAmount.getIssuer()); - // true, if current is high account. bool const bHigh = account_ > uDstAccountID; auto const sle = view().peek(keylet::account(account_)); @@ -350,24 +451,6 @@ TrustSet::doApply() std::uint32_t const uOwnerCount = sle->getFieldU32(sfOwnerCount); - // The reserve that is required to create the line. Note - // that although the reserve increases with every item - // an account owns, in the case of trust lines we only - // *enforce* a reserve if the user owns more than two - // items. - // - // We do this because being able to exchange currencies, - // which needs trust lines, is a powerful XRPL feature. - // So we want to make it easy for a gateway to fund the - // accounts of its users without fear of being tricked. - // - // Without this logic, a gateway that wanted to have a - // new user use its services, would have to give that - // user enough XRP to cover not only the account reserve - // but the incremental reserve for the trust line as - // well. A person with no intention of using the gateway - // could use the extra XRP for their own purposes. - XRPAmount const reserveCreate( (uOwnerCount < 2) ? XRPAmount(beast::kZERO) : view().fees().accountReserve(uOwnerCount + 1)); @@ -441,15 +524,11 @@ TrustSet::doApply() if (!bQualityIn) { - // Not setting. Just get it. - uLowQualityIn = sleRippleState->getFieldU32(sfLowQualityIn); uHighQualityIn = sleRippleState->getFieldU32(sfHighQualityIn); } else if (uQualityIn != 0u) { - // Setting. - sleRippleState->setFieldU32(!bHigh ? sfLowQualityIn : sfHighQualityIn, uQualityIn); uLowQualityIn = !bHigh ? uQualityIn : sleRippleState->getFieldU32(sfLowQualityIn); @@ -457,8 +536,6 @@ TrustSet::doApply() } else { - // Clearing. - sleRippleState->makeFieldAbsent(!bHigh ? sfLowQualityIn : sfHighQualityIn); uLowQualityIn = !bHigh ? 0 : sleRippleState->getFieldU32(sfLowQualityIn); @@ -477,15 +554,11 @@ TrustSet::doApply() if (!bQualityOut) { - // Not setting. Just get it. - uLowQualityOut = sleRippleState->getFieldU32(sfLowQualityOut); uHighQualityOut = sleRippleState->getFieldU32(sfHighQualityOut); } else if (uQualityOut != 0u) { - // Setting. - sleRippleState->setFieldU32(!bHigh ? sfLowQualityOut : sfHighQualityOut, uQualityOut); uLowQualityOut = !bHigh ? uQualityOut : sleRippleState->getFieldU32(sfLowQualityOut); @@ -493,8 +566,6 @@ TrustSet::doApply() } else { - // Clearing. - sleRippleState->makeFieldAbsent(!bHigh ? sfLowQualityOut : sfHighQualityOut); uLowQualityOut = !bHigh ? 0 : sleRippleState->getFieldU32(sfLowQualityOut); @@ -565,7 +636,6 @@ TrustSet::doApply() if (bLowReserveSet && !bLowReserved) { - // Set reserve for low account. adjustOwnerCount(view(), sleLowAccount, 1, viewJ); uFlagsOut |= lsfLowReserve; @@ -575,14 +645,12 @@ TrustSet::doApply() if (bLowReserveClear && bLowReserved) { - // Clear reserve for low account. adjustOwnerCount(view(), sleLowAccount, -1, viewJ); uFlagsOut &= ~lsfLowReserve; } if (bHighReserveSet && !bHighReserved) { - // Set reserve for high account. adjustOwnerCount(view(), sleHighAccount, 1, viewJ); uFlagsOut |= lsfHighReserve; @@ -592,7 +660,6 @@ TrustSet::doApply() if (bHighReserveClear && bHighReserved) { - // Clear reserve for high account. adjustOwnerCount(view(), sleHighAccount, -1, viewJ); uFlagsOut &= ~lsfHighReserve; } @@ -602,8 +669,6 @@ TrustSet::doApply() if (bDefault || badCurrency() == currency) { - // Delete. - terResult = trustDelete(view(), sleRippleState, uLowAccountID, uHighAccountID, viewJ); } // Reserve is not scaled by load. @@ -623,7 +688,6 @@ TrustSet::doApply() JLOG(j_.trace()) << "Modify ripple line"; } } - // Line does not exist. else if ( !saLimitAmount && // Setting default limit. (!bQualityIn || (uQualityIn == 0u)) && // Not setting quality in or @@ -647,14 +711,12 @@ TrustSet::doApply() } else { - // Zero balance in currency. STAmount const saBalance(Issue{currency, noAccount()}); auto const k = keylet::line(account_, uDstAccountID, currency); JLOG(j_.trace()) << "doTrustSet: Creating ripple line: " << to_string(k.key); - // Create a new ripple line. terResult = trustCreate( view(), bHigh, diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp index b6d92a6083..420e9938e9 100644 --- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp @@ -33,6 +33,7 @@ #include namespace xrpl { + NotTEC VaultClawback::preflight(PreflightContext const& ctx) { @@ -60,6 +61,27 @@ VaultClawback::preflight(PreflightContext const& ctx) return tesSUCCESS; } +/** Resolve the effective clawback target when `sfAmount` is absent from the + * transaction. + * + * A zero (absent) `sfAmount` is intentionally underspecified: the correct + * default depends on the submitter's role. When the submitter is the vault + * owner, the implicit target is shares (`sfShareMPTID`), activating the + * share-burn cleanup path. For any other submitter (the asset issuer), the + * implicit target is the vault's underlying asset, activating asset-clawback + * mode. + * + * Callers must guard against the dual-role ambiguity (owner == issuer) before + * invoking this function. `preclaim` rejects that case with `tecWRONG_ASSET`, + * so this helper is never called with an ambiguous zero-amount. + * + * @param vault Vault SLE from which the share MPT ID and owner are read. + * @param maybeAmount The optional `sfAmount` field value from the transaction. + * @param account The submitting account (used to distinguish owner from issuer). + * @return The resolved `STAmount`: the explicit amount verbatim if present, + * a zero-value share amount for the vault owner, or a zero-value + * vault-asset amount for the asset issuer. + */ [[nodiscard]] STAmount clawbackAmount( std::shared_ptr const& vault, @@ -76,6 +98,33 @@ clawbackAmount( return STAmount{vault->at(sfAsset)}; } +/** Verify authorization and resolve the operating mode against ledger state. + * + * Execution ordering: + * 1. Load the vault SLE and the share MPT issuance (missing issuance is + * treated as ledger corruption → `tefINTERNAL`). + * 2. Guard the owner == issuer ambiguity: if `sfAmount` is absent and the + * vault asset's issuer is also the vault owner, it is impossible to infer + * whether the caller intends share-burn or asset-clawback, so `preclaim` + * returns `tecWRONG_ASSET` rather than silently picking one path. The + * caller must supply an explicit `sfAmount` to disambiguate. + * 3. Call `clawbackAmount()` to resolve the effective amount. + * 4. Branch on the resolved amount's asset type: + * - **Share (`sfShareMPTID`)**: only the vault owner may burn shares; + * the vault must have outstanding shares and zero `sfAssetsTotal` / + * `sfAssetsAvailable`; a non-zero explicit amount must equal the + * holder's entire balance (partial burns are rejected with + * `tecLIMIT_EXCEEDED`). + * - **Vault asset**: must be non-XRP; submitter must be the asset issuer + * and must differ from the holder; clawback flags must be set + * (`lsfMPTCanClawback` for MPT, `lsfAllowTrustLineClawback` without + * `lsfNoFreeze` for IOU). + * - Anything else: `tecWRONG_ASSET`. + * + * @param ctx Preclaim context providing read-only ledger access. + * @return `tesSUCCESS` on authorization success, or a failure `TER` + * documented on the header declaration. + */ TER VaultClawback::preclaim(PreclaimContext const& ctx) { @@ -99,7 +148,6 @@ VaultClawback::preclaim(PreclaimContext const& ctx) Asset const share = MPTIssue{mptIssuanceID}; - // Ambiguous case: If Issuer is Owner they must specify the asset if (!maybeAmount && !vaultAsset.native() && vaultAsset.getIssuer() == vault->at(sfOwner)) { JLOG(ctx.j.debug()) << "VaultClawback: must specify amount when issuer is owner."; @@ -114,7 +162,6 @@ VaultClawback::preclaim(PreclaimContext const& ctx) // so here we just enforce checks. if (amount.asset() == share) { - // Only the Vault Owner may clawback shares if (account != vault->at(sfOwner)) { JLOG(ctx.j.debug()) << "VaultClawback: only vault owner can clawback shares."; @@ -125,7 +172,6 @@ VaultClawback::preclaim(PreclaimContext const& ctx) auto const assetsAvailable = vault->at(sfAssetsAvailable); auto const sharesTotal = sleShareIssuance->at(sfOutstandingAmount); - // Owner can clawback funds when the vault has shares but no assets if (sharesTotal == 0 || (assetsTotal != 0 || assetsAvailable != 0)) { JLOG(ctx.j.debug()) << "VaultClawback: vault owner can clawback shares only" @@ -133,7 +179,6 @@ VaultClawback::preclaim(PreclaimContext const& ctx) return tecNO_PERMISSION; } - // If amount is non-zero, the VaultOwner must burn all shares if (amount != beast::kZERO) { Number const& sharesHeld = accountHolds( @@ -144,7 +189,6 @@ VaultClawback::preclaim(PreclaimContext const& ctx) AuthHandling::IgnoreAuth, ctx.j); - // The VaultOwner must burn all shares if (amount != sharesHeld) { JLOG(ctx.j.debug()) << "VaultClawback: vault owner must clawback all " @@ -156,24 +200,20 @@ VaultClawback::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } - // The asset that is being clawed back is the vault asset if (amount.asset() == vaultAsset) { - // XRP cannot be clawed back if (vaultAsset.native()) { JLOG(ctx.j.debug()) << "VaultClawback: cannot clawback XRP."; return tecNO_PERMISSION; } - // Only the Asset Issuer may clawback the asset if (account != vaultAsset.getIssuer()) { JLOG(ctx.j.debug()) << "VaultClawback: only asset issuer can clawback asset."; return tecNO_PERMISSION; } - // The issuer cannot clawback from itself if (account == holder) { JLOG(ctx.j.debug()) << "VaultClawback: issuer cannot be the holder."; @@ -219,10 +259,49 @@ VaultClawback::preclaim(PreclaimContext const& ctx) }); } - // Invalid asset return tecWRONG_ASSET; } +/** Compute the (assetsRecovered, sharesDestroyed) pair for asset-clawback mode. + * + * Algorithm overview: + * - **Zero amount ("all")**: read the holder's full share balance via + * `accountHolds`, then convert shares → assets once via + * `sharesToAssetsWithdraw` to obtain the asset quantity. + * - **Non-zero amount**: perform a double-pass — assets → shares via + * `assetsToSharesWithdraw`, then shares → assets via + * `sharesToAssetsWithdraw`. The round-trip is necessary because MPT shares + * are integer-valued, so converting assets → shares truncates; converting + * back yields the true net asset quantity recoverable for those shares. + * + * After either path, `assetsRecovered` is clamped to `sfAssetsAvailable`. + * This cap exists because outstanding loans may have deployed some vault + * assets externally, making `sfAssetsAvailable < sfAssetsTotal`. When + * clamping is applied, shares are recomputed with `TruncateShares::Yes` + * — deliberate truncation ensures that the re-derived asset amount from the + * truncated share count cannot overshoot the cap. The result is then verified + * a second time; any breach after truncation is treated as an arithmetic bug + * (`tecINTERNAL`). + * + * **`fixSecurity3_1_3` branching (ledger replay compatibility)**: before this + * amendment, a zero-amount clawback returned early without clamping to + * `sfAssetsAvailable`, which allowed recovering more assets than the vault + * had liquid (bypassing outstanding loans). The pre-fix code path is retained + * verbatim under the amendment gate so that historical ledgers replay + * identically on old rules. + * + * Arithmetic overflow from the `Number` subsystem (common with large `sfScale` + * values) is caught and returned as `tecPATH_DRY`. + * + * @param vault Mutable vault SLE; used for exchange-rate, cap, and + * scale reads. + * @param sleShareIssuance Read-only share MPT issuance SLE. + * @param holder Account whose shares are being destroyed. + * @param clawbackAmount Resolved asset amount to recover; zero means all. + * @return `{assetsRecovered, sharesDestroyed}` on success, or `Unexpected`: + * `tecPATH_DRY` on arithmetic overflow, `tecINTERNAL` on asset-mismatch + * guard or conversion helper failure. + */ Expected, TER> VaultClawback::assetsToClawback( std::shared_ptr const& vault, @@ -232,7 +311,6 @@ VaultClawback::assetsToClawback( { if (clawbackAmount.asset() != vault->at(sfAsset)) { - // preclaim should have blocked this , now it's an internal error // LCOV_EXCL_START JLOG(j_.error()) << "VaultClawback: asset mismatch in clawback."; return Unexpected(tecINTERNAL); @@ -292,9 +370,6 @@ VaultClawback::assetsToClawback( if (assetsRecovered > *assetsAvailable) { assetsRecovered = *assetsAvailable; - // Note, it is important to truncate the number of shares, - // otherwise the corresponding assets might breach the - // AssetsAvailable { auto const maybeShares = assetsToSharesWithdraw( vault, sleShareIssuance, assetsRecovered, TruncateShares::Yes); @@ -333,6 +408,46 @@ VaultClawback::assetsToClawback( return std::make_pair(assetsRecovered, sharesDestroyed); } +/** Execute vault clawback mutations in a strictly ordered sequence. + * + * Entry invariant: asserts `sfLossUnrealized ≤ sfAssetsTotal − + * sfAssetsAvailable`, a structural guarantee of the vault's accounting model. + * A violation here indicates out-of-band mutation of vault fields and is + * surfaced as a debug-build assertion failure. + * + * Mutation sequence: + * 1. **Mode dispatch** — if the submitter is the vault owner and the resolved + * amount targets shares, set `sharesDestroyed` to the holder's full share + * balance (share-burn mode, `assetsRecovered` stays zero). Otherwise, + * delegate to `assetsToClawback()` to compute both quantities. + * 2. **Zero-shares guard** — if `sharesDestroyed` is zero after conversion, + * return `tecPRECISION_LOSS`; no mutations have occurred yet. + * 3. **Vault accounting update** — decrement `sfAssetsTotal` and + * `sfAssetsAvailable` on the vault SLE, then call `view().update(vault)` + * to stage the change before any `accountSend` calls. + * 4. **Share transfer** — move `sharesDestroyed` shares from the holder to + * the vault pseudo-account, waiving the transfer fee. + * 5. **MPToken cleanup** — attempt to remove the holder's now-empty MPToken + * entry via `removeEmptyHolding()`. The vault pseudo-account never sets + * `lsfMPTAuthorized`, so authorization flags are ignored. If the holder + * is the vault owner, this step is skipped entirely: the owner's MPToken + * anchors the share issuance and must not be deleted. + * `tecHAS_OBLIGATIONS` (holder still has a balance) is tolerated silently; + * any other error is fatal. + * 6. **Asset transfer** (skipped when `assetsRecovered == 0`) — move the + * recovered assets from the vault pseudo-account to the submitting issuer, + * waiving the transfer fee. Then `accountHolds` is called on the vault + * pseudo-account as a belt-and-suspenders check: a negative result would + * indicate an arithmetic bug producing an invalid ledger state. + * 7. **`associateAsset`** — re-rounds all `STNumber` fields on the vault SLE + * against the vault asset's precision. Per `STTakesAsset` contract, this + * must be the very last operation after all mutations are complete. + * + * @return `tesSUCCESS` on success, `tecPRECISION_LOSS` if the share/asset + * conversion produced zero shares, forwarded `TER` from `assetsToClawback` + * or `accountSend` on failure, `tefINTERNAL` on ledger-corruption + * conditions. + */ TER VaultClawback::doApply() { @@ -367,13 +482,12 @@ VaultClawback::doApply() STAmount sharesDestroyed = {share}; STAmount assetsRecovered = {vault->at(sfAsset)}; - // The Owner is burning shares if (account_ == vault->at(sfOwner) && amount.asset() == share) { sharesDestroyed = accountHolds( view(), holder, share, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, j_); } - else // The Issuer is clawbacking vault assets + else { XRPL_ASSERT(amount.asset() == vaultAsset, "xrpl::VaultClawback::doApply : matching asset"); @@ -393,7 +507,6 @@ VaultClawback::doApply() view().update(vault); auto const& vaultAccount = vault->at(sfAccount); - // Transfer shares from holder to vault. if (auto const ter = accountSend(view(), holder, vaultAccount, sharesDestroyed, j_, WaiveTransferFee::Yes); !isTesSuccess(ter)) @@ -423,18 +536,15 @@ VaultClawback::doApply() return ter; // LCOV_EXCL_STOP } - // else quietly ignore, holder balance is not zero } if (assetsRecovered > beast::kZERO) { - // Transfer assets from vault to issuer. if (auto const ter = accountSend( view(), vaultAccount, account_, assetsRecovered, j_, WaiveTransferFee::Yes); !isTesSuccess(ter)) return ter; - // Sanity check if (accountHolds( view(), vaultAccount, diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index f5831e46aa..4a073f4952 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -31,6 +31,15 @@ namespace xrpl { +/** Pure amendment gate called before `preflight1`. + * + * Rejects the transaction with `temDISABLED` if `featureMPTokensV1` is not + * active, because vaults rely entirely on MPT share issuance. A secondary + * gate protects `sfDomainID`: using it requires `featurePermissionedDomains`, + * so the two features can activate independently without cross-coupling. + * Keeping these checks here (rather than in `preflight`) means a disabled + * transaction is rejected before any field parsing occurs. + */ bool VaultCreate::checkExtraFeatures(PreflightContext const& ctx) { @@ -43,12 +52,38 @@ VaultCreate::checkExtraFeatures(PreflightContext const& ctx) return true; } +/** Returns `tfVaultCreateMask`, restricting valid flags to `tfVaultPrivate` + * and `tfVaultShareNonTransferable`. Any bit outside this mask causes + * `preflight0` to reject with `temINVALID_FLAG`. + */ std::uint32_t VaultCreate::getFlagsMask(PreflightContext const& ctx) { return tfVaultCreateMask; } +/** Stateless, ledger-free field validation run between `preflight1` and + * `preflight2`. + * + * Checks in order, returning `temMALFORMED` on the first violation: + * - `sfData` length must not exceed `kMAX_DATA_PAYLOAD_LENGTH`. + * - `sfWithdrawalPolicy`, if present, must equal + * `kVAULT_STRATEGY_FIRST_COME_FIRST_SERVE` — the only currently supported + * strategy; the field exists for future extensibility. + * - `sfDomainID`, if present, must be non-zero and `tfVaultPrivate` must be + * set. Associating a domain with a public vault would be incoherent: + * public vaults admit all holders, so domain-restricted access has no + * effect. + * - `sfAssetsMaximum`, if present, must not be negative. + * - `sfMPTokenMetadata`, if present, must be non-empty and within + * `kMAX_MP_TOKEN_METADATA_LENGTH`. + * - `sfScale`, if present, is only valid for IOU assets. XRP and MPT assets + * have fixed integer representations for which a scale factor is meaningless, + * so they are rejected. The upper bound `kVAULT_MAXIMUM_IOU_SCALE` (18) is + * chosen because 10^19 exceeds the maximum MPT amount (2^63 − 1 ≈ 10^18.9), + * ensuring that a single IOU unit can always be converted to at least one + * share. + */ NotTEC VaultCreate::preflight(PreflightContext const& ctx) { @@ -57,7 +92,6 @@ VaultCreate::preflight(PreflightContext const& ctx) if (auto const withdrawalPolicy = ctx.tx[~sfWithdrawalPolicy]) { - // Enforce valid withdrawal policy if (*withdrawalPolicy != kVAULT_STRATEGY_FIRST_COME_FIRST_SERVE) return temMALFORMED; } @@ -70,7 +104,8 @@ VaultCreate::preflight(PreflightContext const& ctx) } if ((ctx.tx.getFlags() & tfVaultPrivate) == 0) { - return temMALFORMED; // DomainID only allowed on private vaults + // DomainID only allowed on private vaults + return temMALFORMED; } } @@ -99,6 +134,27 @@ VaultCreate::preflight(PreflightContext const& ctx) return tesSUCCESS; } +/** Read-only ledger checks executed after signature verification. + * + * Checks in order, returning the first failing code: + * 1. `canAddHolding` — delegates to the asset-type-appropriate helper to + * verify the pseudo-account will be able to hold the given asset (e.g., + * MPT issuance limits and similar constraints). + * 2. Pseudo-account issuer rejection — assets issued by a pseudo-account + * (e.g., shares of another vault or AMM LP tokens) are rejected with + * `tecWRONG_ASSET`. Such assets cannot be clawed back through the normal + * mechanism because the issuer has no private key and no direct authority + * path; allowing the vault to hold them would create an irrecoverable + * liability if emergency intervention were ever needed. + * 3. Freeze check — returns `tecFROZEN` (IOU) or `tecLOCKED` (MPT) if the + * asset is frozen for the vault owner. + * 4. Domain existence — if `sfDomainID` is present, the referenced + * `PermissionedDomain` object must exist; returns `tecOBJECT_NOT_FOUND` + * otherwise. + * 5. Address collision — pre-computes the deterministic pseudo-account address + * from the vault keylet; if the result is zero (collision with an existing + * account), returns `terADDRESS_COLLISION` before any state change. + */ TER VaultCreate::preclaim(PreclaimContext const& ctx) { @@ -108,16 +164,12 @@ VaultCreate::preclaim(PreclaimContext const& ctx) if (auto const ter = canAddHolding(ctx.view, vaultAsset)) return ter; - // Check for pseudo-account issuers - we do not want a vault to hold such - // assets (e.g. MPT shares to other vaults or AMM LPTokens) as they would be - // impossible to clawback (should the need arise) if (!vaultAsset.native()) { if (isPseudoAccount(ctx.view, vaultAsset.getIssuer())) return tecWRONG_ASSET; } - // Cannot create Vault for an Asset frozen for the vault owner if (isFrozen(ctx.view, account, vaultAsset)) return vaultAsset.holds() ? tecFROZEN : tecLOCKED; @@ -136,13 +188,57 @@ VaultCreate::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } +/** Mutates the ledger to create the vault and all supporting infrastructure. + * + * All return codes here are `tec`, `ter`, or `tes`; a `tec` return signals + * the framework to discard all mutations via `ApplyContext::discard()`, then + * re-apply only the fee and sequence. Steps are ordered so that the reserve + * check occurs before any irreversible state is written. + * + * Execution order: + * 1. **Directory link + owner count**: `dirLink` inserts the new vault SLE + * into the owner's directory. `adjustOwnerCount` then increments by 2 — + * one for the vault SLE and one for the pseudo-account — *before* the + * reserve check. This order is intentional: the reserve must reflect the + * true post-creation object count to prevent the account from being left + * underfunded. If `preFeeBalance_` is insufficient, returns + * `tecINSUFFICIENT_RESERVE` and the framework rolls back all mutations. + * 2. **Pseudo-account**: `createPseudoAccount` creates a new `AccountRoot` + * SLE at the deterministically-derived address, storing the vault's key in + * `sfVaultID`. This synthetic account holds the pooled asset, issues + * shares, and has no private key — it is controlled solely by the + * transactor logic. Failure here is an invariant violation that `preclaim` + * should have prevented; the branch is marked `LCOV_EXCL_LINE`. + * 3. **Empty asset holding**: `addEmptyHolding` establishes a zero-balance + * `MPToken` (MPT assets) or `TrustLine`/`RippleState` (IOU assets) on the + * pseudo-account, ready to receive deposits. XRP vaults require no holding + * object, but the dispatch is handled uniformly. + * 4. **Share MPT issuance**: `MPTokenIssuanceCreate::create` is called from + * the pseudo-account's perspective at sequence 1 (its first issuance). + * This creates the `MPTokenIssuance` SLE that represents vault shares — + * distinct from the asset holding created in step 3. Flag mapping: + * `tfVaultShareNonTransferable` absent → `lsfMPTCanEscrow | lsfMPTCanTrade + * | lsfMPTCanTransfer`; `tfVaultPrivate` set → `lsfMPTRequireAuth`. + * 5. **Vault SLE population**: All fields are written to the in-memory SLE + * before `view().insert` commits it: `sfAsset`, `sfFlags` (private bit + * only), `sfOwner`, `sfAccount` (pseudo-account ID), `sfAssetsTotal`, + * `sfAssetsAvailable`, `sfLossUnrealized` (all zero), optional + * `sfAssetsMaximum`, `sfShareMPTID`, `sfData`, `sfWithdrawalPolicy` + * (defaulting to `kVAULT_STRATEGY_FIRST_COME_FIRST_SERVE`), and `sfScale` + * (omitted for XRP/MPT assets where scale is always 0). + * 6. **Owner MPToken authorization**: `authorizeMPToken` creates an `MPToken` + * SLE for the vault creator so they can hold shares immediately. For + * private vaults, a second call authorizes the pseudo-account itself (with + * the owner as the authorizing party), which is required for the + * pseudo-account to participate in share issuance mechanics. + * 7. **`associateAsset`**: Must be the final write. Propagates the vault's + * asset type through all `sMD_NeedsAsset` fields (primarily `STNumber` + * fields), tying the asset's decimal scale to the number representation + * so that serialization rounds correctly. + */ TER VaultCreate::doApply() { - // All return codes in `doApply` must be `tec`, `ter`, or `tes`. - // As we move checks into `preflight` and `preclaim`, - // we can consider downgrading them to `tef` or `tem`. - auto const& tx = ctx_.tx; auto const sequence = tx.getSeqValue(); auto const owner = view().peek(keylet::account(account_)); @@ -153,7 +249,6 @@ VaultCreate::doApply() if (auto ter = dirLink(view(), account_, vault)) return ter; - // We will create Vault and PseudoAccount, hence increase OwnerCount by 2 adjustOwnerCount(view(), owner, 2, j_); auto const ownerCount = owner->at(sfOwnerCount); if (preFeeBalance_ < view().fees().accountReserve(ownerCount)) @@ -180,10 +275,10 @@ VaultCreate::doApply() if ((txFlags & tfVaultPrivate) != 0u) mptFlags |= lsfMPTRequireAuth; - // Note, here we are **not** creating an MPToken for the assets held in - // the vault. That MPToken or TrustLine/RippleState is created above, in - // addEmptyHolding. Here we are creating MPTokenIssuance for the shares - // in the vault + // This creates the MPTokenIssuance for vault *shares*, not for the + // underlying asset. The asset holding (MPToken or TrustLine/RippleState) + // was already created above via addEmptyHolding — the two calls are + // structurally similar, and the distinction is easy to miss. auto maybeShare = MPTokenIssuanceCreate::create( view(), j_, @@ -210,13 +305,11 @@ VaultCreate::doApply() vault->at(sfAssetsTotal) = Number(0); vault->at(sfAssetsAvailable) = Number(0); vault->at(sfLossUnrealized) = Number(0); - // Leave default values for AssetTotal and AssetAvailable, both zero. if (auto value = tx[~sfAssetsMaximum]) vault->at(sfAssetsMaximum) = *value; vault->at(sfShareMPTID) = mptIssuanceID; if (auto value = tx[~sfData]) vault->at(sfData) = *value; - // Required field, default to vaultStrategyFirstComeFirstServe if (auto value = tx[~sfWithdrawalPolicy]) { vault->at(sfWithdrawalPolicy) = *value; @@ -229,13 +322,11 @@ VaultCreate::doApply() vault->at(sfScale) = scale; view().insert(vault); - // Explicitly create MPToken for the vault owner if (auto const err = authorizeMPToken(view(), preFeeBalance_, mptIssuanceID, account_, ctx_.journal); !isTesSuccess(err)) return err; - // If the vault is private, set the authorized flag for the vault owner if ((txFlags & tfVaultPrivate) != 0u) { if (auto const err = authorizeMPToken( diff --git a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp index 20415b7816..4ce0573087 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp @@ -33,6 +33,25 @@ VaultDelete::preflight(PreflightContext const& ctx) return tesSUCCESS; } +/** Enforces pre-deletion invariants against the live ledger. + * + * `sfAssetsAvailable` and `sfAssetsTotal` are checked independently because + * a vault carrying unrealized losses (e.g. defaulted loans) may have + * `sfAssetsTotal` > `sfAssetsAvailable`; both must reach zero before the + * vault can be destroyed. Checking only one would allow deletion while the + * other still records outstanding obligations. + * + * The two `MPTokenIssuance` guards — existence of the share issuance SLE and + * the issuer-match check — are wrapped in `LCOV_EXCL_START` because they + * defend against ledger state that cannot arise from valid transaction + * sequences. They are reachable only if earlier transactions have already + * corrupted the ledger; their presence prevents silent destruction of a + * partially dismantled vault cluster. + * + * All user-correctable failures (`tecNO_PERMISSION`, `tecHAS_OBLIGATIONS`, + * `tecNO_ENTRY`) consume the transaction fee, signalling that the submitter + * must resolve the issue before resubmitting. + */ TER VaultDelete::preclaim(PreclaimContext const& ctx) { @@ -86,6 +105,57 @@ VaultDelete::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } +/** Dismantles the vault cluster in strict dependency order. + * + * The five-step sequence must not be reordered: each step removes an object + * that a later step depends on having already cleaned up. + * + * **Step 1 — Asset holding removal.** + * `removeEmptyHolding` erases the trust line (`RippleState`) or `MPToken` + * that the pseudo-account used to hold the underlying asset. The holding is + * guaranteed empty by `preclaim`'s `sfAssetsTotal == 0` check; the call also + * removes the object from the pseudo-account's owner directory and + * decrements its owner count. + * + * **Step 2 — Vault owner's share MPToken removal.** + * If the vault creator holds an `MPToken` for the share issuance + * (`keylet::mptoken(shareMPTID, account_)`), a second `removeEmptyHolding` + * call cleans it up. This is conditioned on the token's existence; the + * vault owner is not required to hold shares. The `LCOV_EXCL` guard covers + * the failure branch, which is unreachable in valid ledger state because + * `preclaim` has already verified `sfOutstandingAmount == 0`. + * + * **Step 3 — Share issuance removal.** + * The `MPTokenIssuance` SLE is removed directly rather than via + * `MPTokenIssuanceDestroy` because that transactor carries fee and + * amendment logic irrelevant here. `dirRemove` uses the cached + * `sfOwnerNode` from the issuance SLE for O(1) directory removal, then + * `adjustOwnerCount(view(), pseudoAcct, -1, j_)` decrements the + * pseudo-account's count before the SLE is erased. + * + * **Step 4 — Pseudo-account cleanup verification and erasure.** + * After Steps 1–3, the pseudo-account's owner directory must be empty. + * The `view().peek(keylet::ownerDir(pseudoID))` guard is the one + * `tec` code emitted from `doApply` (vs. `tef` codes for true corruption), + * marked `LCOV_EXCL_LINE` because it is a forward-safety valve: a future + * ledger feature could attach additional objects to the pseudo-account's + * directory, and this guard prevents silently destroying a pseudo-account + * that still owns unhandled objects. The subsequent balance and owner-count + * checks are `LCOV_EXCL`-guarded corruption sentinels; if reached, they + * return `tecHAS_OBLIGATIONS` rather than `tef` to let the invariant checker + * log diagnostics before fee collection. + * + * **Step 5 — Vault SLE removal and owner-count adjustment.** + * The vault is removed from the real owner's `ownerDir` via `dirRemove`, + * then `adjustOwnerCount(view(), owner, -2, j_)` fires. The `-2` is the + * exact inverse of `VaultCreate`'s `+2`, accounting for both the vault SLE + * and the pseudo-account destroyed in Step 4. The vault SLE is erased last. + * + * Errors indicating impossible ledger state (missing pseudo-account, + * mismatched issuance, failed directory removal) return `tefBAD_LEDGER` or + * `tefINTERNAL` rather than fee-claiming `tec` codes, signalling internal + * inconsistency rather than a user-correctable condition. + */ TER VaultDelete::doApply() { diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp index 4b8d8b5c18..d090ea5122 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp @@ -1,3 +1,12 @@ +/** @file VaultDeposit.cpp + * Implementation of the `ttVAULT_DEPOSIT` transactor. + * + * Deposits a fungible asset (XRP, IOU, or MPT) into a vault and mints + * vault-share MPTokens for the depositor. The share/asset exchange rate is + * determined by the vault's current `sfAssetsTotal` and the share issuance's + * `sfOutstandingAmount`; on a fresh vault both are zero and the initial rate + * is seeded via `sfScale`. See VaultHelpers.h for the arithmetic. + */ #include #include @@ -40,6 +49,31 @@ VaultDeposit::preflight(PreflightContext const& ctx) return tesSUCCESS; } +/** Read-only ledger validation for a vault deposit. + * + * Checks are ordered from cheapest to most expensive and fail-fast on the + * first violation. The private-vault authorization path has two deliberate + * design choices worth noting: + * + * - **`tecEXPIRED` suppression**: `credentials::validDomain` may return + * `tecEXPIRED` when the account's credential has expired. This code is + * suppressed here (the transaction proceeds) because deleting the expired + * credential is a ledger state mutation, which is only permitted in + * `doApply`. The `doApply` call to `enforceMPTokenAuthorization` handles + * the deletion. + * + * - **No issuer-grant fallback**: the vault's share MPT issuance is managed + * by the vault pseudo-account, which cannot sign transactions and therefore + * cannot proactively grant MPT authorizations to individual holders. The + * only supported admission mechanism for private vaults is the domain- + * credential path via `sfDomainID` on the share issuance. Absence of + * `sfDomainID` on a private vault's issuance is an unconditional + * `tecNO_AUTH`. + * + * The `lsfMPTLocked` and identical-asset-type checks are classified as + * `tefINTERNAL` and marked `LCOV_EXCL` because the `VaultCreate` transactor + * guarantees they cannot occur under correct ledger logic. + */ TER VaultDeposit::preclaim(PreclaimContext const& ctx) { @@ -87,26 +121,17 @@ VaultDeposit::preclaim(PreclaimContext const& ctx) // LCOV_EXCL_STOP } - // Cannot deposit inside Vault an Asset frozen for the depositor if (isFrozen(ctx.view, account, vaultAsset)) return vaultAsset.holds() ? tecFROZEN : tecLOCKED; - // Cannot deposit if the shares of the vault are frozen if (isFrozen(ctx.view, account, vaultShare)) return tecLOCKED; if (vault->isFlag(lsfVaultPrivate) && account != vault->at(sfOwner)) { auto const maybeDomainID = sleIssuance->at(~sfDomainID); - // Since this is a private vault and the account is not its owner, we - // perform authorization check based on DomainID read from sleIssuance. - // Had the vault shares been a regular MPToken, we would allow - // authorization granted by the Issuer explicitly, but Vault uses Issuer - // pseudo-account, which cannot grant an authorization. if (maybeDomainID) { - // As per validDomain documentation, we suppress tecEXPIRED error - // here, so we can delete any expired credentials inside doApply. if (auto const err = credentials::validDomain(ctx.view, *maybeDomainID, account); !isTesSuccess(err) && err != tecEXPIRED) return err; @@ -117,7 +142,6 @@ VaultDeposit::preclaim(PreclaimContext const& ctx) } } - // Source MPToken must exist (if asset is an MPT) if (auto const ter = requireAuth(ctx.view, vaultAsset, account); !isTesSuccess(ter)) return ter; @@ -134,6 +158,63 @@ VaultDeposit::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } +/** Mutate the ledger to execute a vault deposit. + * + * ### MPToken provisioning + * + * Before any arithmetic, the depositor must hold an MPToken for vault shares. + * Two distinct code paths handle this: + * + * - **Private vault, non-owner**: `enforceMPTokenAuthorization` is called. + * It deletes any expired credentials found in the ledger (the deferred + * side-effect suppressed in `preclaim`) and then creates the MPToken entry + * for the depositor if it does not yet exist. + * + * - **Public vault, or vault owner**: `authorizeMPToken` is called only when + * the MPToken does not yet exist — no domain check is required. When the + * depositor is the vault owner of a *private* vault, a second + * `authorizeMPToken` call additionally provisions an MPToken on the vault's + * pseudo-account (`sleIssuance->at(sfIssuer)`) authorised for the owner. + * The pseudo-account cannot self-authorise, so this is the only mechanism + * by which it can hold shares for internal accounting. + * + * ### Exchange arithmetic (two-step, depositor-protective) + * + * 1. `assetsToSharesDeposit(vault, sleIssuance, amount)` — converts the + * offered asset amount into shares, **truncating** (floor) to an integral + * MPT unit. On a fresh vault (`sfAssetsTotal == 0`) the initial rate is + * seeded using `sfScale`: `shares = floor(assets.mantissa * 10^(assets.exponent + scale))`. + * Returns `tecPRECISION_LOSS` when the floor rounds to zero — the deposit + * is too small relative to the current share price. + * + * 2. `sharesToAssetsDeposit(vault, sleIssuance, sharesCreated)` — inverts the + * truncated share count back to an exact asset amount, guaranteeing + * `assetsDeposited <= amount`. Any sub-share-unit fractional asset + * remainder stays with the depositor. An assertion verifies this invariant; + * violation indicates a bug in the arithmetic helpers. + * + * `std::overflow_error` from either helper (reachable when `sfScale` is large + * and balances are high) is caught and converted to `tecPATH_DRY`. The log + * level is intentionally `debug`, not `error`, because this is a + * user-triggerable condition rather than a ledger-corruption sentinel. + * + * ### Mutation ordering and cap enforcement + * + * `sfAssetsTotal` and `sfAssetsAvailable` are incremented and the vault SLE + * flushed to the view *before* the cap check and before either `accountSend`. + * This ordering is deliberate: the `sfAssetsMaximum` guard must see the + * post-deposit total to correctly reject a deposit that would overflow the + * cap. If it would exceed the cap the transaction returns `tecLIMIT_EXCEEDED` + * and the framework discards all view mutations. + * + * Both `accountSend` calls use `WaiveTransferFee::Yes` to prevent asset + * transfer fees from distorting `sfAssetsTotal` accounting and corrupting the + * exchange rate for future depositors. + * + * `associateAsset` is the final call; per the `STTakesAsset` contract, it + * re-rounds stored numeric values to the asset's precision and must execute + * after all other writes to the vault SLE are complete. + */ TER VaultDeposit::doApply() { @@ -143,7 +224,6 @@ VaultDeposit::doApply() auto const vaultAsset = vault->at(sfAsset); auto const amount = ctx_.tx[sfAmount]; - // Make sure the depositor can hold shares. auto const mptIssuanceID = (*vault)[sfShareMPTID]; auto const sleIssuance = view().read(keylet::mptIssuance(mptIssuanceID)); if (!sleIssuance) @@ -155,7 +235,6 @@ VaultDeposit::doApply() } auto const& vaultAccount = vault->at(sfAccount); - // Note, vault owner is always authorized if (vault->isFlag(lsfVaultPrivate) && account_ != vault->at(sfOwner)) { if (auto const err = enforceMPTokenAuthorization( @@ -163,9 +242,8 @@ VaultDeposit::doApply() !isTesSuccess(err)) return err; } - else // !vault->isFlag(lsfVaultPrivate) || account_ == vault->at(sfOwner) + else { - // No authorization needed, but must ensure there is MPToken if (!view().exists(keylet::mptoken(mptIssuanceID, account_))) { if (auto const err = authorizeMPToken( @@ -174,7 +252,6 @@ VaultDeposit::doApply() return err; } - // If the vault is private, set the authorized flag for the vault owner if (vault->isFlag(lsfVaultPrivate)) { // This follows from the reverse of the outer enclosing if condition @@ -197,7 +274,6 @@ VaultDeposit::doApply() STAmount sharesCreated = {vault->at(sfShareMPTID)}, assetsDeposited; try { - // Compute exchange before transferring any amounts. { auto const maybeShares = assetsToSharesDeposit(vault, sleIssuance, amount); if (!maybeShares) @@ -223,8 +299,6 @@ VaultDeposit::doApply() } catch (std::overflow_error const&) { - // It's easy to hit this exception from Number with large enough Scale - // so we avoid spamming the log and only use debug here. JLOG(j_.debug()) // << "VaultDeposit: overflow error with" << " scale=" << (int)vault->at(sfScale).value() // @@ -241,18 +315,15 @@ VaultDeposit::doApply() vault->at(sfAssetsAvailable) += assetsDeposited; view().update(vault); - // A deposit must not push the vault over its limit. auto const maximum = *vault->at(sfAssetsMaximum); if (maximum != 0 && *vault->at(sfAssetsTotal) > maximum) return tecLIMIT_EXCEEDED; - // Transfer assets from depositor to vault. if (auto const ter = accountSend(view(), account_, vaultAccount, assetsDeposited, j_, WaiveTransferFee::Yes); !isTesSuccess(ter)) return ter; - // Sanity check if (accountHolds( view(), account_, @@ -267,7 +338,6 @@ VaultDeposit::doApply() // LCOV_EXCL_STOP } - // Transfer shares from vault to depositor. if (auto const ter = accountSend(view(), vaultAccount, account_, sharesCreated, j_, WaiveTransferFee::Yes); !isTesSuccess(ter)) diff --git a/src/libxrpl/tx/transactors/vault/VaultSet.cpp b/src/libxrpl/tx/transactors/vault/VaultSet.cpp index 78627f22b7..cb5722c456 100644 --- a/src/libxrpl/tx/transactors/vault/VaultSet.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultSet.cpp @@ -19,12 +19,32 @@ namespace xrpl { +/** Gate `sfDomainID` on the `featurePermissionedDomains` amendment. + * + * Allows domain support to be deployed independently of vault support: a + * network that has vaults but not permissioned domains will reject any + * `VaultSet` that tries to set `sfDomainID`, while all other `VaultSet` + * transactions continue to work normally. + */ bool VaultSet::checkExtraFeatures(PreflightContext const& ctx) { return !ctx.tx.isFieldPresent(sfDomainID) || ctx.rules.enabled(featurePermissionedDomains); } +/** Validate transaction fields without ledger access. + * + * Checks are ordered from cheapest to most specific: + * 1. `sfVaultID` must be non-zero — a zero ID cannot address any real vault. + * 2. `sfData`, if present, must be non-empty and within `kMAX_DATA_PAYLOAD_LENGTH`. + * 3. `sfAssetsMaximum`, if present, must be non-negative (zero means no cap). + * 4. At least one mutable field must be present — a no-op transaction is + * rejected as `temMALFORMED` to prevent fee-burning with no ledger effect. + * + * The cap-vs-total comparison (`tecLIMIT_EXCEEDED`) cannot be done here + * because `sfAssetsTotal` is only readable from the ledger; that check lives + * in `doApply`. + */ NotTEC VaultSet::preflight(PreflightContext const& ctx) { @@ -62,6 +82,25 @@ VaultSet::preflight(PreflightContext const& ctx) return tesSUCCESS; } +/** Read-only ownership and consistency checks after signature verification. + * + * Checks are ordered to fail as early as possible on the cheapest condition: + * 1. Vault existence — `tecNO_ENTRY` if the vault SLE is absent. + * 2. Ownership — `tecNO_PERMISSION` if submitter is not `sfOwner`. + * 3. Issuance existence — `tefINTERNAL` if the vault's `MPTokenIssuance` is + * missing. This path is covered by `LCOV_EXCL_*` because `VaultCreate` + * and the `ValidVault` invariant checker together ensure a vault always + * has a corresponding issuance; reaching this branch implies ledger + * corruption, not a user error. + * 4. Domain checks (only when `sfDomainID` is present in the transaction): + * - `lsfVaultPrivate` must be set on the vault (`tecNO_PERMISSION`); + * domain restrictions can only be applied to private vaults. + * - A non-zero domain hash must resolve to an existing + * `PermissionedDomain` object (`tecOBJECT_NOT_FOUND`). + * - `lsfMPTRequireAuth` must be set on the issuance (`tefINTERNAL`); + * this flag is enforced at `VaultCreate` time for private vaults, so + * its absence indicates ledger corruption (also `LCOV_EXCL_*`). + */ TER VaultSet::preclaim(PreclaimContext const& ctx) { @@ -88,7 +127,6 @@ VaultSet::preclaim(PreclaimContext const& ctx) if (auto const domain = ctx.tx[~sfDomainID]) { - // We can only set domain if private flag was originally set if (!vault->isFlag(lsfVaultPrivate)) { JLOG(ctx.j.debug()) << "VaultSet: vault is not private"; @@ -102,7 +140,6 @@ VaultSet::preclaim(PreclaimContext const& ctx) return tecOBJECT_NOT_FOUND; } - // Sanity check only, this should be enforced by VaultCreate if ((sleIssuance->getFlags() & lsfMPTRequireAuth) == 0) { // LCOV_EXCL_START @@ -115,6 +152,37 @@ VaultSet::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } +/** Apply vault configuration changes to the mutable ledger view. + * + * Re-fetches the vault and its `MPTokenIssuance` via `view().peek()` to + * obtain mutable SLE references, then applies updates in order: + * + * **sfData**: written directly to the vault SLE when present. + * + * **sfAssetsMaximum**: the ledger-state check that `preflight` cannot perform + * happens here — if the requested cap is non-zero but less than the current + * `sfAssetsTotal`, the transaction fails with `tecLIMIT_EXCEEDED`. Zero is + * the sentinel for "no cap" and always succeeds. + * + * **sfDomainID**: written to (or removed from) the `MPTokenIssuance` SLE, NOT + * the vault SLE. The domain lives on the issuance because the MPT + * authorization machinery (`lsfMPTRequireAuth`, `credentials::validDomain`) + * operates at the issuance level; this avoids special-casing the vault layer + * in the depositor authorization path. A zero hash calls `makeFieldAbsent` + * to clear the restriction compactly rather than storing a zero value. + * Clearing the domain does not remove `lsfVaultPrivate` — once private, + * always private (making a private vault public is not currently supported). + * + * **view().update(vault)** is always called even when only the issuance + * changed. The `ValidVault` invariant checker inspects modified vault SLEs + * to validate the operation; omitting the update would make it invisible to + * the checker. + * + * **associateAsset** is the final step and must remain last. It re-rounds + * all `STNumber` / `STTakesAsset`-derived fields in the vault SLE to the + * precision of the vault's underlying asset type. Calling it earlier would + * produce silent precision corruption on any field written afterwards. + */ TER VaultSet::doApply() { @@ -124,7 +192,6 @@ VaultSet::doApply() auto const& tx = ctx_.tx; - // Update existing object. auto vault = view().peek(keylet::vault(tx[sfVaultID])); if (!vault) return tefINTERNAL; // LCOV_EXCL_LINE @@ -141,7 +208,6 @@ VaultSet::doApply() // LCOV_EXCL_STOP } - // Update mutable flags and fields if given. if (tx.isFieldPresent(sfData)) vault->at(sfData) = tx[sfData]; if (tx.isFieldPresent(sfAssetsMaximum)) @@ -155,11 +221,6 @@ VaultSet::doApply() { if (*domainId != beast::kZERO) { - // In VaultSet::preclaim we enforce that lsfVaultPrivate must have - // been set in the vault. We currently do not support making such a - // vault public (i.e. removal of lsfVaultPrivate flag). The - // sfDomainID flag must be set in the MPTokenIssuance object and can - // be freely updated. sleIssuance->setFieldH256(sfDomainID, *domainId); } else if (sleIssuance->isFieldPresent(sfDomainID)) @@ -169,9 +230,6 @@ VaultSet::doApply() view().update(sleIssuance); } - // Note, we must update Vault object even if only DomainID is being updated - // in Issuance object. Otherwise it's really difficult for Vault invariants - // to verify the operation. view().update(vault); associateAsset(*vault, vaultAsset); diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index 94c6f0f6d2..709c67c9df 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -1,3 +1,16 @@ +/** @file VaultWithdraw.cpp + * Implementation of the `ttVAULT_WITHDRAW` transactor. + * + * Redeems vault-share MPTokens for the underlying pooled asset — the inverse + * of `VaultDeposit`. The user specifies `sfAmount` in either the vault's + * underlying asset (fixed assets, variable shares) or the vault's share MPT + * (fixed shares, variable assets); the complementary quantity is computed via + * `assetsToSharesWithdraw` / `sharesToAssetsWithdraw` from VaultHelpers.h. + * + * Liquidity is gated on `sfAssetsAvailable` rather than the vault + * pseudo-account's raw balance, so that assets pledged to lending brokers + * are correctly excluded from on-demand redemptions. + */ #include #include @@ -26,6 +39,21 @@ namespace xrpl { +/** Stateless, ledger-free validation of a `ttVAULT_WITHDRAW` transaction. + * + * Checks are ordered cheapest-first and fail on the first violation. Each + * rejected condition is a programming error or a structurally invalid + * request that would be meaningless against any ledger state: + * + * - A zero `sfVaultID` is always a null key; no valid vault can have this ID. + * - A non-positive `sfAmount` cannot represent a redemption of any value. + * - A zero explicit `sfDestination`, when present, cannot identify any real + * account; only the absent-field case (withdraw to self) is permitted to + * omit a destination. + * + * @param ctx Preflight context carrying the transaction and active rules. + * @return `tesSUCCESS`, `temMALFORMED`, or `temBAD_AMOUNT`. + */ NotTEC VaultWithdraw::preflight(PreflightContext const& ctx) { @@ -49,6 +77,41 @@ VaultWithdraw::preflight(PreflightContext const& ctx) return tesSUCCESS; } +/** Read-only ledger validation for a vault withdrawal. + * + * Checks are ordered from cheapest to most expensive and fail fast on the + * first violation. Several design choices merit explanation: + * + * **`fixSecurity3_1_3` and the share-limit gap**: before this amendment, when + * `sfAmount` was share-denominated, the withdrawal-limit check (`canWithdraw`) + * was silently skipped — only the simpler two-argument overload ran. Post- + * amendment, shares are first converted to an equivalent asset amount via + * `sharesToAssetsWithdraw`, and that asset amount is fed to the full + * four-argument `canWithdraw` overload. Overflow during this conversion + * (easily reachable with large `sfScale` values) returns `tecPATH_DRY` rather + * than crashing; the log level is deliberately `debug` because this is a + * user-triggerable condition, not a ledger-corruption sentinel. + * + * **Two-tier authorization**: if the destination is the submitting account + * itself, `WeakAuth` is used — `doApply` will create a trust line or MPToken + * on the submitter's behalf as needed. If `sfDestination` names a third party, + * `StrongAuth` is required: the holding must already exist before the + * withdrawal is applied. + * + * **Dual freeze checks**: one call verifies the vault's underlying asset is + * not frozen for the destination (the receiver must be able to accept it), and + * a second call verifies the vault's share MPT is not frozen or locked for the + * submitter (the redeemer must be able to surrender shares). + * + * The `sfWithdrawalPolicy` guard and the missing-issuance guard are marked + * `LCOV_EXCL` because `VaultCreate` invariants make them unreachable under + * correct ledger state. + * + * @param ctx Preclaim context carrying a read-only ledger view and rules. + * @return `tesSUCCESS`, `tecNO_ENTRY`, `tecWRONG_ASSET`, `tecPATH_DRY`, + * `tefINTERNAL`, or a code from `canTransfer`, `canWithdraw`, + * `requireAuth`, or `checkFrozen`. + */ TER VaultWithdraw::preclaim(PreclaimContext const& ctx) { @@ -71,7 +134,6 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) return ter; } - // Enforce valid withdrawal policy if (vault->at(sfWithdrawalPolicy) != kVAULT_STRATEGY_FIRST_COME_FIRST_SERVE) { // LCOV_EXCL_START @@ -82,10 +144,6 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) if (ctx.view.rules().enabled(fixSecurity3_1_3) && amount.asset() == vaultShare) { - // Post-fixSecurity3_1_3: if the user specified shares, convert - // to the equivalent asset amount before checking withdrawal - // limits. Pre-amendment the limit check was skipped for - // share-denominated withdrawals. auto const sleIssuance = ctx.view.read(keylet::mptIssuance(vaultShare)); if (!sleIssuance) { @@ -128,25 +186,80 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) return ret; } - // If sending to Account (i.e. not a transfer), we will also create (only - // if authorized) a trust line or MPToken as needed, in doApply(). - // Destination MPToken or trust line must exist if _not_ sending to Account. AuthType const authType = account == dstAcct ? AuthType::WeakAuth : AuthType::StrongAuth; if (auto const ter = requireAuth(ctx.view, vaultAsset, dstAcct, authType); !isTesSuccess(ter)) return ter; - // Cannot withdraw from a Vault an Asset frozen for the destination account if (auto const ret = checkFrozen(ctx.view, dstAcct, vaultAsset)) return ret; - // Cannot return shares to the vault, if the underlying asset was frozen for - // the submitter if (auto const ret = checkFrozen(ctx.view, account, Asset{vaultShare})) return ret; return tesSUCCESS; } +/** Mutate the ledger to execute a vault withdrawal. + * + * ### Exchange rate computation (two modes) + * + * **Asset-denominated** (`sfAmount.asset() == vaultAsset`): `assetsToSharesWithdraw` + * converts the requested asset quantity to an integer share count (truncating). + * The result is immediately re-converted via `sharesToAssetsWithdraw` to + * determine the exact assets to disburse — this double-conversion is not + * redundant: it corrects for MPT integer truncation so the vault always pays + * out the precise amount that the integer share count entitles, never more. + * If the first conversion produces zero shares, `tecPRECISION_LOSS` is returned + * (the requested amount is too small to represent even one share unit). + * + * **Share-denominated** (`sfAmount.asset() == share`): the share count is used + * directly; `sharesToAssetsWithdraw` computes the corresponding asset payout. + * No rounding step is needed because the share count is already integral. + * + * Both modes catch `std::overflow_error` and return `tecPATH_DRY`. The log + * level is `debug` because large `sfScale` values make overflow arithmetically + * trivial for legitimate users — this is not a ledger-corruption sentinel. + * + * ### Liquidity gating + * + * The check against `sfAssetsAvailable` (not the raw pseudo-account balance) + * is deliberate: a vault may have pledged assets to lending brokers, reducing + * what is actually liquid. `sfAssetsAvailable` tracks only unencumbered assets. + * Both `sfAssetsTotal` and `sfAssetsAvailable` are decremented by + * `assetsWithdrawn` on success. + * + * ### Share redemption and MPToken cleanup + * + * Shares flow back to the vault pseudo-account via `accountSend` with + * `WaiveTransferFee::Yes` — shares are internal bookkeeping tokens, not + * economic transfers subject to issuer-configured transfer fees. + * + * After redemption, if the submitter's share balance drops to zero and the + * submitter is not the vault owner, `removeEmptyHolding` attempts to delete + * the now-empty MPToken. `tecHAS_OBLIGATIONS` is silently ignored (the holder + * has associated obligations and must retain the MPToken). Vault owners are + * excluded from cleanup because their MPToken may be needed for future + * deposits and internal accounting. + * + * ### `lsfVaultPrivate` omission (intentional) + * + * `doApply` does not re-check the `lsfVaultPrivate` flag. Possession of vault + * shares is proof of prior authorized deposit; withdrawal authorization is + * effectively irrevocable once shares are held, regardless of whether the + * vault's private flag or domain credentials have subsequently changed. + * + * ### Completion ordering + * + * `associateAsset` is called before `doWithdraw` to re-round stored `STNumber` + * fields to the vault asset's precision. Per the `STTakesAsset` contract, this + * must be the final write to the vault SLE. `doWithdraw` then handles trust + * line or MPToken creation for the destination (under `WeakAuth`) and executes + * the final `accountSend` from the vault pseudo-account to the destination. + * + * @return `tesSUCCESS`, `tecPRECISION_LOSS`, `tecPATH_DRY`, + * `tecINSUFFICIENT_FUNDS`, `tefINTERNAL`, or a code from `accountSend`, + * `removeEmptyHolding`, or `doWithdraw`. + */ TER VaultWithdraw::doApply() { @@ -164,11 +277,6 @@ VaultWithdraw::doApply() // LCOV_EXCL_STOP } - // Note, we intentionally do not check lsfVaultPrivate flag on the Vault. If - // you have a share in the vault, it means you were at some point authorized - // to deposit into it, and this means you are also indefinitely authorized - // to withdraw from it. - auto const amount = ctx_.tx[sfAmount]; Asset const vaultAsset = vault->at(sfAsset); @@ -179,7 +287,6 @@ VaultWithdraw::doApply() { if (amount.asset() == vaultAsset) { - // Fixed assets, variable shares. { auto const maybeShares = assetsToSharesWithdraw(vault, sleIssuance, amount); if (!maybeShares) @@ -196,7 +303,6 @@ VaultWithdraw::doApply() } else if (amount.asset() == share) { - // Fixed shares, variable assets. sharesRedeemed = amount; auto const maybeAssets = sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed); if (!maybeAssets) @@ -236,9 +342,6 @@ VaultWithdraw::doApply() lossUnrealized <= (assetsTotal - assetsAvailable), "xrpl::VaultWithdraw::doApply : loss and assets do balance"); - // The vault must have enough assets on hand. The vault may hold assets - // that it has already pledged. That is why we look at AssetAvailable - // instead of the pseudo-account balance. if (*assetsAvailable < assetsWithdrawn) { JLOG(j_.debug()) << "VaultWithdraw: vault doesn't hold enough assets"; @@ -250,15 +353,12 @@ VaultWithdraw::doApply() view().update(vault); auto const& vaultAccount = vault->at(sfAccount); - // Transfer shares from depositor to vault. if (auto const ter = accountSend(view(), account_, vaultAccount, sharesRedeemed, j_, WaiveTransferFee::Yes); !isTesSuccess(ter)) return ter; - // Try to remove MPToken for shares, if the account balance is zero. Vault - // pseudo-account will never set lsfMPTAuthorized, so we ignore flags. - // Keep MPToken if holder is the vault owner. + // Vault pseudo-account will never set lsfMPTAuthorized, so we ignore flags. if (account_ != vault->at(sfOwner)) { if (auto const ter = removeEmptyHolding(view(), account_, sharesRedeemed.asset(), j_); @@ -280,7 +380,6 @@ VaultWithdraw::doApply() return ter; // LCOV_EXCL_STOP } - // else quietly ignore, account balance is not zero } auto const dstAcct = ctx_.tx[~sfDestination].value_or(account_); diff --git a/src/xrpld/rpc/handlers/server_info/Feature.cpp b/src/xrpld/rpc/handlers/server_info/Feature.cpp index bd7198b61c..4dbc03e1d8 100644 --- a/src/xrpld/rpc/handlers/server_info/Feature.cpp +++ b/src/xrpld/rpc/handlers/server_info/Feature.cpp @@ -13,16 +13,50 @@ namespace xrpl { -// { -// feature : -// vetoed : true/false -// } +/** RPC handler for the `feature` command. + * + * Exposes the XRPL amendment system to external clients and privileged + * operators. Operates in two modes depending on whether the `feature` + * parameter is present. + * + * **List mode** (no `feature` param): returns a `features` object keyed by + * amendment hash, sourced from `AmendmentTable::getJson(isAdmin)` and + * augmented with majority timestamps read directly from the validated ledger's + * amendment SLE. Reading majority data from the ledger (rather than relying + * solely on the `AmendmentTable`'s internal vote tracking) is authoritative + * because the two can briefly diverge. + * + * **Single-feature mode** (with `feature` param): resolves the identifier + * first as a human-readable amendment name via `AmendmentTable::find()`, + * then falls back to parsing it as a 256-bit hex hash. Returns + * `rpcBAD_FEATURE` if neither succeeds, or if the resolved hash is not + * known to the amendment table. + * + * **Veto control** (admin only): when `vetoed` is also present, calls + * `AmendmentTable::veto()` or `AmendmentTable::unVeto()` to control whether + * the local validator votes for the amendment. Non-admin callers who supply + * `vetoed` receive `rpcNO_PERMISSION`. The handler is registered as + * `Role::USER`, so any client can invoke the read paths; only the mutation + * path is gated here by the explicit admin check. + * + * Majority timestamps in the response are raw `NetClock::time_point` ticks + * — seconds since the Ripple epoch (2000-01-01 00:00:00 UTC), **not** Unix + * time. Callers must add the 946684800-second offset to convert to Unix time. + * + * @param context The RPC dispatch context, including request params, role, + * app reference, and ledger master. + * @return A JSON object with a top-level `features` map (list mode) or a + * single-amendment object keyed by hash (single-feature mode), or an + * RPC error value on invalid input or insufficient permissions. + * @note The `feature` parameter must be a JSON string; passing a number, + * boolean, null, object, or array returns `rpcINVALID_PARAMS`. + * @see AmendmentTable, getMajorityAmendments + */ json::Value doFeature(RPC::JsonContext& context) { if (context.params.isMember(jss::feature)) { - // ensure that the `feature` param is a string if (!context.params[jss::feature].isString()) { return rpcError(RpcInvalidParams); @@ -30,7 +64,6 @@ doFeature(RPC::JsonContext& context) } bool const isAdmin = context.role == Role::ADMIN; - // Get majority amendment status majorityAmendments_t majorities; if (auto const valLedger = context.ledgerMaster.getValidatedLedger()) @@ -54,8 +87,6 @@ doFeature(RPC::JsonContext& context) auto feature = table.find(context.params[jss::feature].asString()); - // If the feature is not found by name, try to parse the `feature` param as - // a feature ID. If that fails, return an error. if (!feature && !feature.parseHex(context.params[jss::feature].asString())) return rpcError(RpcBadFeature); diff --git a/src/xrpld/rpc/handlers/server_info/Fee.cpp b/src/xrpld/rpc/handlers/server_info/Fee.cpp index 1fe5476d50..8ca9daf07a 100644 --- a/src/xrpld/rpc/handlers/server_info/Fee.cpp +++ b/src/xrpld/rpc/handlers/server_info/Fee.cpp @@ -1,3 +1,20 @@ +/** @file + * Handler for the `fee` RPC command. + * + * Exposes the node's current transaction fee environment to any connected + * client (`Role::USER`). The response is assembled entirely by + * `TxQ::doRPC`; this translation unit contributes only the dispatch point + * and a defensive type check on the return value. + * + * Registration in `Handler.cpp`: + * @code + * {"fee", byRef(&doFee), Role::USER, NEEDS_CURRENT_LEDGER} + * @endcode + * The `NEEDS_CURRENT_LEDGER` condition means the framework guarantees that + * the open ledger exists before `doFee` is ever called, making the + * null-view guard inside `TxQ::doRPC` unreachable from this handler. + */ + #include #include #include @@ -7,6 +24,28 @@ #include namespace xrpl { + +/** Return the node's current transaction fee schedule. + * + * Delegates entirely to `TxQ::doRPC`, which queries the open ledger via + * `TxQ::getMetrics` and returns a JSON object with two sub-objects: + * + * - `levels` — fee values in normalized fee-level units: + * `reference_level`, `minimum_level`, `median_level`, `open_ledger_level`. + * - `drops` — absolute amounts in XRP drops: + * `base_fee`, `median_fee`, `minimum_fee`, `open_ledger_fee`. + * + * Top-level fields include `ledger_current_index`, `current_ledger_size`, + * `expected_ledger_size`, `current_queue_size`, and (when a queue size cap + * is configured) `max_queue_size`. + * + * @param context RPC dispatch context; must have a current open ledger + * (enforced by the `NEEDS_CURRENT_LEDGER` handler condition). + * @return JSON object with `levels` and `drops` sub-objects on success. + * The failure branch — triggered only when `TxQ::doRPC` returns a + * non-object value, which cannot happen via this handler — is excluded + * from coverage (`LCOV_EXCL`) and marked `UNREACHABLE`. + */ json::Value doFee(RPC::JsonContext& context) { diff --git a/src/xrpld/rpc/handlers/server_info/Manifest.cpp b/src/xrpld/rpc/handlers/server_info/Manifest.cpp index cb1771750b..905d59a887 100644 --- a/src/xrpld/rpc/handlers/server_info/Manifest.cpp +++ b/src/xrpld/rpc/handlers/server_info/Manifest.cpp @@ -1,5 +1,15 @@ // Copyright (c) 2019 Dev Null Productions +/** @file + * Implements the `manifest` RPC handler, which exposes a read-only view of + * the validator manifest cache over RPC. + * + * A manifest is a signed certificate binding a long-lived validator master key + * to a rotating ephemeral signing key. Clients can supply either key type; + * the handler resolves whichever was provided to the canonical master key + * before querying the cache. + */ + #include #include @@ -11,6 +21,36 @@ #include namespace xrpl { +/** Look up and return the manifest for a validator public key. + * + * Accepts either a master key or an active ephemeral signing key in + * `public_key`. The input is resolved to a master key via + * `ManifestCache::getMasterKey`, which returns the input unchanged when no + * reverse ephemeral→master mapping exists, making the lookup uniform for + * both key types. + * + * If the resolved master key has no active signing key in the cache (manifest + * absent or revoked), the response contains only the `requested` field and no + * error is raised — absence is not treated as a failure. + * + * On success the response contains: + * - `requested` — the caller's original input, always present. + * - `manifest` — Base64-encoded raw manifest bytes, when available. + * - `details` — object with `master_key` and `ephemeral_key` (Base58), + * and optionally `seq` and `domain` when recorded. + * + * @param context RPC dispatch context carrying `params` and the application + * reference used to reach `ManifestCache`. + * @return JSON response object. Returns a `missingFieldError` if `public_key` + * is absent, `rpcINVALID_PARAMS` if the value is not a valid Base58 + * NodePublic key, or a sparse response (only `requested`) when the + * manifest is not found. + * @note All `ManifestCache` methods called here are protected by a + * `std::shared_mutex` and are safe for concurrent invocation. This + * handler holds no locks and carries no mutable state. + * @see doValidatorInfo for the analogous handler that looks up the node's own + * configured validation key without accepting external input. + */ json::Value doManifest(RPC::JsonContext& context) { @@ -31,14 +71,10 @@ doManifest(RPC::JsonContext& context) return ret; } - // first attempt to use params as ephemeral key, - // if this lookup succeeds master key will be returned, - // else an unseated optional is returned auto const mk = context.app.getValidatorManifests().getMasterKey(*pk); auto const ek = context.app.getValidatorManifests().getSigningKey(mk); - // if ephemeral key not found, we don't have specified manifest if (!ek) return ret; diff --git a/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp b/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp index a5a9836e27..7f72d812b9 100644 --- a/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp +++ b/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp @@ -1,3 +1,13 @@ +/** @file + * Implements the `server_definitions` RPC handler, which exposes the + * complete XRPL protocol schema as a static JSON payload. + * + * The payload is assembled once at startup (Meyers singleton), hashed via + * SHA-512-half for cache validation, and then served read-only for the + * lifetime of the process. Clients that already hold a matching hash + * receive only the hash in the response, avoiding re-transfer of the + * large definitions object on every call. + */ #include #include @@ -29,25 +39,75 @@ namespace xrpl { namespace detail { +/** Immutable protocol schema snapshot used by the `server_definitions` RPC endpoint. + * + * Built exactly once at first use (Meyers singleton via `getDefinitions()`). + * Populates nine top-level JSON sections — TYPES, LEDGER_ENTRY_TYPES, + * TRANSACTION_TYPES, FIELDS, TRANSACTION_RESULTS, TRANSACTION_FORMATS, + * LEDGER_ENTRY_FORMATS, TRANSACTION_FLAGS, LEDGER_ENTRY_FLAGS, and + * ACCOUNT_SET_FLAGS — then computes a SHA-512-half fingerprint of the + * serialized payload for client-side cache invalidation. + * + * @note Thread-safe after construction; all public methods are read-only. + */ class ServerDefinitions { private: + /** Convert a raw `STI_`-stripped type name to the client-facing naming convention. + * + * Applies the following rules in order: + * - Names containing `UINT` with a fixed bit-width (128, 160, 192, 256, 384, 512) + * become `Hash` (e.g., `UINT256` → `Hash256`), reflecting that these + * fixed-width types are used as cryptographic digests, not arithmetic integers. + * - Other `UINT` names become `UInt` (e.g., `UINT32` → `UInt32`). + * - A fixed lookup table handles special cases: `VL` → `Blob`, + * `ACCOUNT` → `AccountID`, `OBJECT` → `STObject`, `ARRAY` → `STArray`, etc. + * - All remaining names are converted from SCREAMING_SNAKE_CASE to CamelCase. + * + * @param inp Raw type name with the `STI_` prefix already removed. + * @return Client-facing type name string. + */ static std::string - // translate e.g. STI_LEDGERENTRY to LedgerEntry translate(std::string const& inp); + /** SHA-512-half fingerprint of the serialized definitions payload. */ uint256 defsHash_; + + /** The complete protocol schema as a JSON object. */ json::Value defs_; public: + /** Construct and populate the full protocol schema. + * + * Iterates `SField::getKnownCodeToField()`, `LedgerFormats`, `TxFormats`, + * `transResults()`, `getAllTxFlags()`, `getAllLedgerFlags()`, and + * `getAsfFlagMap()` to build all nine sections. After all sections are + * assembled, serializes the object via `Json::FastWriter` and stores the + * SHA-512-half of the result in both `defsHash_` and `defs_[jss::hash]`. + */ ServerDefinitions(); + /** Return true if @p hash matches the current definitions fingerprint. + * + * Used by `doServerDefinitions` to implement the bandwidth-saving + * short-circuit: when the caller already holds an up-to-date copy, + * only the hash is returned rather than the full payload. + * + * @param hash The `uint256` hash supplied by the caller. + * @return `true` if @p hash equals `defsHash_`; `false` otherwise. + */ [[nodiscard]] bool hashMatches(uint256 hash) const { return defsHash_ == hash; } + /** Return a const reference to the complete definitions JSON object. + * + * The returned reference is valid for the lifetime of the process. + * + * @return The fully populated protocol schema, including the `hash` field. + */ [[nodiscard]] json::Value const& get() const { @@ -64,7 +124,7 @@ ServerDefinitions::translate(std::string const& inp) return out; }; - // TODO: use string::contains with C++23 + // TODO: use string::contains with C++23 once the minimum language version is raised. auto contains = [&](std::string_view s) -> bool { return inp.find(s) != std::string::npos; }; if (contains("UINT")) @@ -124,7 +184,9 @@ ServerDefinitions::translate(std::string const& inp) ServerDefinitions::ServerDefinitions() : defs_{json::ValueType::Object} { - // populate SerializedTypeID names and values + // --- TYPES --- + // Map client-facing type names to their SerializedTypeID integer codes. + // typeMap is retained for reverse lookup when setting each field's `type` string below. defs_[jss::TYPES] = json::ValueType::Object; defs_[jss::TYPES]["Done"] = -1; @@ -136,7 +198,8 @@ ServerDefinitions::ServerDefinitions() : defs_{json::ValueType::Object} typeMap[typeValue] = typeName; } - // populate LedgerEntryType names and values + // --- LEDGER_ENTRY_TYPES --- + // Seed with sentinel Invalid = -1; then append all registered ledger entry names. defs_[jss::LEDGER_ENTRY_TYPES] = json::ValueType::Object; defs_[jss::LEDGER_ENTRY_TYPES][jss::Invalid] = -1; @@ -145,7 +208,9 @@ ServerDefinitions::ServerDefinitions() : defs_{json::ValueType::Object} defs_[jss::LEDGER_ENTRY_TYPES][f.getName()] = f.getType(); } - // populate SField serialization data + // --- FIELDS --- + // Six entries are hard-coded before the registry loop because they either have no + // canonical SField entry or require explicit control over their serialization attributes. defs_[jss::FIELDS] = json::ValueType::Array; uint32_t i = 0; @@ -189,6 +254,8 @@ ServerDefinitions::ServerDefinitions() : defs_{json::ValueType::Object} defs_[jss::FIELDS][i++] = a; } + // taker_gets_funded / taker_pays_funded: synthetic DEX fields (nth 258/259) that exist + // in the offer-crossing path but are not persisted or signed. { json::Value a = json::ValueType::Array; a[0U] = "taker_gets_funded"; @@ -215,7 +282,8 @@ ServerDefinitions::ServerDefinitions() : defs_{json::ValueType::Object} defs_[jss::FIELDS][i++] = a; } - // copy into a sorted map to ensure deterministic output order (sorted by fieldCode) + // Sort by fieldCode for deterministic output; the unordered source map gives no ordering + // guarantee across platforms. static std::map const kSORTED_FIELDS( xrpl::SField::getKnownCodeToField().begin(), xrpl::SField::getKnownCodeToField().end()); @@ -230,21 +298,21 @@ ServerDefinitions::ServerDefinitions() : defs_{json::ValueType::Object} innerObj[jss::nth] = field->fieldValue; - // whether the field is variable-length encoded this means that the length is included - // before the content + // VL-encoded types encode their byte length as a prefix in the wire format. + // Only three types qualify: Blob (7), AccountID (8), Vector256 (19). innerObj[jss::isVLEncoded] = (type == STI_VL || type == STI_ACCOUNT || type == STI_VECTOR256); static_assert( STI_VL == 7U && STI_ACCOUNT == 8U && STI_VECTOR256 == 19U, "STI_VL, STI_ACCOUNT, STI_VECTOR256 must be 7, 8, 19 respectively"); - // whether the field is included in serialization + // Container pseudo-types (type >= 10000: STI_TRANSACTION, STI_LEDGERENTRY, + // STI_VALIDATION, STI_METADATA) and the computed fields `hash` and `index` + // are not stored in serialized objects. innerObj[jss::isSerialized] = (type < 10000 && field->fieldName != "hash" && - field->fieldName != - "index"); // hash, index, TRANSACTION, LEDGER_ENTRY, VALIDATION, METADATA + field->fieldName != "index"); - // whether the field is included in serialization when signing innerObj[jss::isSigningField] = field->shouldInclude(false); innerObj[jss::type] = typeMap[type]; @@ -256,7 +324,8 @@ ServerDefinitions::ServerDefinitions() : defs_{json::ValueType::Object} defs_[jss::FIELDS][i++] = innerArray; } - // populate TER code names and values + // --- TRANSACTION_RESULTS --- + // Maps every TER code name (e.g., "tesSUCCESS", "tecDIR_FULL") to its integer value. defs_[jss::TRANSACTION_RESULTS] = json::ValueType::Object; for (auto const& [code, terInfo] : transResults()) @@ -264,7 +333,8 @@ ServerDefinitions::ServerDefinitions() : defs_{json::ValueType::Object} defs_[jss::TRANSACTION_RESULTS][terInfo.first] = code; } - // populate TxType names and values + // --- TRANSACTION_TYPES --- + // Seed with sentinel Invalid = -1; then append all registered transaction type names. defs_[jss::TRANSACTION_TYPES] = json::ValueType::Object; defs_[jss::TRANSACTION_TYPES][jss::Invalid] = -1; for (auto const& f : TxFormats::getInstance()) @@ -272,7 +342,10 @@ ServerDefinitions::ServerDefinitions() : defs_{json::ValueType::Object} defs_[jss::TRANSACTION_TYPES][f.getName()] = f.getType(); } - // populate TxFormats + // --- TRANSACTION_FORMATS --- + // Two-level schema: "common" lists fields shared by every transaction type; each + // type-specific key lists only the fields not already in "common". The txCommonFields + // set drives the skip check in the per-type loop. defs_[jss::TRANSACTION_FORMATS] = json::ValueType::Object; defs_[jss::TRANSACTION_FORMATS][jss::common] = json::ValueType::Array; @@ -302,7 +375,8 @@ ServerDefinitions::ServerDefinitions() : defs_{json::ValueType::Object} defs_[jss::TRANSACTION_FORMATS][format.getName()] = templateArray; } - // populate LedgerFormats + // --- LEDGER_ENTRY_FORMATS --- + // Same two-level common/per-type pattern as TRANSACTION_FORMATS. defs_[jss::LEDGER_ENTRY_FORMATS] = json::ValueType::Object; defs_[jss::LEDGER_ENTRY_FORMATS][jss::common] = json::ValueType::Array; auto ledgerCommonFields = std::set(); @@ -330,6 +404,9 @@ ServerDefinitions::ServerDefinitions() : defs_{json::ValueType::Object} defs_[jss::LEDGER_ENTRY_FORMATS][format.getName()] = templateArray; } + // --- TRANSACTION_FLAGS / LEDGER_ENTRY_FLAGS --- + // Both sourced from X-macro-driven Meyers singletons; keyed by type name + // with a "universal" entry for globally applicable flags. defs_[jss::TRANSACTION_FLAGS] = json::ValueType::Object; for (auto const& [name, value] : getAllTxFlags()) { @@ -352,13 +429,15 @@ ServerDefinitions::ServerDefinitions() : defs_{json::ValueType::Object} defs_[jss::LEDGER_ENTRY_FLAGS][name] = ledgerObj; } + // --- ACCOUNT_SET_FLAGS --- defs_[jss::ACCOUNT_SET_FLAGS] = json::ValueType::Object; for (auto const& [name, value] : getAsfFlagMap()) { defs_[jss::ACCOUNT_SET_FLAGS][name] = value; } - // generate hash + // Fingerprint the complete payload before embedding the hash field. + // The hash covers all nine sections but not the hash field itself. { std::string const out = json::FastWriter().write(defs_); defsHash_ = xrpl::sha512Half(xrpl::Slice{out.data(), out.size()}); @@ -366,6 +445,12 @@ ServerDefinitions::ServerDefinitions() : defs_{json::ValueType::Object} } } +/** Return the process-lifetime singleton `ServerDefinitions` instance. + * + * Constructed on first call via C++11 thread-safe static initialization. + * + * @return Const reference to the singleton; valid for the lifetime of the process. + */ ServerDefinitions const& getDefinitions() { @@ -375,12 +460,37 @@ getDefinitions() } // namespace detail +/** Return the complete protocol schema JSON built by the `server_definitions` singleton. + * + * Provides direct access to the definitions payload for non-RPC contexts + * (e.g., the `--definitions` CLI flag in `Main.cpp`). The returned reference + * is valid for the lifetime of the process. + * + * @return Const reference to the fully populated definitions JSON object, + * including the `hash` field. + * @see doServerDefinitions + */ json::Value const& getServerDefinitionsJson() { return detail::getDefinitions().get(); } +/** Handle the `server_definitions` RPC request. + * + * Returns the complete XRPL protocol schema assembled at startup. If the + * caller supplies a `hash` parameter matching the current definitions + * fingerprint, only `{"hash": "..."}` is returned to save bandwidth. A + * hash mismatch (or no hash) causes the full payload to be returned so the + * client can update its cached copy. + * + * @param context RPC dispatch context; `context.params` may contain an + * optional `hash` string field (64 hex characters). + * @return The full definitions JSON object on a cache miss, or a + * single-key `{"hash": "..."}` object on a cache hit. Returns an + * `invalidFieldError` response if `hash` is present but not a valid + * 64-hex-character `uint256`. + */ json::Value doServerDefinitions(RPC::JsonContext& context) { diff --git a/src/xrpld/rpc/handlers/server_info/ServerDefinitions.h b/src/xrpld/rpc/handlers/server_info/ServerDefinitions.h index 60143f9811..0fda46300c 100644 --- a/src/xrpld/rpc/handlers/server_info/ServerDefinitions.h +++ b/src/xrpld/rpc/handlers/server_info/ServerDefinitions.h @@ -1,9 +1,36 @@ +/** @file + * Public interface for the `server_definitions` RPC endpoint. + * + * Exposes a single function that returns the complete XRPL protocol schema + * as a static, process-lifetime JSON object. The payload is built once + * (Meyers singleton) and includes a SHA-512-half fingerprint for + * client-side cache validation. + * + * @see ServerDefinitions.cpp for the full construction details. + */ #pragma once #include namespace xrpl { +/** Return the complete XRPL protocol schema assembled at process startup. + * + * The returned object contains eleven top-level keys: + * `TYPES`, `LEDGER_ENTRY_TYPES`, `TRANSACTION_TYPES`, `FIELDS`, + * `TRANSACTION_RESULTS`, `TRANSACTION_FORMATS`, `LEDGER_ENTRY_FORMATS`, + * `TRANSACTION_FLAGS`, `LEDGER_ENTRY_FLAGS`, `ACCOUNT_SET_FLAGS`, and + * `hash` (a SHA-512-half fingerprint of the other ten keys). + * + * This function is the non-RPC access path to the same singleton used by + * the `server_definitions` JSON-RPC handler — for example, it is called by + * the `--definitions` CLI flag in `Main.cpp` to dump the schema to stdout. + * + * @return Const reference to the fully populated definitions JSON object, + * valid for the lifetime of the process. + * @note The reference is safe to capture and hold indefinitely; the + * underlying singleton is never destroyed. + */ json::Value const& getServerDefinitionsJson(); diff --git a/src/xrpld/rpc/handlers/server_info/ServerInfo.cpp b/src/xrpld/rpc/handlers/server_info/ServerInfo.cpp index aaad9d2b02..31650de7fb 100644 --- a/src/xrpld/rpc/handlers/server_info/ServerInfo.cpp +++ b/src/xrpld/rpc/handlers/server_info/ServerInfo.cpp @@ -1,3 +1,35 @@ +/** @file + * Implements the `server_info` JSON-RPC handler (`doServerInfo`). + * + * The handler is intentionally thin: it constructs an empty JSON object, + * delegates all data assembly to `NetworkOPs::getServerInfo()`, and wraps + * the result under the `jss::info` key. All substantive logic — gathering + * warnings, ledger positions, peer counts, consensus state, and optional + * performance counters — lives in `NetworkOPsImp::getServerInfo()`. + * + * The `human = true` flag passed here is the sole behavioral difference + * between this handler and its sibling `ServerState.cpp` (`human = false`). + * When `human` is true, `getServerInfo` includes human-readable fields such + * as `hostid` and formats numeric quantities for readability; when false, it + * emits compact machine-friendly representations. Both handlers use `jss::info` + * vs. `jss::state` as their respective response keys over the same data source. + * + * Role gating (`context.role == Role::ADMIN`) is forwarded directly to + * `getServerInfo`, which uses it to suppress sensitive fields (amendment-majority + * warnings, node configuration) for non-admin callers. Authentication and role + * resolution are performed upstream by the RPC framework before this handler + * is ever invoked. + * + * The `counters` parameter is guarded with `isMember` before `asBool()` to + * avoid undefined behavior on a missing JSON field. If the field is present + * but not boolean, `asBool()` raises `Json::LogicError` — acceptable since + * such a request is malformed. + * + * @see doServerState Sibling handler emitting machine-friendly (`human=false`) + * representation of the same data under `jss::state`. + * @see NetworkOPs::getServerInfo The function that assembles the full response. + */ + #include #include diff --git a/src/xrpld/rpc/handlers/server_info/ServerState.cpp b/src/xrpld/rpc/handlers/server_info/ServerState.cpp index acf4e9eb43..0de7b19e9a 100644 --- a/src/xrpld/rpc/handlers/server_info/ServerState.cpp +++ b/src/xrpld/rpc/handlers/server_info/ServerState.cpp @@ -1,3 +1,34 @@ +/** @file + * Implements the `server_state` JSON-RPC handler (`doServerState`). + * + * This handler is the machine-readable counterpart to `ServerInfo.cpp`. + * Both handlers delegate all data assembly to `NetworkOPs::getServerInfo()` + * and differ only in the `human` flag passed to that function: + * - `doServerState` passes `human = false` — XRP amounts are returned as + * raw integer drop counts, fees as integer basis points, and timestamps as + * Unix epoch integers, suited for programmatic clients and monitoring tools. + * - `doServerInfo` passes `human = true` — the same fields are formatted with + * units and floating-point representations for human operators. + * + * The response is wrapped under the `jss::state` key; the parallel sibling + * uses `jss::info`. Both keys are compile-time constants from the `jss` + * namespace, preventing typo-class bugs at compile time. + * + * Role gating (`context.role == Role::ADMIN`) is resolved upstream by the + * RPC framework before this handler is invoked; passing the boolean directly + * means no trust decisions are made here. Admin callers receive additional + * fields such as peer counts, internal queue depths, and load factor details. + * + * The `counters` parameter opt-in is guarded with `isMember` before + * `asBool()` to avoid a `Json::LogicError` on a missing field. A malformed + * (non-boolean) value will raise `Json::LogicError`; an absent or falsy + * value silently opts out via short-circuit `&&`. + * + * @see doServerInfo Sibling handler emitting human-readable (`human=true`) + * representation of the same data under `jss::info`. + * @see NetworkOPs::getServerInfo The function that assembles the full response. + */ + #include #include @@ -7,6 +38,19 @@ namespace xrpl { +/** Handle the `server_state` RPC command. + * + * Returns machine-readable server status by delegating to + * `NetworkOPs::getServerInfo` with `human = false`. Numeric quantities + * (XRP amounts, fees, timestamps) are emitted as raw integers rather than + * human-friendly strings. The result is nested under the `jss::state` key + * in the returned JSON object. + * + * @param context RPC dispatch context carrying the JSON params, resolved + * role, and references to application subsystems. + * @return JSON object with a single `"state"` key whose value is the full + * server status payload from `NetworkOPs::getServerInfo`. + */ json::Value doServerState(RPC::JsonContext& context) { diff --git a/src/xrpld/rpc/handlers/server_info/Version.h b/src/xrpld/rpc/handlers/server_info/Version.h index c3ae5cf39f..5c576ffd24 100644 --- a/src/xrpld/rpc/handlers/server_info/Version.h +++ b/src/xrpld/rpc/handlers/server_info/Version.h @@ -4,20 +4,73 @@ namespace xrpl::RPC { +/** New-style class-based handler for the `version` RPC endpoint. + * + * Allows API clients to discover which API versions a node supports before + * making substantive requests. One of only two class-based ("new-style") + * handlers in the codebase — the other is `LedgerHandler`. All other RPC + * commands are registered as bare function pointers in the legacy + * `handlerArray` table in `Handler.cpp`. This class is registered via + * `HandlerTable::addHandler()` during singleton construction. + * + * The new-style handler contract: expose a constructor taking `JsonContext&`, + * a static `check()` method, a `writeResult()` method, and the static + * constexpr metadata fields (`name`, `minApiVer`, `maxApiVer`, `role`, + * `condition`). The `handlerFrom()` template bridges this shape into the + * `Handler` struct used by the dispatch infrastructure. + * + * `maxApiVer` is deliberately set to `kAPI_MAXIMUM_VALID_VERSION` (the beta + * ceiling) rather than `kAPI_MAXIMUM_SUPPORTED_VERSION`. This allows clients + * running against a beta-enabled node to reach this endpoint at API version 3 + * and discover that the beta version is available — if the cap were lower, the + * `HandlerTable` lookup at version 3 would return `nullptr` before the client + * could query capabilities. + * + * @note `role = USER` and `condition = NoCondition`: no admin credentials are + * required and network-sync/ledger-availability checks are bypassed. A node + * that is still syncing or amendment-blocked can still truthfully report the + * API versions it supports. + * + * @see setVersion() in `ApiVersion.h` for the format of the emitted JSON. + */ class VersionHandler { public: + /** Construct the handler, capturing version context from the request. + * + * Only `apiVersion` and the `BETA_RPC_API` config flag are needed; all + * other context fields are ignored by this handler. + * + * @param c The RPC dispatch context for the incoming request. + */ explicit VersionHandler(JsonContext& c) : apiVersion_(c.apiVersion), betaEnabled_(c.app.config().BETA_RPC_API) { } + /** Validate the request prior to result generation. + * + * Always succeeds — asking "what versions do you support?" is valid + * regardless of node state. + * + * @return `Status::kOK` unconditionally. + */ static Status check() { return Status::kOK; } + /** Populate the response object with supported API version information. + * + * Delegates to `setVersion()`. For API version 1 callers, emits legacy + * semver strings (`first`, `good`, `last` all `"1.0.0"`). For version 2+ + * callers, emits integer bounds: `first = kAPI_MINIMUM_SUPPORTED_VERSION`, + * `last = kAPI_BETA_VERSION` if beta is enabled, otherwise + * `kAPI_MAXIMUM_SUPPORTED_VERSION`. + * + * @param obj The JSON object into which the `version` sub-object is written. + */ void writeResult(json::Value& obj) const { @@ -25,14 +78,24 @@ public: } // NOLINTBEGIN(readability-identifier-naming) + /** RPC method name used for handler table registration and dispatch. */ static constexpr char const* name = "version"; + /** Lowest API version that routes to this handler (inclusive). */ static constexpr unsigned minApiVer = RPC::kAPI_MINIMUM_SUPPORTED_VERSION; + /** Highest API version that routes to this handler (inclusive). + * + * Set to `kAPI_MAXIMUM_VALID_VERSION` (the beta ceiling) so that clients + * can reach this discovery endpoint even when requesting at the beta + * version level. + */ static constexpr unsigned maxApiVer = RPC::kAPI_MAXIMUM_VALID_VERSION; + /** Minimum caller role; `USER` means no admin credentials are required. */ static constexpr Role role = Role::USER; + /** Node-state prerequisite; `NoCondition` bypasses all sync/ledger checks. */ static constexpr Condition condition = Condition::NoCondition; // NOLINTEND(readability-identifier-naming) diff --git a/src/xrpld/rpc/handlers/subscribe/Subscribe.cpp b/src/xrpld/rpc/handlers/subscribe/Subscribe.cpp index adad854eb9..33d7b6b57b 100644 --- a/src/xrpld/rpc/handlers/subscribe/Subscribe.cpp +++ b/src/xrpld/rpc/handlers/subscribe/Subscribe.cpp @@ -1,3 +1,12 @@ +/** @file + * Implements the `subscribe` RPC/WebSocket command handler. + * + * Registers a client connection as a listener on named event streams, account + * filters, or order books. The symmetric counterpart `doUnsubscribe` in + * Unsubscribe.cpp tears down these registrations using identical parameter + * parsing logic. + */ + #include #include #include @@ -26,6 +35,56 @@ namespace xrpl { +/** Register event subscriptions for a WebSocket or HTTP-callback client. + * + * Resolves the subscriber identity, then processes up to five independent + * subscription categories from the request: + * + * - **streams** — named fan-out queues (`ledger`, `transactions`, + * `transactions_proposed`, `validations`, `manifests`, `server`, + * `book_changes`, `consensus`, `peer_status`). + * - **accounts** — validated transaction notifications for specific accounts. + * - **accounts_proposed** — proposed (pre-validation) transaction notifications. + * - **books** — order book updates, with an optional current-state snapshot. + * - **account_history_tx_stream** — experimental historical + live replay for + * one account (requires `useTxTables()`). + * + * Subscriber identity is determined as follows: if the request contains a + * `url` parameter an `RPCSub` HTTP-push object is created (or reused from the + * server registry); otherwise `context.infoSub` — the WebSocket connection's + * `InfoSub` — is used directly. The `url` path requires `Role::ADMIN`. + * + * For book subscriptions the handler registers the live listener *before* + * reading the snapshot, eliminating the race where an offer update fires + * between the snapshot read and the subscription registration. Clients may + * therefore observe duplicates between the snapshot and the live stream, but + * will never miss an update. + * + * Every validation step returns immediately on failure without partial + * side effects, so the client either gets all requested subscriptions or + * an error with no state changed. + * + * @param context RPC dispatch context carrying the parsed request params, + * the `InfoSub` for the WebSocket connection (if any), the `NetworkOPs` + * reference used for all sub/unsub calls, and the resource consumer. + * @return JSON object with the initial subscription state; includes current + * ledger info for `ledger` stream, offer snapshots under `offers`/ + * `bids`/`asks` when `snapshot` is requested, and a `warning` field + * for `account_history_tx_stream`. Returns an `rpcError` object on any + * validation failure. + * @note Callers without an active WebSocket connection (plain HTTP) must + * supply a `url` parameter; without either, `rpcINVALID_PARAMS` is + * returned immediately. + * @note `rt_transactions` is a deprecated alias for `transactions_proposed`; + * `rt_accounts` is a deprecated alias for `accounts_proposed`; + * `both_sides` is a deprecated alias for `both`; `state_now` is a + * deprecated alias for `snapshot`. All aliases remain supported. + * @note The `book_changes` stream has no unsubscribe path; once registered, + * it cannot be removed for the lifetime of the connection. + * @note Book snapshot reads charge `feeMediumBurdenRPC` to the resource + * consumer, as does `account_history_tx_stream` subscription. + * @see doUnsubscribe + */ json::Value doSubscribe(RPC::JsonContext& context) { @@ -34,7 +93,6 @@ doSubscribe(RPC::JsonContext& context) if (!context.infoSub && !context.params.isMember(jss::url)) { - // Must be a JSON-RPC call. JLOG(context.j.info()) << "doSubscribe: RPC subscribe requires a url"; return rpcError(RpcInvalidParams); } diff --git a/src/xrpld/rpc/handlers/subscribe/Unsubscribe.cpp b/src/xrpld/rpc/handlers/subscribe/Unsubscribe.cpp index 36dae615b3..afa9b94baa 100644 --- a/src/xrpld/rpc/handlers/subscribe/Unsubscribe.cpp +++ b/src/xrpld/rpc/handlers/subscribe/Unsubscribe.cpp @@ -1,3 +1,12 @@ +/** @file + * Implements the `unsubscribe` RPC/WebSocket command handler. + * + * Tears down active push subscriptions previously registered by + * `doSubscribe`. The symmetric counterpart is Subscribe.cpp in the same + * directory. All state removal is delegated to `NetworkOPs` via + * `context.netOps`. + */ + #include #include #include @@ -17,6 +26,57 @@ namespace xrpl { +/** Remove active push subscriptions for a WebSocket or HTTP-callback client. + * + * Resolves the subscriber identity, then processes up to five independent + * subscription categories from the request, each gated by an `isMember` + * check so clients need only include the categories they want removed: + * + * - **streams** — named fan-out queues: `server`, `ledger`, `manifests`, + * `transactions`, `transactions_proposed` (alias: `rt_transactions`), + * `validations`, `peer_status`, `consensus`. Note: `book_changes` has no + * unsubscribe path; those subscriptions are implicitly torn down when the + * connection closes. + * - **accounts** — validated transaction notifications for specific accounts. + * - **accounts_proposed** (alias: `rt_accounts`) — proposed transaction + * notifications. + * - **books** — order book updates. `both`/`both_sides` (deprecated) triggers + * a second `unsubBook` call with the reversed book to match the symmetric + * subscribe behavior. Unlike `doSubscribe`, no `isConsistent` check is + * performed — a subscription must be removable regardless of current state. + * - **account_history_tx_stream** — experimental historical + live replay; + * the optional `stop_history_tx_only` boolean halts historical replay while + * preserving the live account subscription. + * + * Subscriber identity is determined as follows: if the request carries a + * `url` parameter, the existing `RPCSub` HTTP-push object is looked up by URL + * (requires `Role::ADMIN`); if not found the handler returns an empty success + * rather than an error — unsubscribing from a URL that was never registered + * is a no-op. Otherwise `context.infoSub` (the live WebSocket `InfoSub`) is + * used. A call with neither is rejected with `rpcINVALID_PARAMS`. + * + * When the `url` path is taken, `tryRemoveRpcSub` is called only *after* all + * individual `unsub*` operations complete. This ensures the subscriber is + * fully drained before the URL registration is cleaned up. + * + * @param context RPC dispatch context carrying the parsed request params, + * the `InfoSub` for the WebSocket connection (if any), the `NetworkOPs` + * reference used for all unsub calls, and the resolved `Role`. + * @return Empty JSON object on success. Returns an `rpcError` object on the + * first validation failure encountered: + * `rpcINVALID_PARAMS` for structural problems, + * `rpcNO_PERMISSION` for the admin-only URL path, + * `rpcSTREAM_MALFORMED` for unknown or non-string stream names, + * `rpcACT_MALFORMED` for unparseable account addresses, + * `rpcBAD_MARKET` for degenerate (same-asset) order books, + * `rpcDOMAIN_MALFORMED` for malformed domain hex. + * @note `peer_status` requires `Role::ADMIN` to *subscribe* but no role + * check to *unsubscribe* — removal is always safe regardless of + * permission level. + * @note `book_changes` cannot be unsubscribed via this handler; it is torn + * down automatically on connection close. + * @see doSubscribe + */ json::Value doUnsubscribe(RPC::JsonContext& context) { @@ -26,7 +86,6 @@ doUnsubscribe(RPC::JsonContext& context) if (!context.infoSub && !context.params.isMember(jss::url)) { - // Must be a JSON-RPC call. return rpcError(RpcInvalidParams); } @@ -75,7 +134,7 @@ doUnsubscribe(RPC::JsonContext& context) } else if ( streamName == "transactions_proposed" || - streamName == "rt_transactions") // DEPRECATED + streamName == "rt_transactions") { context.netOps.unsubRTTransactions(ispSub->getSeq()); } @@ -100,7 +159,7 @@ doUnsubscribe(RPC::JsonContext& context) auto accountsProposed = context.params.isMember(jss::accounts_proposed) ? jss::accounts_proposed - : jss::rt_accounts; // DEPRECATED + : jss::rt_accounts; if (context.params.isMember(accountsProposed)) { if (!context.params[accountsProposed].isArray()) @@ -188,7 +247,6 @@ doUnsubscribe(RPC::JsonContext& context) context.netOps.unsubBook(ispSub->getSeq(), book); - // both_sides is deprecated. if ((jv.isMember(jss::both) && jv[jss::both].asBool()) || (jv.isMember(jss::both_sides) && jv[jss::both_sides].asBool())) { diff --git a/src/xrpld/rpc/handlers/transaction/Simulate.cpp b/src/xrpld/rpc/handlers/transaction/Simulate.cpp index 6f7940d4aa..c602643a3b 100644 --- a/src/xrpld/rpc/handlers/transaction/Simulate.cpp +++ b/src/xrpld/rpc/handlers/transaction/Simulate.cpp @@ -1,3 +1,16 @@ +/** @file + * Implements the `simulate` RPC command for dry-run transaction execution. + * + * A client can test a transaction against the current ledger state and + * receive realistic metadata and a TER result code without broadcasting + * to the network, spending XRP, or persisting any state change. The + * mechanism is `TapDryRun`: after the full transactor execution path + * (including metadata generation), the snapshot of the open ledger is + * discarded rather than committed. + * + * @see doSimulate + */ + #include #include #include @@ -42,6 +55,24 @@ namespace xrpl { +/** Derive the sequence number to use when autofilling a simulated transaction. + * + * Queries `TxQ::nextQueuableSeq()` against the live open ledger to obtain a + * sequence number that is consistent with the account's current state. When + * the transaction carries a `TicketSequence`, the wire-format rule requires + * `Sequence = 0`, so that value is returned directly without a ledger lookup. + * + * @param txJson The transaction JSON object; must contain a valid `Account` + * field. + * @param context The RPC dispatch context providing access to the open ledger + * and transaction queue. + * @return The sequence number to insert, or an error JSON value if the source + * account does not exist in the current ledger and no `TicketSequence` is + * present, or if `Account` is malformed. + * @note The account-not-found branch returns `rpcSRC_ACT_NOT_FOUND`, not a + * fatal error — callers that supply `TicketSequence` bypass this check + * entirely. + */ static Expected getAutofillSequence(json::Value const& txJson, RPC::JsonContext& context) { @@ -75,6 +106,27 @@ getAutofillSequence(json::Value const& txJson, RPC::JsonContext& context) return hasTicketSeq ? 0 : context.app.getTxQ().nextQueuableSeq(sle).value(); } +/** Autofill signature-related fields on a transaction or signer object. + * + * Sets `SigningPubKey` and `TxnSignature` to empty strings when absent — + * the canonical XRPL representation of an unsigned transaction. The + * `TapDryRun` flag tells `Transactor::checkSign` to skip validation when + * these fields are empty. + * + * If the caller has pre-populated `TxnSignature` with a non-empty value + * (on the top-level transaction or on any element of the `Signers` array), + * the function returns `rpcTX_SIGNED` immediately. Simulate must not silently + * execute an already-signed transaction, as that would mislead the caller + * into believing signature validation passed. + * + * @param sigObject The transaction JSON object (or a `Signer` sub-object for + * multi-sign) to inspect and mutate. + * @return An error JSON value if the object is already signed or structurally + * invalid; `std::nullopt` on success. + * @note `Transactor.cpp` contains a defensive comment: "This code should + * never be hit because it's checked in the `simulate` RPC" — this + * function is the authoritative first line of defence. + */ static std::optional autofillSignature(json::Value& sigObject) { @@ -128,6 +180,25 @@ autofillSignature(json::Value& sigObject) return std::nullopt; } +/** Synthesize mandatory transaction fields that the caller omitted. + * + * Handles `Fee`, `Sequence`, `NetworkID`, and the signature fields in one + * pass. Each field is only written when absent — explicit caller values are + * preserved unchanged. + * + * Fee is computed last because `RPC::getCurrentNetworkFee()` may depend on + * other fields (e.g. transaction type) already being set. Sequence is filled + * via `getAutofillSequence()`, which queries the live open ledger. + * `NetworkID` is injected only when the network ID exceeds 1024, per the + * XRPL protocol rule that mainnet and other low-numbered networks omit the + * field. + * + * @param txJson The transaction JSON object to mutate in place. + * @param context RPC dispatch context providing access to fee-track, TxQ, + * and the network-ID service. + * @return An error JSON value if any autofill step fails (e.g. account not + * found, transaction already signed); `std::nullopt` on success. + */ static std::optional autofillTx(json::Value& txJson, RPC::JsonContext& context) { @@ -169,6 +240,22 @@ autofillTx(json::Value& txJson, RPC::JsonContext& context) return std::nullopt; } +/** Extract and normalize the transaction from the RPC request parameters. + * + * Accepts either `tx_blob` (hex-encoded binary) or `tx_json` (structured + * object), but not both. When `tx_blob` is supplied it is hex-decoded and + * deserialized through `SerialIter` into an `STObject`, then immediately + * re-serialized to JSON — this round-trip through canonical binary format + * normalizes the input before downstream processing. + * + * @param params The top-level RPC request parameters object. + * @return The extracted transaction as a JSON object, or a JSON error object + * (containing an `error` key) if both or neither input key is present, + * if `tx_blob` cannot be decoded or deserialized, or if the resulting + * object is missing `TransactionType` or `Account`. + * @note Mutual exclusion between `tx_blob` and `tx_json` is checked first + * and is fatal — the caller must not supply both. + */ static json::Value getTxJsonFromParams(json::Value const& params) { @@ -228,6 +315,31 @@ getTxJsonFromParams(json::Value const& params) return txJson; } +/** Execute a dry-run simulation of a transaction and assemble the response. + * + * Copies the current `OpenView` by value, then invokes `TxQ::apply()` with + * `TapDryRun`. The flag causes the full transactor execution path — including + * metadata generation — to run, but forces `applied = false` at the end of + * `Transactor::apply()` so the ledger snapshot is never committed. The copy + * is therefore discarded without side effects. + * + * The response always contains `applied`, `ledger_index`, `engine_result`, + * `engine_result_code`, and `engine_result_message`. For `tesSUCCESS` the + * message is overridden to "The simulated transaction would have been + * applied." to prevent the generic success string from implying the + * transaction was committed. When metadata is available it is serialized as + * `meta_blob` (hex) or `meta` (JSON) according to the `binary` parameter; + * JSON metadata receives the same three enrichment calls used in the live + * transaction pipeline (`insertDeliveredAmount`, `insertNFTSyntheticInJson`, + * `insertMPTokenIssuanceID`). The autofilled transaction is echoed back as + * `tx_blob` or `tx_json`. + * + * @param context RPC dispatch context providing access to the open + * ledger and transaction queue. + * @param transaction The fully constructed and autofilled transaction to + * simulate. + * @return A JSON object containing the simulation result. + */ static json::Value simulateTxn(RPC::JsonContext& context, std::shared_ptr transaction) { @@ -299,6 +411,39 @@ simulateTxn(RPC::JsonContext& context, std::shared_ptr transaction) return jvResult; } +/** Handler for the `simulate` RPC command. + * + * Validates the request, autofills any absent mandatory fields, and runs the + * transaction through the full engine execution path with `TapDryRun` so no + * state is committed. The caller receives a realistic TER result code and + * complete transaction metadata without broadcasting to the network or + * spending XRP. + * + * Accepted parameters: + * - `tx_blob` **XOR** `tx_json` — the transaction to simulate. + * - `binary` (bool, optional) — if true, metadata and transaction are + * returned as hex blobs (`meta_blob`, `tx_blob`) instead of JSON objects. + * + * Credential fields (`secret`, `seed`, `seed_hex`, `passphrase`) are + * explicitly rejected before any other processing — simulate is not a signing + * endpoint and must not be a channel for key material. + * + * `ttBATCH` transactions are rejected with `rpcNOT_IMPL`: batch execution + * semantics cannot be faithfully replicated by the single-transaction + * dry-run path. + * + * @param context RPC dispatch context for this call. + * @return JSON object containing `applied`, `ledger_index`, `engine_result*`, + * metadata (if generated), and the autofilled transaction. Returns a JSON + * error object on invalid input or if the transaction cannot be + * constructed. + * @note Resource cost is `feeMediumBurdenRPC` — a full engine execution is + * more expensive than a read-only query, though cheaper than a real + * submission that triggers peer propagation and queue management. + * @note The outer `try/catch` around `simulateTxn()` is marked `LCOV_EXCL` + * and is not expected to fire under normal conditions; it exists solely + * to prevent an unexpected exception from crashing the server. + */ // { // tx_blob: XOR tx_json: , // binary: diff --git a/src/xrpld/rpc/handlers/transaction/Submit.cpp b/src/xrpld/rpc/handlers/transaction/Submit.cpp index 774297391a..61af3510b6 100644 --- a/src/xrpld/rpc/handlers/transaction/Submit.cpp +++ b/src/xrpld/rpc/handlers/transaction/Submit.cpp @@ -1,3 +1,11 @@ +/** @file + * Implements the `submit` RPC command handler. + * + * Supports two mutually exclusive submission modes: a pre-signed binary blob + * (`tx_blob`) and a server-side signing path (`tx_json` + `secret`). The blob + * path is the production-safe mode; the signing path is deprecated. + */ + #include #include #include @@ -26,6 +34,19 @@ namespace xrpl { +/** Parse and validate the optional `fail_hard` request parameter. + * + * Converts the boolean `fail_hard` field from the request JSON into a + * `NetworkOPs::FailHard` enum value. When `fail_hard` is true, the network + * layer will reject the transaction outright if it cannot be applied to the + * current open ledger, rather than queuing it for a future ledger. Omitting + * the field produces lenient (non-hard-fail) behavior. + * + * @param context The RPC request context; `context.params` is inspected for + * `jss::fail_hard`. + * @return The resolved `NetworkOPs::FailHard` value on success, or an error + * JSON value if `fail_hard` is present but not a boolean. + */ static Expected getFailHard(RPC::JsonContext const& context) { @@ -37,10 +58,41 @@ getFailHard(RPC::JsonContext const& context) context.params.isMember(jss::fail_hard) && context.params[jss::fail_hard].asBool()); } -// { -// tx_blob: XOR tx_json: , -// secret: -// } +/** Handle the `submit` RPC command. + * + * Dispatches to one of two submission paths based on the presence of `tx_blob` + * in the request parameters: + * + * - **tx_blob path (primary):** The hex-encoded, pre-signed transaction binary + * is decoded, deserialized into an `STTx`, validated (signature and local + * rules), wrapped in a `Transaction`, and forwarded to `NetworkOPs` for + * application to the open ledger and P2P broadcast. + * - **tx_json path (deprecated):** Falls back to server-side signing via + * `RPC::transactionSubmit`. Requires `ADMIN` role or `canSign()` enabled in + * configuration. Every response from this path includes a `deprecated` + * warning. + * + * On success the response contains `tx_json`, `tx_blob`, and — when the + * network reached a deterministic result — `engine_result`, + * `engine_result_code`, `engine_result_message`, `accepted`, `applied`, + * `broadcast`, `queued`, `kept`, and advisory ledger-state fields + * (`account_sequence_next`, `account_sequence_available`, + * `open_ledger_cost`, `validated_ledger_index`). + * + * @param context The RPC request context. `context.params` must contain + * either `tx_blob` (hex string) or `tx_json` + `secret`. The optional + * `fail_hard` boolean controls queuing behaviour on rejection. + * @return A JSON object representing the submission outcome, or a structured + * error object. Possible error keys: `invalidTransaction` (bad binary or + * local-check failure), `internalSubmit` (exception during network + * dispatch), `internalJson` (exception during response serialization). + * @note When `context.app.checkSigs()` is false, signature verification is + * skipped via `forceValidity` (pre-marking the transaction as + * `SigGoodOnly` in the `HashRouter` cache). This is intended only for + * trusted internal submissions where the caller guarantees validity. + * @note Resource cost is registered as `feeMediumBurdenRPC` at entry, + * regardless of outcome. + */ json::Value doSubmit(RPC::JsonContext& context) { diff --git a/src/xrpld/rpc/handlers/transaction/SubmitMultiSigned.cpp b/src/xrpld/rpc/handlers/transaction/SubmitMultiSigned.cpp index 80ec0c6702..929a9f264e 100644 --- a/src/xrpld/rpc/handlers/transaction/SubmitMultiSigned.cpp +++ b/src/xrpld/rpc/handlers/transaction/SubmitMultiSigned.cpp @@ -8,10 +8,30 @@ namespace xrpl { -// { -// SigningAccounts , -// tx_json: , -// } +/** Entry point for the `submit_multisigned` RPC command. + * + * Classifies the request as `kFEE_HEAVY_BURDEN_RPC` (weight 3000) before + * doing any other work, so the resource manager can rate-limit the call even + * if it fails early. The handler itself is intentionally thin: it extracts + * the optional `fail_hard` boolean (defaults to `false` when absent), converts + * it to `NetworkOPs::FailHard` via `NetworkOPs::doFailHard`, then delegates + * all structural validation, cryptographic signature checking, ledger lookups, + * and network submission to `RPC::transactionSubmitMultiSigned`. + * + * The validated-ledger age is forwarded from `LedgerMaster` so the callee can + * refuse fee auto-fill when the node's view of the network is stale (>2 min). + * The `ProcessTransactionFn` closure produced by `getProcessTxnFn` captures + * `NetworkOPs` by reference, decoupling the business-logic layer from the full + * RPC context and enabling injection of a mock in unit tests. + * + * @param context RPC dispatch context carrying `params`, `role`, `apiVersion`, + * `ledgerMaster`, `netOps`, and the resource load tracker. + * @return JSON object with `tx_json`, `tx_blob`, and engine result fields on + * success, or a JSON error object on validation or submission failure. + * @note `fail_hard: true` rejects the transaction if it cannot be immediately + * applied to the open ledger; the default (`false`) allows it to be queued. + * @see RPC::transactionSubmitMultiSigned, RPC::getProcessTxnFn + */ json::Value doSubmitMultiSigned(RPC::JsonContext& context) { diff --git a/src/xrpld/rpc/handlers/transaction/TransactionEntry.cpp b/src/xrpld/rpc/handlers/transaction/TransactionEntry.cpp index bb68226334..adcd842ebf 100644 --- a/src/xrpld/rpc/handlers/transaction/TransactionEntry.cpp +++ b/src/xrpld/rpc/handlers/transaction/TransactionEntry.cpp @@ -13,13 +13,49 @@ namespace xrpl { -// { -// ledger_hash : , -// ledger_index : -// } -// -// XXX In this case, not specify either ledger does not mean ledger current. It -// means any ledger. +/** Retrieve a transaction and its metadata from a specific, caller-identified ledger. + * + * Implements the `transaction_entry` RPC command. Unlike `tx` (which searches all + * available history by hash alone), this handler requires the caller to name the + * containing ledger via `ledger_hash` or `ledger_index`. Omitting both fields is + * NOT equivalent to requesting the current ledger — `lookupLedger` will resolve + * to an arbitrary ledger, and the open-ledger guard below will then reject it + * with `notYetImplemented`. Callers must always supply an explicit ledger specifier. + * + * The validation chain is layered from cheapest to most expensive: + * 1. Ledger resolution — `RPC::lookupLedger` validates the specifier and populates + * `lpLedger`; returns the error immediately if the ledger cannot be resolved. + * 2. `tx_hash` presence — absence yields `fieldNotFoundTransaction`. + * 3. Open/current ledger rejection — if `lookupLedger` did not set `ledger_hash` + * in `jvResult` the resolved ledger is open/current, which is unsupported; + * yields `notYetImplemented`. + * 4. Hex parsing — `parseHex` on the `tx_hash` string; invalid input yields + * `malformedRequest`. In practice `parseHex` rejects any non-hex or wrong-length + * value, so this is the effective input-validation gate. + * 5. Transaction existence — `txRead` against the `ReadView`; absence yields + * `transactionNotFound`. + * + * Response shape diverges by API version: + * - **v1**: `tx_json` with legacy serialisation (`JsonOptions::none`); metadata + * key is `metadata`; `hash` is embedded inside `tx_json`. + * - **v2+**: `tx_json` with `DisableApiPriorV2` serialisation; top-level `hash`, + * `ledger_hash` (closed ledgers only), `validated`, `ledger_index`, and + * `close_time_iso` (validated ledgers only); metadata key is `meta`. + * For Payment transactions both versions have `insertDeliverMax` applied, which + * copies (v1) or renames (v2+) `Amount` to `DeliverMax`. + * + * @param context RPC dispatch context carrying `params`, `apiVersion`, + * `ledgerMaster`, and the resource consumer for this request. + * @return JSON object with `tx_json` and metadata on success, or an error + * object whose `error` field is one of: `fieldNotFoundTransaction`, + * `notYetImplemented`, `malformedRequest`, `transactionNotFound`, or + * any error code propagated by `RPC::lookupLedger`. + * @note `stobj` (transaction metadata) may legitimately be null for + * transactions in open ledgers — metadata is produced only at close. + * The open-ledger guard above makes this case unreachable in practice, + * but the serialisation step is still guarded by a null check for safety. + * @see doTxJson for the broader sibling command that searches all history. + */ json::Value doTransactionEntry(RPC::JsonContext& context) { @@ -35,16 +71,11 @@ doTransactionEntry(RPC::JsonContext& context) } else if (jvResult.get(jss::ledger_hash, json::ValueType::Null).isNull()) { - // We don't work on ledger current. - - // XXX We don't support any transaction yet. jvResult[jss::error] = "notYetImplemented"; } else { uint256 uTransID; - // XXX Relying on trusted WSS client. Would be better to have a strict - // routine, returning success or failure. if (!uTransID.parseHex(context.params[jss::tx_hash].asString())) { jvResult[jss::error] = "malformedRequest"; @@ -89,9 +120,6 @@ doTransactionEntry(RPC::JsonContext& context) auto const jsonMeta = (context.apiVersion > 1 ? jss::meta : jss::metadata); if (stobj) jvResult[jsonMeta] = stobj->getJson(JsonOptions::Values::None); - // 'accounts' - // 'engine_...' - // 'ledger_...' } } diff --git a/src/xrpld/rpc/handlers/transaction/Tx.cpp b/src/xrpld/rpc/handlers/transaction/Tx.cpp index 099caeac42..1bf9841290 100644 --- a/src/xrpld/rpc/handlers/transaction/Tx.cpp +++ b/src/xrpld/rpc/handlers/transaction/Tx.cpp @@ -1,3 +1,18 @@ +/** @file + * Implements `doTxJson`, the server-side handler for the XRPL `tx` RPC method. + * + * A client sends a transaction hash or a Concise Transaction ID (CTID) and + * receives back the full transaction, its execution metadata, and validation + * status. The file is organised into three tightly scoped phases: + * + * 1. **`doTxJson`** — argument parsing and network-ID validation. + * 2. **`doTxHelp`** — ledger-range checks, database lookup via + * `TransactionMaster::fetch`, and CTID re-encoding. + * 3. **`populateJsonResponse`** — pure serialization; reads only from + * `TxResult`, never touches the database or ledger state. + * + * @see RPC::decodeCTID, RPC::encodeCTID, TransactionMaster::fetch + */ #include #include #include @@ -37,6 +52,24 @@ namespace xrpl { +/** Determine whether a closed ledger identified by sequence and hash is fully validated. + * + * Applies three sequential guards before concluding a ledger is validated: + * (1) the local store must actually hold the ledger, (2) its sequence must + * not exceed the current validated ledger's sequence (a closed-but-not-yet- + * validated ledger would fail here), and (3) the stored hash for that + * sequence must match the supplied hash — the final guard against ledgers + * that were present but later reorganised out of the canonical chain. + * + * @param ledgerMaster The ledger master instance used to query local state. + * @param seq The ledger sequence number to check. + * @param hash The expected ledger hash at @p seq. + * @return `true` only when all three guards pass; `false` otherwise. + * + * @note This is intentionally stricter than merely checking `haveLedger`: + * a node can hold a closed ledger whose sequence is still above the + * validated frontier, in which case `"validated": true` would be wrong. + */ static bool isValidated(LedgerMaster& ledgerMaster, std::uint32_t seq, uint256 const& hash) { @@ -49,25 +82,121 @@ isValidated(LedgerMaster& ledgerMaster, std::uint32_t seq, uint256 const& hash) return ledgerMaster.getHashBySeq(seq) == hash; } +/** Carries the raw output of a `tx` lookup across the phase boundary into the + * response-serialization layer. + * + * All fields except `txn` and `searchedAll` are populated only when the + * transaction was located in a closed ledger and the requisite data was + * available. + * + * The `meta` field is a discriminated union: in binary mode `doTxHelp` + * serializes `TxMeta` to a raw `Blob` up front, so the response layer never + * needs to re-serialize. In non-binary mode the `shared_ptr` is + * stored directly. + * + * `searchedAll` defaults to `TxSearched::Unknown` (field absent from + * response) and is only set to `All` or `Some` when a ledger-range search + * was requested and the transaction was not found. + */ struct TxResult { + /** The located transaction object, or null if lookup failed. */ Transaction::pointer txn; + + /** Execution metadata: `Blob` in binary mode, `shared_ptr` otherwise. + * Empty (default-constructed variant) until `doTxHelp` populates it. */ std::variant, Blob> meta; + + /** Whether the containing ledger has been validated by consensus. */ bool validated = false; + + /** Re-encoded CTID for this transaction, absent if any component exceeds + * its bit budget (`txnIdx > 0xFFFF`, `netID >= 0xFFFF`, or + * `lgrSeq >= 0x0FFF'FFFF`). */ std::optional ctid; + + /** Close time of the validated ledger that included this transaction. + * Populated only when `validated` is true. */ std::optional closeTime; + + /** Hash of the closed ledger that included this transaction. + * Absent for open ledgers. */ std::optional ledgerHash; + + /** Completeness of the range search. `Unknown` suppresses the + * `searched_all` field in the response for backward compatibility. */ TxSearched searchedAll = TxSearched::Unknown; }; +/** Parsed and validated inputs for a single `tx` RPC call. + * + * Exactly one of `hash` or `ctid` will be non-empty after `doTxJson` + * finishes parsing; supplying both is rejected before this struct is + * populated. If `ctid` is present, `doTxHelp` resolves it to a hash via + * `LedgerMaster::txnIdFromIndex` before dispatching to `TransactionMaster`. + */ struct TxArgs { + /** Raw transaction hash supplied via `"transaction"` param, or the hash + * resolved from a CTID by `doTxHelp`. */ std::optional hash; + + /** Decoded CTID as `{ledgerSeq, txnIndex}`. The network ID has already + * been validated against the local node before this struct is used. */ std::optional> ctid; + + /** Whether to return transaction and metadata in binary (hex) form. */ bool binary = false; + + /** Optional inclusive ledger-sequence range `{min_ledger, max_ledger}`. + * When present, the lookup is restricted to this range and `searched_all` + * is reported in the response. Range span is capped at 1000 ledgers. */ std::optional> ledgerRange; }; +/** Resolve and fetch a transaction from the local database. + * + * Performs ledger-range validation, hash resolution, database lookup, and + * CTID re-encoding in sequence: + * + * 1. **Range validation.** If `args.ledgerRange` is set, checks that the + * range is non-inverted and spans no more than 1000 ledgers + * (`kMAX_RANGE`). Returns `RpcInvalidLgrRange` or + * `RpcExcessiveLgrRange` on violation. + * + * 2. **Hash resolution.** If `args.ctid` is set, calls + * `LedgerMaster::txnIdFromIndex` to convert `{ledgerSeq, txnIndex}` + * into a transaction hash. A successful resolution also collapses the + * search range to the single ledger named by the CTID. If the index + * lookup returns no hash, `RpcTxnNotFound` is returned immediately. + * + * 3. **Database fetch.** Calls the appropriate overload of + * `TransactionMaster::fetch`: the range-bounded overload when a + * `ledgerRange` was supplied, the full-history overload otherwise. + * Both return `std::variant`. A `TxSearched` + * result means the transaction was absent; the sentinel is forwarded + * into `TxResult::searchedAll` and `RpcTxnNotFound` is returned. + * + * 4. **Metadata serialization.** In binary mode, `TxMeta` is serialized + * to a raw `Blob` here — avoiding re-serialization in the response + * phase. In non-binary mode the `shared_ptr` is stored as-is. + * + * 5. **Validation check.** Calls `isValidated` with the ledger's sequence + * and hash. Only if validated is the close time fetched and stored. + * + * 6. **CTID re-encoding.** Attempts to encode a CTID from the found + * transaction's `sfTransactionIndex`, the ledger sequence, and the + * local network ID. Omits the field silently if any component exceeds + * its bit budget rather than returning a truncated value. + * + * @param context RPC dispatch context providing access to app services. + * @param args Validated input arguments from `doTxJson`. + * @return A pair of `TxResult` (populated on success) and `RPC::Status` + * indicating success or the specific failure reason. + * + * @note This function must not emit JSON directly; all output goes through + * `TxResult` fields consumed by `populateJsonResponse`. + */ std::pair doTxHelp(RPC::Context& context, TxArgs args) { @@ -133,10 +262,12 @@ doTxHelp(RPC::Context& context, TxArgs args) return {result, RpcTxnNotFound}; } - // populate transaction data result.txn = txn; if (txn->getLedger() == 0) { + // Transaction is known but not yet included in any closed ledger + // (e.g., still in the transaction queue). Return what we have with + // no metadata or validation status. return {result, RpcSuccess}; } @@ -161,7 +292,9 @@ doTxHelp(RPC::Context& context, TxArgs args) if (result.validated) result.closeTime = context.ledgerMaster.getCloseTimeBySeq(txn->getLedger()); - // compute outgoing CTID + // Attempt to re-encode a CTID. All three components must fit within + // their respective bit budgets; if any exceeds the limit the field is + // simply omitted rather than emitting a truncated or invalid value. if (meta->getAsObject().isFieldPresent(sfTransactionIndex)) { uint32_t const lgrSeq = ledger->header().seq; @@ -176,6 +309,43 @@ doTxHelp(RPC::Context& context, TxArgs args) return {result, RpcSuccess}; } +/** Build the final JSON response for a `tx` RPC request. + * + * Pure serialization layer — must not access the database, ledger master, + * or any other application service. All required data is already in `res`. + * + * **Error path.** When `res.second` indicates failure, the error is injected + * via `RPC::Status::inject`. The one special case is `RpcTxnNotFound` with + * a known `searchedAll` value (i.e., a range-bounded search that concluded): + * in that case a `searched_all` boolean is added before the error fields so + * the client can distinguish genuine absence from an incomplete local history. + * + * **API version branching.** For API v2+, the response is restructured: + * - Transaction goes under `"tx_json"` (non-binary) or `"tx_blob"` (binary). + * - `"ledger_hash"`, `"hash"`, and `"close_time_iso"` become top-level fields. + * - `JsonOptions::disable_API_prior_V2` suppresses deprecated legacy fields. + * For API v1 the legacy flat layout from `Transaction::getJson` is used. + * + * **Metadata enrichment.** Three post-processing calls augment the `"meta"` + * JSON object after core serialization: + * - `insertDeliveredAmount` — fills `delivered_amount` for payments and + * check-cash transactions. + * - `RPC::insertNFTSyntheticInJson` — adds NFT-specific synthetic fields. + * - `RPC::insertMPTokenIssuanceID` — injects `mpt_issuance_id` for + * `MPTokenIssuanceCreate` transactions. + * These are applied only in non-binary mode where `result.meta` holds a + * `shared_ptr`. + * + * @param res The lookup result produced by `doTxHelp`. + * @param args The original parsed arguments (used for `binary` flag and + * API-version-neutral field selection). + * @param context RPC context supplying `apiVersion` for branching logic. + * @return A `Json::Value` object ready to be returned to the caller. + * + * @note `result.ledgerHash` is only populated for closed or validated + * ledgers (never for open ones), so `"ledger_hash"` is correctly absent + * when the transaction is pending. + */ json::Value populateJsonResponse( std::pair const& res, @@ -185,7 +355,6 @@ populateJsonResponse( json::Value response; RPC::Status const& error = res.second; TxResult const& result = res.first; - // handle errors if (error.toErrorCode() != RpcSuccess) { if (error.toErrorCode() == RpcTxnNotFound && result.searchedAll != TxSearched::Unknown) @@ -199,7 +368,6 @@ populateJsonResponse( error.inject(response); } } - // no errors else if (result.txn) { auto const& sttx = result.txn->getSTransaction(); @@ -219,8 +387,6 @@ populateJsonResponse( response[jss::tx_json], sttx->getTxnType(), context.apiVersion); } - // Note, result.ledgerHash is only set in a closed or validated - // ledger - as seen in `doTxHelp` if (result.ledgerHash) response[jss::ledger_hash] = to_string(*result.ledgerHash); @@ -239,14 +405,12 @@ populateJsonResponse( RPC::insertDeliverMax(response, sttx->getTxnType(), context.apiVersion); } - // populate binary metadata if (auto blob = std::get_if(&result.meta)) { XRPL_ASSERT(args.binary, "xrpl::populateJsonResponse : binary is set"); auto jsonMeta = (context.apiVersion > 1 ? jss::meta_blob : jss::meta); response[jsonMeta] = strHex(makeSlice(*blob)); } - // populate meta data else if (auto m = std::get_if>(&result.meta)) { auto& meta = *m; @@ -266,21 +430,45 @@ populateJsonResponse( return response; } +/** Entry point for the `tx` RPC method — parses arguments and drives the lookup pipeline. + * + * Validates that exactly one of `"transaction"` (a 64-hex-character hash) or + * `"ctid"` (a 16-hex-character Concise Transaction ID) is present. Supplying + * both is rejected with `rpcINVALID_PARAMS` because the intent would be + * ambiguous. Supplying neither is also rejected. + * + * **CTID network-ID check.** A CTID encodes the network it was issued on. If + * the decoded `networkID` does not match `NetworkIDService::getNetworkID()`, a + * human-readable error is returned naming the expected network rather than + * silently doing a hash lookup on a different chain. + * + * **Ledger range.** When both `"min_ledger"` and `"max_ledger"` are present + * they are extracted via `Json::Value::asUInt`, which throws on type mismatch. + * The `try`/`catch` converts that exception into `rpcINVALID_LGR_RANGE`; the + * range span limit (1000 ledgers) is enforced later in `doTxHelp`. + * + * **Transaction-table guard.** The handler returns `rpcNOT_ENABLED` + * immediately when `config().useTxTables()` is false — the relational + * database tables that back the lookup are not present. + * + * After argument parsing, delegates to `doTxHelp` for the database lookup + * and then to `populateJsonResponse` for serialization. + * + * @param context RPC dispatch context carrying JSON params, app services, + * and the resolved API version. + * @return A `Json::Value` with the transaction, metadata, and validation + * status, or an error object if any validation or lookup step fails. + */ json::Value doTxJson(RPC::JsonContext& context) { if (!context.app.config().useTxTables()) return rpcError(RpcNotEnabled); - // Deserialize and validate JSON arguments - TxArgs args; if (context.params.isMember(jss::transaction) && context.params.isMember(jss::ctid)) - { - // specifying both is ambiguous return rpcError(RpcInvalidParams); - } if (context.params.isMember(jss::transaction)) { @@ -322,7 +510,7 @@ doTxJson(RPC::JsonContext& context) } catch (...) { - // One of the calls to `asUInt ()` failed. + // asUInt() throws if the JSON value is not an unsigned integer. return rpcError(RpcInvalidLgrRange); } } diff --git a/src/xrpld/rpc/handlers/transaction/TxHistory.cpp b/src/xrpld/rpc/handlers/transaction/TxHistory.cpp index 75d03951a4..4179825606 100644 --- a/src/xrpld/rpc/handlers/transaction/TxHistory.cpp +++ b/src/xrpld/rpc/handlers/transaction/TxHistory.cpp @@ -12,9 +12,51 @@ namespace xrpl { -// { -// start: -// } +/** @file + * Implements the `tx_history` RPC command, a paginated raw transaction log + * browser backed by the node's relational database. + * + * This command is available only in API version 1; API v2+ returns + * `unknownCmd` via the handler table's version range restriction. + */ + +/** Return a page of up to 20 historical transactions starting at a given + * offset, sorted in descending ledger-sequence order. + * + * The handler is a thin bridge between the RPC dispatch layer and + * `RelationalDatabase::getTxHistory()`. It performs no filtering by account + * or transaction type; it is a raw log browser, not a query engine. + * + * Validation sequence (short-circuits on first failure): + * 1. `useTxTables()` config flag — returns `rpcNOT_ENABLED` if absent. + * 2. Presence of the `start` field — returns `rpcINVALID_PARAMS` if missing. + * 3. Deep-pagination cap: `start > 10000` is rejected with `rpcNO_PERMISSION` + * unless `isUnlimited(context.role)` is true (ADMIN / IDENTIFIED roles). + * + * After validation, `getRelationalDatabase().getTxHistory(startIndex)` is + * called. The database layer enforces the 20-transaction page size. Each + * transaction is serialized via `getJson(JsonOptions::None)` and enriched + * with `RPC::insertDeliverMax()` to inject the `DeliverMax` field (and, for + * API version > 1, remove the legacy `Amount` field) on Payment transactions, + * consistent with the enrichment applied in `Tx.cpp` and + * `TransactionEntry.cpp`. + * + * The response echoes the requested `index` value so callers can track their + * pagination position across calls. + * + * @note `context.params[jss::start].asUInt()` silently converts non-integer + * JSON values to `0` rather than returning a type error. A `start` of + * `0` is valid, so this is harmless in practice and is consistent with + * how other XRPL RPC handlers treat loosely-typed JSON integers. + * @note This command is retired in API v2 and later. Clients on v2+ receive + * `unknownCmd` from the handler table before this function is called. + * @note The resource load type is set to `kFEE_MEDIUM_BURDEN_RPC` because + * the backing call is a database scan rather than a point lookup. + * @param context The RPC dispatch context carrying the request parameters, + * application state, caller role, and API version. + * @return A JSON object with `"index"` (the requested start offset) and + * `"txs"` (array of transaction JSON objects), or an RPC error object. + */ json::Value doTxHistory(RPC::JsonContext& context) { diff --git a/src/xrpld/rpc/handlers/transaction/TxReduceRelay.cpp b/src/xrpld/rpc/handlers/transaction/TxReduceRelay.cpp index edc4eff057..75e83dd991 100644 --- a/src/xrpld/rpc/handlers/transaction/TxReduceRelay.cpp +++ b/src/xrpld/rpc/handlers/transaction/TxReduceRelay.cpp @@ -1,3 +1,13 @@ +/** @file + * RPC handler for the `tx_reduce_relay` command. + * + * Provides a read-only introspection endpoint that surfaces transaction relay + * performance metrics collected by the node's peer overlay network. The + * handler is registered with `Role::USER` and `NO_CONDITION`, so any connected + * client may query it without special privileges or an active network + * connection requirement. + */ + #include #include #include @@ -6,6 +16,38 @@ namespace xrpl { +/** Return a rolling-average snapshot of transaction reduce-relay metrics. + * + * Delegates unconditionally to `Overlay::txMetrics()`, which serializes the + * `metrics::TxMetrics` struct maintained by `OverlayImpl` into a JSON object. + * There is no input parsing, no parameter validation, and no transformation of + * the result — the `Json::Value` produced by the overlay is returned directly + * to the caller. + * + * The returned object contains thirteen `jss::txr_*`-keyed fields covering: + * - Per-second rolling averages (message count and byte size) for the five + * tracked protocol message types: `TMTransaction`, `TMHaveTransactions`, + * `TMGetLedger`, `TMLedgerData`, and `TMTransactions`. + * - Per-transaction sample averages for selected, suppressed, and + * feature-disabled peers — the three-way partition produced by the relay + * algorithm each time a transaction is forwarded. + * - A per-second rate of transactions received in `TMTransactions` bundles + * that the local node was missing, indicating reactive-request load induced + * by relay suppression. + * + * All values reflect a trailing ~30-second exponential window. Fields with + * no recent activity report `"0"`. Values are serialized as JSON strings + * rather than JSON integers; callers must parse them for numeric comparison. + * + * Thread safety and data correctness are the responsibility of `TxMetrics` + * (which guards its state with an internal `mutex`) and `OverlayImpl` (which + * additionally serializes writes via its Boost.Asio strand). + * + * @param context Standard RPC dispatch context; only `context.app` is used to + * reach the overlay via `getOverlay()`. + * @return A `Json::Value` object containing the current reduce-relay metric + * snapshot, as produced by `Overlay::txMetrics()`. + */ json::Value doTxReduceRelay(RPC::JsonContext& context) { diff --git a/src/xrpld/rpc/handlers/utility/Ping.cpp b/src/xrpld/rpc/handlers/utility/Ping.cpp index 0d34b9e0fc..28d378d35c 100644 --- a/src/xrpld/rpc/handlers/utility/Ping.cpp +++ b/src/xrpld/rpc/handlers/utility/Ping.cpp @@ -10,6 +10,43 @@ namespace RPC { struct JsonContext; } // namespace RPC +/** Handle the `ping` RPC command, returning a role-conditional session + * introspection object. + * + * The response is sparse by design — only the fields relevant to the + * caller's privilege level are emitted: + * + * - `Role::ADMIN`: sets `"role": "admin"`. + * - `Role::IDENTIFIED`: sets `"role": "identified"`, `"username"` from + * `context.headers.user`, and `"ip"` from `context.headers.forwardedFor` + * when non-empty. This role is assigned when a `secure_gateway` proxy + * has authenticated a downstream user via the `X-User` HTTP header. + * - `Role::PROXY`: sets `"role": "proxied"` and `"ip"` when non-empty. + * The client is behind a gateway but has not been individually identified. + * - `Role::GUEST`, `Role::USER`, `Role::FORBID`: fall through to `default`, + * producing an empty object. These callers learn nothing about their + * classification from the response. + * + * The `"ip"` field is written only when `forwardedFor` is non-empty, so + * direct connections never receive a spurious `"ip": ""` key. + * + * For WebSocket sessions, `context.infoSub` is non-null and an additional + * `"unlimited": true` field is emitted when the consumer is exempt from + * resource throttling. HTTP callers always receive a null `infoSub` and + * therefore never see this field; the null check is the sole guard. + * + * All JSON keys are referenced via `jss::` namespace constants rather than + * raw string literals, ensuring a single point of change across the codebase. + * + * @param context The JSON-RPC dispatch envelope, pre-populated with the + * resolved `role`, `headers` (`user`, `forwardedFor`), and `infoSub` + * by the HTTP/WebSocket dispatch layer before this handler is called. + * @return A `json::Value` object containing the applicable session fields, + * or an empty object for unprivileged / unclassified callers. + * @note `role` is resolved by `requestRole()` (in `Role.h`) before dispatch, + * so this handler contains no authorization logic of its own. + * @see Role.h, Context.h + */ json::Value doPing(RPC::JsonContext& context) { @@ -33,7 +70,6 @@ doPing(RPC::JsonContext& context) default:; } - // This is only accessible on ws sessions. if (context.infoSub) { if (context.infoSub->getConsumer().isUnlimited()) diff --git a/src/xrpld/rpc/handlers/utility/Random.cpp b/src/xrpld/rpc/handlers/utility/Random.cpp index 56df442cf1..4c0965bcb4 100644 --- a/src/xrpld/rpc/handlers/utility/Random.cpp +++ b/src/xrpld/rpc/handlers/utility/Random.cpp @@ -14,15 +14,51 @@ namespace RPC { struct JsonContext; } // namespace RPC -// Result: -// { -// random: -// } +/** Handler for the `random` RPC command. + * + * Draws 256 bits of entropy from the node's cryptographically secure PRNG + * and returns the value to the caller as a 64-character lowercase hex string + * under the `random` key. + * + * Registered in `Handler.cpp` as: + * @code + * {"random", byRef(&doRandom), Role::USER, NO_CONDITION} + * @endcode + * + * The `Role::USER` designation allows any connected client — not just + * admins — to invoke it. `NO_CONDITION` means no ledger state is required; + * the handler runs unconditionally because it never touches ledger data. + * + * **Algorithm:** + * 1. Declare a zero-initialised `uint256` (32 bytes). + * 2. Fill it via `beast::rngfill` using `cryptoPrng()`, which returns a + * reference to the process-wide mutex-protected `csprng_engine` singleton. + * The engine seeds from `std::random_device` and satisfies the C++ + * `UniformRandomNumberEngine` named requirement. + * 3. Serialise the filled value to hex via `to_string()` and return it under + * `jss::random`. + * + * **Exception handling:** The body is wrapped in a `try/catch` that returns + * `rpcError(RpcInternal)` on any `std::exception`. This guard is a legacy + * defensive pattern — the RPC dispatch layer already has a top-level catch — + * and the branch is marked `LCOV_EXCL_LINE` because it is never expected to + * execute in practice. + * + * **Use case:** Clients that want a trusted external entropy source (e.g., for + * combined randomness schemes) can call this endpoint and mix the result with + * locally generated entropy. The trust model is that the client trusts the + * node operator's CSPRNG seeding. + * + * @param context The RPC dispatch context. Its contents are not read by this + * handler; the parameter exists only to satisfy the handler signature + * contract. + * @return A JSON object `{ "random": "<64-char hex string>" }` on success, + * or `rpcError(RpcInternal)` if an unexpected exception propagates out + * of `beast::rngfill` (effectively unreachable). + */ json::Value doRandom(RPC::JsonContext& context) { - // TODO(tom): the try/catch is almost certainly redundant, we catch at the - // top level too. try { uint256 rand; diff --git a/src/xrpld/rpc/json_body.h b/src/xrpld/rpc/json_body.h index 9f881cacbc..8cf98bd271 100644 --- a/src/xrpld/rpc/json_body.h +++ b/src/xrpld/rpc/json_body.h @@ -1,3 +1,14 @@ +/** @file + * Custom Boost.Beast HTTP body type that holds a `Json::Value` as the + * in-memory representation and serializes it to wire bytes on demand. + * + * Used by `OverlayImpl` (diagnostic endpoints: `/crawl`, `/health`, `/vl/`) + * and `ServerHandler` (RPC dispatch layer) when building outbound HTTP + * responses. There is intentionally no BodyWriter (deserialization) — every + * call site uses `JsonBody` only for responses; incoming request bodies are + * parsed separately via `Json::Reader`. + */ + #pragma once #include @@ -8,13 +19,40 @@ namespace xrpl { -/// Body that holds JSON +/** Boost.Beast HTTP body type whose in-memory value is a `Json::Value`. + * + * Satisfies the Beast `Body` concept by providing a `value_type` alias and + * nested `reader`/`writer` classes that both implement the Beast BodyReader + * interface (serialization only — there is no deserialization path). + * + * The struct itself carries no data; Beast only instantiates `reader` and + * `writer` internally. The `explicit` default constructor suppresses accidental + * implicit construction while preserving the Beast concept requirements. + * + * @note Both `reader` and `writer` serialize eagerly at construction time, so + * by the time Beast's async I/O layer requests bytes they are already + * available. Neither class defers or streams output incrementally. + * @see JsonBody::reader, JsonBody::writer + */ struct JsonBody { explicit JsonBody() = default; + /** The in-memory body representation used by Beast message types. */ using value_type = json::Value; + /** Old-style Beast BodyReader that serializes a `Json::Value` into a + * `boost::beast::multi_buffer` using `Json::stream()`. + * + * The entire JSON payload is written into `buffer_` at construction time + * via a chunked-write callback. Subsequent calls to `init()`, `get()`, + * and `finish()` are therefore trivial. `is_deferred = std::false_type` + * informs Beast that buffer preparation does not need to be deferred. + * + * This older constructor form takes the full `http::message` (header + + * body together), as opposed to the newer split-argument form used by + * `writer`. + */ class reader // NOLINT(readability-identifier-naming) -- Boost.Beast body concept name { using dynamic_buffer_type = boost::beast::multi_buffer; @@ -22,10 +60,22 @@ struct JsonBody dynamic_buffer_type buffer_; public: + /** Buffer sequence type returned by `get()`. Satisfies the + * Beast `ConstBufferSequence` requirement; may span multiple + * discontiguous memory regions. */ using const_buffers_type = typename dynamic_buffer_type::const_buffers_type; + /** Tells Beast that buffer preparation is not deferred; the buffer is + * fully populated by the constructor before `init()` is called. */ using is_deferred = std::false_type; + /** Construct the reader and eagerly serialize `m.body()` into an + * internal `multi_buffer` via `Json::stream()`. + * + * @tparam IsRequest Whether the enclosing message is a request. + * @tparam Fields The HTTP header fields type. + * @param m The HTTP message whose body is to be serialized. + */ template explicit reader(boost::beast::http::message const& m) { @@ -35,32 +85,66 @@ struct JsonBody }); } + /** No-op: the buffer is already fully populated by the constructor. + * + * @param ec Unused; not modified. + */ void init(boost::beast::error_code&) noexcept { } - // get() must return a boost::optional (not a std::optional) to meet - // requirements of a boost::beast::BodyReader. + /** Return all serialized bytes in a single call. + * + * Returns `boost::optional` (not `std::optional`) to satisfy the + * Beast BodyReader concept. The `bool` element of the pair is `false`, + * signaling that all data is available and no further `get()` calls + * are needed. + * + * @param ec Unused; not modified. + * @return An optional pair of (buffer sequence, more-data flag). The + * more-data flag is always `false`. + */ boost::optional> get(boost::beast::error_code& ec) { return {{buffer_.data(), false}}; } + /** No-op: no resources require explicit release. + * + * @param ec Unused; not modified. + */ void finish(boost::beast::error_code&) { } }; + /** Newer-style Beast BodyReader that serializes a `Json::Value` into a + * single `std::string` using `Json::to_string()`. + * + * The constructor takes the HTTP header and body value as separate + * arguments (the newer Beast BodyReader interface), rather than the full + * message. The entire JSON payload is produced in one allocation at + * construction time and exposed as a `boost::asio::const_buffer`. + */ class writer // NOLINT(readability-identifier-naming) -- Boost.Beast body concept name { std::string body_string_; public: + /** Single contiguous buffer wrapping `body_string_`. */ using const_buffers_type = boost::asio::const_buffer; + /** Construct the writer and eagerly serialize `value` to a string. + * + * @tparam IsRequest Whether the enclosing message is a request. + * @tparam Fields The HTTP header fields type. + * @param fields The HTTP header (unused; present to satisfy the + * Beast BodyReader concept). + * @param value The JSON value to serialize. + */ template explicit writer( boost::beast::http::header const& fields, @@ -69,14 +153,29 @@ struct JsonBody { } + /** Clear `ec` to signal that initialization succeeded. + * + * Unlike `reader::init()`, this method explicitly clears the error + * code rather than leaving it unmodified. + * + * @param ec Set to a zero/success value. + */ static void init(boost::beast::error_code& ec) { ec.assign(0, ec.category()); } - // get() must return a boost::optional (not a std::optional) to meet - // requirements of a boost::beast::BodyWriter. + /** Return the serialized body as a single const buffer. + * + * Returns `boost::optional` (not `std::optional`) to satisfy the + * Beast BodyWriter concept. The `bool` element of the pair is `false`, + * signaling that all data is available in one shot. + * + * @param ec Set to a zero/success value before returning. + * @return An optional pair of (const buffer over `body_string_`, + * more-data flag). The more-data flag is always `false`. + */ boost::optional> get(boost::beast::error_code& ec) {