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.
This commit is contained in:
Vito
2026-06-09 14:33:52 +02:00
parent 577d7457f1
commit d0a54d1159
5 changed files with 265 additions and 66 deletions

View File

@@ -0,0 +1,76 @@
#pragma once
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <optional>
#include <vector>
namespace xrpl {
/**
* @brief Collects vault and share-issuance snapshots from ledger entry visits.
*
* Used by per-transaction invariant checks (e.g. VaultCreate) that need
* vault and MPTokenIssuance state without the full balance-delta tracking
* that ValidVault maintains.
*/
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&);
};
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;
private:
std::vector<Vault> afterVault_;
std::vector<Vault> beforeVault_;
std::vector<Shares> afterMPTs_;
};
} // 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

@@ -544,61 +544,13 @@ 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 ttLOAN_SET:
case ttLOAN_MANAGE:
case ttLOAN_PAY:
// Create-specific checks live in VaultCreate::finalizeInvariants.
// Loan checks are TBD.
return true;
case ttVAULT_SET: {
bool result = true;
@@ -1042,13 +994,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,80 @@
#include <xrpl/tx/invariants/VaultInvariantData.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
namespace xrpl {
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");
if (before && before->getType() == ltVAULT)
beforeVault_.push_back(Vault::make(*before));
if (!isDelete && after)
{
switch (after->getType())
{
case ltVAULT:
afterVault_.push_back(Vault::make(*after));
break;
case ltMPTOKEN_ISSUANCE:
afterMPTs_.push_back(Shares::make(*after));
break;
default:;
}
}
}
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;
}
} // 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;
}