This commit is contained in:
Denis Angell
2026-05-14 08:43:32 +02:00
parent 315d1fdb06
commit a05f951a0c
41 changed files with 3143 additions and 494 deletions

View File

@@ -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 <xrpl/tx/transactors/payment_channel/PaymentChannelClaim.h>
#include <xrpl/beast/utility/Zero.h>
@@ -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)

View File

@@ -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<SLE>(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);

View File

@@ -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 <xrpl/tx/transactors/payment_channel/PaymentChannelFund.h>
#include <xrpl/beast/utility/Journal.h>
@@ -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&,

View File

@@ -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 <xrpl/tx/transactors/permissioned_domain/PermissionedDomainDelete.h>
#include <xrpl/basics/Log.h>
@@ -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&,

View File

@@ -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 <xrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.h>
#include <xrpl/beast/utility/Zero.h>
@@ -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);
}

View File

@@ -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<XRPAmount::value_type>::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<uint256> uniqueHashes;
std::unordered_map<AccountID, std::unordered_set<std::uint32_t>> 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<AccountID> requiredSigners;
for (STObject const& rb : rawTxns)
{
@@ -406,13 +401,11 @@ Batch::preflightSigValidated(PreflightContext const& ctx)
requiredSigners.insert(*counterparty);
}
// Validation Batch Signers
std::unordered_set<AccountID> 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<SLE const> const&,
std::shared_ptr<SLE const> 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;
}

View File

