mirror of
https://github.com/XRPLF/rippled.git
synced 2026-10-09 13:18:09 +00:00
1080 lines
44 KiB
C++
1080 lines
44 KiB
C++
#include <xrpl/tx/invariants/MPTInvariant.h>
|
|
|
|
#include <xrpl/basics/Log.h>
|
|
#include <xrpl/basics/base_uint.h>
|
|
#include <xrpl/beast/utility/Journal.h>
|
|
#include <xrpl/beast/utility/Zero.h>
|
|
#include <xrpl/beast/utility/instrumentation.h>
|
|
#include <xrpl/ledger/ReadView.h>
|
|
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
|
|
#include <xrpl/ledger/helpers/LendingHelpers.h>
|
|
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
|
|
#include <xrpl/protocol/AccountID.h>
|
|
#include <xrpl/protocol/Feature.h>
|
|
#include <xrpl/protocol/Indexes.h>
|
|
#include <xrpl/protocol/LedgerFormats.h>
|
|
#include <xrpl/protocol/MPTIssue.h>
|
|
#include <xrpl/protocol/Protocol.h>
|
|
#include <xrpl/protocol/Rules.h>
|
|
#include <xrpl/protocol/SField.h>
|
|
#include <xrpl/protocol/STLedgerEntry.h>
|
|
#include <xrpl/protocol/STTx.h>
|
|
#include <xrpl/protocol/TER.h>
|
|
#include <xrpl/protocol/TxFormats.h>
|
|
#include <xrpl/protocol/UintTypes.h>
|
|
#include <xrpl/protocol/XRPAmount.h>
|
|
#include <xrpl/tx/invariants/InvariantCheckPrivilege.h>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <memory>
|
|
|
|
namespace xrpl {
|
|
|
|
namespace {
|
|
constexpr auto kConfidentialMptTxTypes = std::to_array<TxType>({
|
|
ttCONFIDENTIAL_MPT_SEND,
|
|
ttCONFIDENTIAL_MPT_CONVERT,
|
|
ttCONFIDENTIAL_MPT_CONVERT_BACK,
|
|
ttCONFIDENTIAL_MPT_MERGE_INBOX,
|
|
ttCONFIDENTIAL_MPT_CLAWBACK,
|
|
ttCONFIDENTIAL_MPT_MIRROR_UPDATE,
|
|
ttCONFIDENTIAL_MPT_HOLDER_KEY_UPDATE,
|
|
});
|
|
|
|
// Clamp to the cap (== INT64_MAX) before the signed conversion. Invariant
|
|
// tests can inject INT64_MAX + 1, which would result in undefined behavior
|
|
// under UBSan if converted directly.
|
|
std::int64_t
|
|
toSignedMPTAmount(std::uint64_t amount)
|
|
{
|
|
return static_cast<std::int64_t>(std::min(amount, kMaxMpTokenAmount));
|
|
}
|
|
|
|
std::int64_t
|
|
addMPTAmountDelta(std::int64_t delta, std::uint64_t amount)
|
|
{
|
|
return delta + toSignedMPTAmount(amount);
|
|
}
|
|
|
|
std::int64_t
|
|
subtractMPTAmountDelta(std::int64_t delta, std::uint64_t amount)
|
|
{
|
|
return delta - toSignedMPTAmount(amount);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void
|
|
ValidMPTIssuance::visitEntry(bool isDelete, SLE::ConstRef before, SLE::ConstRef after)
|
|
{
|
|
// The sfReferenceHolding tracking and the deleted-holding capture are
|
|
// only meaningful post-fixCleanup3_2_0 (the field is never set
|
|
// pre-amendment, and the holding-deletion rule does not apply).
|
|
// Skip both blocks when the amendment is off so we avoid wasted work
|
|
// on the hot path, except where noted for fixCleanup3_5_0 below.
|
|
bool const fix320Enabled = isFeatureEnabled(fixCleanup3_2_0);
|
|
|
|
if (after && after->getType() == ltMPTOKEN_ISSUANCE)
|
|
{
|
|
if (isDelete)
|
|
{
|
|
mptIssuancesDeleted_++;
|
|
}
|
|
else if (!before)
|
|
{
|
|
mptIssuancesCreated_++;
|
|
if (fix320Enabled && after->isFieldPresent(sfReferenceHolding))
|
|
referenceHoldingSetOnCreate_ = true;
|
|
}
|
|
else if (fix320Enabled)
|
|
{
|
|
// Modified issuance: detect any change to sfReferenceHolding.
|
|
bool const beforePresent = before->isFieldPresent(sfReferenceHolding);
|
|
bool const afterPresent = after->isFieldPresent(sfReferenceHolding);
|
|
if (beforePresent != afterPresent ||
|
|
(afterPresent &&
|
|
before->getFieldH256(sfReferenceHolding) !=
|
|
after->getFieldH256(sfReferenceHolding)))
|
|
{
|
|
referenceHoldingMutated_ = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (after && after->getType() == ltMPTOKEN)
|
|
{
|
|
if (isDelete)
|
|
{
|
|
mptokensDeleted_++;
|
|
// deletedHoldings_ also feeds finalize()'s erase-time public
|
|
// balance check, gated on fixCleanup3_5_0 independently of
|
|
// fixCleanup3_2_0.
|
|
if (fix320Enabled || isFeatureEnabled(fixCleanup3_5_0))
|
|
deletedHoldings_.push_back(after);
|
|
}
|
|
else if (!before)
|
|
{
|
|
mptokensCreated_++;
|
|
MPTIssue const mptIssue{after->at(sfMPTokenIssuanceID)};
|
|
if (mptIssue.getIssuer() == after->at(sfAccount))
|
|
mptCreatedByIssuer_ = true;
|
|
}
|
|
}
|
|
|
|
// Capture deleted RippleState SLEs so finalize() can verify none of
|
|
// them were owned by a vault pseudo-account outside VaultDelete.
|
|
if (fix320Enabled && isDelete && after && after->getType() == ltRIPPLE_STATE)
|
|
deletedHoldings_.push_back(after);
|
|
}
|
|
|
|
bool
|
|
ValidMPTIssuance::finalize(
|
|
STTx const& tx,
|
|
TER const result,
|
|
XRPAmount const fee,
|
|
ReadView const& view,
|
|
beast::Journal const& j) const
|
|
{
|
|
auto const& rules = view.rules();
|
|
bool const mptV2Enabled = rules.enabled(featureMPTokensV2);
|
|
|
|
// Post-fixCleanup3_2_0:
|
|
// - sfReferenceHolding is set only by VaultCreate at share-issuance
|
|
// creation, and is immutable thereafter.
|
|
// - A vault pseudo-account's MPToken or RippleState may only be
|
|
// deleted by VaultDelete; the share's sfReferenceHolding pointer
|
|
// must not dangle outside that controlled lifecycle.
|
|
if (rules.enabled(fixCleanup3_2_0))
|
|
{
|
|
// Not an amendment gate like the same-named flags below, just an
|
|
// accumulator, so that every violation gets logged before returning.
|
|
bool invariantPasses = true;
|
|
if (referenceHoldingMutated_)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: sfReferenceHolding was modified "
|
|
"on an existing MPTokenIssuance";
|
|
invariantPasses = false;
|
|
}
|
|
if (referenceHoldingSetOnCreate_ && tx.getTxnType() != ttVAULT_CREATE)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: sfReferenceHolding set on a new "
|
|
"MPTokenIssuance by a non-VaultCreate transaction";
|
|
invariantPasses = false;
|
|
}
|
|
if (!deletedHoldings_.empty() && tx.getTxnType() != ttVAULT_DELETE)
|
|
{
|
|
auto const isVaultPseudo = [&](AccountID const& acct) {
|
|
auto const sle = view.read(keylet::account(acct));
|
|
return sle && sle->isFieldPresent(sfVaultID);
|
|
};
|
|
for (auto const& sleHolding : deletedHoldings_)
|
|
{
|
|
bool offending = false;
|
|
if (sleHolding->getType() == ltMPTOKEN)
|
|
{
|
|
offending = isVaultPseudo(sleHolding->at(sfAccount));
|
|
}
|
|
else // ltRIPPLE_STATE
|
|
{
|
|
auto const lowLimit = sleHolding->getFieldAmount(sfLowLimit);
|
|
auto const highLimit = sleHolding->getFieldAmount(sfHighLimit);
|
|
// Each limit's STAmount.issuer is the COUNTERPARTY of
|
|
// that side's owner: lowLimit's issuer is the high
|
|
// account, highLimit's issuer is the low account.
|
|
offending =
|
|
isVaultPseudo(lowLimit.getIssuer()) || isVaultPseudo(highLimit.getIssuer());
|
|
}
|
|
if (offending)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: vault pseudo-account holding "
|
|
"deleted by a non-VaultDelete transaction";
|
|
invariantPasses = false;
|
|
}
|
|
}
|
|
}
|
|
if (!invariantPasses)
|
|
return false;
|
|
}
|
|
|
|
// Deleting an MPToken with a non-zero MPTAmount is rejected.
|
|
if (rules.enabled(fixCleanup3_5_0))
|
|
{
|
|
for (auto const& sleHolding : deletedHoldings_)
|
|
{
|
|
if (sleHolding->getType() == ltMPTOKEN && sleHolding->getFieldU64(sfMPTAmount) > 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPToken deleted with non-zero balance";
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isTesSuccess(result) || (mptV2Enabled && result == tecINCOMPLETE))
|
|
{
|
|
[[maybe_unused]]
|
|
bool const enforceCreatedByIssuer =
|
|
rules.enabled(featureSingleAssetVault) || rules.enabled(featureLendingProtocol);
|
|
if (mptCreatedByIssuer_)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPToken created for the MPT issuer";
|
|
// The comment above starting with "assert(enforce)" explains this
|
|
// assert.
|
|
XRPL_ASSERT_PARTS(
|
|
enforceCreatedByIssuer, "xrpl::ValidMPTIssuance::finalize", "no issuer MPToken");
|
|
if (enforceCreatedByIssuer)
|
|
return false;
|
|
}
|
|
|
|
auto const txnType = tx.getTxnType();
|
|
if (hasPrivilege(tx, Privilege::CreateMptIssuance))
|
|
{
|
|
if (mptIssuancesCreated_ == 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: transaction "
|
|
"succeeded without creating a MPT issuance";
|
|
}
|
|
else if (mptIssuancesDeleted_ != 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: transaction "
|
|
"succeeded while removing MPT issuances";
|
|
}
|
|
else if (mptIssuancesCreated_ > 1)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: transaction "
|
|
"succeeded but created multiple issuances";
|
|
}
|
|
|
|
return mptIssuancesCreated_ == 1 && mptIssuancesDeleted_ == 0;
|
|
}
|
|
|
|
if (hasPrivilege(tx, Privilege::DestroyMptIssuance))
|
|
{
|
|
if (mptIssuancesDeleted_ == 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion "
|
|
"succeeded without removing a MPT issuance";
|
|
}
|
|
else if (mptIssuancesCreated_ > 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion "
|
|
"succeeded while creating MPT issuances";
|
|
}
|
|
else if (mptIssuancesDeleted_ > 1)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion "
|
|
"succeeded but deleted multiple issuances";
|
|
}
|
|
|
|
return mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 1;
|
|
}
|
|
|
|
bool const lendingProtocolEnabled = rules.enabled(featureLendingProtocol);
|
|
// ttESCROW_FINISH may authorize an MPT, but it can't have the
|
|
// mayAuthorizeMPT privilege, because that may cause
|
|
// non-amendment-gated side effects.
|
|
bool const enforceEscrowFinish = (txnType == ttESCROW_FINISH) &&
|
|
(rules.enabled(featureSingleAssetVault) || lendingProtocolEnabled);
|
|
if (hasPrivilege(tx, Privilege::MustAuthorizeMpt | Privilege::MayAuthorizeMpt) ||
|
|
enforceEscrowFinish)
|
|
{
|
|
bool const submittedByIssuer = tx.isFieldPresent(sfHolder);
|
|
|
|
if (mptIssuancesCreated_ > 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPT authorize "
|
|
"succeeded but created MPT issuances";
|
|
return false;
|
|
}
|
|
if (mptIssuancesDeleted_ > 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPT authorize "
|
|
"succeeded but deleted issuances";
|
|
return false;
|
|
}
|
|
if (mptV2Enabled && hasPrivilege(tx, Privilege::MayAuthorizeMpt) &&
|
|
(txnType == ttAMM_WITHDRAW || txnType == ttAMM_CLAWBACK))
|
|
{
|
|
if (submittedByIssuer && txnType == ttAMM_WITHDRAW && mptokensCreated_ > 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPT authorize "
|
|
"submitted by issuer succeeded "
|
|
"but created bad number of mptokens";
|
|
return false;
|
|
}
|
|
// At most two MPToken may be created on withdraw/clawback since:
|
|
// - Liquidity Provider must have at least one token in order
|
|
// participate in AMM pool liquidity or have LPTokens only.
|
|
// - At most two MPTokens may be deleted if AMM pool, which has exactly
|
|
// two tokens, is empty after withdraw/clawback.
|
|
SOMETIMES(mptokensCreated_ == 2, "AMM withdraw/clawback recreated two MPTokens");
|
|
if (mptokensCreated_ > 2 || mptokensDeleted_ > 2)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded "
|
|
"but created/deleted bad number of mptokens";
|
|
return false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Cap on MPToken creates and deletes while featureLendingProtocol is enabled.
|
|
// - LoanSet: at most two creates and no deletes.
|
|
// - VaultWithdraw: at most one create and one delete.
|
|
// - Other MayAuthorizeMpt types: created + deleted <= 1.
|
|
// - MustAuthorizeMpt still requires exactly one create or delete below.
|
|
auto const mptokensExceedAuthorizeCap = [&] {
|
|
if (!lendingProtocolEnabled)
|
|
return false;
|
|
if (rules.enabled(fixCleanup3_4_0))
|
|
{
|
|
if (txnType == ttLOAN_SET)
|
|
return mptokensDeleted_ != 0 || mptokensCreated_ > 2;
|
|
if (txnType == ttVAULT_WITHDRAW)
|
|
return mptokensCreated_ > 1 || mptokensDeleted_ > 1;
|
|
}
|
|
return (mptokensCreated_ + mptokensDeleted_) > 1;
|
|
};
|
|
if (mptokensExceedAuthorizeCap())
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded "
|
|
"but created/deleted bad number mptokens";
|
|
return false;
|
|
}
|
|
if (submittedByIssuer && (mptokensCreated_ > 0 || mptokensDeleted_ > 0))
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by issuer "
|
|
"succeeded but created/deleted mptokens";
|
|
return false;
|
|
}
|
|
if (!submittedByIssuer && hasPrivilege(tx, Privilege::MustAuthorizeMpt) &&
|
|
(mptokensCreated_ + mptokensDeleted_ != 1))
|
|
{
|
|
// if the holder submitted this tx, then a mptoken must be
|
|
// either created or deleted.
|
|
JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by holder "
|
|
"succeeded but created/deleted bad number of mptokens";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
if (hasPrivilege(tx, Privilege::MayCreateMpt))
|
|
{
|
|
bool const submittedByIssuer = tx.isFieldPresent(sfHolder);
|
|
|
|
if (mptIssuancesCreated_ > 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPT authorize "
|
|
"succeeded but created MPT issuances";
|
|
return false;
|
|
}
|
|
if (mptIssuancesDeleted_ > 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPT authorize "
|
|
"succeeded but deleted issuances";
|
|
return false;
|
|
}
|
|
if (mptokensDeleted_ > 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPT authorize "
|
|
"succeeded but deleted MPTokens";
|
|
return false;
|
|
}
|
|
// AMMCreate may auto-create up to two MPT objects:
|
|
// - one per asset side in an MPT/MPT AMM, or one in an IOU/MPT AMM.
|
|
// CheckCash may auto-create at most one MPT object for the receiver.
|
|
if ((txnType == ttAMM_CREATE && mptokensCreated_ > 2) ||
|
|
(txnType == ttCHECK_CASH && mptokensCreated_ > 1))
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPT authorize "
|
|
"succeeded but created bad number of mptokens";
|
|
return false;
|
|
}
|
|
if (submittedByIssuer)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by issuer "
|
|
"succeeded but created mptokens";
|
|
return false;
|
|
}
|
|
|
|
// Offer crossing or payment may consume multiple offers
|
|
// where takerPays is MPT amount. If the offer owner doesn't
|
|
// own MPT then MPT is created automatically.
|
|
return true;
|
|
}
|
|
|
|
if (txnType == ttESCROW_FINISH)
|
|
{
|
|
// ttESCROW_FINISH may authorize an MPT, but it can't have the
|
|
// mayAuthorizeMPT privilege, because that may cause
|
|
// non-amendment-gated side effects.
|
|
XRPL_ASSERT_PARTS(
|
|
!enforceEscrowFinish, "xrpl::ValidMPTIssuance::finalize", "not escrow finish tx");
|
|
return true;
|
|
}
|
|
|
|
if (hasPrivilege(tx, Privilege::MayDeleteMpt) &&
|
|
((txnType == ttAMM_DELETE && mptokensDeleted_ <= 2) || mptokensDeleted_ == 1) &&
|
|
mptokensCreated_ == 0 && mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 0)
|
|
return true;
|
|
}
|
|
|
|
if (mptIssuancesCreated_ != 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: a MPT issuance was created";
|
|
}
|
|
else if (mptIssuancesDeleted_ != 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: a MPT issuance was deleted";
|
|
}
|
|
else if (mptokensCreated_ != 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: a MPToken was created";
|
|
}
|
|
else if (mptokensDeleted_ != 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: a MPToken was deleted";
|
|
}
|
|
|
|
return mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 0 && mptokensCreated_ == 0 &&
|
|
mptokensDeleted_ == 0;
|
|
}
|
|
|
|
void
|
|
ValidMPTBalanceChanges::visitEntry(bool, SLE::ConstRef before, SLE::ConstRef after)
|
|
{
|
|
if (overflow_)
|
|
return;
|
|
|
|
auto makeKey = [](SLE const& sle) {
|
|
if (sle.getType() == ltMPTOKEN_ISSUANCE)
|
|
return makeMptID(sle[sfSequence], sle[sfIssuer]);
|
|
return sle[sfMPTokenIssuanceID];
|
|
};
|
|
|
|
auto update = [&](SLE const& sle, Order order) -> bool {
|
|
auto const type = sle.getType();
|
|
if (type == ltMPTOKEN_ISSUANCE)
|
|
{
|
|
auto const outstanding = sle[sfOutstandingAmount];
|
|
if (outstanding > kMaxMpTokenAmount)
|
|
{
|
|
overflow_ = true;
|
|
return false;
|
|
}
|
|
data_[makeKey(sle)].outstanding[static_cast<std::size_t>(order)] = outstanding;
|
|
}
|
|
else if (type == ltMPTOKEN)
|
|
{
|
|
auto const mptAmt = sle[sfMPTAmount];
|
|
auto const lockedAmt = sle[~sfLockedAmount].value_or(0);
|
|
if (mptAmt > kMaxMpTokenAmount || lockedAmt > kMaxMpTokenAmount ||
|
|
lockedAmt > (kMaxMpTokenAmount - mptAmt))
|
|
{
|
|
overflow_ = true;
|
|
return false;
|
|
}
|
|
auto const res = static_cast<std::int64_t>(mptAmt + lockedAmt);
|
|
// subtract before from after
|
|
if (order == Order::Before)
|
|
{
|
|
data_[makeKey(sle)].mptAmount -= res;
|
|
}
|
|
else
|
|
{
|
|
data_[makeKey(sle)].mptAmount += res;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
if (before && !update(*before, Order::Before))
|
|
return;
|
|
|
|
if (after)
|
|
{
|
|
if (after->getType() == ltMPTOKEN_ISSUANCE)
|
|
{
|
|
overflow_ = (*after)[sfOutstandingAmount] > maxMPTAmount(*after);
|
|
}
|
|
if (!update(*after, Order::After))
|
|
return;
|
|
}
|
|
}
|
|
|
|
bool
|
|
ValidMPTBalanceChanges::finalize(
|
|
STTx const& tx,
|
|
TER const result,
|
|
XRPAmount const,
|
|
ReadView const& view,
|
|
beast::Journal const& j)
|
|
{
|
|
auto const fix340Enabled = view.rules().enabled(fixCleanup3_4_0);
|
|
|
|
if (isTesSuccess(result) || fix340Enabled)
|
|
{
|
|
// Confidential transactions are validated by ValidConfidentialMPToken.
|
|
// They modify encrypted fields and sfConfidentialOutstandingAmount
|
|
// rather than sfMPTAmount/sfOutstandingAmount in the standard way,
|
|
// so ValidMPTPayment's accounting does not apply to them.
|
|
if (std::ranges::find(kConfidentialMptTxTypes, tx.getTxnType()) !=
|
|
kConfidentialMptTxTypes.end())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Returned when a violation is found below, so this is the log-only
|
|
// condition. Either amendment makes the checks enforcing.
|
|
auto const invariantPasses = !(view.rules().enabled(featureMPTokensV2) || fix340Enabled);
|
|
if (overflow_)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: OutstandingAmount overflow";
|
|
return invariantPasses;
|
|
}
|
|
|
|
auto const signedMax = static_cast<std::int64_t>(kMaxMpTokenAmount);
|
|
for (auto const& [id, data] : data_)
|
|
{
|
|
(void)id;
|
|
static constexpr auto kIBefore = static_cast<std::size_t>(Order::Before);
|
|
static constexpr auto kIAfter = static_cast<std::size_t>(Order::After);
|
|
bool const addOverflows =
|
|
(data.mptAmount > 0 && data.outstanding[kIBefore] > (signedMax - data.mptAmount)) ||
|
|
(data.mptAmount < 0 && data.outstanding[kIBefore] < (-signedMax - data.mptAmount));
|
|
if (addOverflows ||
|
|
data.outstanding[kIAfter] != (data.outstanding[kIBefore] + data.mptAmount))
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: invalid OutstandingAmount balance "
|
|
<< data.outstanding[kIBefore] << " " << data.outstanding[kIAfter]
|
|
<< " " << data.mptAmount;
|
|
return invariantPasses;
|
|
}
|
|
|
|
// A failed transaction must not have moved MPT value; the check
|
|
// above ties mptAmount to the OutstandingAmount delta. No result
|
|
// code is exempt: on any tec the transactor discards the view and
|
|
// re-applies only offer, trust line, NFT offer and credential
|
|
// deletions (Transactor::typesForResult), none of which touch MPTs.
|
|
if (!isTesSuccess(result) && data.mptAmount != 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: OutstandingAmount balance changed on failure "
|
|
<< tx.getTxnType() << " " << result;
|
|
return invariantPasses;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
void
|
|
ValidConfidentialMPToken::visitEntry(
|
|
bool isDelete,
|
|
std::shared_ptr<SLE const> const& before,
|
|
std::shared_ptr<SLE const> const& after)
|
|
{
|
|
// Helper to get MPToken Issuance ID safely
|
|
auto const getMptID = [](std::shared_ptr<SLE const> const& sle) -> UInt192 {
|
|
if (!sle)
|
|
return beast::kZero;
|
|
if (sle->getType() == ltMPTOKEN)
|
|
return sle->getFieldH192(sfMPTokenIssuanceID);
|
|
if (sle->getType() == ltMPTOKEN_ISSUANCE)
|
|
return makeMptID(sle->getFieldU32(sfSequence), sle->getAccountID(sfIssuer));
|
|
return beast::kZero;
|
|
};
|
|
|
|
if (before && before->getType() == ltMPTOKEN)
|
|
{
|
|
UInt192 const id = getMptID(before);
|
|
auto& change = changes_[id];
|
|
change.mptAmountDelta =
|
|
subtractMPTAmountDelta(change.mptAmountDelta, before->getFieldU64(sfMPTAmount));
|
|
|
|
// Cannot delete MPToken with non-zero confidential state.
|
|
if (isDelete)
|
|
{
|
|
// changes_ is keyed by issuance, so sibling holders erased by the
|
|
// same transaction share this entry. Only ever set these flags,
|
|
// never clear them, or an empty sibling visited later would mask
|
|
// a funded MPToken.
|
|
|
|
// Retired by fixCleanup3_5_0, which moved the public balance
|
|
// check to ValidMPTIssuance::finalize. Kept pre-amendment for
|
|
// consensus safety: a non-zero public balance used to feed the
|
|
// confidential gate below, rejecting the erase whenever the
|
|
// issuance's COA was non-zero, and already-validated ledgers
|
|
// depend on that.
|
|
if (!isFeatureEnabled(fixCleanup3_5_0) && before->getFieldU64(sfMPTAmount) > 0)
|
|
changes_[id].deletedWithBalanceBefore = true;
|
|
|
|
if (before->isFieldPresent(sfConfidentialBalanceSpending) ||
|
|
before->isFieldPresent(sfConfidentialBalanceInbox) ||
|
|
before->isFieldPresent(sfIssuerEncryptedBalance) ||
|
|
before->isFieldPresent(sfAuditorEncryptedBalance))
|
|
changes_[id].deletedWithEncrypted = true;
|
|
}
|
|
}
|
|
|
|
if (after && after->getType() == ltMPTOKEN)
|
|
{
|
|
UInt192 const id = getMptID(after);
|
|
auto& change = changes_[id];
|
|
change.mptAmountDelta =
|
|
addMPTAmountDelta(change.mptAmountDelta, after->getFieldU64(sfMPTAmount));
|
|
|
|
// Encrypted field existence consistency
|
|
bool const hasIssuerBalance = after->isFieldPresent(sfIssuerEncryptedBalance);
|
|
bool const hasHolderInbox = after->isFieldPresent(sfConfidentialBalanceInbox);
|
|
bool const hasHolderSpending = after->isFieldPresent(sfConfidentialBalanceSpending);
|
|
bool const hasAuditorBalance = after->isFieldPresent(sfAuditorEncryptedBalance);
|
|
|
|
// The core encrypted balances must all exist or not exist at the same time. The auditor
|
|
// balance is optional, but cannot exist without the core fields.
|
|
if (hasHolderInbox != hasHolderSpending || hasHolderInbox != hasIssuerBalance ||
|
|
(hasAuditorBalance && !hasIssuerBalance))
|
|
changes_[id].badConsistency = true;
|
|
|
|
auto const confidentialBalanceFieldChanged = [&before, &after](auto const& field) {
|
|
auto const afterValue = (*after)[~field];
|
|
if (!afterValue)
|
|
return false;
|
|
|
|
if (!before || before->getType() != ltMPTOKEN)
|
|
return true; // LCOV_EXCL_LINE
|
|
|
|
return (*before)[~field] != afterValue;
|
|
};
|
|
|
|
if (confidentialBalanceFieldChanged(sfConfidentialBalanceInbox) ||
|
|
confidentialBalanceFieldChanged(sfConfidentialBalanceSpending) ||
|
|
confidentialBalanceFieldChanged(sfIssuerEncryptedBalance) ||
|
|
confidentialBalanceFieldChanged(sfAuditorEncryptedBalance))
|
|
{
|
|
changes_[id].changesConfidentialFields = true;
|
|
}
|
|
}
|
|
|
|
if (before && before->getType() == ltMPTOKEN_ISSUANCE)
|
|
{
|
|
UInt192 const id = getMptID(before);
|
|
auto& change = changes_[id];
|
|
if (before->isFieldPresent(sfConfidentialOutstandingAmount))
|
|
{
|
|
change.coaDelta = subtractMPTAmountDelta(
|
|
change.coaDelta, before->getFieldU64(sfConfidentialOutstandingAmount));
|
|
}
|
|
change.outstandingDelta = subtractMPTAmountDelta(
|
|
change.outstandingDelta, before->getFieldU64(sfOutstandingAmount));
|
|
}
|
|
|
|
if (after && after->getType() == ltMPTOKEN_ISSUANCE)
|
|
{
|
|
UInt192 const id = getMptID(after);
|
|
auto& change = changes_[id];
|
|
|
|
bool const hasCOA = after->isFieldPresent(sfConfidentialOutstandingAmount);
|
|
std::uint64_t const coa = (*after)[~sfConfidentialOutstandingAmount].value_or(0);
|
|
std::uint64_t const oa = after->getFieldU64(sfOutstandingAmount);
|
|
|
|
if (hasCOA)
|
|
change.coaDelta = addMPTAmountDelta(change.coaDelta, coa);
|
|
|
|
change.outstandingDelta = addMPTAmountDelta(change.outstandingDelta, oa);
|
|
change.issuance = after;
|
|
|
|
// COA <= OutstandingAmount
|
|
if (coa > oa)
|
|
change.badCOA = true;
|
|
}
|
|
|
|
if (before && after && before->getType() == ltMPTOKEN && after->getType() == ltMPTOKEN)
|
|
{
|
|
UInt192 const id = getMptID(after);
|
|
|
|
// sfConfidentialBalanceVersion must change when spending changes
|
|
auto const spendingBefore = (*before)[~sfConfidentialBalanceSpending];
|
|
auto const spendingAfter = (*after)[~sfConfidentialBalanceSpending];
|
|
auto const versionBefore = (*before)[~sfConfidentialBalanceVersion];
|
|
auto const versionAfter = (*after)[~sfConfidentialBalanceVersion];
|
|
|
|
if (spendingBefore.has_value() && spendingBefore != spendingAfter)
|
|
{
|
|
if (versionBefore == versionAfter)
|
|
changes_[id].badVersion = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool
|
|
ValidConfidentialMPToken::finalize(
|
|
STTx const& tx,
|
|
TER const result,
|
|
XRPAmount const,
|
|
ReadView const& view,
|
|
beast::Journal const& j)
|
|
{
|
|
if (result != tesSUCCESS)
|
|
return true;
|
|
|
|
bool const fix350Enabled = view.rules().enabled(fixCleanup3_5_0);
|
|
|
|
for (auto const& [id, checks] : changes_)
|
|
{
|
|
// Find the MPTokenIssuance
|
|
auto const issuance = [&]() -> std::shared_ptr<SLE const> {
|
|
if (checks.issuance)
|
|
return checks.issuance;
|
|
return view.read(keylet::mptokenIssuance(id));
|
|
}();
|
|
|
|
// Skip all invariance checks if issuance doesn't exist because that means the MPT has been
|
|
// deleted
|
|
if (!issuance)
|
|
continue;
|
|
|
|
// Cannot delete MPToken with non-zero confidential state.
|
|
//
|
|
// Before fixCleanup3_5_0 this gate also absorbed the pre-transaction
|
|
// public balance, so any drain-then-erase of an MPToken -- an
|
|
// AMMWithdraw of the whole pool, a LoanBrokerDelete returning cover --
|
|
// was rejected whenever some unrelated holder of the same issuance
|
|
// held a confidential balance. The COA gate itself is correct for
|
|
// ciphertext and mirrors MPTokenAuthorize::preclaim; only the public
|
|
// balance leg was misplaced.
|
|
bool const deletedWithEncrypted = fix350Enabled
|
|
? checks.deletedWithEncrypted
|
|
: (checks.deletedWithEncrypted || checks.deletedWithBalanceBefore);
|
|
|
|
if (deletedWithEncrypted)
|
|
{
|
|
if ((*issuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0)
|
|
{
|
|
JLOG(j.fatal())
|
|
<< "Invariant failed: MPToken deleted with encrypted fields while COA > 0";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Encrypted field existence consistency
|
|
if (checks.badConsistency)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPToken encrypted field "
|
|
"existence inconsistency";
|
|
return false;
|
|
}
|
|
|
|
// COA <= OutstandingAmount
|
|
if (checks.badCOA)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: Confidential outstanding amount "
|
|
"exceeds total outstanding amount";
|
|
return false;
|
|
}
|
|
|
|
// Confidential balance fields may remain on a holder MPToken after all
|
|
// confidential balances have returned to zero. Only creating or
|
|
// changing those fields requires the issuance privacy flag.
|
|
if (checks.changesConfidentialFields)
|
|
{
|
|
if (!issuance->isFlag(lsfMPTCanHoldConfidentialBalance))
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPToken has encrypted "
|
|
"fields but Issuance does not have "
|
|
"lsfMPTCanHoldConfidentialBalance set";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// We only enforce this when Confidential Outstanding Amount changes (Convert, ConvertBack,
|
|
// ConfidentialClawback). This avoids falsely failing on Escrow or AMM operations that lock
|
|
// public tokens outside of ltMPTOKEN. Convert / ConvertBack:
|
|
// - COA and MPTAmount must have opposite deltas, which cancel each other out to zero.
|
|
// - OA remains unchanged.
|
|
// - Therefore, the net delta on both sides of the equation is zero.
|
|
//
|
|
// Clawback:
|
|
// - MPTAmount remains unchanged.
|
|
// - COA and OA must have identical deltas (mirrored on each side).
|
|
// - The equation remains balanced as both sides have equal offsets.
|
|
if (checks.coaDelta != 0)
|
|
{
|
|
if (checks.mptAmountDelta + checks.coaDelta != checks.outstandingDelta)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: Token conservation "
|
|
"violation for MPT "
|
|
<< to_string(id);
|
|
return false;
|
|
}
|
|
}
|
|
else if (
|
|
std::ranges::find(kConfidentialMptTxTypes, tx.getTxnType()) !=
|
|
kConfidentialMptTxTypes.end())
|
|
{
|
|
// Confidential Txns should not modify public MPTAmount balance
|
|
// if Confidential Amount Delta is 0
|
|
if (checks.mptAmountDelta != 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPTAmount changed by confidential "
|
|
"transaction that should not modify this field."
|
|
<< to_string(id);
|
|
return false;
|
|
}
|
|
|
|
// Reaching here means this confidential MPT transaction left coaDelta
|
|
// unmodified (e.g. ConfidentialMPTSend, ConfidentialMPTMergeInbox, or
|
|
// ConfidentialMPTHolderKeyUpdate/ConfidentialMPTMirrorUpdate, none of which touch
|
|
// sfConfidentialOutstandingAmount), so it must not modify sfOutstandingAmount either.
|
|
if (checks.outstandingDelta != 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: OutstandingAmount changed "
|
|
"by confidential transaction that should not "
|
|
"modify it for MPT "
|
|
<< to_string(id);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (checks.badVersion)
|
|
{
|
|
JLOG(j.fatal())
|
|
<< "Invariant failed: MPToken sfConfidentialBalanceVersion not updated when "
|
|
"sfConfidentialBalanceSpending changed";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
void
|
|
ValidMPTTransfer::visitEntry(
|
|
bool isDelete,
|
|
std::shared_ptr<SLE const> const& before,
|
|
std::shared_ptr<SLE const> const& after)
|
|
{
|
|
// Record the before/after MPTAmount for each (issuanceID, account) pair
|
|
// so finalize() can determine whether a transfer actually occurred.
|
|
auto update = [&](SLE const& sle, bool isBefore) {
|
|
if (sle.getType() == ltMPTOKEN)
|
|
{
|
|
auto const issuanceID = sle[sfMPTokenIssuanceID];
|
|
auto const account = sle[sfAccount];
|
|
auto const amount = sle[sfMPTAmount];
|
|
if (isBefore)
|
|
{
|
|
amount_[issuanceID][account].amtBefore = amount;
|
|
}
|
|
else
|
|
{
|
|
amount_[issuanceID][account].amtAfter = amount;
|
|
}
|
|
if (isDelete && isBefore)
|
|
{
|
|
deletedAuthorized_[sle.key()] = sle.isFlag(lsfMPTAuthorized);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (before)
|
|
update(*before, true);
|
|
|
|
if (after)
|
|
update(*after, false);
|
|
|
|
// Record whether every touched AccountRoot was a pseudo-account BEFORE
|
|
// the transaction applied (true and false). A transaction that erases a
|
|
// pseudo-account (and moves MPT out of it) in the same transaction leaves
|
|
// no trace of its pseudo-account status in the post-transaction view
|
|
// isAuthorized() sees at finalize() time.
|
|
if (before && before->getType() == ltACCOUNT_ROOT)
|
|
pseudoAccountsBefore_[before->at(sfAccount)] = isPseudoAccount(before);
|
|
}
|
|
|
|
bool
|
|
ValidMPTTransfer::isAuthorized(
|
|
ReadView const& view,
|
|
MPTID const& mptid,
|
|
AccountID const& holder,
|
|
bool reqAuth) const
|
|
{
|
|
// Pseudo-accounts (Vault, LoanBroker, AMM) hold assets on behalf of their
|
|
// participants and are implicitly authorized for any MPT they hold,
|
|
// including vault shares whose underlying asset would otherwise require
|
|
// auth. Exempt them here rather than relying on requireAuth: the recursive
|
|
// share -> underlying descent in requireAuth fails for a pseudo-account
|
|
// that holds the share but not the underlying.
|
|
//
|
|
// Use the pre-transaction classification for any account this
|
|
// transaction touched (pseudoAccountsBefore_): the post-transaction view
|
|
// is wrong for an account this same transaction erased. Untouched
|
|
// accounts aren't in the map, so fall back to the current view, which is
|
|
// still accurate for them since nothing changed.
|
|
auto const pseudoIt = pseudoAccountsBefore_.find(holder);
|
|
bool const isPseudo =
|
|
pseudoIt != pseudoAccountsBefore_.end() ? pseudoIt->second : isPseudoAccount(view, holder);
|
|
if (isPseudo)
|
|
return true;
|
|
|
|
auto const key = keylet::mptoken(mptid, holder);
|
|
auto const it = deletedAuthorized_.find(key.key);
|
|
if (it != deletedAuthorized_.end())
|
|
return !reqAuth || it->second;
|
|
return isTesSuccess(requireAuth(view, MPTIssue{mptid}, holder));
|
|
}
|
|
|
|
bool
|
|
ValidMPTTransfer::finalize(
|
|
STTx const& tx,
|
|
TER const result,
|
|
XRPAmount const,
|
|
ReadView const& view,
|
|
beast::Journal const& j)
|
|
{
|
|
if (hasPrivilege(tx, Privilege::OverrideFreeze))
|
|
return true;
|
|
|
|
// XLS-0066: a broker must be able to default an already-late loan
|
|
// regardless of the vault asset's lock state. Gated behind
|
|
// fixCleanup3_4_0, and scoped below to exactly the broker/vault
|
|
// pseudo-accounts and the vault's own MPT issuance -- see
|
|
// FreezeInvariant.cpp's TransfersNotFrozen::finalize for the IOU-side
|
|
// equivalent and rationale.
|
|
auto const loanDefaultAccounts = getLoanDefaultFreezeExemptAccounts(view, tx);
|
|
|
|
// DEX transactions (AMM[Create,Deposit], cross-currency payments, offer creates) are
|
|
// subject to the MPTCanTrade flag in addition to the standard transfer rules.
|
|
// A payment is only DEX if it is a cross-currency payment.
|
|
auto const txnType = tx.getTxnType();
|
|
auto const isDEX = [&] {
|
|
if (txnType == ttPAYMENT)
|
|
{
|
|
// A payment is cross-currency (and thus DEX) only if SendMax is present
|
|
// and its asset differs from the destination asset.
|
|
auto const amount = tx[sfAmount];
|
|
return tx[~sfSendMax].value_or(amount).asset() != amount.asset();
|
|
}
|
|
return txnType == ttAMM_CREATE || txnType == ttAMM_DEPOSIT || txnType == ttOFFER_CREATE;
|
|
}();
|
|
|
|
auto const fix340Enabled = view.rules().enabled(fixCleanup3_4_0);
|
|
// Returned when a violation is found below, so this is the log-only
|
|
// condition. Either amendment makes the checks enforcing.
|
|
auto const invariantPasses = !(view.rules().enabled(featureMPTokensV2) || fix340Enabled);
|
|
|
|
// A failed transaction must not persist an MPToken deletion. Pre-loop
|
|
// because deletedAuthorized_ is not issuance-scoped and orphans continue.
|
|
if (fix340Enabled && !isTesSuccess(result) && !deletedAuthorized_.empty())
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPToken deleted on failure " << txnType << " "
|
|
<< result;
|
|
return invariantPasses;
|
|
}
|
|
|
|
for (auto const& [mptID, values] : amount_)
|
|
{
|
|
std::uint16_t senders = 0;
|
|
std::uint16_t receivers = 0;
|
|
bool invalidTransfer = false;
|
|
auto const sleIssuance = view.read(keylet::mptokenIssuance(mptID));
|
|
if (!sleIssuance)
|
|
{
|
|
// MPTokenIssuanceDestroy only requires a zero OutstandingAmount, so
|
|
// an orphaned MPToken can outlive its issuance and be cleaned up
|
|
// later by a transaction of any type. There are no transfer rules
|
|
// left to check, but its balance is zero and nothing can raise it,
|
|
// so any change other than deletion is a bug.
|
|
for (auto const& [account, value] : values)
|
|
{
|
|
if (value.amtAfter.has_value() && value.amtBefore.value_or(0) != *value.amtAfter)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: orphaned MPToken balance changed "
|
|
<< txnType << " " << result;
|
|
return invariantPasses;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// These transactions are recovery/settlement paths. They may move an
|
|
// existing MPT position even after the issuer clears CanTransfer, so
|
|
// holders are not trapped in AMM, vault, or loan protocol accounts.
|
|
auto const waivesCanTransfer = txnType == ttAMM_WITHDRAW ||
|
|
(view.rules().enabled(fixCleanup3_2_0) &&
|
|
(txnType == ttVAULT_WITHDRAW || txnType == ttLOAN_BROKER_COVER_WITHDRAW ||
|
|
txnType == ttLOAN_PAY));
|
|
auto const canTransfer = sleIssuance->isFlag(lsfMPTCanTransfer) || waivesCanTransfer;
|
|
auto const canTrade = sleIssuance->isFlag(lsfMPTCanTrade);
|
|
auto const reqAuth = sleIssuance->isFlag(lsfMPTRequireAuth);
|
|
|
|
// This issuance is the LoanManage default's own vault asset, so the
|
|
// broker/vault freeze exemption applies to it -- an unrelated MPT
|
|
// issuance the same accounts happen to hold is still caught.
|
|
bool const isLoanDefaultAsset = loanDefaultAccounts &&
|
|
loanDefaultAccounts->asset.holds<MPTIssue>() &&
|
|
loanDefaultAccounts->asset.get<MPTIssue>().getMptID() == mptID;
|
|
|
|
for (auto const& [account, value] : values)
|
|
{
|
|
// Classify each account as a sender or receiver based on whether their MPTAmount
|
|
// decreased or increased. Count new MPToken holders (no amtBefore) as receivers.
|
|
// Skip deleted MPToken holders (amtAfter is nullopt); deletion requires zero balance.
|
|
if (value.amtAfter.has_value() && value.amtBefore.value_or(0) != *value.amtAfter)
|
|
{
|
|
if (!value.amtBefore.has_value() || *value.amtAfter > *value.amtBefore)
|
|
{
|
|
++receivers;
|
|
}
|
|
else
|
|
{
|
|
++senders;
|
|
}
|
|
|
|
// Check once: if any involved account is frozen, the whole issuance transfer is
|
|
// considered frozen. Only need to check for frozen if there is a transfer of funds.
|
|
//
|
|
// The LoanManage default exemption only waives the frozen check, and only for
|
|
// the specific broker/vault pseudo-accounts identified above -- authorization is
|
|
// still enforced for them, and both checks still apply to every other account.
|
|
bool const exemptFromFreeze = isLoanDefaultAsset && loanDefaultAccounts &&
|
|
(account == loanDefaultAccounts->broker ||
|
|
account == loanDefaultAccounts->vault);
|
|
if (!invalidTransfer &&
|
|
((!exemptFromFreeze && isFrozen(view, account, *sleIssuance)) ||
|
|
!isAuthorized(view, mptID, account, reqAuth)))
|
|
{
|
|
invalidTransfer = true;
|
|
}
|
|
}
|
|
}
|
|
// A transfer between holders has occurred (senders > 0 && receivers > 0).
|
|
// Fail if the issuance is frozen, does not permit transfers, or — for
|
|
// DEX transactions — does not permit trading.
|
|
if ((invalidTransfer || !canTransfer || (isDEX && !canTrade)) && senders > 0 &&
|
|
receivers > 0)
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: invalid MPToken transfer between holders";
|
|
return invariantPasses;
|
|
}
|
|
|
|
// A failed transaction must not have changed a holder's balance. One
|
|
// side is enough, unlike the transfer check above, so this also catches
|
|
// a lock/unlock moving value between sfMPTAmount and sfLockedAmount.
|
|
if (fix340Enabled && !isTesSuccess(result) && (senders > 0 || receivers > 0))
|
|
{
|
|
JLOG(j.fatal()) << "Invariant failed: MPToken balance changed on failure " << txnType
|
|
<< " " << result;
|
|
return invariantPasses;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
} // namespace xrpl
|