Compare commits

...

2 Commits

Author SHA1 Message Date
Vito
457f8d04ce feat: Move VaultWithdraw invariants from ValidVault to VaultWithdraw 2026-06-09 15:40:14 +02:00
Vito
d0a54d1159 feat: Move VaultCreate invariants from ValidVault to VaultCreate
Introduce VaultInvariantData to collect vault and MPTokenIssuance
snapshots during visitInvariantEntry, then implement the six
ttVAULT_CREATE-specific post-conditions in VaultCreate::finalizeInvariants
(empty vault, pseudo-account linkage). ValidVault now passes through
ttVAULT_CREATE in its switch, keeping only the universal vault checks and
the cross-transaction sentinel that guards against other tx types creating
vaults.
2026-06-09 14:33:52 +02:00
7 changed files with 818 additions and 237 deletions

View File

@@ -0,0 +1,173 @@
#pragma once
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/XRPAmount.h>
#include <optional>
#include <unordered_map>
#include <vector>
namespace xrpl {
/**
* @brief Collects vault and share-issuance snapshots from ledger entry visits,
* including full balance-delta tracking for per-transaction invariant checks.
*
* Used by per-transaction invariant checks (e.g. VaultCreate, VaultWithdraw)
* that need vault and MPTokenIssuance state with balance-delta tracking.
*/
class VaultInvariantData
{
public:
struct Vault
{
uint256 key = beast::kZero;
Asset asset;
AccountID pseudoId;
AccountID owner;
uint192 shareMPTID = beast::kZero;
Number assetsTotal = 0;
Number assetsAvailable = 0;
Number assetsMaximum = 0;
Number lossUnrealized = 0;
static Vault
make(SLE const&);
};
struct Shares
{
MPTIssue share;
std::uint64_t sharesTotal = 0;
std::uint64_t sharesMaximum = 0;
static Shares
make(SLE const&);
};
struct DeltaInfo
{
Number delta = kNumZero;
std::optional<int> scale;
// Compute the delta between two Numbers, taking the coarsest scale
[[nodiscard]] static DeltaInfo
makeDelta(Number const& before, Number const& after, Asset const& asset);
};
void
visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after);
[[nodiscard]] std::vector<Vault> const&
afterVaults() const
{
return afterVault_;
}
[[nodiscard]] std::vector<Vault> const&
beforeVaults() const
{
return beforeVault_;
}
/** Find shares in afterMPTs_ whose mptID matches. */
[[nodiscard]] std::optional<Shares>
findShares(uint192 const& mptID) const;
/**
* @brief Find a deleted (before-only) MPTokenIssuance whose mptID matches.
*
* Returns the Shares snapshot captured in beforeMPTs_ for the given mptID,
* or std::nullopt if none was deleted in this transaction.
*/
[[nodiscard]] std::optional<Shares>
findDeletedShares(uint192 const& mptID) const;
/** All MPTokenIssuance snapshots captured before modification or deletion. */
[[nodiscard]] std::vector<Shares> const&
beforeMPTIssuances() const
{
return beforeMPTs_;
}
/**
* @brief Return the vault-asset balance-change delta for an account.
*
* Looks up the ledger-entry delta recorded during visitEntry for the
* account entry (XRP), trust line (IOU), or MPToken (MPT) that corresponds
* to the vault asset held by id.
*
* @param id Account whose asset delta is requested.
* @returns The delta, or std::nullopt if the entry was not touched.
*/
[[nodiscard]] std::optional<DeltaInfo>
deltaAssets(AccountID const& id) const;
/**
* @brief Return the vault-asset delta for the transaction's sending
* account, adjusted for the fee.
*
* Calls deltaAssets for tx[sfAccount] and, for non-delegated XRP
* transactions, adds the consumed fee back so the invariant sees the net
* asset movement rather than the fee-reduced balance change.
*
* @param tx The transaction being applied.
* @param fee Fee charged by this transaction.
* @returns The fee-adjusted delta, or std::nullopt if the net delta is
* zero or the account entry was not touched.
*/
[[nodiscard]] std::optional<DeltaInfo>
deltaAssetsTxAccount(STTx const& tx, XRPAmount fee) const;
/**
* @brief Return the vault-share balance-change delta for an account.
*
* For the vault's pseudo-account the MPTokenIssuance outstanding-amount
* delta is returned; for all other accounts the MPToken delta is returned.
*
* @param id Account whose share delta is requested.
* @returns The delta, or std::nullopt if the entry was not touched.
*/
[[nodiscard]] std::optional<DeltaInfo>
deltaShares(AccountID const& id) const;
/**
* @brief Compute the coarsest scale required to represent all numbers.
*/
[[nodiscard]] static std::int32_t
computeCoarsestScale(std::vector<DeltaInfo> const& numbers);
/**
* @brief Compute the minimum STAmount scale for rounding invariant
* calculations.
*
* Post-amendment (fixCleanup3_2_0) this is simply the posterior
* assetsTotal scale. Pre-amendment it is the coarsest scale across
* vaultDelta and both asset-field deltas.
*
* @param vaultDelta Delta of the vault's asset balance for this transaction.
* @param rules Active ledger rules (used to check the amendment).
* @returns The minimum scale to apply when rounding vault-related amounts.
*/
[[nodiscard]] std::int32_t
computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const;
private:
std::vector<Vault> afterVault_;
std::vector<Vault> beforeVault_;
std::vector<Shares> afterMPTs_;
std::vector<Shares> beforeMPTs_;
std::unordered_map<uint256, DeltaInfo> deltas_;
[[nodiscard]] std::optional<DeltaInfo>
lookupDelta(uint256 const& key) const;
};
} // namespace xrpl