@@ -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 <xrpl/tx/transactors/system/Change.h>
#include <xrpl/basics/Log.h>
@@ -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<Change>(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<Change>(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<SLE const> const&,
std::shared_ptr<SLE const> 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;
}

View File

@@ -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 <xrpl/tx/transactors/system/LedgerStateFix.h>
#include <xrpl/beast/utility/Journal.h>
@@ -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()
{

View File

@@ -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&,

View File

@@ -33,6 +33,24 @@ template <ValidIssueType T>
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<Issue>(PreflightContext const& ctx)
@@ -52,6 +70,20 @@ preflightHelper<Issue>(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<MPTIssue>(PreflightContext const& ctx)
@@ -75,6 +107,16 @@ preflightHelper<MPTIssue>(PreflightContext const& ctx)
return tesSUCCESS;
}
/** Dispatch to the asset-type-specific preflight helper.
*
* Resolves the runtime `std::variant<Issue, MPTIssue>` inside `sfAmount`
* to a compile-time template parameter and forwards to
* `preflightHelper<Issue>` or `preflightHelper<MPTIssue>`. 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<Issue>(
@@ -128,15 +205,9 @@ preclaimHelper<Issue>(
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<Issue>(
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<MPTIssue>(
@@ -184,6 +288,28 @@ preclaimHelper<MPTIssue>(
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<T>`.
*
* @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 <ValidIssueType T>
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<Issue>().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<Issue>(ApplyContext& ctx)
@@ -231,7 +382,9 @@ applyHelper<Issue>(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<Issue>(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<MPTIssue>(ApplyContext& ctx)
@@ -252,7 +425,9 @@ applyHelper<MPTIssue>(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<MPTIssue>(ApplyContext& ctx)
ctx.journal);
}
/** Dispatch to the asset-type-specific apply helper and commit mutations.
*
* Resolves the runtime `std::variant<Issue, MPTIssue>` in `sfAmount` to a
* compile-time template parameter and forwards to `applyHelper<Issue>` or
* `applyHelper<MPTIssue>`. 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&)
{

View File

@@ -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 <xrpl/tx/transactors/token/MPTokenAuthorize.h>
#include <xrpl/core/ServiceRegistry.h>
@@ -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<T>()` 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<SLE const> 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&,

View File

@@ -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<MPTID, TER>
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&,

View File

@@ -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&,

View File

@@ -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<MPTMutabilityFlags, 6> kMPT_MUTABILITY_FLAGS = {
{{.setFlag = tmfMPTSetCanLock,
.clearFlag = tmfMPTClearCanLock,
@@ -72,6 +88,31 @@ static constexpr std::array<MPTMutabilityFlags, 6> 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");

View File

@@ -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<Issue>().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,

View File

@@ -33,6 +33,7 @@
#include <utility>
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<SLE const> 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<std::pair<STAmount, STAmount>, TER>
VaultClawback::assetsToClawback(
std::shared_ptr<SLE> 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,

View File

@@ -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<Issue>() ? 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(

View File

@@ -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 13, 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()
{

View File

@@ -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 <xrpl/tx/transactors/vault/VaultDeposit.h>
#include <xrpl/basics/Log.h>
@@ -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<Issue>() ? 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))

View File

@@ -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);

View File

@@ -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 <xrpl/tx/transactors/vault/VaultWithdraw.h>
#include <xrpl/basics/Log.h>
@@ -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_);

View File

@@ -13,16 +13,50 @@
namespace xrpl {
// {
// feature : <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);

View File

@@ -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 <xrpld/app/main/Application.h>
#include <xrpld/app/misc/TxQ.h>
#include <xrpld/rpc/Context.h>
@@ -7,6 +24,28 @@
#include <xrpl/protocol/ErrorCodes.h>
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)
{

View File

@@ -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 <xrpld/app/main/Application.h>
#include <xrpld/rpc/Context.h>
@@ -11,6 +21,36 @@
#include <xrpl/protocol/tokens.h>
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;

View File

@@ -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 <xrpld/rpc/handlers/server_info/ServerDefinitions.h>
#include <xrpld/rpc/Context.h>
@@ -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<N>` (e.g., `UINT256` → `Hash256`), reflecting that these
* fixed-width types are used as cryptographic digests, not arithmetic integers.
* - Other `UINT` names become `UInt<N>` (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<int, SField const*> 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<std::string>();
@@ -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)
{

View File

@@ -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 <xrpl/json/json_value.h>
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();

View File

@@ -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 <xrpld/rpc/Context.h>
#include <xrpld/rpc/Role.h>

View File

@@ -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 <xrpld/rpc/Context.h>
#include <xrpld/rpc/Role.h>
@@ -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)
{

View File

@@ -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<VersionHandler>()` 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<T>()` 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)

View File

@@ -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 <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/main/Application.h>
#include <xrpld/rpc/Context.h>
@@ -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);
}

View File

@@ -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 <xrpld/rpc/Context.h>
#include <xrpld/rpc/Role.h>
#include <xrpld/rpc/detail/RPCHelpers.h>
@@ -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()))
{

View File

@@ -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 <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/ledger/OpenLedger.h>
#include <xrpld/app/misc/Transaction.h>
@@ -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<std::uint32_t, json::Value>
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<json::Value>
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<json::Value>
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> transaction)
{
@@ -299,6 +411,39 @@ simulateTxn(RPC::JsonContext& context, std::shared_ptr<Transaction> 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: <string> XOR tx_json: <object>,
// binary: <bool>

View File

@@ -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 <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/misc/Transaction.h>
#include <xrpld/rpc/Context.h>
@@ -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<NetworkOPs::FailHard, json::Value>
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: <string> XOR tx_json: <object>,
// secret: <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)
{

View File

@@ -8,10 +8,30 @@
namespace xrpl {
// {
// SigningAccounts <array>,
// tx_json: <object>,
// }
/** 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)
{

View File

@@ -13,13 +13,49 @@
namespace xrpl {
// {
// ledger_hash : <ledger>,
// ledger_index : <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_...'
}
}

View File

@@ -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 <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/ledger/TransactionMaster.h>
#include <xrpld/app/misc/DeliverMax.h>
@@ -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<TxMeta>` 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<TxMeta>` otherwise.
* Empty (default-constructed variant) until `doTxHelp` populates it. */
std::variant<std::shared_ptr<TxMeta>, 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<std::string> ctid;
/** Close time of the validated ledger that included this transaction.
* Populated only when `validated` is true. */
std::optional<NetClock::time_point> closeTime;
/** Hash of the closed ledger that included this transaction.
* Absent for open ledgers. */
std::optional<uint256> 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<uint256> hash;
/** Decoded CTID as `{ledgerSeq, txnIndex}`. The network ID has already
* been validated against the local node before this struct is used. */
std::optional<std::pair<uint32_t, uint16_t>> 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<std::pair<uint32_t, uint32_t>> 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<TxPair, TxSearched>`. 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<TxMeta>` 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<TxResult, RPC::Status>
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<TxMeta>`.
*
* @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<TxResult, RPC::Status> 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<Blob>(&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<std::shared_ptr<TxMeta>>(&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);
}
}

View File

@@ -12,9 +12,51 @@
namespace xrpl {
// {
// start: <index>
// }
/** @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)
{

View File

@@ -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 <xrpld/app/main/Application.h>
#include <xrpld/overlay/Overlay.h>
#include <xrpld/rpc/Context.h>
@@ -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)
{

View File

@@ -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())

View File

@@ -14,15 +14,51 @@ namespace RPC {
struct JsonContext;
} // namespace RPC
// Result:
// {
// random: <uint256>
// }
/** 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;

View File

@@ -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 <xrpl/json/json_value.h>
@@ -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 <bool IsRequest, class Fields>
explicit reader(boost::beast::http::message<IsRequest, JsonBody, Fields> 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<std::pair<const_buffers_type, bool>>
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 <bool IsRequest, class Fields>
explicit writer(
boost::beast::http::header<IsRequest, Fields> 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<std::pair<const_buffers_type, bool>>
get(boost::beast::error_code& ec)
{