feat: Add FixedPrecision vault scale helpers and Open-zone deposit checks

New vaults created under V1.2 stay on a lifetime base grid so optional inflows cannot coarsen AssetsTotal, while pre-V1.2 vaults keep the dynamic scale they were created with.
This commit is contained in:
Vito
2026-09-22 11:55:56 +02:00
parent 551a19b10d
commit 71d271ebed
22 changed files with 1089 additions and 152 deletions

View File

@@ -17,6 +17,69 @@ namespace xrpl {
class STTx;
/**
* Return the Vault's current live exponent.
*
* Legacy and CashBasis Vaults use the exponent of AssetsTotal. FixedPrecision
* Vaults floor that exponent at their lifetime base exponent.
*/
[[nodiscard]] int
getVaultScale(SLE::const_ref vault);
/**
* Return the Vault's base exponent.
*
* Legacy and CashBasis Vaults use their current live exponent. FixedPrecision
* Vaults use -Scale, or 0 for integral assets.
*/
[[nodiscard]] int
getVaultBaseScale(SLE::const_ref vault);
/**
* Return the Vault's posterior live exponent after applying an unrounded delta.
*/
[[nodiscard]] int
getPosteriorVaultScale(SLE::const_ref vault, STAmount const& delta);
/**
* Round an amount at the Vault's current live exponent.
*
* Reserved for LoanPay. Vault deposit, withdraw, and clawback round at the
* posterior live exponent instead.
*/
[[nodiscard]] STAmount
roundToVaultScale(SLE::const_ref vault, STAmount const& amount, Number::RoundingMode roundingMode);
/**
* Round an amount at the Vault's posterior live exponent.
*/
[[nodiscard]] STAmount
roundToPosteriorVaultScale(
SLE::const_ref vault,
STAmount const& amount,
Number::RoundingMode roundingMode);
/**
* Open-zone capacity ceiling: 9 * 10^(15 + baseScale).
*
* Defined only for FixedPrecision Vaults, where this is 9 * 10^(15 - P).
*/
[[nodiscard]] Number
getVaultOpenLimit(SLE::const_ref vault);
/**
* Check whether `amount` is an admissible optional inflow.
*
* Legacy and CashBasis Vaults always succeed. FixedPrecision Vaults must
* remain at their base scale after applying the rounded amount, and the
* posterior capacity (AssetsTotal + YieldUnrealized + rounded amount) must
* stay within the Open zone.
*
* The amount is rounded toward zero at the posterior live exponent.
*/
[[nodiscard]] TER
checkOptionalVaultInflow(SLE::const_ref vault, STAmount const& amount);
/**
* From the perspective of a vault, return the number of shares to give
* depositor when they offer a fixed amount of assets. Note, since shares are
@@ -53,9 +116,10 @@ sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co
* Rounding strategy:
* - Debits (withdrawals): Rounds down `|delta|` on the new scale to prevent
* paying out more than requested.
* - Credits (deposits): Floors the resulting total asset balance and returns the
* difference from the current total. This prevents crediting the vault with
* more assets than the user deposited.
* - Legacy/CashBasis credits: Floors the resulting total asset balance and
* returns the difference from the current total.
* - FixedPrecision credits: Rounds the delta toward zero at the posterior live
* scale.
*
* Key rules:
* - The returned magnitude never exceeds `|delta|`.

View File

@@ -319,14 +319,20 @@ constexpr std::uint8_t kVaultDefaultIouScale = 6;
constexpr std::uint8_t kVaultMaximumIouScale = 18;
/**
* Vault ledger-entry schema versions. Assigned to newly created
* Vaults once featureLendingProtocolV1_1 is enabled. Vaults created before
* activation are left without LEVersion (implicit legacy version 0,
* Maximum fixed-precision IOU scale factor for a Vault.
*/
constexpr std::uint8_t kVaultMaximumFixedIouScale = 10;
/**
* Vault ledger-entry schema versions. Assigned to newly created Vaults by
* featureLendingProtocolV1_1 and later protocol amendments. Vaults created
* before activation are left without LEVersion (implicit legacy version 0,
* accrual-basis accounting).
*/
enum class VaultVersion : uint8_t {
Legacy = 0,
CashBasis,
FixedPrecision,
};
/**

View File

@@ -16,6 +16,7 @@
// Keep it sorted in reverse chronological order.
XRPL_FEATURE(SmartEscrow, Supported::No, VoteBehavior::DefaultNo)
// Requires LendingProtocolV1_1. New vaults take FixedPrecision plus cash-basis.
XRPL_FEATURE(LendingProtocolV1_2, Supported::No, VoteBehavior::DefaultNo)
XRPL_FIX (Cleanup3_5_0, Supported::Yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(ConfidentialMPTKeyRotation, Supported::No, VoteBehavior::DefaultNo)

View File

@@ -513,6 +513,7 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({
{sfAssetsAvailable, SoeDefault},
{sfAssetsMaximum, SoeDefault},
{sfLossUnrealized, SoeDefault},
{sfYieldUnrealized, SoeDefault},
{sfShareMPTID, SoeRequired},
{sfWithdrawalPolicy, SoeRequired},
{sfScale, SoeDefault},

View File

@@ -239,6 +239,7 @@ TYPED_SFIELD(sfPrincipalRequested, NUMBER, 14)
TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15, SField::kSmdNeedsAsset | SField::kSmdDefault)
TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16)
TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17, SField::kSmdNeedsAsset | SField::kSmdDefault)
TYPED_SFIELD(sfYieldUnrealized, NUMBER, 18, SField::kSmdNeedsAsset | SField::kSmdDefault)
// 32-bit signed (common)
TYPED_SFIELD(sfLoanScale, INT32, 1)

View File

@@ -242,6 +242,30 @@ public:
return this->sle_->isFieldPresent(sfLossUnrealized);
}
/**
* @brief Get sfYieldUnrealized (SoeDefault)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_NUMBER::type::value_type>
getYieldUnrealized() const
{
if (hasYieldUnrealized())
return this->sle_->at(sfYieldUnrealized);
return std::nullopt;
}
/**
* @brief Check if sfYieldUnrealized is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasYieldUnrealized() const
{
return this->sle_->isFieldPresent(sfYieldUnrealized);
}
/**
* @brief Get sfShareMPTID (SoeRequired)
* @return The field value.
@@ -571,6 +595,17 @@ public:
return *this;
}
/**
* @brief Set sfYieldUnrealized (SoeDefault)
* @return Reference to this builder for method chaining.
*/
VaultBuilder&
setYieldUnrealized(std::decay_t<typename SF_NUMBER::type::value_type> const& value)
{
object_[sfYieldUnrealized] = value;
return *this;
}
/**
* @brief Set sfShareMPTID (SoeRequired)
* @return Reference to this builder for method chaining.

View File

@@ -248,14 +248,13 @@ loanPaymentDeltas(LoanPaymentParts const& parts)
namespace {
// Cash-basis accounting applies only when featureLendingProtocolV1_1 is
// enabled AND the specific Vault was created under it (LEVersion ==
// VaultVersion::CashBasis). Vaults created before activation keep accrual-basis
// accounting forever, even after the amendment later turns on.
// Cash-basis accounting applies to Vaults created under
// featureLendingProtocolV1_1 or a later version. Vaults created before
// activation keep accrual-basis accounting forever.
bool
cashBasisEnabled(SLE::const_ref vaultSle)
{
return getVaultVersion(vaultSle) == VaultVersion::CashBasis;
return getVaultVersion(vaultSle) >= VaultVersion::CashBasis;
}
} // namespace

View File

@@ -7,6 +7,7 @@
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/CredentialHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h> // IWYU pragma: keep
#include <xrpl/protocol/Protocol.h>
@@ -17,6 +18,7 @@
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <algorithm>
#include <cstdint>
#include <expected>
#include <optional>
@@ -24,6 +26,169 @@
namespace xrpl {
namespace {
[[nodiscard]] int
fixedBaseScale(SLE::const_ref vault)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::fixedBaseScale : valid Vault sle");
if (vault->at(sfAsset).integral())
return 0;
return -static_cast<int>(vault->at(sfScale));
}
[[nodiscard]] int
liveScale(Number const& reference, Asset const& asset, int baseScale)
{
if (reference == beast::kZero)
return baseScale;
return std::max(baseScale, scale(reference, asset));
}
[[nodiscard]] VaultKind
decodeVaultKind(std::optional<std::uint8_t> vaultKind)
{
if (vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded))
return VaultKind::ClosedEnded;
return VaultKind::OpenEnded;
}
} // namespace
[[nodiscard]] int
getVaultScale(SLE::const_ref vault)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultScale : valid Vault sle");
switch (getVaultVersion(vault))
{
case VaultVersion::Legacy:
case VaultVersion::CashBasis:
return scale(vault->at(sfAssetsTotal), vault->at(sfAsset));
case VaultVersion::FixedPrecision:
return liveScale(vault->at(sfAssetsTotal), vault->at(sfAsset), fixedBaseScale(vault));
}
// LCOV_EXCL_START
UNREACHABLE("xrpl::getVaultScale : valid VaultVersion");
return Number::kMinExponent - 1;
// LCOV_EXCL_STOP
}
[[nodiscard]] int
getVaultBaseScale(SLE::const_ref vault)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultBaseScale : valid Vault sle");
switch (getVaultVersion(vault))
{
case VaultVersion::Legacy:
case VaultVersion::CashBasis:
return getVaultScale(vault);
case VaultVersion::FixedPrecision:
return fixedBaseScale(vault);
}
// LCOV_EXCL_START
UNREACHABLE("xrpl::getVaultBaseScale : valid VaultVersion");
return Number::kMinExponent - 1;
// LCOV_EXCL_STOP
}
[[nodiscard]] int
getPosteriorVaultScale(SLE::const_ref vault, STAmount const& delta)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT, "xrpl::getPosteriorVaultScale : valid Vault sle");
XRPL_ASSERT(
delta.asset() == vault->at(sfAsset),
"xrpl::getPosteriorVaultScale : delta and Vault asset match");
Number const posterior = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
return vault->at(sfAssetsTotal) + delta;
}();
switch (getVaultVersion(vault))
{
case VaultVersion::Legacy:
case VaultVersion::CashBasis:
return scale(posterior, vault->at(sfAsset));
case VaultVersion::FixedPrecision:
return liveScale(posterior, vault->at(sfAsset), fixedBaseScale(vault));
}
// LCOV_EXCL_START
UNREACHABLE("xrpl::getPosteriorVaultScale : valid VaultVersion");
return Number::kMinExponent - 1;
// LCOV_EXCL_STOP
}
[[nodiscard]] STAmount
roundToVaultScale(SLE::const_ref vault, STAmount const& amount, Number::RoundingMode roundingMode)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::roundToVaultScale : valid Vault sle");
XRPL_ASSERT(
amount.asset() == vault->at(sfAsset),
"xrpl::roundToVaultScale : amount and Vault asset match");
if (amount.integral())
return amount;
return roundToScale(amount, getVaultScale(vault), roundingMode);
}
[[nodiscard]] STAmount
roundToPosteriorVaultScale(
SLE::const_ref vault,
STAmount const& amount,
Number::RoundingMode roundingMode)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT, "xrpl::roundToPosteriorVaultScale : valid Vault sle");
XRPL_ASSERT(
amount.asset() == vault->at(sfAsset),
"xrpl::roundToPosteriorVaultScale : amount and Vault asset match");
if (amount.integral())
return amount;
return roundToScale(amount, getPosteriorVaultScale(vault, amount), roundingMode);
}
[[nodiscard]] Number
getVaultOpenLimit(SLE::const_ref vault)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultOpenLimit : valid Vault sle");
XRPL_ASSERT(
getVaultVersion(vault) == VaultVersion::FixedPrecision,
"xrpl::getVaultOpenLimit : FixedPrecision Vault");
return Number{9, 15 + getVaultBaseScale(vault)};
}
[[nodiscard]] TER
checkOptionalVaultInflow(SLE::const_ref vault, STAmount const& amount)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT, "xrpl::checkOptionalVaultInflow : valid Vault sle");
XRPL_ASSERT(
amount.asset() == vault->at(sfAsset),
"xrpl::checkOptionalVaultInflow : amount and Vault asset match");
XRPL_ASSERT(!amount.negative(), "xrpl::checkOptionalVaultInflow : non-negative amount");
if (getVaultVersion(vault) != VaultVersion::FixedPrecision)
return tesSUCCESS;
STAmount const rounded =
roundToPosteriorVaultScale(vault, amount, Number::RoundingMode::TowardsZero);
int const baseScale = getVaultBaseScale(vault);
// Keep this explicit even though a non-negative YieldUnrealized makes the
// Open-zone capacity ceiling reject every coarsening transition too. The
// protocol defines both conditions independently.
if (getPosteriorVaultScale(vault, rounded) != baseScale)
return tecLIMIT_EXCEEDED;
Number const capacity = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::TowardsZero);
return vault->at(sfAssetsTotal) + vault->at(sfYieldUnrealized) + rounded;
}();
if (capacity > getVaultOpenLimit(vault))
return tecLIMIT_EXCEEDED;
return tesSUCCESS;
}
[[nodiscard]] std::optional<STAmount>
assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& assets)
{
@@ -85,37 +250,47 @@ clampToAssetsTotalScale(SLE::const_ref vault, STAmount const& delta)
{
return magnitude;
}
Number const assetsTotal = vault->at(sfAssetsTotal);
// Calculate the scale after applying the delta using ToNearest rounding.
// This aligns the delta with scale checks used by vault invariants.
int const postScale = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
return scale(assetsTotal + delta, asset);
}();
STAmount actualDelta;
if (delta.negative())
if (getVaultVersion(vault) == VaultVersion::FixedPrecision)
{
// For withdrawals (debits), floor the magnitude to the target scale
// to ensure exact grid alignment without paying out extra assets.
actualDelta = roundToScale(magnitude, postScale, Number::RoundingMode::Downward);
STAmount const rounded =
roundToPosteriorVaultScale(vault, delta, Number::RoundingMode::TowardsZero);
actualDelta = rounded.negative() ? -rounded : rounded;
}
else
{
// For deposits (credits), derive actualDelta from the floored posterior total.
// This prevents grid alignment issues from crediting the vault more than deposited.
//
// Sum using Downward rounding so intermediate precision doesn't round up
// and exceed the original requested amount.
Number const posterior = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::Downward);
return assetsTotal + magnitude;
Number const assetsTotal = vault->at(sfAssetsTotal);
// Calculate the scale after applying the delta using ToNearest rounding.
// This aligns the delta with scale checks used by vault invariants.
int const postScale = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
return scale(assetsTotal + delta, asset);
}();
Number const roundedPosterior =
roundToAsset(asset, posterior, postScale, Number::RoundingMode::Downward);
actualDelta = STAmount{asset, roundedPosterior - assetsTotal};
if (delta.negative())
{
// For withdrawals (debits), floor the magnitude to the target scale
// to ensure exact grid alignment without paying out extra assets.
actualDelta = roundToScale(magnitude, postScale, Number::RoundingMode::Downward);
}
else
{
// For deposits (credits), derive actualDelta from the floored posterior total.
// This prevents grid alignment issues from crediting the vault more than deposited.
//
// Sum using Downward rounding so intermediate precision doesn't round up
// and exceed the original requested amount.
Number const posterior = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::Downward);
return assetsTotal + magnitude;
}();
Number const roundedPosterior =
roundToAsset(asset, posterior, postScale, Number::RoundingMode::Downward);
actualDelta = STAmount{asset, roundedPosterior - assetsTotal};
}
}
XRPL_ASSERT(
@@ -225,7 +400,7 @@ getVaultVersion(SLE::const_ref vault)
return VaultVersion::Legacy;
auto const version = vault->at(sfLEVersion);
if (version > std::to_underlying(VaultVersion::CashBasis))
if (version > std::to_underlying(VaultVersion::FixedPrecision))
{
// LCOV_EXCL_START
UNREACHABLE("xrpl::getVaultVersion : invalid vault version");
@@ -235,18 +410,6 @@ getVaultVersion(SLE::const_ref vault)
return static_cast<VaultVersion>(version);
}
namespace {
[[nodiscard]] VaultKind
decodeVaultKind(std::optional<std::uint8_t> vaultKind)
{
if (vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded))
return VaultKind::ClosedEnded;
return VaultKind::OpenEnded;
}
} // namespace
[[nodiscard]] VaultKind
getVaultKind(SLE::const_ref vault)
{

View File

@@ -347,7 +347,7 @@ LoanSet::preclaim(PreclaimContext const& ctx)
// already at AssetsMaximum cannot take another loan. Cash-basis origination
// does not change AssetsTotal (see cash_basis::loanOriginationDeltas), so
// this leftover accrual gate must not apply there.
if (getVaultVersion(vault) != VaultVersion::CashBasis && vault->at(sfAssetsMaximum) != 0 &&
if (getVaultVersion(vault) < VaultVersion::CashBasis && vault->at(sfAssetsMaximum) != 0 &&
vault->at(sfAssetsTotal) >= vault->at(sfAssetsMaximum))
{
JLOG(ctx.j.warn()) << "Vault at maximum assets limit. Can't add another loan.";
@@ -496,7 +496,7 @@ LoanSet::doApply()
XRPL_ASSERT_PARTS(
*vaultSle->at(sfAssetsMaximum) == 0 ||
getVaultVersion(vaultSle) == VaultVersion::CashBasis ||
getVaultVersion(vaultSle) >= VaultVersion::CashBasis ||
*vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy,
"xrpl::LoanSet::doApply",
"accrual vault is below maximum limit");

View File

@@ -101,7 +101,10 @@ VaultCreate::preflight(PreflightContext const& ctx)
if (vaultAsset.holds<MPTIssue>() || vaultAsset.native())
return temMALFORMED;
if (scale > kVaultMaximumIouScale)
auto const maximumScale = ctx.rules.enabled(featureLendingProtocolV1_2)
? kVaultMaximumFixedIouScale
: kVaultMaximumIouScale;
if (scale > maximumScale)
return temMALFORMED;
}
@@ -273,10 +276,24 @@ VaultCreate::doApply()
}
if (scale != 0u)
vault->at(sfScale) = scale;
if (view().rules().enabled(featureLendingProtocolV1_1))
// featureLendingProtocolV1_2 is defined to require V1.1: new vaults get
// FixedPrecision plus the V1.1 VaultKind fields even if a test enables
// only V1.2. YieldUnrealized is SoeDefault, so writing zero stores the
// field as absent, matching LossUnrealized.
bool const fixedPrecision = view().rules().enabled(featureLendingProtocolV1_2);
bool const cashBasis = view().rules().enabled(featureLendingProtocolV1_1);
if (fixedPrecision)
{
vault->at(sfLEVersion) = std::to_underlying(VaultVersion::FixedPrecision);
vault->at(sfYieldUnrealized) = Number(0);
}
else if (cashBasis)
{
vault->at(sfLEVersion) = std::to_underlying(VaultVersion::CashBasis);
}
if (fixedPrecision || cashBasis)
{
auto const kind = getVaultKind(tx);
vault->at(sfVaultKind) = std::to_underlying(kind);
if (kind == VaultKind::ClosedEnded)

View File

@@ -31,24 +31,6 @@
namespace xrpl {
[[nodiscard]]
static STAmount
roundToVaultScale(STAmount const& amount, SLE::const_ref vault)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::roundToVaultScale : valid vault sle");
XRPL_ASSERT(
amount.asset() == vault->at(sfAsset), "xrpl::roundToVaultScale : valid vault asset");
if (amount.integral())
return amount;
int const postScale = [&]() {
NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
return scale(vault->at(sfAssetsTotal) + amount, vault->at(sfAsset));
}();
return roundToScale(amount, postScale, Number::RoundingMode::Downward);
}
// True if debiting `assets` would leave the depositor's balance where it started, so the deposit
// would mint shares against a transfer that never happened. Asking the balance directly whether it
// notices the debit avoids having to infer the rounding step: it has to be the stored balance that
@@ -187,7 +169,9 @@ VaultDeposit::preclaim(PreclaimContext const& ctx)
if (auto const ter = requireAuth(ctx.view, vaultAsset, account); !isTesSuccess(ter))
return ter;
auto const roundedAmount = fix320Enabled ? roundToVaultScale(amount, vault) : amount;
auto const roundedAmount = fix320Enabled
? roundToPosteriorVaultScale(vault, amount, Number::RoundingMode::TowardsZero)
: amount;
if (fix320Enabled && roundedAmount == beast::kZero)
{
@@ -237,10 +221,11 @@ VaultDeposit::doApply()
return tefINTERNAL; // LCOV_EXCL_LINE
auto const vaultAsset = vault->at(sfAsset);
// Post-amendment IOU only: round Downward to the AssetsTotal precision so
// Post-amendment IOU only: round toward zero to the AssetsTotal precision so
// a sub-ULP tail can't be silently absorbed by one rail and not the other.
auto const amount =
fix320Enabled ? roundToVaultScale(ctx_.tx[sfAmount], vault) : ctx_.tx[sfAmount];
auto const amount = fix320Enabled
? roundToPosteriorVaultScale(vault, ctx_.tx[sfAmount], Number::RoundingMode::TowardsZero)
: ctx_.tx[sfAmount];
// We validated zero-amount in preclaim, if we ended up with zero now, fail hard.
if (amount == beast::kZero)
@@ -374,6 +359,9 @@ VaultDeposit::doApply()
return tecPATH_DRY;
}
if (auto const ter = checkOptionalVaultInflow(vault, assetsDeposited); !isTesSuccess(ter))
return ter;
XRPL_ASSERT(
sharesCreated.asset() != assetsDeposited.asset(),
"xrpl::VaultDeposit::doApply : assets are not shares");

View File

@@ -471,7 +471,7 @@ VaultWithdraw::doApply()
// re-derived: any trimmed residue stays with remaining shareholders.
auto const maybeClamped = clampToAssetsTotalScale(vault, -assetsWithdrawn);
if (!maybeClamped)
return maybeClamped.error(); // LCOV_EXCL_LINE
return maybeClamped.error();
assetsWithdrawn = *maybeClamped;
}
// LCOV_EXCL_START

View File

@@ -1675,7 +1675,6 @@ class LendingHelpers_test : public beast::unit_test::Suite
Number const interestDue{75};
auto const legacyVault = makeVaultSle();
auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis);
{
testcase(
@@ -1691,14 +1690,17 @@ class LendingHelpers_test : public beast::unit_test::Suite
{
testcase(
"loanOriginationDeltas dispatcher: amendment enabled, LEVersion == "
"VaultVersion::CashBasis picks CashBasis");
"loanOriginationDeltas dispatcher: CashBasis and FixedPrecision "
"Vaults pick cash-basis accounting");
Env const env{*this};
auto const deltas =
loanOriginationDeltas(cashBasisVault, principalRequested, interestDue);
auto const expected = xrpl::cash_basis::loanOriginationDeltas(principalRequested);
BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
for (auto const version : {VaultVersion::CashBasis, VaultVersion::FixedPrecision})
{
auto const deltas =
loanOriginationDeltas(makeVaultSle(version), principalRequested, interestDue);
auto const expected = xrpl::cash_basis::loanOriginationDeltas(principalRequested);
BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
}
}
}
@@ -1713,7 +1715,6 @@ class LendingHelpers_test : public beast::unit_test::Suite
Number const interestDue{101};
auto const legacyVault = makeVaultSle(std::nullopt, vaultMaximum, vaultTotal);
auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis, vaultMaximum, vaultTotal);
{
testcase(
@@ -1728,12 +1729,16 @@ class LendingHelpers_test : public beast::unit_test::Suite
{
testcase(
"loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, LEVersion == "
"VaultVersion::CashBasis picks CashBasis");
"loanOriginationExceedsVaultMaximum dispatcher: CashBasis and "
"FixedPrecision Vaults pick cash-basis accounting");
Env const env{*this};
BEAST_EXPECT(
loanOriginationExceedsVaultMaximum(cashBasisVault, vaultTotal, interestDue) ==
false);
for (auto const version : {VaultVersion::CashBasis, VaultVersion::FixedPrecision})
{
BEAST_EXPECT(
loanOriginationExceedsVaultMaximum(
makeVaultSle(version, vaultMaximum, vaultTotal), vaultTotal, interestDue) ==
false);
}
}
}
@@ -1743,7 +1748,6 @@ class LendingHelpers_test : public beast::unit_test::Suite
using namespace jtx;
auto const legacyVault = makeVaultSle();
auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis);
{
testcase("loanVaultExposure dispatcher: amendment enabled, legacy vault picks Accrual");
@@ -1755,13 +1759,16 @@ class LendingHelpers_test : public beast::unit_test::Suite
{
testcase(
"loanVaultExposure dispatcher: amendment enabled, LEVersion == "
"VaultVersion::CashBasis "
"picks CashBasis");
"loanVaultExposure dispatcher: CashBasis and FixedPrecision "
"Vaults pick cash-basis accounting");
Env const env{*this};
auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
BEAST_EXPECT(
loanVaultExposure(cashBasisVault, sle) == xrpl::cash_basis::loanVaultExposure(sle));
for (auto const version : {VaultVersion::CashBasis, VaultVersion::FixedPrecision})
{
BEAST_EXPECT(
loanVaultExposure(makeVaultSle(version), sle) ==
xrpl::cash_basis::loanVaultExposure(sle));
}
}
}
@@ -1777,7 +1784,6 @@ class LendingHelpers_test : public beast::unit_test::Suite
.feePaid = Number{3}};
auto const legacyVault = makeVaultSle();
auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis);
{
testcase("loanPaymentDeltas dispatcher: amendment enabled, legacy vault picks Accrual");
@@ -1790,14 +1796,16 @@ class LendingHelpers_test : public beast::unit_test::Suite
{
testcase(
"loanPaymentDeltas dispatcher: amendment enabled, LEVersion == "
"VaultVersion::CashBasis "
"picks CashBasis");
"loanPaymentDeltas dispatcher: CashBasis and FixedPrecision "
"Vaults pick cash-basis accounting");
Env const env{*this};
auto const deltas = loanPaymentDeltas(cashBasisVault, parts);
auto const expected = xrpl::cash_basis::loanPaymentDeltas(parts);
BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
for (auto const version : {VaultVersion::CashBasis, VaultVersion::FixedPrecision})
{
auto const deltas = loanPaymentDeltas(makeVaultSle(version), parts);
auto const expected = xrpl::cash_basis::loanPaymentDeltas(parts);
BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
}
}
}

View File

@@ -83,12 +83,11 @@ protected:
// Ensure that all the features needed for Lending Protocol are included,
// even if they are set to unsupported.
//
// featureLendingProtocolV1_1 is excluded from the default set: it changes
// Vault/LoanBroker accounting (AssetsTotal/DebtTotal/LossUnrealized), and
// most of this file's tests assert whole-life-specific expected values
// for those fields. Tests that specifically exercise the amendment opt
// it back in explicitly (e.g. `all_ | featureLendingProtocolV1_1`).
FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1};
// Later Lending amendments are excluded from the default set because
// they change accounting and precision behavior. Tests that exercise an
// amendment opt it back in explicitly.
FeatureBitset const all_{
jtx::testableAmendments() - featureLendingProtocolV1_1 - featureLendingProtocolV1_2};
std::string const iouCurrency_{"IOU"};
struct BrokerParameters
@@ -346,7 +345,7 @@ protected:
{
auto const expectedDebt =
env.current()->rules().enabled(featureLendingProtocolV1_1) &&
getVaultVersion(vaultSle) == VaultVersion::CashBasis
getVaultVersion(vaultSle) >= VaultVersion::CashBasis
? principalOutstanding
: principalOutstanding + interestOwed;
env.test.BEAST_EXPECT(brokerDebt == expectedDebt);
@@ -451,7 +450,7 @@ protected:
env.test.BEAST_EXPECT(
vaultSle->at(sfLossUnrealized) ==
(env.current()->rules().enabled(featureLendingProtocolV1_1) &&
getVaultVersion(vaultSle) == VaultVersion::CashBasis
getVaultVersion(vaultSle) >= VaultVersion::CashBasis
? principalOutstanding
: totalValue - managementFeeOutstanding));
}
@@ -666,7 +665,7 @@ protected:
vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable);
auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) +
(env.current()->rules().enabled(featureLendingProtocolV1_1) &&
getVaultVersion(vaultSle) == VaultVersion::CashBasis
getVaultVersion(vaultSle) >= VaultVersion::CashBasis
? state.principalOutstanding
: state.totalValue - state.managementFeeOutstanding);

View File

@@ -127,13 +127,13 @@ private:
testcase(
"bug: VaultWithdraw to destination at IOU precision boundary fires "
"invariant (pre-fixCleanup3_2_0)");
runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
runScenario(all_ - fixCleanup3_2_0, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultWithdraw to destination at IOU precision boundary succeeds "
"when destroyed amount is sub-ULP (post-fixCleanup3_2_0)");
runScenario(testableAmendments(), tesSUCCESS);
runScenario(all_, tesSUCCESS);
}
}
@@ -192,13 +192,13 @@ private:
testcase(
"bug: VaultDeposit by issuer at IOU edge fires "
"tecINVARIANT_FAILED at finalize (pre-fixCleanup3_2_0)");
runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
runScenario(all_ - fixCleanup3_2_0, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultDeposit by issuer at IOU edge rejects with "
"tecPRECISION_LOSS proactively (post-fixCleanup3_2_0)");
runScenario(testableAmendments(), tecPRECISION_LOSS);
runScenario(all_, tecPRECISION_LOSS);
}
}
@@ -271,13 +271,13 @@ private:
testcase(
"bug: VaultDeposit across IOU scale boundary fires invariant "
"(pre-fixCleanup3_2_0)");
runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
runScenario(all_ - fixCleanup3_2_0, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultDeposit across IOU scale boundary succeeds "
"(post-fixCleanup3_2_0)");
runScenario(testableAmendments(), tecPRECISION_LOSS);
runScenario(all_, tecPRECISION_LOSS);
}
}
@@ -344,13 +344,13 @@ private:
testcase(
"bug: VaultWithdraw across IOU scale boundary fires invariant "
"(pre-fixCleanup3_2_0)");
runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
runScenario(all_ - fixCleanup3_2_0, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultWithdraw across IOU scale boundary succeeds "
"(post-fixCleanup3_2_0)");
runScenario(testableAmendments(), tesSUCCESS);
runScenario(all_, tesSUCCESS);
}
}
@@ -436,14 +436,13 @@ private:
// Also remove fixCleanup3_4_0 so the VaultDeposit clamp
// introduced by that amendment does not short-circuit this
// pre-fixCleanup3_2_0 scenario with tecPRECISION_LOSS.
runScenario(
testableAmendments() - fixCleanup3_2_0 - fixCleanup3_4_0, tecINVARIANT_FAILED);
runScenario(all_ - fixCleanup3_2_0 - fixCleanup3_4_0, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultDeposit below Vault precision canonicalized to zero "
"(post-fixCleanup3_2_0)");
runScenario(testableAmendments(), tecPRECISION_LOSS);
runScenario(all_, tecPRECISION_LOSS);
}
}
@@ -581,7 +580,7 @@ private:
// pattern that only makes sense on open-ended vaults. The gate
// added by LP V1.1 is unrelated to the truncation bug asserted
// here.
auto const legacy = testableAmendments() - featureLendingProtocolV1_1;
auto const legacy = all_ - featureLendingProtocolV1_1;
{
testcase(
"bug: VaultDeposit share truncation lets depositor debit "
@@ -697,27 +696,25 @@ private:
testcase(
"bug: VaultWithdraw to third-party at IOU edge fires invariant "
"(pre-fixCleanup3_2_0)");
runScenario(
testableAmendments() - fixCleanup3_2_0, DestKind::ThirdParty, tecINVARIANT_FAILED);
runScenario(all_ - fixCleanup3_2_0, DestKind::ThirdParty, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultWithdraw to third-party at IOU edge succeeds "
"(post-fixCleanup3_2_0)");
runScenario(testableAmendments(), DestKind::ThirdParty, tesSUCCESS);
runScenario(all_, DestKind::ThirdParty, tesSUCCESS);
}
{
testcase(
"bug: VaultWithdraw to self at IOU edge fires invariant "
"(pre-fixCleanup3_2_0)");
runScenario(
testableAmendments() - fixCleanup3_2_0, DestKind::Self, tecINVARIANT_FAILED);
runScenario(all_ - fixCleanup3_2_0, DestKind::Self, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultWithdraw to self at IOU edge succeeds "
"(post-fixCleanup3_2_0)");
runScenario(testableAmendments(), DestKind::Self, tesSUCCESS);
runScenario(all_, DestKind::Self, tesSUCCESS);
}
}
@@ -1007,14 +1004,14 @@ private:
"IOU vault deposit exceeding depositor's balance but "
"within counterparty's trust limit, pre-fixCleanup3_2_0 "
"(tefINTERNAL)");
runTest(test::jtx::testableAmendments() - fixCleanup3_2_0, tefINTERNAL);
runTest(all_ - fixCleanup3_2_0, tefINTERNAL);
}
{
testcase(
"IOU vault deposit exceeding depositor's balance but "
"within counterparty's trust limit, post-fixCleanup3_2_0 "
"(tesSUCCESS)");
runTest(test::jtx::testableAmendments(), tesSUCCESS);
runTest(all_, tesSUCCESS);
}
}
@@ -1026,7 +1023,7 @@ private:
using namespace test::jtx;
testcase("Bug6 - limit bypass with share-denominated withdrawal");
auto const allAmendments = testableAmendments() | featureSingleAssetVault;
auto const allAmendments = all_ | featureSingleAssetVault;
for (auto const& features : {allAmendments, allAmendments - fixCleanup3_1_3})
{
@@ -1283,13 +1280,13 @@ private:
testcase(
"bug: VaultClawback round-trip overshoot lets issuer recover "
"more than requested (pre-fixCleanup3_4_0)");
runScenario(testableAmendments() - fixCleanup3_4_0, false);
runScenario(all_ - fixCleanup3_4_0, false);
}
{
testcase(
"bug: VaultClawback round-trip overshoot is clamped so "
"assetsRecovered <= clawbackAmount (post-fixCleanup3_4_0)");
runScenario(testableAmendments(), true);
runScenario(all_, true);
}
}
@@ -1346,13 +1343,13 @@ private:
testcase(
"bug: VaultWithdraw round-trip overshoot delivers more than "
"requested (pre-fixCleanup3_4_0)");
runScenario(testableAmendments() - fixCleanup3_4_0, false);
runScenario(all_ - fixCleanup3_4_0, false);
}
{
testcase(
"bug: VaultWithdraw round-trip overshoot is clamped so "
"assetsWithdrawn <= requested (post-fixCleanup3_4_0)");
runScenario(testableAmendments(), true);
runScenario(all_, true);
}
}

View File

@@ -0,0 +1,419 @@
#include <test/app/vault/VaultTestBase.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/amount.h>
#include <test/jtx/flags.h>
#include <test/jtx/mpt.h>
#include <test/jtx/pay.h>
#include <test/jtx/ter.h>
#include <test/jtx/trust.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <cstdint>
#include <utility>
namespace xrpl {
class VaultFixedPrecision_test : public VaultTestBase
{
static FeatureBitset
features()
{
return test::jtx::testableAmendments() | featureLendingProtocolV1_1 |
featureLendingProtocolV1_2;
}
void
testCreate()
{
using namespace test::jtx;
Account const issuer{"issuer"};
Account const owner{"owner"};
PrettyAsset const asset{issuer["USD"]};
{
testcase("VaultCreate writes FixedPrecision fields");
Env env(*this, features());
env.fund(XRP(1'000'000), issuer, owner);
env.close();
Vault const vault{env};
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
auto const sle = env.le(keylet);
BEAST_EXPECT(sle);
BEAST_EXPECT(sle->at(sfLEVersion) == std::to_underlying(VaultVersion::FixedPrecision));
BEAST_EXPECT(sle->at(sfScale) == kVaultDefaultIouScale);
BEAST_EXPECT(sle->at(sfYieldUnrealized) == beast::kZero);
}
for (std::uint8_t const scaleValue :
{kVaultMaximumFixedIouScale,
static_cast<std::uint8_t>(kVaultMaximumFixedIouScale + 1)})
{
testcase(
scaleValue == kVaultMaximumFixedIouScale
? "VaultCreate accepts fixed Scale maximum"
: "VaultCreate rejects Scale above fixed maximum");
Env env(*this, features());
env.fund(XRP(1'000'000), issuer, owner);
env.close();
Vault const vault{env};
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
tx[sfScale] = scaleValue;
if (scaleValue == kVaultMaximumFixedIouScale)
{
env(tx);
}
else
{
env(tx, Ter(temMALFORMED));
}
env.close();
BEAST_EXPECT(
static_cast<bool>(env.le(keylet)) == (scaleValue == kVaultMaximumFixedIouScale));
}
{
testcase("CashBasis Vault retains legacy Scale maximum");
auto const legacyFeatures = features() - featureLendingProtocolV1_2;
Env env(*this, legacyFeatures);
env.fund(XRP(1'000'000), issuer, owner);
env.close();
Vault const vault{env};
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
tx[sfScale] = kVaultMaximumIouScale;
env(tx);
env.close();
auto const sle = env.le(keylet);
BEAST_EXPECT(sle);
BEAST_EXPECT(sle->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis));
BEAST_EXPECT(!sle->isFieldPresent(sfYieldUnrealized));
}
}
void
testDepositAdmission()
{
using namespace test::jtx;
Account const issuer{"issuer"};
Account const owner{"owner"};
PrettyAsset const asset{issuer["USD"]};
Number const open{9, 9};
Number const baseUnit{1, -6};
Env env(*this, features());
env.fund(XRP(1'000'000), issuer, owner);
env.close();
env(trust(owner, asset(open + Number{1})));
env.close();
env(pay(issuer, owner, asset(open + Number{1})));
env.close();
Vault const vault{env};
auto [create, keylet] = vault.create({.owner = owner, .asset = asset});
create[sfScale] = 6;
env(create);
env.close();
testcase("VaultDeposit admits the Open boundary");
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(open)}));
env.close();
auto const atOpen = env.le(keylet);
BEAST_EXPECT(atOpen);
BEAST_EXPECT(atOpen->at(sfAssetsTotal) == open);
BEAST_EXPECT(atOpen->at(sfAssetsAvailable) == open);
testcase("VaultDeposit rejects one base unit above Open");
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(baseUnit)}),
Ter(tecLIMIT_EXCEEDED));
env.close();
auto const afterRejected = env.le(keylet);
BEAST_EXPECT(afterRejected);
BEAST_EXPECT(afterRejected->at(sfAssetsTotal) == open);
BEAST_EXPECT(afterRejected->at(sfAssetsAvailable) == open);
}
void
testExistingCashBasisVault()
{
using namespace test::jtx;
testcase("V1.2 does not migrate an existing CashBasis Vault");
Account const issuer{"issuer"};
Account const owner{"owner"};
PrettyAsset const asset{issuer["USD"]};
Number const deposit{9'999'999'999'999'999LL};
Env env(*this, features() - featureLendingProtocolV1_2);
env.fund(XRP(1'000'000), issuer, owner);
env.close();
env(trust(owner, STAmount{asset.raw(), 2, 16}));
env.close();
env(pay(issuer, owner, asset(deposit)));
env.close();
Vault const vault{env};
auto [create, keylet] = vault.create({.owner = owner, .asset = asset});
create[sfScale] = 0;
env(create);
env.close();
auto const before = env.le(keylet);
BEAST_EXPECT(before);
BEAST_EXPECT(before->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis));
env.enableFeature(featureLendingProtocolV1_2);
env.close();
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(deposit)}));
env.close();
auto const after = env.le(keylet);
BEAST_EXPECT(after);
BEAST_EXPECT(after->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis));
BEAST_EXPECT(!after->isFieldPresent(sfYieldUnrealized));
BEAST_EXPECT(after->at(sfAssetsTotal) == deposit);
}
void
testPartialTowardZeroRounding()
{
using namespace test::jtx;
// On a fresh vault the share price is one base unit, so the share
// round-trip already lands on the Scale-6 grid before
// clampToAssetsTotalScale. These cases check that the deposit,
// withdraw, and clawback paths still book that truncated amount.
// A non-unit share price (loan yield) is needed to exercise the
// clamp itself; that arrives with the lending PR.
Account const issuer{"issuer"};
Account const owner{"owner"};
PrettyAsset const asset{issuer["USD"]};
Number const depositRequested{32'345'678, -7}; // 3.2345678
Number const outflowRequested{10'000'005, -7}; // 1.0000005
Env env(*this, features());
env.fund(XRP(1'000'000), issuer, owner);
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
env(trust(owner, asset(4)));
env.close();
env(pay(issuer, owner, asset(4)));
env.close();
Vault const vault{env};
auto [create, keylet] = vault.create({.owner = owner, .asset = asset});
create[sfScale] = 6;
env(create);
env.close();
testcase("VaultDeposit books the truncated amount on the base grid");
env(vault.deposit(
{.depositor = owner, .id = keylet.key, .amount = asset(depositRequested)}));
env.close();
auto afterDeposit = env.le(keylet);
BEAST_EXPECT(afterDeposit);
BEAST_EXPECT(afterDeposit->at(sfAssetsTotal) == (Number{3'234'567, -6}));
BEAST_EXPECT(afterDeposit->at(sfAssetsAvailable) == (Number{3'234'567, -6}));
BEAST_EXPECT(env.balance(owner, asset) == asset(Number{765'433, -6}));
testcase("VaultWithdraw books the truncated amount on the base grid");
env(vault.withdraw(
{.depositor = owner, .id = keylet.key, .amount = asset(outflowRequested)}));
env.close();
auto afterWithdraw = env.le(keylet);
BEAST_EXPECT(afterWithdraw);
BEAST_EXPECT(afterWithdraw->at(sfAssetsTotal) == (Number{2'234'567, -6}));
BEAST_EXPECT(afterWithdraw->at(sfAssetsAvailable) == (Number{2'234'567, -6}));
BEAST_EXPECT(env.balance(owner, asset) == asset(Number{1'765'433, -6}));
testcase("VaultClawback books the truncated amount on the base grid");
env(vault.clawback(
{.issuer = issuer,
.id = keylet.key,
.holder = owner,
.amount = asset(outflowRequested).value()}));
env.close();
auto const afterClawback = env.le(keylet);
BEAST_EXPECT(afterClawback);
BEAST_EXPECT(afterClawback->at(sfAssetsTotal) == (Number{1'234'567, -6}));
BEAST_EXPECT(afterClawback->at(sfAssetsAvailable) == (Number{1'234'567, -6}));
}
void
testIntegralAssetCapacity()
{
using namespace test::jtx;
testcase("FixedPrecision MPT Vault enforces integral Open zone");
Account const issuer{"issuer"};
Account const owner{"owner"};
constexpr std::uint64_t open = 9'000'000'000'000'000;
constexpr std::uint64_t maximum = open + 1;
Env env(*this, features());
env.fund(XRP(1'000'000), issuer, owner);
env.close();
MPTTester mpt{env, issuer, kMptInitNoFund};
mpt.create({.maxAmt = maximum, .flags = tfMPTCanTransfer});
PrettyAsset const asset = mpt.issuanceID();
mpt.authorize({.account = owner});
env(pay(issuer, owner, asset(maximum)));
env.close();
Vault const vault{env};
auto [create, keylet] = vault.create({.owner = owner, .asset = asset});
env(create);
env.close();
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(open)}));
env.close();
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
Ter(tecLIMIT_EXCEEDED));
auto const sle = env.le(keylet);
BEAST_EXPECT(sle);
BEAST_EXPECT(sle->at(sfAssetsTotal) == Number{open});
}
void
testDepositDust()
{
using namespace test::jtx;
testcase("VaultDeposit rejects sub-base-unit dust");
Account const issuer{"issuer"};
Account const owner{"owner"};
PrettyAsset const asset{issuer["USD"]};
Env env(*this, features());
env.fund(XRP(1'000'000), issuer, owner);
env.close();
env(trust(owner, asset(1)));
env.close();
env(pay(issuer, owner, asset(1)));
env.close();
Vault const vault{env};
auto [create, keylet] = vault.create({.owner = owner, .asset = asset});
create[sfScale] = 6;
env(create);
env.close();
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(Number{1, -7})}),
Ter(tecPRECISION_LOSS));
}
void
testWithdrawDust()
{
using namespace test::jtx;
testcase("VaultWithdraw rejects sub-base-unit dust");
Account const issuer{"issuer"};
Account const owner{"owner"};
PrettyAsset const asset{issuer["USD"]};
Env env(*this, features());
env.fund(XRP(1'000'000), issuer, owner);
env.close();
env(trust(owner, asset(2)));
env.close();
env(pay(issuer, owner, asset(2)));
env.close();
Vault const vault{env};
auto [create, keylet] = vault.create({.owner = owner, .asset = asset});
create[sfScale] = 6;
env(create);
env.close();
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
env.close();
env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(Number{1, -7})}),
Ter(tecPRECISION_LOSS));
}
void
testClawbackDust()
{
using namespace test::jtx;
testcase("VaultClawback rejects sub-base-unit dust");
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const depositor{"depositor"};
PrettyAsset const asset{issuer["USD"]};
Env env(*this, features());
env.fund(XRP(1'000'000), issuer, owner, depositor);
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
env(trust(depositor, asset(2)));
env.close();
env(pay(issuer, depositor, asset(2)));
env.close();
Vault const vault{env};
auto [create, keylet] = vault.create({.owner = owner, .asset = asset});
create[sfScale] = 6;
env(create);
env.close();
env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1)}));
env.close();
env(vault.clawback(
{.issuer = issuer,
.id = keylet.key,
.holder = depositor,
.amount = asset(Number{1, -7}).value()}),
Ter(tecPRECISION_LOSS));
}
public:
void
run() override
{
testCreate();
testDepositAdmission();
testExistingCashBasisVault();
testPartialTowardZeroRounding();
testIntegralAssetCapacity();
testDepositDust();
testWithdrawDust();
testClawbackDust();
}
};
BEAST_DEFINE_TESTSUITE(VaultFixedPrecision, app, xrpl);
} // namespace xrpl

View File

@@ -27,10 +27,10 @@
namespace xrpl {
// True unit test of `clampToAssetsTotalScale`. The function under test only
// reads sfAsset and sfAssetsTotal from the vault SLE and never touches a
// ledger view or Rules, so a bare in-memory ltVAULT SLE is enough; there is
// no jtx::Env and no transaction submitted anywhere in this file.
// True unit test of `clampToAssetsTotalScale`. The function under test reads
// only fields from the vault SLE and never touches a ledger view or Rules, so
// a bare in-memory ltVAULT SLE is enough; there is no jtx::Env and no
// transaction submitted anywhere in this file.
//
// Number regime: this suite relies on the default thread_local Number
// mantissa range, which src/libxrpl/basics/Number.cpp initializes to
@@ -58,11 +58,8 @@ private:
std::optional<Number> expected; // nullopt means tecPRECISION_LOSS
};
// Builds a bare ltVAULT SLE with only sfAsset and sfAssetsTotal set,
// mirroring what a transactor does: set the STNumber field, then call
// associateAsset() so it is quantized to the asset's STAmount grid, the
// same way VaultDeposit::doApply does for a real vault (see
// src/libxrpl/tx/transactors/vault/VaultDeposit.cpp).
// Builds a bare ltVAULT SLE with sfLEVersion absent, preserving the
// pre-V1.2 Legacy behavior exercised by this existing clamp table.
static std::shared_ptr<SLE>
makeVault(Asset const& asset, Number const& assetsTotal)
{

View File

@@ -77,7 +77,7 @@ private:
// attaching a loan broker). featureLendingProtocolV1_1 adds a
// closed-ended vault gate on LoanBrokerSet::preclaim and is
// orthogonal to what this suite asserts, so strip it here.
Env env{*this, testableAmendments() - featureLendingProtocolV1_1};
Env env{*this, all_ - featureLendingProtocolV1_1};
Account const owner{"owner"};
Account const issuer{"issuer"};
Account const depositor{"depositor"};
@@ -1018,7 +1018,7 @@ private:
using namespace test::jtx;
Env env{*this, testableAmendments()};
Env env{*this, all_};
Account const owner{"owner"};
Account const issuer{"issuer"};

View File

@@ -113,7 +113,9 @@ protected:
return {.vault = vault, .keylet = keylet, .sub = sub, .red = red};
}
FeatureBitset const all_{test::jtx::testableAmendments()};
// Keep legacy Vault suites on their pre-V1.2 behavior. Tests for the
// fixed-precision protocol enable featureLendingProtocolV1_2 explicitly.
FeatureBitset const all_{test::jtx::testableAmendments() - featureLendingProtocolV1_2};
std::string const iouCurrency_{"IOU"};
};

View File

@@ -369,7 +369,8 @@ private:
BEAST_EXPECT(sleVault);
BEAST_EXPECT((*sleVault)[sfScale] == 6);
}
});
},
{.features = testableAmendments() - featureLendingProtocolV1_2});
testCase(
[&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
@@ -1003,8 +1004,9 @@ private:
{
testcase("VaultCreate LEVersion: featureLendingProtocolV1_1 disabled, field absent");
Env env{*this};
env.disableFeature(featureLendingProtocolV1_1);
Env env{
*this,
testableAmendments() - featureLendingProtocolV1_1 - featureLendingProtocolV1_2};
env.fund(XRP(1'000'000), owner);
env.close();
@@ -1022,7 +1024,7 @@ private:
testcase(
"VaultCreate LEVersion: featureLendingProtocolV1_1 enabled, LEVersion == "
"VaultVersion::CashBasis");
Env env{*this};
Env env{*this, testableAmendments() - featureLendingProtocolV1_2};
env.fund(XRP(1'000'000), owner);
env.close();

View File

@@ -0,0 +1,211 @@
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STIssue.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/STTakesAsset.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/UintTypes.h>
#include <gtest/gtest.h>
#include <helpers/Account.h>
#include <cstdint>
#include <memory>
#include <optional>
#include <utility>
namespace xrpl {
namespace {
std::shared_ptr<SLE>
makeVault(
Asset const& asset,
Number const& assetsTotal,
std::optional<VaultVersion> version,
std::uint8_t scaleValue = kVaultDefaultIouScale)
{
auto vault = std::make_shared<SLE>(keylet::vault(uint256(1)));
vault->setFieldIssue(sfAsset, STIssue{sfAsset, asset});
vault->at(sfAssetsTotal) = assetsTotal;
if (!asset.integral())
vault->at(sfScale) = scaleValue;
if (version)
vault->at(sfLEVersion) = std::to_underlying(*version);
associateAsset(*vault, asset);
return vault;
}
TEST(VaultGrid, BaseAndLiveScale)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto const legacy = makeVault(iou, Number{1'000'000}, VaultVersion::Legacy, 6);
EXPECT_EQ(getVaultScale(legacy), -9);
EXPECT_EQ(getVaultBaseScale(legacy), getVaultScale(legacy));
auto const empty = makeVault(iou, Number{0}, VaultVersion::FixedPrecision, 6);
EXPECT_EQ(getVaultScale(empty), -6);
EXPECT_EQ(getVaultBaseScale(empty), -6);
auto const small = makeVault(iou, Number{1'000'000}, VaultVersion::FixedPrecision, 6);
EXPECT_EQ(getVaultScale(small), -6);
EXPECT_EQ(getVaultBaseScale(small), -6);
auto const coarsened = makeVault(iou, Number{10'000'000'000}, VaultVersion::FixedPrecision, 6);
EXPECT_GT(getVaultScale(coarsened), getVaultBaseScale(coarsened));
EXPECT_EQ(getVaultBaseScale(coarsened), -6);
}
TEST(VaultGrid, PreV12BehaviorIsPreserved)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
Number const assetsTotal{1'000'000};
STAmount const onGrid{iou, Number{2, -9}};
STAmount const dust{iou, Number{4, -10}};
STAmount const overOpen{iou, Number{10, 9}};
auto const downward = Number::RoundingMode::Downward;
auto const legacy = makeVault(iou, assetsTotal, VaultVersion::Legacy);
int const expectedLive = getVaultScale(legacy);
int const expectedPosterior = getPosteriorVaultScale(legacy, onGrid);
STAmount const expectedLiveRound = roundToVaultScale(legacy, onGrid, downward);
STAmount const expectedPosteriorRound = roundToPosteriorVaultScale(legacy, dust, downward);
for (auto const version :
{std::optional<VaultVersion>{},
std::optional{VaultVersion::Legacy},
std::optional{VaultVersion::CashBasis}})
{
auto const vault = makeVault(iou, assetsTotal, version);
EXPECT_EQ(getVaultScale(vault), expectedLive);
EXPECT_EQ(getVaultBaseScale(vault), expectedLive);
EXPECT_EQ(getPosteriorVaultScale(vault, onGrid), expectedPosterior);
EXPECT_EQ(roundToVaultScale(vault, onGrid, downward), expectedLiveRound);
EXPECT_EQ(roundToPosteriorVaultScale(vault, dust, downward), expectedPosteriorRound);
EXPECT_EQ(checkOptionalVaultInflow(vault, overOpen), tesSUCCESS);
}
}
TEST(VaultGrid, PreV12CreditClampFloorsPosteriorTotal)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
Number const assetsTotal{9'999'999'999'999'999LL, -15};
STAmount const delta{iou, Number{5}};
STAmount const expected{iou, Number{4'999'999'999'999'991LL, -15}};
for (auto const version :
{std::optional<VaultVersion>{},
std::optional{VaultVersion::Legacy},
std::optional{VaultVersion::CashBasis}})
{
auto const result = clampToAssetsTotalScale(makeVault(iou, assetsTotal, version), delta);
ASSERT_TRUE(result.has_value());
EXPECT_EQ(*result, expected);
}
}
TEST(VaultGrid, IntegralScaleIsZero)
{
auto const vault = makeVault(xrpIssue(), Number{1'000}, VaultVersion::FixedPrecision, 0);
STAmount const delta{xrpIssue(), 7};
EXPECT_EQ(getVaultScale(vault), 0);
EXPECT_EQ(getVaultBaseScale(vault), 0);
EXPECT_EQ(getPosteriorVaultScale(vault, delta), 0);
EXPECT_EQ(roundToPosteriorVaultScale(vault, delta, Number::RoundingMode::TowardsZero), delta);
}
TEST(VaultGrid, PosteriorScaleRoundsDelta)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto const vault =
makeVault(iou, Number{9'999'999'999'999'999, -6}, VaultVersion::FixedPrecision, 6);
STAmount const delta{iou, Number{21, -6}};
EXPECT_EQ(getVaultScale(vault), -6);
EXPECT_EQ(getPosteriorVaultScale(vault, delta), -5);
EXPECT_EQ(roundToVaultScale(vault, delta, Number::RoundingMode::TowardsZero), delta);
EXPECT_EQ(
roundToPosteriorVaultScale(vault, delta, Number::RoundingMode::TowardsZero),
STAmount(iou, Number{20, -6}));
}
TEST(VaultGrid, PosteriorScaleRejectsDustAtCallSite)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto const vault = makeVault(iou, Number{10'000'000'000}, VaultVersion::FixedPrecision, 6);
STAmount const dust{iou, Number{1, -6}};
EXPECT_EQ(
roundToPosteriorVaultScale(vault, dust, Number::RoundingMode::TowardsZero), beast::kZero);
}
TEST(VaultGrid, PosteriorOutflowCanRefineScale)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto const vault =
makeVault(iou, Number{1'000'000'000'000'001, -5}, VaultVersion::FixedPrecision, 6);
STAmount const delta{iou, -Number{11, -6}};
EXPECT_EQ(getVaultScale(vault), -5);
EXPECT_EQ(getPosteriorVaultScale(vault, delta), -6);
EXPECT_EQ(roundToPosteriorVaultScale(vault, delta, Number::RoundingMode::TowardsZero), delta);
}
TEST(VaultGrid, OptionalInflowCapacityBoundaries)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto fixedIou = makeVault(iou, Number{9, 5}, VaultVersion::FixedPrecision, 10);
STAmount const iouDust{iou, Number{1, -10}};
EXPECT_EQ(getVaultOpenLimit(fixedIou), (Number{9, 5}));
EXPECT_EQ(checkOptionalVaultInflow(fixedIou, STAmount{iou}), tesSUCCESS);
EXPECT_EQ(checkOptionalVaultInflow(fixedIou, iouDust), tecLIMIT_EXCEEDED);
auto fixedXrp = makeVault(xrpIssue(), Number{9, 15}, VaultVersion::FixedPrecision, 0);
STAmount const xrpUnit{xrpIssue(), 1};
EXPECT_EQ(getVaultOpenLimit(fixedXrp), (Number{9, 15}));
EXPECT_EQ(checkOptionalVaultInflow(fixedXrp, STAmount{xrpIssue()}), tesSUCCESS);
EXPECT_EQ(checkOptionalVaultInflow(fixedXrp, xrpUnit), tecLIMIT_EXCEEDED);
auto legacy = makeVault(iou, Number{10, 5}, VaultVersion::Legacy, 10);
EXPECT_EQ(checkOptionalVaultInflow(legacy, iouDust), tesSUCCESS);
auto coarsening =
makeVault(iou, Number{9'999'999'999'999'999, -6}, VaultVersion::FixedPrecision, 6);
STAmount const coarseningDelta{iou, Number{21, -6}};
EXPECT_EQ(checkOptionalVaultInflow(coarsening, coarseningDelta), tecLIMIT_EXCEEDED);
}
TEST(VaultGrid, OptionalInflowIncludesYieldUnrealized)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto vault = makeVault(iou, Number{8'999'999'999}, VaultVersion::FixedPrecision, 6);
STAmount const amount{iou, Number{1}};
EXPECT_EQ(checkOptionalVaultInflow(vault, amount), tesSUCCESS);
vault->at(sfYieldUnrealized) = Number{1};
associateAsset(*vault, iou);
EXPECT_EQ(checkOptionalVaultInflow(vault, amount), tecLIMIT_EXCEEDED);
}
} // namespace
} // namespace xrpl

View File

@@ -32,6 +32,7 @@ TEST(VaultTests, BuilderSettersRoundTrip)
auto const assetsAvailableValue = canonical_NUMBER();
auto const assetsMaximumValue = canonical_NUMBER();
auto const lossUnrealizedValue = canonical_NUMBER();
auto const yieldUnrealizedValue = canonical_NUMBER();
auto const shareMPTIDValue = canonical_UINT192();
auto const withdrawalPolicyValue = canonical_UINT8();
auto const scaleValue = canonical_UINT8();
@@ -57,6 +58,7 @@ TEST(VaultTests, BuilderSettersRoundTrip)
builder.setAssetsAvailable(assetsAvailableValue);
builder.setAssetsMaximum(assetsMaximumValue);
builder.setLossUnrealized(lossUnrealizedValue);
builder.setYieldUnrealized(yieldUnrealizedValue);
builder.setScale(scaleValue);
builder.setLEVersion(lEVersionValue);
builder.setVaultKind(vaultKindValue);
@@ -166,6 +168,14 @@ TEST(VaultTests, BuilderSettersRoundTrip)
EXPECT_TRUE(entry.hasLossUnrealized());
}
{
auto const& expected = yieldUnrealizedValue;
auto const actualOpt = entry.getYieldUnrealized();
ASSERT_TRUE(actualOpt.has_value());
expectEqualField(expected, *actualOpt, "sfYieldUnrealized");
EXPECT_TRUE(entry.hasYieldUnrealized());
}
{
auto const& expected = scaleValue;
auto const actualOpt = entry.getScale();
@@ -231,6 +241,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
auto const assetsAvailableValue = canonical_NUMBER();
auto const assetsMaximumValue = canonical_NUMBER();
auto const lossUnrealizedValue = canonical_NUMBER();
auto const yieldUnrealizedValue = canonical_NUMBER();
auto const shareMPTIDValue = canonical_UINT192();
auto const withdrawalPolicyValue = canonical_UINT8();
auto const scaleValue = canonical_UINT8();
@@ -253,6 +264,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
sle->at(sfAssetsAvailable) = assetsAvailableValue;
sle->at(sfAssetsMaximum) = assetsMaximumValue;
sle->at(sfLossUnrealized) = lossUnrealizedValue;
sle->at(sfYieldUnrealized) = yieldUnrealizedValue;
sle->at(sfShareMPTID) = shareMPTIDValue;
sle->at(sfWithdrawalPolicy) = withdrawalPolicyValue;
sle->at(sfScale) = scaleValue;
@@ -425,6 +437,19 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
expectEqualField(expected, *fromBuilderOpt, "sfLossUnrealized");
}
{
auto const& expected = yieldUnrealizedValue;
auto const fromSleOpt = entryFromSle.getYieldUnrealized();
auto const fromBuilderOpt = entryFromBuilder.getYieldUnrealized();
ASSERT_TRUE(fromSleOpt.has_value());
ASSERT_TRUE(fromBuilderOpt.has_value());
expectEqualField(expected, *fromSleOpt, "sfYieldUnrealized");
expectEqualField(expected, *fromBuilderOpt, "sfYieldUnrealized");
}
{
auto const& expected = scaleValue;
@@ -570,6 +595,8 @@ TEST(VaultTests, OptionalFieldsReturnNullopt)
EXPECT_FALSE(entry.getAssetsMaximum().has_value());
EXPECT_FALSE(entry.hasLossUnrealized());
EXPECT_FALSE(entry.getLossUnrealized().has_value());
EXPECT_FALSE(entry.hasYieldUnrealized());
EXPECT_FALSE(entry.getYieldUnrealized().has_value());
EXPECT_FALSE(entry.hasScale());
EXPECT_FALSE(entry.getScale().has_value());
EXPECT_FALSE(entry.hasLEVersion());