View File

@@ -1,6 +1,7 @@
#pragma once
#include <xrpl/tx/Transactor.h>
#include <xrpl/tx/invariants/VaultInvariantData.h>
namespace xrpl {
@@ -38,6 +39,9 @@ public:
XRPAmount fee,
ReadView const& view,
beast::Journal const& j) override;
private:
VaultInvariantData data_;
};
} // namespace xrpl

View File

@@ -1,6 +1,7 @@
#pragma once
#include <xrpl/tx/Transactor.h>
#include <xrpl/tx/invariants/VaultInvariantData.h>
namespace xrpl {
@@ -32,6 +33,9 @@ public:
XRPAmount fee,
ReadView const& view,
beast::Journal const& j) override;
private:
VaultInvariantData data_;
};
} // namespace xrpl

View File

@@ -5,7 +5,6 @@
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
@@ -525,8 +524,7 @@ ValidVault::finalize(
}();
if (!beforeShares &&
(tx.getTxnType() == ttVAULT_DEPOSIT || //
tx.getTxnType() == ttVAULT_WITHDRAW || //
(tx.getTxnType() == ttVAULT_DEPOSIT || //
tx.getTxnType() == ttVAULT_CLAWBACK))
{
JLOG(j.fatal()) << "Invariant failed: vault operation succeeded "
@@ -544,61 +542,15 @@ ValidVault::finalize(
result &= [&]() {
switch (txnType)
{
case ttVAULT_CREATE: {
bool result = true;
if (!beforeVault_.empty())
{
JLOG(j.fatal()) //
<< "Invariant failed: create operation must not have "
"updated a vault";
result = false;
}
if (afterVault.assetsAvailable != kZero || afterVault.assetsTotal != kZero ||
afterVault.lossUnrealized != kZero || updatedShares->sharesTotal != 0)
{
JLOG(j.fatal()) //
<< "Invariant failed: created vault must be empty";
result = false;
}
if (afterVault.pseudoId != updatedShares->share.getIssuer())
{
JLOG(j.fatal()) //
<< "Invariant failed: shares issuer and vault "
"pseudo-account must be the same";
result = false;
}
auto const sleSharesIssuer =
view.read(keylet::account(updatedShares->share.getIssuer()));
if (!sleSharesIssuer)
{
JLOG(j.fatal()) //
<< "Invariant failed: shares issuer must exist";
return false;
}
if (!isPseudoAccount(sleSharesIssuer))
{
JLOG(j.fatal()) //
<< "Invariant failed: shares issuer must be a "
"pseudo-account";
result = false;
}
if (auto const vaultId = (*sleSharesIssuer)[~sfVaultID];
!vaultId || *vaultId != afterVault.key)
{
JLOG(j.fatal()) //
<< "Invariant failed: shares issuer pseudo-account "
"must point back to the vault";
result = false;
}
return result;
}
case ttVAULT_CREATE:
case ttVAULT_WITHDRAW:
case ttLOAN_SET:
case ttLOAN_MANAGE:
case ttLOAN_PAY:
// Create-specific checks live in VaultCreate::finalizeInvariants.
// Withdraw-specific checks live in VaultWithdraw::finalizeInvariants.
// Loan checks are TBD.
return true;
case ttVAULT_SET: {
bool result = true;
@@ -787,166 +739,6 @@ ValidVault::finalize(
return result;
}
case ttVAULT_WITHDRAW: {
bool result = true;
XRPL_ASSERT(
!beforeVault_.empty(),
"xrpl::ValidVault::finalize : withdrawal updated a vault");
auto const& beforeVault = beforeVault_[0];
auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
if (!maybeVaultDeltaAssets)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal must change vault balance";
return false; // That's all we can do
}
// Get the posterior scale to round calculations to
auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules());
auto const vaultPseudoDeltaAssets =
roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
if (vaultPseudoDeltaAssets >= kZero)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal must decrease vault balance";
result = false;
}
// Any payments (including withdrawal) going to the issuer
// do not change their balance, but destroy funds instead.
bool const issuerWithdrawal = [&]() -> bool {
if (vaultAsset.native())
return false;
auto const destination = tx[~sfDestination].value_or(tx[sfAccount]);
return destination == vaultAsset.getIssuer();
}();
if (!issuerWithdrawal)
{
auto const maybeAccDelta = deltaAssetsTxAccount(tx, fee);
auto const maybeOtherAccDelta = [&]() -> std::optional<DeltaInfo> {
if (auto const destination = tx[~sfDestination];
destination && *destination != tx[sfAccount])
return deltaAssets(*destination);
return std::nullopt;
}();
if (maybeAccDelta.has_value() == maybeOtherAccDelta.has_value())
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must change one destination balance";
return false;
}
auto const destinationDelta = //
maybeAccDelta ? *maybeAccDelta : *maybeOtherAccDelta;
// the scale of destinationDelta can be coarser than
// minScale, so we take that into account when rounding
auto const destinationScale = computeCoarsestScale({destinationDelta});
auto const localMinScale = std::max(minScale, destinationScale);
auto const roundedDestinationDelta =
roundToAsset(vaultAsset, destinationDelta.delta, localMinScale);
// Post-fixCleanup3_2_0: Tolerate zero-rounded destination deltas for IOUs only.
// If the receiver's trust line sits at a coarser scale, the inflow may
// safely round down to zero.
//
// XRP and MPT remain strict. Because they are integer-exact, a zero
// destination delta indicates a true accounting bug, not a rounding artifact.
bool const tolerateZeroDelta =
view.rules().enabled(fixCleanup3_2_0) && !vaultAsset.integral();
auto const invalidBalanceChange = tolerateZeroDelta
? roundedDestinationDelta < kZero
: roundedDestinationDelta <= kZero;
if (invalidBalanceChange)
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must increase destination balance";
result = false;
}
auto const localPseudoDeltaAssets =
roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale);
// For IOU assets near a precision boundary the destination's STAmount
// exponent can shift, making part of the sent value unrepresentable at the
// receiver's new scale — that portion is irreversibly absorbed by the IOU
// rail. Tolerate the mismatch only when the destroyed amount (vault outflow
// minus destination inflow, in Number space) is itself sub-ULP at the
// destination's scale. Floor rounding is used so that values exactly at the
// step boundary are not mistakenly dismissed. Any representable discrepancy
// indicates a real accounting bug and must be caught.
auto const destroyedIsSubUlp = tolerateZeroDelta &&
roundToAsset(
vaultAsset,
maybeVaultDeltaAssets->delta * -1 - destinationDelta.delta,
destinationScale,
Number::RoundingMode::Downward) == kZero;
if (!destroyedIsSubUlp &&
localPseudoDeltaAssets * -1 != roundedDestinationDelta)
{
JLOG(j.fatal()) << "Invariant failed: " << //
"withdrawal must change vault and destination balance by equal "
"amount";
result = false;
}
}
// We don't round shares, they are integral MPT
auto const accountDeltaShares = deltaShares(tx[sfAccount]);
if (!accountDeltaShares)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal must change depositor shares";
return false;
}
if (accountDeltaShares->delta >= kZero)
{
JLOG(j.fatal())
<< "Invariant failed: withdrawal must decrease depositor shares";
result = false;
}
// We don't round shares, they are integral MPT
auto const vaultDeltaShares = deltaShares(afterVault.pseudoId);
if (!vaultDeltaShares || vaultDeltaShares->delta == kZero)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal must change vault shares";
return false; // That's all we can do
}
if (vaultDeltaShares->delta * -1 != accountDeltaShares->delta)
{
JLOG(j.fatal()) << "Invariant failed: " << //
"withdrawal must change depositor and vault shares by equal amount";
result = false;
}
auto const assetTotalDelta = roundToAsset(
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
// Note, vaultBalance is negative (see check above)
if (assetTotalDelta != vaultPseudoDeltaAssets)
{
JLOG(j.fatal())
<< "Invariant failed: withdrawal and assets outstanding must add up";
result = false;
}
auto const assetAvailableDelta = roundToAsset(
vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
if (assetAvailableDelta != vaultPseudoDeltaAssets)
{
JLOG(j.fatal())
<< "Invariant failed: withdrawal and assets available must add up";
result = false;
}
return result;
}
case ttVAULT_CLAWBACK: {
bool result = true;
@@ -1042,13 +834,6 @@ ValidVault::finalize(
return result;
}
case ttLOAN_SET:
case ttLOAN_MANAGE:
case ttLOAN_PAY: {
// TBD
return true;
}
default:
// LCOV_EXCL_START
UNREACHABLE("xrpl::ValidVault::finalize : unknown transaction type");

View File

@@ -0,0 +1,337 @@
#include <xrpl/tx/invariants/VaultInvariantData.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/XRPAmount.h>
#include <algorithm>
#include <cstdint>
#include <optional>
#include <variant>
#include <vector>
namespace xrpl {
namespace {
constexpr Number kZero{};
} // namespace
VaultInvariantData::Vault
VaultInvariantData::Vault::make(SLE const& from)
{
XRPL_ASSERT(from.getType() == ltVAULT, "VaultInvariantData::Vault::make : from Vault object");
Vault self;
self.key = from.key();
self.asset = from.at(sfAsset);
self.pseudoId = from.getAccountID(sfAccount);
self.owner = from.at(sfOwner);
self.shareMPTID = from.getFieldH192(sfShareMPTID);
self.assetsTotal = from.at(sfAssetsTotal);
self.assetsAvailable = from.at(sfAssetsAvailable);
self.assetsMaximum = from.at(sfAssetsMaximum);
self.lossUnrealized = from.at(sfLossUnrealized);
return self;
}
VaultInvariantData::Shares
VaultInvariantData::Shares::make(SLE const& from)
{
XRPL_ASSERT(
from.getType() == ltMPTOKEN_ISSUANCE,
"VaultInvariantData::Shares::make : from MPTokenIssuance object");
Shares self;
self.share = MPTIssue(makeMptID(from.getFieldU32(sfSequence), from.getAccountID(sfIssuer)));
self.sharesTotal = from.getFieldU64(sfOutstandingAmount);
self.sharesMaximum = from[~sfMaximumAmount].value_or(kMaxMpTokenAmount);
return self;
}
void
VaultInvariantData::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
{
XRPL_ASSERT(
after != nullptr && (before != nullptr || !isDelete),
"xrpl::VaultInvariantData::visitEntry : some object is available");
// Number balanceDelta will capture the difference (delta) between "before"
// state (zero if created) and "after" state (zero if destroyed), and
// preserves value scale (exponent) to round values to the same scale during
// validation. It is used to validate that the change in account
// balances matches the change in vault balances, stored to deltas_ at the
// end of this function.
DeltaInfo balanceDelta{.delta = kNumZero, .scale = std::nullopt};
std::int8_t sign = 0;
if (before)
{
switch (before->getType())
{
case ltVAULT:
beforeVault_.push_back(Vault::make(*before));
break;
case ltMPTOKEN_ISSUANCE:
// At this moment we have no way of telling if this object holds
// vault shares or something else. Save it for finalize.
beforeMPTs_.push_back(Shares::make(*before));
balanceDelta.delta =
static_cast<std::int64_t>(before->getFieldU64(sfOutstandingAmount));
// MPTs are ints, so the scale is always 0.
balanceDelta.scale = 0;
sign = 1;
break;
case ltMPTOKEN:
balanceDelta.delta = static_cast<std::int64_t>(before->getFieldU64(sfMPTAmount));
// MPTs are ints, so the scale is always 0.
balanceDelta.scale = 0;
sign = -1;
break;
case ltACCOUNT_ROOT:
balanceDelta.delta = before->getFieldAmount(sfBalance);
// Account balance is XRP, which is an int, so the scale is
// always 0.
balanceDelta.scale = 0;
sign = -1;
break;
case ltRIPPLE_STATE: {
auto const amount = before->getFieldAmount(sfBalance);
balanceDelta.delta = amount;
// Trust Line balances are STAmounts, so we can use the exponent
// directly to get the scale.
balanceDelta.scale = amount.exponent();
sign = -1;
break;
}
default:;
}
}
if (!isDelete && after)
{
switch (after->getType())
{
case ltVAULT:
afterVault_.push_back(Vault::make(*after));
break;
case ltMPTOKEN_ISSUANCE:
// At this moment we have no way of telling if this object holds
// vault shares or something else. Save it for finalize.
afterMPTs_.push_back(Shares::make(*after));
balanceDelta.delta -=
Number(static_cast<std::int64_t>(after->getFieldU64(sfOutstandingAmount)));
// MPTs are ints, so the scale is always 0.
balanceDelta.scale = 0;
sign = 1;
break;
case ltMPTOKEN:
balanceDelta.delta -=
Number(static_cast<std::int64_t>(after->getFieldU64(sfMPTAmount)));
// MPTs are ints, so the scale is always 0.
balanceDelta.scale = 0;
sign = -1;
break;
case ltACCOUNT_ROOT:
balanceDelta.delta -= Number(after->getFieldAmount(sfBalance));
// Account balance is XRP, which is an int, so the scale is
// always 0.
balanceDelta.scale = 0;
sign = -1;
break;
case ltRIPPLE_STATE: {
auto const amount = after->getFieldAmount(sfBalance);
balanceDelta.delta -= Number(amount);
// Trust Line balances are STAmounts, so we can use the exponent
// directly to get the scale.
if (amount.exponent() > balanceDelta.scale)
{
balanceDelta.scale = amount.exponent();
}
sign = -1;
break;
}
default:;
}
}
uint256 const key = (before ? before->key() : after->key());
// Append to deltas if sign is non-zero, i.e. an object of an interesting
// type has been updated. A transaction may update an object even when
// its balance has not changed, e.g. transaction fee equals the amount
// transferred to the account. We intentionally do not compare balanceDelta
// against zero, to avoid missing such updates.
if (sign != 0)
{
XRPL_ASSERT_PARTS(
balanceDelta.scale, "xrpl::VaultInvariantData::visitEntry", "scale initialized");
balanceDelta.delta *= sign;
deltas_[key] = balanceDelta;
}
}
std::optional<VaultInvariantData::Shares>
VaultInvariantData::findShares(uint192 const& mptID) const
{
for (auto const& s : afterMPTs_)
{
if (s.share.getMptID() == mptID)
return s;
}
return std::nullopt;
}
std::optional<VaultInvariantData::Shares>
VaultInvariantData::findDeletedShares(uint192 const& mptID) const
{
for (auto const& s : beforeMPTs_)
{
if (s.share.getMptID() == mptID)
{
// Only return if it was deleted (not in afterMPTs_)
bool const inAfter = [&]() {
for (auto const& a : afterMPTs_)
{
if (a.share.getMptID() == mptID)
return true;
}
return false;
}();
if (!inAfter)
return s;
}
}
return std::nullopt;
}
std::optional<VaultInvariantData::DeltaInfo>
VaultInvariantData::lookupDelta(uint256 const& key) const
{
auto const it = deltas_.find(key);
if (it == deltas_.end())
return std::nullopt;
return it->second;
}
std::optional<VaultInvariantData::DeltaInfo>
VaultInvariantData::deltaAssets(AccountID const& id) const
{
auto const& vaultAsset = afterVault_[0].asset;
return std::visit(
[&]<typename TIss>(TIss const& issue) -> std::optional<DeltaInfo> {
if constexpr (std::is_same_v<TIss, Issue>)
{
if (isXRP(issue))
{
return lookupDelta(keylet::account(id).key);
}
auto result = lookupDelta(keylet::line(id, issue).key);
// Trust-line balance is stored from the low-account's perspective;
// negate if id is the high account so the delta is in id's terms.
if (result && id > issue.getIssuer())
result->delta = -result->delta;
return result;
}
else if constexpr (std::is_same_v<TIss, MPTIssue>)
{
return lookupDelta(keylet::mptoken(issue.getMptID(), id).key);
}
},
vaultAsset.value());
}
std::optional<VaultInvariantData::DeltaInfo>
VaultInvariantData::deltaAssetsTxAccount(STTx const& tx, XRPAmount fee) const
{
auto const& vaultAsset = afterVault_[0].asset;
auto ret = deltaAssets(tx[sfAccount]);
if (!ret.has_value() || !vaultAsset.native())
return ret;
if (auto const delegate = tx[~sfDelegate]; delegate.has_value() && *delegate != tx[sfAccount])
return ret;
ret->delta += fee.drops();
if (ret->delta == kZero)
return std::nullopt;
return ret;
}
std::optional<VaultInvariantData::DeltaInfo>
VaultInvariantData::deltaShares(AccountID const& id) const
{
auto const& afterVault = afterVault_[0];
auto const it = [&]() {
if (id == afterVault.pseudoId)
return deltas_.find(keylet::mptIssuance(afterVault.shareMPTID).key);
return deltas_.find(keylet::mptoken(afterVault.shareMPTID, id).key);
}();
return it != deltas_.end() ? std::optional<DeltaInfo>(it->second) : std::nullopt;
}
[[nodiscard]] VaultInvariantData::DeltaInfo
VaultInvariantData::DeltaInfo::makeDelta(
Number const& before,
Number const& after,
Asset const& asset)
{
return {
.delta = after - before,
.scale = std::max(xrpl::scale(after, asset), xrpl::scale(before, asset))};
}
[[nodiscard]] std::int32_t
VaultInvariantData::computeCoarsestScale(std::vector<DeltaInfo> const& numbers)
{
if (numbers.empty())
return 0;
auto const max = std::ranges::max_element(
numbers, [](auto const& a, auto const& b) -> bool { return a.scale < b.scale; });
XRPL_ASSERT_PARTS(
max->scale,
"xrpl::VaultInvariantData::computeCoarsestScale",
"scale set for destinationDelta");
return max->scale.value_or(STAmount::kMaxOffset);
}
std::int32_t
VaultInvariantData::computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const
{
// Returns the posterior `assetsTotal` scale.
//
// 1. Because STAmounts are normalized, `assetsTotal` (being >= `assetsAvailable`)
// safely represents the coarsest exponent needed for both fields.
//
// 2. The scale may decrease (withdraw/clawback) or increase (deposit). In both cases
// we ensure the vault is in a legitimate state in the post-transaction scale.
auto const& afterVault = afterVault_[0];
auto const& vaultAsset = afterVault.asset;
if (rules.enabled(fixCleanup3_2_0))
{
NumberRoundModeGuard const roundGuard(Number::RoundingMode::ToNearest);
return scale(afterVault.assetsTotal, vaultAsset);
}
auto const& beforeVault = beforeVault_[0];
auto const totalDelta =
DeltaInfo::makeDelta(beforeVault.assetsTotal, afterVault.assetsTotal, vaultAsset);
auto const availableDelta =
DeltaInfo::makeDelta(beforeVault.assetsAvailable, afterVault.assetsAvailable, vaultAsset);
return computeCoarsestScale({vaultDelta, totalDelta, availableDelta});
}
} // namespace xrpl

View File

@@ -1,8 +1,10 @@
#include <xrpl/tx/transactors/vault/VaultCreate.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
@@ -263,15 +265,107 @@ VaultCreate::doApply()
}
void
VaultCreate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
VaultCreate::visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
{
// No transaction-specific invariants yet (future work).
data_.visitEntry(isDelete, before, after);
}
bool
VaultCreate::finalizeInvariants(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&)
VaultCreate::finalizeInvariants(
STTx const&,
TER result,
XRPAmount,
ReadView const& view,
beast::Journal const& j)
{
// No transaction-specific invariants yet (future work).
bool const enforce = view.rules().enabled(featureSingleAssetVault);
if (!isTesSuccess(result))
return true;
auto const& afterVaults = data_.afterVaults();
if (afterVaults.empty())
return true;
auto const& afterVault = afterVaults[0];
bool checkResult = true;
if (!data_.beforeVaults().empty())
{
JLOG(j.fatal()) << "Invariant failed: create operation must not have updated a vault";
checkResult = false;
}
// The MPTokenIssuance may not be in the modified set (e.g. only the vault
// was touched in the test), so fall back to a view read if needed.
auto const updatedShares = [&]() -> std::optional<VaultInvariantData::Shares> {
if (auto found = data_.findShares(afterVault.shareMPTID))
return found;
auto const sleShares = view.read(keylet::mptIssuance(afterVault.shareMPTID));
return sleShares ? std::optional<VaultInvariantData::Shares>(
VaultInvariantData::Shares::make(*sleShares))
: std::nullopt;
}();
static constexpr Number kZero{};
if (afterVault.assetsAvailable != kZero || afterVault.assetsTotal != kZero ||
afterVault.lossUnrealized != kZero || (updatedShares && updatedShares->sharesTotal != 0))
{
JLOG(j.fatal()) << "Invariant failed: created vault must be empty";
checkResult = false;
}
if (!updatedShares)
{
JLOG(j.fatal()) << "Invariant failed: updated vault must have shares";
XRPL_ASSERT(enforce, "xrpl::VaultCreate::finalizeInvariants : vault has shares invariant");
if (!checkResult)
{
XRPL_ASSERT(enforce, "xrpl::VaultCreate::finalizeInvariants : vault create invariants");
}
return !enforce;
}
if (afterVault.pseudoId != updatedShares->share.getIssuer())
{
JLOG(j.fatal())
<< "Invariant failed: shares issuer and vault pseudo-account must be the same";
checkResult = false;
}
auto const sleSharesIssuer = view.read(keylet::account(updatedShares->share.getIssuer()));
if (!sleSharesIssuer)
{
JLOG(j.fatal()) << "Invariant failed: shares issuer must exist";
XRPL_ASSERT(
enforce, "xrpl::VaultCreate::finalizeInvariants : shares issuer exists invariant");
if (!checkResult)
{
XRPL_ASSERT(enforce, "xrpl::VaultCreate::finalizeInvariants : vault create invariants");
}
return !enforce;
}
if (!isPseudoAccount(sleSharesIssuer))
{
JLOG(j.fatal()) << "Invariant failed: shares issuer must be a pseudo-account";
checkResult = false;
}
if (auto const vaultId = (*sleSharesIssuer)[~sfVaultID]; !vaultId || *vaultId != afterVault.key)
{
JLOG(j.fatal())
<< "Invariant failed: shares issuer pseudo-account must point back to the vault";
checkResult = false;
}
if (!checkResult)
{
XRPL_ASSERT(enforce, "xrpl::VaultCreate::finalizeInvariants : vault create invariants");
return !enforce;
}
return true;
}

View File

@@ -1,7 +1,9 @@
#include <xrpl/tx/transactors/vault/VaultWithdraw.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.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>
@@ -15,6 +17,7 @@
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/STTakesAsset.h>
@@ -367,20 +370,201 @@ VaultWithdraw::doApply()
}
void
VaultWithdraw::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
VaultWithdraw::visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
{
// No transaction-specific invariants yet (future work).
data_.visitEntry(isDelete, before, after);
}
bool
VaultWithdraw::finalizeInvariants(
STTx const&,
TER,
XRPAmount,
ReadView const&,
beast::Journal const&)
STTx const& tx,
TER result,
XRPAmount fee,
ReadView const& view,
beast::Journal const& j)
{
// No transaction-specific invariants yet (future work).
static constexpr Number kZero{};
bool const enforce = view.rules().enabled(featureSingleAssetVault);
if (!isTesSuccess(result))
return true;
auto const& afterVaults = data_.afterVaults();
if (afterVaults.empty())
return true;
auto const& afterVault = afterVaults[0];
auto const& vaultAsset = afterVault.asset;
XRPL_ASSERT(
!data_.beforeVaults().empty(),
"xrpl::VaultWithdraw::finalizeInvariants : withdrawal updated a vault");
auto const& beforeVault = data_.beforeVaults()[0];
auto const maybeVaultDeltaAssets = data_.deltaAssets(afterVault.pseudoId);
if (!maybeVaultDeltaAssets)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal must change vault balance";
XRPL_ASSERT(enforce, "xrpl::VaultWithdraw::finalizeInvariants : withdrawal changed vault");
return !enforce;
}
// Get the posterior scale to round calculations to
auto const minScale = data_.computeVaultMinScale(*maybeVaultDeltaAssets, view.rules());
auto const vaultPseudoDeltaAssets =
roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
bool result_ = true;
if (vaultPseudoDeltaAssets >= kZero)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal must decrease vault balance";
result_ = false;
}
// Any payments (including withdrawal) going to the issuer
// do not change their balance, but destroy funds instead.
bool const issuerWithdrawal = [&]() -> bool {
if (vaultAsset.native())
return false;
auto const destination = tx[~sfDestination].value_or(tx[sfAccount]);
return destination == vaultAsset.getIssuer();
}();
if (!issuerWithdrawal)
{
auto const maybeAccDelta = data_.deltaAssetsTxAccount(tx, fee);
auto const maybeOtherAccDelta = [&]() -> std::optional<VaultInvariantData::DeltaInfo> {
if (auto const destination = tx[~sfDestination];
destination && *destination != tx[sfAccount])
{
return data_.deltaAssets(*destination);
}
return std::nullopt;
}();
if (maybeAccDelta.has_value() == maybeOtherAccDelta.has_value())
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must change one destination balance";
XRPL_ASSERT(
enforce,
"xrpl::VaultWithdraw::finalizeInvariants : withdrawal changed one destination");
return !enforce;
}
auto const destinationDelta = //
maybeAccDelta ? *maybeAccDelta : *maybeOtherAccDelta;
// the scale of destinationDelta can be coarser than
// minScale, so we take that into account when rounding
auto const destinationScale = VaultInvariantData::computeCoarsestScale({destinationDelta});
auto const localMinScale = std::max(minScale, destinationScale);
auto const roundedDestinationDelta =
roundToAsset(vaultAsset, destinationDelta.delta, localMinScale);
// Post-fixCleanup3_2_0: Tolerate zero-rounded destination deltas for IOUs only.
// If the receiver's trust line sits at a coarser scale, the inflow may
// safely round down to zero.
//
// XRP and MPT remain strict. Because they are integer-exact, a zero
// destination delta indicates a true accounting bug, not a rounding artifact.
bool const tolerateZeroDelta =
view.rules().enabled(fixCleanup3_2_0) && !vaultAsset.integral();
auto const invalidBalanceChange =
tolerateZeroDelta ? roundedDestinationDelta < kZero : roundedDestinationDelta <= kZero;
if (invalidBalanceChange)
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must increase destination balance";
result_ = false;
}
auto const localPseudoDeltaAssets =
roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale);
// For IOU assets near a precision boundary the destination's STAmount
// exponent can shift, making part of the sent value unrepresentable at the
// receiver's new scale — that portion is irreversibly absorbed by the IOU
// rail. Tolerate the mismatch only when the destroyed amount (vault outflow
// minus destination inflow, in Number space) is itself sub-ULP at the
// destination's scale. Floor rounding is used so that values exactly at the
// step boundary are not mistakenly dismissed. Any representable discrepancy
// indicates a real accounting bug and must be caught.
auto const destroyedIsSubUlp = tolerateZeroDelta &&
roundToAsset(
vaultAsset,
maybeVaultDeltaAssets->delta * -1 - destinationDelta.delta,
destinationScale,
Number::RoundingMode::Downward) == kZero;
if (!destroyedIsSubUlp && localPseudoDeltaAssets * -1 != roundedDestinationDelta)
{
JLOG(j.fatal()) << "Invariant failed: " << //
"withdrawal must change vault and destination balance by equal amount";
result_ = false;
}
}
// We don't round shares, they are integral MPT
auto const accountDeltaShares = data_.deltaShares(tx[sfAccount]);
if (!accountDeltaShares)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal must change depositor shares";
XRPL_ASSERT(
enforce,
"xrpl::VaultWithdraw::finalizeInvariants : withdrawal changed depositor shares");
return !enforce;
}
if (accountDeltaShares->delta >= kZero)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal must decrease depositor shares";
result_ = false;
}
// We don't round shares, they are integral MPT
auto const vaultDeltaShares = data_.deltaShares(afterVault.pseudoId);
if (!vaultDeltaShares || vaultDeltaShares->delta == kZero)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal must change vault shares";
XRPL_ASSERT(
enforce, "xrpl::VaultWithdraw::finalizeInvariants : withdrawal changed vault shares");
return !enforce;
}
if (vaultDeltaShares->delta * -1 != accountDeltaShares->delta)
{
JLOG(j.fatal()) << "Invariant failed: " << //
"withdrawal must change depositor and vault shares by equal amount";
result_ = false;
}
auto const assetTotalDelta =
roundToAsset(vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
// Note, vaultPseudoDeltaAssets is negative (see check above)
if (assetTotalDelta != vaultPseudoDeltaAssets)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal and assets outstanding must add up";
result_ = false;
}
auto const assetAvailableDelta = roundToAsset(
vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
if (assetAvailableDelta != vaultPseudoDeltaAssets)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal and assets available must add up";
result_ = false;
}
if (!result_)
{
XRPL_ASSERT(enforce, "xrpl::VaultWithdraw::finalizeInvariants : vault invariants");
return !enforce;
}
return true;
}