Compare commits

...

10 Commits

Author SHA1 Message Date
Vito
19519c6b19 refactor: Extract FixedPrecision LoanPay deltas and tighten coverage
Move interest-first AssetsTotal, DebtTotal, and vault-credit rounding into fixed_precision::loanPaymentDeltas, warn before clamping YieldUnrealized, and cover management fees plus early full payoff.
2026-09-23 18:04:05 +02:00
Vito
4e83215457 feat: Apply FixedPrecision interest-first accounting to LoanPay
Record paid interest in AssetsTotal under explicit downward rounding, then credit AssetsAvailable with principal plus recorded interest at its posterior scale. Keep FixedPrecision DebtTotal exact, update YieldUnrealized from scheduled interest, and round redirected broker fees at the posterior cover scale.
2026-09-23 17:00:22 +02:00
Vito
5157a2866b feat: Apply FixedPrecision Open-zone admission to LoanSet
Origination on LEVersion 2 vaults uses the vault base scale, rejects coarsened vaults and InterestDue that would leave the Open zone, and books accepted InterestDue into YieldUnrealized.
2026-09-22 16:37:54 +02:00
Vito
663229cfdf fix: Rename LendingHelpers' liveScale to avoid unity-build clash
VaultHelpers.cpp defines its own file-local liveScale with the same
signature. Under -Dunity=ON the ledger module batches sources in
groups of 15, so the two anonymous-namespace helpers can land in the
same translation unit and collide.
2026-09-22 16:30:59 +02:00
Vito
89028a2ec6 style: Fix clang-tidy include-cleaner, braces, and return-move warnings 2026-09-22 15:48:40 +02:00
Vito
bb820683cb feat: Apply FixedPrecision cover grid to LoanBroker first-loss capital
Cover deposit, withdraw, and clawback round at the posterior CoverAvailable exponent so optional inflows cannot coarsen past the Open zone, while outflows may re-fine. DebtMaximum and minimum cover use the vault base scale, and FixedPrecision withdraw/clawback skip the live-scale canApplyToBrokerCover guard that would reject a valid re-fine.
2026-09-22 15:48:40 +02:00
Vito
5e0e36f87d Apply FixedPrecision rounding clamp to Deposit/Withdraw/Clawback; fix vault gate/test gaps
VaultDeposit/VaultWithdraw/VaultClawback now apply the fixCleanup3_4_0
posterior-scale rounding clamp unconditionally for FixedPrecision vaults,
not only when fix340Enabled. VaultCreate's featureLendingProtocolV1_1 gate
check now also accepts V1_2, matching the "V1.2 implies V1.1" semantics
already encoded in doApply.

Test changes:
- VaultHelpers_test: add a FixedPrecision-tagged clamp table to
  clampToAssetsTotalScale coverage; previously only Legacy/CashBasis was
  exercised.
- VaultClosedEnded_test: fix the closed-ended gate test, which asserted
  temDISABLED after subtracting only V1_1 even though V1_2 alone already
  satisfies the gate; add a case proving V1_2-only still opens it.
- VaultFixedPrecision_test: dedupe repeated scaled-vault setup into a
  shared helper.
- VaultTestBase: keep V1_2 excluded from all_ with a comment explaining
  why VaultBugs_test's precision-boundary scenarios are structurally
  unreachable under FixedPrecision's Open-zone cap, not just deferred.
2026-09-22 15:39:12 +02:00
Vito
9ae863d89e docs: Clarify LEVersion and deprecate kVaultMaximumIouScale.
Split the pre-V1.2 Scale cap to kVaultMaximumLegacyIouScale so the old name can warn without breaking V1.2 call sites.
2026-09-22 13:41:10 +02:00
Vito
b17c737e74 Merge remote-tracking branch 'origin/develop' into tapanito/sav-fixed-precision-vault-rounding 2026-09-22 12:41:17 +02:00
Vito
71d271ebed 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.
2026-09-22 11:58:18 +02:00
33 changed files with 2929 additions and 245 deletions

View File

@@ -10,7 +10,6 @@
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/LedgerFormats.h> // IWYU pragma: keep
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
@@ -56,6 +55,46 @@ canApplyToBrokerCover(
beast::Journal j,
std::string_view logPrefix);
/**
* Return a LoanBroker's current live cover exponent.
*
* Legacy and CashBasis Vaults use the exponent of CoverAvailable.
* FixedPrecision Vaults floor that exponent at the Vault's base exponent.
*
* Reserved for fee redirection into cover. Cover deposit, withdraw, and
* clawback round at the posterior live exponent instead.
*/
[[nodiscard]] int
getBrokerCoverScale(SLE::const_ref vault, SLE::const_ref broker);
/**
* Return a LoanBroker's posterior live cover exponent after applying an
* unrounded delta.
*/
[[nodiscard]] int
getPosteriorBrokerCoverScale(SLE::const_ref vault, SLE::const_ref broker, STAmount const& delta);
/**
* Round a cover delta at the LoanBroker's posterior live exponent.
*/
[[nodiscard]] STAmount
roundToPosteriorBrokerCoverScale(
SLE::const_ref vault,
SLE::const_ref broker,
STAmount const& delta,
Number::RoundingMode roundingMode);
/**
* Check whether `amount` is an admissible optional cover inflow.
*
* Legacy and CashBasis Vaults always succeed. A LoanBroker attached to a
* FixedPrecision Vault must remain at the Vault's base scale after applying
* the rounded amount, and its posterior CoverAvailable must stay within the
* Open zone.
*/
[[nodiscard]] TER
checkOptionalBrokerCoverInflow(SLE::const_ref vault, SLE::const_ref broker, STAmount const& amount);
// Lending protocol has dependencies, so capture them here.
bool
checkLendingProtocolDependencies(Rules const& rules, STTx const& tx);
@@ -262,20 +301,16 @@ getAssetsTotalScale(SLE::const_ref vaultSle)
return scale(vaultSle->at(sfAssetsTotal), vaultSle->at(sfAsset));
}
// Compute the minimum required broker cover, rounded consistently.
// DebtTotal is a broker-level aggregate maintained at vault scale, so the
// rounding must also use vault scale — never an individual loan's scale.
inline Number
minimumBrokerCover(Number const& debtTotal, TenthBips32 coverRateMinimum, SLE::const_ref vaultSle)
{
XRPL_ASSERT(
vaultSle && vaultSle->getType() == ltVAULT, "xrpl::minimumBrokerCover : valid Vault sle");
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
return roundToAsset(
vaultSle->at(sfAsset),
tenthBipsOfValue(debtTotal, coverRateMinimum),
getAssetsTotalScale(vaultSle));
}
/**
* Minimum required broker cover, rounded up.
*
* DebtTotal is a broker-level aggregate, never rounded at an individual
* loan's scale. Legacy and CashBasis Vaults round at the live AssetsTotal
* exponent. FixedPrecision Vaults round at the Vault's base exponent
* (`-Scale`, or 0 for integral assets).
*/
Number
minimumBrokerCover(Number const& debtTotal, TenthBips32 coverRateMinimum, SLE::const_ref vaultSle);
TER
checkLoanGuards(
@@ -379,6 +414,22 @@ loanPaymentDeltas(LoanPaymentParts const& parts);
} // namespace cash_basis
// FixedPrecision payment accounting records interest into AssetsTotal before
// deriving the cash credit sent to the Vault pseudo-account.
namespace fixed_precision {
struct PaymentDeltas
{
Number assetsTotalDelta;
Number debtTotalDelta;
Number vaultCredit;
};
PaymentDeltas
loanPaymentDeltas(SLE::const_ref vaultSle, LoanPaymentParts const& parts);
} // namespace fixed_precision
// Public dispatchers: pick cash_basis:: if featureLendingProtocolV1_1 is
// enabled AND the Vault's LEVersion (VaultHelpers::getVaultVersion) is
// VaultVersion::CashBasis, else instant_recognition::. These are the only entry points

View File

@@ -17,6 +17,79 @@ 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);
/**
* Round the LoanPay cash-credit delta at the posterior live exponent of
* AssetsAvailable. The reference is AssetsAvailable, not AssetsTotal.
*/
[[nodiscard]] STAmount
roundToPosteriorAvailableScale(
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 +126,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|`.
@@ -171,11 +245,13 @@ sharesToAssetsWithdraw(
isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref issuance);
/**
* Resolves a Vault's LEVersion, the single point every accounting touch
* point should call to determine which recognition model (instant interest
* recognition vs. cash-basis) a Vault uses. Vaults created before featureLendingProtocolV1_1
* activated never have sfLEVersion set, which resolves here to
* VaultVersion::Legacy.
* Resolves a Vault's LEVersion.
*
* LEVersion is the single point every accounting and rounding helper
* should call to decide which protocol a Vault follows. It is written
* at VaultCreate and is not updated afterwards, so a Vault created
* under an older amendment keeps that behaviour after later amendments
* activate. Absent sfLEVersion resolves to VaultVersion::Legacy.
*
* @param vault The vault SLE.
*

View File

@@ -311,22 +311,45 @@ constexpr std::uint8_t kVaultStrategyFirstComeFirstServe = 1;
* Default IOU scale factor for a Vault
*/
constexpr std::uint8_t kVaultDefaultIouScale = 6;
/**
* Maximum scale factor for a Vault. The number is chosen to ensure that
* 1 IOU can be always converted to shares.
* 10^19 > maxMPTokenAmount (2^64-1) > 10^18
*/
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,
* instant interest recognition).
* Maximum Scale for a Vault created before featureLendingProtocolV1_2.
* Chosen so 1 IOU can always convert to shares:
* 10^19 > maxMPTokenAmount (2^64-1) > 10^18.
*/
constexpr std::uint8_t kVaultMaximumLegacyIouScale = 18;
/**
* Maximum Scale for a Vault created under featureLendingProtocolV1_2.
*/
constexpr std::uint8_t kVaultMaximumFixedIouScale = 10;
/**
* @deprecated Use kVaultMaximumFixedIouScale for V1.2 vaults, or
* kVaultMaximumLegacyIouScale for pre-V1.2 vaults.
*/
[[deprecated("Use kVaultMaximumFixedIouScale or kVaultMaximumLegacyIouScale")]]
constexpr std::uint8_t kVaultMaximumIouScale = kVaultMaximumLegacyIouScale;
/**
* Vault ledger-entry schema versions, persisted as sfLEVersion.
*
* LEVersion records which protocol a Vault was created under so later
* amendments can change the rules for new Vaults without rewriting
* existing ones. VaultCreate writes it from the then-active lending
* amendments; later transactions do not update it. A Vault created
* under an older amendment keeps that amendment's behaviour for its
* lifetime, even after a newer lending amendment activates.
*
* Absent sfLEVersion is implicit Legacy (version 0): instant interest
* recognition and a dynamic AssetsTotal scale. CashBasis (V1.1) uses
* cash-basis recognition on the same dynamic scale. FixedPrecision
* (V1.2) keeps cash-basis recognition and adds the lifetime base grid.
*/
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

@@ -35,6 +35,18 @@
namespace xrpl {
namespace {
[[nodiscard]] int
liveCoverScale(Number const& reference, Asset const& asset, int baseScale)
{
if (reference == beast::kZero)
return baseScale;
return std::max(baseScale, scale(reference, asset));
}
} // namespace
[[nodiscard]] TER
canApplyToBrokerCover(
ReadView const& view,
@@ -66,6 +78,126 @@ canApplyToBrokerCover(
return tesSUCCESS;
}
[[nodiscard]] int
getBrokerCoverScale(SLE::const_ref vault, SLE::const_ref broker)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT, "xrpl::getBrokerCoverScale : valid Vault sle");
XRPL_ASSERT(
broker && broker->getType() == ltLOAN_BROKER,
"xrpl::getBrokerCoverScale : valid LoanBroker sle");
switch (getVaultVersion(vault))
{
case VaultVersion::Legacy:
case VaultVersion::CashBasis:
return scale(broker->at(sfCoverAvailable), vault->at(sfAsset));
case VaultVersion::FixedPrecision:
return liveCoverScale(
broker->at(sfCoverAvailable), vault->at(sfAsset), getVaultBaseScale(vault));
}
// LCOV_EXCL_START
UNREACHABLE("xrpl::getBrokerCoverScale : valid VaultVersion");
return Number::kMinExponent - 1;
// LCOV_EXCL_STOP
}
[[nodiscard]] int
getPosteriorBrokerCoverScale(SLE::const_ref vault, SLE::const_ref broker, STAmount const& delta)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT,
"xrpl::getPosteriorBrokerCoverScale : valid Vault sle");
XRPL_ASSERT(
broker && broker->getType() == ltLOAN_BROKER,
"xrpl::getPosteriorBrokerCoverScale : valid LoanBroker sle");
XRPL_ASSERT(
delta.asset() == vault->at(sfAsset),
"xrpl::getPosteriorBrokerCoverScale : delta and Vault asset match");
Number const posterior = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
return broker->at(sfCoverAvailable) + delta;
}();
switch (getVaultVersion(vault))
{
case VaultVersion::Legacy:
case VaultVersion::CashBasis:
return scale(posterior, vault->at(sfAsset));
case VaultVersion::FixedPrecision:
return liveCoverScale(posterior, vault->at(sfAsset), getVaultBaseScale(vault));
}
// LCOV_EXCL_START
UNREACHABLE("xrpl::getPosteriorBrokerCoverScale : valid VaultVersion");
return Number::kMinExponent - 1;
// LCOV_EXCL_STOP
}
[[nodiscard]] STAmount
roundToPosteriorBrokerCoverScale(
SLE::const_ref vault,
SLE::const_ref broker,
STAmount const& delta,
Number::RoundingMode roundingMode)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT,
"xrpl::roundToPosteriorBrokerCoverScale : valid Vault sle");
XRPL_ASSERT(
broker && broker->getType() == ltLOAN_BROKER,
"xrpl::roundToPosteriorBrokerCoverScale : valid LoanBroker sle");
XRPL_ASSERT(
delta.asset() == vault->at(sfAsset),
"xrpl::roundToPosteriorBrokerCoverScale : delta and Vault asset match");
if (delta.integral())
return delta;
return roundToScale(delta, getPosteriorBrokerCoverScale(vault, broker, delta), roundingMode);
}
[[nodiscard]] TER
checkOptionalBrokerCoverInflow(SLE::const_ref vault, SLE::const_ref broker, STAmount const& amount)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT,
"xrpl::checkOptionalBrokerCoverInflow : valid Vault sle");
XRPL_ASSERT(
broker && broker->getType() == ltLOAN_BROKER,
"xrpl::checkOptionalBrokerCoverInflow : valid LoanBroker sle");
XRPL_ASSERT(
amount.asset() == vault->at(sfAsset),
"xrpl::checkOptionalBrokerCoverInflow : amount and Vault asset match");
XRPL_ASSERT(!amount.negative(), "xrpl::checkOptionalBrokerCoverInflow : non-negative amount");
if (getVaultVersion(vault) != VaultVersion::FixedPrecision)
return tesSUCCESS;
STAmount const rounded =
roundToPosteriorBrokerCoverScale(vault, broker, amount, Number::RoundingMode::TowardsZero);
int const baseScale = getVaultBaseScale(vault);
if (getPosteriorBrokerCoverScale(vault, broker, rounded) != baseScale)
return tecLIMIT_EXCEEDED;
Number const posterior = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::TowardsZero);
return broker->at(sfCoverAvailable) + rounded;
}();
if (posterior > getVaultOpenLimit(vault))
return tecLIMIT_EXCEEDED;
return tesSUCCESS;
}
Number
minimumBrokerCover(Number const& debtTotal, TenthBips32 coverRateMinimum, SLE::const_ref vaultSle)
{
XRPL_ASSERT(
vaultSle && vaultSle->getType() == ltVAULT, "xrpl::minimumBrokerCover : valid Vault sle");
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
return roundToAsset(
vaultSle->at(sfAsset),
tenthBipsOfValue(debtTotal, coverRateMinimum),
getVaultBaseScale(vaultSle));
}
bool
checkLendingProtocolDependencies(Rules const& rules, STTx const& tx)
{
@@ -246,16 +378,49 @@ loanPaymentDeltas(LoanPaymentParts const& parts)
} // namespace cash_basis
namespace fixed_precision {
PaymentDeltas
loanPaymentDeltas(SLE::const_ref vaultSle, LoanPaymentParts const& parts)
{
XRPL_ASSERT(
vaultSle && vaultSle->getType() == ltVAULT,
"xrpl::fixed_precision::loanPaymentDeltas : valid Vault sle");
XRPL_ASSERT(
getVaultVersion(vaultSle) == VaultVersion::FixedPrecision,
"xrpl::fixed_precision::loanPaymentDeltas : FixedPrecision Vault");
Asset const asset = vaultSle->at(sfAsset);
Number const assetsTotalBefore = vaultSle->at(sfAssetsTotal);
Number const assetsTotalAfter = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::Downward);
// Floor the posterior rather than the interest delta because a prior
// AssetsTotal may be off the posterior grid when this payment coarsens
// the Vault. The STAmount conversion also clamps integral assets.
return Number{STAmount{asset, assetsTotalBefore + parts.interestPaid}};
}();
Number const assetsTotalDelta = assetsTotalAfter - assetsTotalBefore;
Number const creditRaw = parts.principalPaid + assetsTotalDelta;
Number const vaultCredit = roundToPosteriorAvailableScale(
vaultSle, STAmount{asset, creditRaw}, Number::RoundingMode::Downward);
return {
.assetsTotalDelta = assetsTotalDelta,
.debtTotalDelta = parts.principalPaid,
.vaultCredit = vaultCredit};
}
} // namespace fixed_precision
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 instant
// interest recognition 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 instant interest recognition 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,192 @@
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]] int
posteriorScale(SLE::const_ref vault, Number const& reference, STAmount const& delta)
{
Number const posterior = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
return reference + 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::posteriorScale : valid VaultVersion");
return Number::kMinExponent - 1;
// LCOV_EXCL_STOP
}
[[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");
return posteriorScale(vault, vault->at(sfAssetsTotal), delta);
}
[[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]] STAmount
roundToPosteriorAvailableScale(
SLE::const_ref vault,
STAmount const& amount,
Number::RoundingMode roundingMode)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT,
"xrpl::roundToPosteriorAvailableScale : valid Vault sle");
XRPL_ASSERT(
amount.asset() == vault->at(sfAsset),
"xrpl::roundToPosteriorAvailableScale : amount and Vault asset match");
if (amount.integral())
return amount;
return roundToScale(
amount, posteriorScale(vault, vault->at(sfAssetsAvailable), 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 +273,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 +423,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 +433,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

@@ -8,6 +8,7 @@
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Concepts.h>
@@ -158,7 +159,7 @@ determineAsset(
std::expected<STAmount, TER>
determineClawAmount(
SLE const& sleBroker,
SLE::const_ref sleBroker,
Asset const& vaultAsset,
std::optional<STAmount> const& amount,
SLE::const_ref vaultSle,
@@ -166,20 +167,23 @@ determineClawAmount(
{
auto const maxClawAmount = [&]() {
auto const minRequiredCover = [&]() {
if (rules.enabled(fixCleanup3_2_0))
if (rules.enabled(fixCleanup3_2_0) ||
getVaultVersion(vaultSle) == VaultVersion::FixedPrecision)
{
return minimumBrokerCover(
sleBroker[sfDebtTotal], TenthBips32(sleBroker[sfCoverRateMinimum]), vaultSle);
sleBroker->at(sfDebtTotal),
TenthBips32(sleBroker->at(sfCoverRateMinimum)),
vaultSle);
}
// Always round the minimum required up
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
return tenthBipsOfValue(
sleBroker[sfDebtTotal], TenthBips32(sleBroker[sfCoverRateMinimum]));
sleBroker->at(sfDebtTotal), TenthBips32(sleBroker->at(sfCoverRateMinimum)));
}();
// The subtraction probably won't round, but round down if it does.
NumberRoundModeGuard const mg(Number::RoundingMode::Downward);
return sleBroker[sfCoverAvailable] - minRequiredCover;
return sleBroker->at(sfCoverAvailable) - minRequiredCover;
}();
if (maxClawAmount <= beast::kZero)
return std::unexpected(tecINSUFFICIENT_FUNDS);
@@ -187,12 +191,24 @@ determineClawAmount(
// Use the vaultAsset here, because it will be the right type in all
// circumstances. The amount may be an IOU indicating the pseudo-account's
// asset, which is correct, but not what is needed here.
if (!amount || *amount == beast::kZero)
return STAmount{vaultAsset, maxClawAmount};
Number const magnitude{*amount};
if (magnitude > maxClawAmount)
return STAmount{vaultAsset, maxClawAmount};
return STAmount{vaultAsset, magnitude};
STAmount requested = [&] {
if (!amount || *amount == beast::kZero)
return STAmount{vaultAsset, maxClawAmount};
Number const magnitude{*amount};
if (magnitude > maxClawAmount)
return STAmount{vaultAsset, maxClawAmount};
return STAmount{vaultAsset, magnitude};
}();
if (getVaultVersion(vaultSle) != VaultVersion::FixedPrecision)
return requested;
// Negate so the posterior is CoverAvailable minus amount.
STAmount rounded = -roundToPosteriorBrokerCoverScale(
vaultSle, sleBroker, -requested, Number::RoundingMode::TowardsZero);
if (rounded == beast::kZero)
return std::unexpected(tecPRECISION_LOSS);
return rounded;
}
template <ValidIssueType T>
@@ -294,7 +310,7 @@ LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx)
}
auto const findClawAmount =
determineClawAmount(*sleBroker, vaultAsset, amount, vault, ctx.view.rules());
determineClawAmount(sleBroker, vaultAsset, amount, vault, ctx.view.rules());
if (!findClawAmount)
{
JLOG(ctx.j.warn()) << "LoanBroker cover is already at minimum.";
@@ -302,9 +318,15 @@ LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx)
}
STAmount const& clawAmount = *findClawAmount;
if (auto const ret = canApplyToBrokerCover(
ctx.view, sleBroker, vaultAsset, clawAmount, ctx.j, "LoanBrokerCoverClawback"))
return ret;
// FixedPrecision outflows already rounded at the posterior exponent; the
// live CoverAvailable scale used by canApplyToBrokerCover would reject a
// re-fining clawback as sub-ULP.
if (getVaultVersion(vault) != VaultVersion::FixedPrecision)
{
if (auto const ret = canApplyToBrokerCover(
ctx.view, sleBroker, vaultAsset, clawAmount, ctx.j, "LoanBrokerCoverClawback"))
return ret;
}
// Explicitly check the balance of the trust line / MPT to make sure the
// balance is actually there. It should always match `sfCoverAvailable`, so
@@ -357,7 +379,7 @@ LoanBrokerCoverClawback::doApply()
auto const vaultAsset = vault->at(sfAsset);
auto const findClawAmount =
determineClawAmount(*sleBroker, vaultAsset, amount, vault, view().rules());
determineClawAmount(sleBroker, vaultAsset, amount, vault, view().rules());
if (!findClawAmount)
return tecINTERNAL; // LCOV_EXCL_LINE
STAmount const& clawAmount = *findClawAmount;

View File

@@ -5,8 +5,10 @@
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
@@ -104,22 +106,32 @@ LoanBrokerCoverDeposit::preclaim(PreclaimContext const& ctx)
// here in preclaim lets us reject sub-cover-scale dust early with tecPRECISION_LOSS instead of
// failing only in doApply.
auto const roundedAmount = [&]() -> STAmount {
if (getVaultVersion(vault) == VaultVersion::FixedPrecision)
{
return roundToPosteriorBrokerCoverScale(
vault, sleBroker, amount, Number::RoundingMode::TowardsZero);
}
if (!fix320Enabled)
return tx[sfAmount];
return STAmount{amount};
return roundToScale(
tx[sfAmount],
amount,
scale(sleBroker->at(sfCoverAvailable), vaultAsset),
Number::RoundingMode::Downward);
}();
if (fix320Enabled && roundedAmount == beast::kZero)
if ((fix320Enabled || getVaultVersion(vault) == VaultVersion::FixedPrecision) &&
roundedAmount == beast::kZero)
{
JLOG(ctx.j.warn()) << "LoanBrokerCoverDeposit: deposit amount: " << tx[sfAmount]
JLOG(ctx.j.warn()) << "LoanBrokerCoverDeposit: deposit amount: " << amount
<< " is zero at loan broker scale";
return tecPRECISION_LOSS;
}
if (auto const ter = checkOptionalBrokerCoverInflow(vault, sleBroker, roundedAmount);
!isTesSuccess(ter))
return ter;
if (accountHolds(
ctx.view,
account,
@@ -155,6 +167,11 @@ LoanBrokerCoverDeposit::doApply()
// see the rationale comment in preclaim.
bool const fix320Enabled = view().rules().enabled(fixCleanup3_2_0);
auto const amount = [&]() -> STAmount {
if (getVaultVersion(vault) == VaultVersion::FixedPrecision)
{
return roundToPosteriorBrokerCoverScale(
vault, broker, tx[sfAmount], Number::RoundingMode::TowardsZero);
}
if (!fix320Enabled)
return tx[sfAmount];

View File

@@ -8,6 +8,7 @@
#include <xrpl/ledger/helpers/CredentialHelpers.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Protocol.h>
@@ -103,10 +104,29 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
if (amount.asset() != vaultAsset)
return tecWRONG_ASSET;
// Helper handles both IOU and MPT correctly without explicit branching.
if (auto const ret = canApplyToBrokerCover(
ctx.view, sleBroker, vaultAsset, amount, ctx.j, "LoanBrokerCoverWithdraw"))
return ret;
auto const roundedAmount = [&] {
if (getVaultVersion(vault) != VaultVersion::FixedPrecision)
return STAmount{amount};
// Negate so the posterior is CoverAvailable minus amount.
return -roundToPosteriorBrokerCoverScale(
vault, sleBroker, -amount, Number::RoundingMode::TowardsZero);
}();
if (getVaultVersion(vault) == VaultVersion::FixedPrecision && roundedAmount == beast::kZero)
{
JLOG(ctx.j.warn()) << "LoanBrokerCoverWithdraw: withdraw amount: " << amount
<< " is zero at loan broker scale";
return tecPRECISION_LOSS;
}
// FixedPrecision outflows already rounded at the posterior exponent; the
// live CoverAvailable scale used by canApplyToBrokerCover would reject a
// re-fining withdrawal as sub-ULP.
if (getVaultVersion(vault) != VaultVersion::FixedPrecision)
{
if (auto const ret = canApplyToBrokerCover(
ctx.view, sleBroker, vaultAsset, roundedAmount, ctx.j, "LoanBrokerCoverWithdraw"))
return ret;
}
// The broker's pseudo-account is the source of funds.
auto const pseudoAccountID = sleBroker->at(sfAccount);
@@ -170,7 +190,7 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
// Cover Rate is in 1/10 bips units
auto const currentDebtTotal = sleBroker->at(sfDebtTotal);
auto const minimumCover = [&]() {
if (fix320Enabled)
if (fix320Enabled || getVaultVersion(vault) == VaultVersion::FixedPrecision)
{
return minimumBrokerCover(
currentDebtTotal, TenthBips32{sleBroker->at(sfCoverRateMinimum)}, vault);
@@ -184,9 +204,9 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
tenthBipsOfValue(currentDebtTotal, TenthBips32(sleBroker->at(sfCoverRateMinimum))),
scale(currentDebtTotal, vaultAsset));
}();
if (coverAvail < amount)
if (coverAvail < roundedAmount)
return tecINSUFFICIENT_FUNDS;
if ((coverAvail - amount) < minimumCover)
if ((coverAvail - roundedAmount) < minimumCover)
return tecINSUFFICIENT_FUNDS;
auto const freezeHandling = fix330Enabled && dstAcct == vaultAsset.getIssuer()
@@ -199,7 +219,7 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
vaultAsset,
freezeHandling,
AuthHandling::ZeroIfUnauthorized,
ctx.j) < amount)
ctx.j) < roundedAmount)
return tecINSUFFICIENT_FUNDS;
return tesSUCCESS;
@@ -211,7 +231,7 @@ LoanBrokerCoverWithdraw::doApply()
auto const& tx = ctx_.tx;
auto const brokerID = tx[sfLoanBrokerID];
auto const amount = tx[sfAmount];
auto const requestedAmount = tx[sfAmount];
auto const dstAcct = tx[~sfDestination].value_or(accountID_);
auto broker = view().peek(keylet::loanBroker(brokerID));
@@ -223,6 +243,11 @@ LoanBrokerCoverWithdraw::doApply()
return tecINTERNAL; // LCOV_EXCL_LINE
auto const vaultAsset = vault->at(sfAsset);
auto const amount = getVaultVersion(vault) == VaultVersion::FixedPrecision
// Negate so the posterior is CoverAvailable minus amount.
? -roundToPosteriorBrokerCoverScale(
vault, broker, -requestedAmount, Number::RoundingMode::TowardsZero)
: requestedAmount;
auto const brokerPseudoID = *broker->at(sfAccount);

View File

@@ -14,6 +14,7 @@
#include <xrpl/protocol/Indexes.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>
#include <xrpl/protocol/STTakesAsset.h>
@@ -174,7 +175,12 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx)
// type. This is mostly only relevant for integral (non-IOU) types
for (auto const& field : getValueFields())
{
if (auto const value = tx[field]; value && STAmount{asset, *value} != *value)
if (auto const value = tx[field]; value &&
(STAmount{asset, *value} != *value ||
(getVaultVersion(sleVault) == VaultVersion::FixedPrecision &&
roundToAsset(
asset, *value, getVaultBaseScale(sleVault), Number::RoundingMode::TowardsZero) !=
*value)))
{
JLOG(ctx.j.warn()) << field.f->getName() << " (" << *value
<< ") can not be represented as a(n) " << to_string(asset) << ".";

View File

@@ -9,6 +9,7 @@
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
@@ -360,6 +361,7 @@ LoanPay::doApply()
TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
auto debtTotalProxy = brokerSle->at(sfDebtTotal);
bool const fixedPrecision = getVaultVersion(vaultSle) == VaultVersion::FixedPrecision;
auto const vaultScale = getAssetsTotalScale(vaultSle);
// Send the broker fee to the owner if they have sufficient cover available,
@@ -375,7 +377,7 @@ LoanPay::doApply()
// DebtTotal) use vaultScale. The legacy path below intentionally retains
// its pre-amendment loanScale behavior.
auto const minCover = [&]() {
if (view.rules().enabled(fixCleanup3_2_0))
if (view.rules().enabled(fixCleanup3_2_0) || fixedPrecision)
{
return minimumBrokerCover(debtTotalProxy.value(), coverRateMinimum, vaultSle);
}
@@ -419,6 +421,12 @@ LoanPay::doApply()
}
}
auto const scheduledInterest = [&loanSle] {
return loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfPrincipalOutstanding) -
loanSle->at(sfManagementFeeOutstanding);
};
Number const scheduledInterestBefore = fixedPrecision ? scheduledInterest() : kNumZero;
LoanPaymentType const paymentType = [&tx]() {
// preflight already checked that at most one flag is set.
if (tx.isFlag(tfLoanLatePayment))
@@ -444,6 +452,9 @@ LoanPay::doApply()
// has been modified.
view.update(loanSle);
Number const scheduledInterestDelta =
fixedPrecision ? scheduledInterest() - scheduledInterestBefore : kNumZero;
XRPL_ASSERT_PARTS(
// It is possible to pay 0 principal
paymentParts->principalPaid >= 0,
@@ -470,32 +481,56 @@ LoanPay::doApply()
// LCOV_EXCL_STOP
}
auto const [assetsTotalDelta, debtTotalDelta] = loanPaymentDeltas(vaultSle, *paymentParts);
JLOG(j_.debug()) << "Loan Pay: principal paid: " << paymentParts->principalPaid
<< ", interest paid: " << paymentParts->interestPaid
<< ", fee paid: " << paymentParts->feePaid
<< ", assets total delta: " << assetsTotalDelta
<< ", debt total delta: " << debtTotalDelta;
//------------------------------------------------------
// LoanBroker object state changes
view.update(brokerSle);
auto assetsAvailableProxy = vaultSle->at(sfAssetsAvailable);
auto assetsTotalProxy = vaultSle->at(sfAssetsTotal);
Number const assetsAvailableBefore = *assetsAvailableProxy;
Number const assetsTotalBefore = *assetsTotalProxy;
auto const totalPaidToVaultRaw = paymentParts->principalPaid + paymentParts->interestPaid;
auto const totalPaidToVaultRounded =
roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::RoundingMode::Downward);
Number assetsTotalDelta;
Number debtTotalDelta;
Number totalPaidToVaultRounded;
if (fixedPrecision)
{
auto const deltas = fixed_precision::loanPaymentDeltas(vaultSle, *paymentParts);
assetsTotalDelta = deltas.assetsTotalDelta;
debtTotalDelta = deltas.debtTotalDelta;
totalPaidToVaultRounded = deltas.vaultCredit;
}
else
{
auto const deltas = loanPaymentDeltas(vaultSle, *paymentParts);
assetsTotalDelta = deltas.assetsTotalDelta;
debtTotalDelta = deltas.debtTotalDelta;
totalPaidToVaultRounded =
roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::RoundingMode::Downward);
}
XRPL_ASSERT_PARTS(
!asset.integral() || totalPaidToVaultRaw == totalPaidToVaultRounded,
"xrpl::LoanPay::doApply",
"rounding does nothing for integral asset");
auto const totalPaidToBroker = paymentParts->feePaid;
Number const totalPaidToBrokerRaw = paymentParts->feePaid;
Number const totalPaidToBroker = fixedPrecision && !sendBrokerFeeToOwner
? Number{roundToPosteriorBrokerCoverScale(
vaultSle,
brokerSle,
STAmount{asset, totalPaidToBrokerRaw},
Number::RoundingMode::TowardsZero)}
: totalPaidToBrokerRaw;
JLOG(j_.debug()) << "Loan Pay: principal paid: " << paymentParts->principalPaid
<< ", interest paid: " << paymentParts->interestPaid
<< ", fee paid: " << paymentParts->feePaid
<< ", assets total delta: " << assetsTotalDelta
<< ", debt total delta: " << debtTotalDelta
<< ", scheduled interest delta: " << scheduledInterestDelta;
XRPL_ASSERT_PARTS(
(totalPaidToVaultRaw + totalPaidToBroker) ==
(totalPaidToVaultRaw + totalPaidToBrokerRaw) ==
(paymentParts->principalPaid + paymentParts->interestPaid + paymentParts->feePaid),
"xrpl::LoanPay::doApply",
"payments add up");
@@ -507,17 +542,22 @@ LoanPay::doApply()
isRounded(asset, debtTotalDelta, loanScale),
"xrpl::LoanPay::doApply",
"debtTotalDelta rounding good");
// Despite our best efforts, it's possible for rounding errors to accumulate
// in the loan broker's debt total. This is because the broker may have more
// than one loan with significantly different scales.
adjustImpreciseNumber(debtTotalProxy, -debtTotalDelta, asset, vaultScale);
if (fixedPrecision)
{
debtTotalProxy -= debtTotalDelta;
}
else
{
// Despite our best efforts, it's possible for rounding errors to accumulate
// in the loan broker's debt total. This is because the broker may have more
// than one loan with significantly different scales.
adjustImpreciseNumber(debtTotalProxy, -debtTotalDelta, asset, vaultScale);
}
//------------------------------------------------------
// Vault object state changes
view.update(vaultSle);
Number const assetsAvailableBefore = *assetsAvailableProxy;
Number const assetsTotalBefore = *assetsTotalProxy;
#if !NDEBUG
{
Number const pseudoAccountBalanceBefore = accountHolds(
@@ -537,6 +577,17 @@ LoanPay::doApply()
assetsAvailableProxy += totalPaidToVaultRounded;
assetsTotalProxy += assetsTotalDelta;
if (fixedPrecision)
{
auto yieldUnrealizedProxy = vaultSle->at(sfYieldUnrealized);
yieldUnrealizedProxy += scheduledInterestDelta;
if (*yieldUnrealizedProxy < beast::kZero)
{
JLOG(j_.warn()) << "LoanPay: YieldUnrealized became negative before clamping: "
<< *yieldUnrealizedProxy;
yieldUnrealizedProxy = kNumZero;
}
}
XRPL_ASSERT_PARTS(
*assetsAvailableProxy <= *assetsTotalProxy,
@@ -557,9 +608,10 @@ LoanPay::doApply()
if (!sendBrokerFeeToOwner)
{
// If there is not enough first-loss capital, add the fee to First Loss
// Cover Pool. Note that this moves the entire fee - it does not attempt
// to split it. The broker can Withdraw it later if they want, or leave
// it for future needs.
// Cover Pool. FixedPrecision rounds the redirected fee at the
// posterior cover scale; any sub-unit remainder is forgiven. The
// broker can Withdraw the credited amount later or leave it for future
// needs.
coverAvailableProxy += totalPaidToBroker;
}
@@ -578,9 +630,10 @@ LoanPay::doApply()
if (assetsAvailableAfter == assetsAvailableBefore)
{
// An unchanged assetsAvailable indicates that the amount paid to the
// vault was zero, or rounded to zero. That should be impossible, but I
// can't rule it out for extreme edge cases, so fail gracefully if it
// happens.
// vault was zero, or rounded to zero. FixedPrecision LoanSet requires
// positive first-payment principal, and no transaction-generated
// schedule currently produces a non-terminal zero-credit payment.
// Fail gracefully if an extreme edge case still reaches this branch.
//
// LCOV_EXCL_START
JLOG(j_.warn()) << "LoanPay: Vault assets available unchanged after rounding: " //

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 instant-recognition 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.";
@@ -467,7 +467,7 @@ LoanSet::doApply()
auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable);
auto vaultTotalProxy = vaultSle->at(sfAssetsTotal);
auto const vaultScale = getAssetsTotalScale(vaultSle);
auto const vaultScale = getVaultBaseScale(vaultSle);
if (vaultAvailableProxy < principalRequested)
{
JLOG(j_.warn()) << "Insufficient assets available in the Vault to fund the 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",
"instant-recognition vault is below maximum limit");
@@ -543,6 +543,24 @@ LoanSet::doApply()
// LCOV_EXCL_STOP
}
if (getVaultVersion(vaultSle) == VaultVersion::FixedPrecision)
{
Number const capacity = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::TowardsZero);
return vaultSle->at(sfAssetsTotal) + vaultSle->at(sfYieldUnrealized) +
state.interestDue;
}();
if (getVaultScale(vaultSle) != getVaultBaseScale(vaultSle) ||
capacity > getVaultOpenLimit(vaultSle))
{
JLOG(j_.warn()) << "Loan interest would exceed the FixedPrecision Vault's Open zone.";
return tecLIMIT_EXCEEDED;
}
XRPL_ASSERT(
properties.loanScale == getVaultBaseScale(vaultSle),
"xrpl::LoanSet::doApply : FixedPrecision loan uses Vault base scale");
}
auto const originationFee = tx[~sfLoanOriginationFee].value_or(Number{});
auto const loanAssetsToBorrower = principalRequested - originationFee;
@@ -559,7 +577,8 @@ LoanSet::doApply()
TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
{
auto const minCover = [&]() {
if (ctx_.view().rules().enabled(fixCleanup3_2_0))
if (ctx_.view().rules().enabled(fixCleanup3_2_0) ||
getVaultVersion(vaultSle) == VaultVersion::FixedPrecision)
{
return minimumBrokerCover(newDebtTotal, coverRateMinimum, vaultSle);
}
@@ -694,6 +713,8 @@ LoanSet::doApply()
// Update the balances in the vault
vaultAvailableProxy -= principalRequested;
vaultTotalProxy += assetsTotalDelta;
if (getVaultVersion(vaultSle) == VaultVersion::FixedPrecision)
vaultSle->at(sfYieldUnrealized) += state.interestDue;
XRPL_ASSERT_PARTS(
*vaultAvailableProxy <= *vaultTotalProxy,
"xrpl::LoanSet::doApply",

View File

@@ -360,7 +360,9 @@ VaultClawback::assetsToClawback(
// rails change by the same representable delta. sharesDestroyed is intentionally NOT
// re-derived here: the holder's shares are burned for their pre-clamp value, so any
// sub-ULP trimmed off stays in the vault for the remaining shareholders.
if (ctx_.view().rules().enabled(fixCleanup3_4_0) && assetsRecovered > beast::kZero)
if ((ctx_.view().rules().enabled(fixCleanup3_4_0) ||
getVaultVersion(vault) == VaultVersion::FixedPrecision) &&
assetsRecovered > beast::kZero)
{
auto const maybeClamped = clampToAssetsTotalScale(vault, -assetsRecovered);
if (!maybeClamped)

View File

@@ -45,6 +45,7 @@ VaultCreate::checkExtraFeatures(PreflightContext const& ctx)
return false;
if (!ctx.rules.enabled(featureLendingProtocolV1_1) &&
!ctx.rules.enabled(featureLendingProtocolV1_2) &&
(ctx.tx.isFieldPresent(sfVaultKind) || ctx.tx.isFieldPresent(sfSubscriptionDate) ||
ctx.tx.isFieldPresent(sfRedemptionDate)))
return false;
@@ -101,7 +102,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
: kVaultMaximumLegacyIouScale;
if (scale > maximumScale)
return temMALFORMED;
}
@@ -273,10 +277,24 @@ VaultCreate::doApply()
}
if (scale != 0u)
vault->at(sfScale) = scale;
if (view().rules().enabled(featureLendingProtocolV1_1))
// Treat featureLendingProtocolV1_2 as implying V1.1 when creating a vault;
// there is no FeatureBitset-level dependency lock. 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) || fixedPrecision;
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)
@@ -344,7 +329,7 @@ VaultDeposit::doApply()
// Post-fixCleanup3_4_0: round the deposit to the sfAssetsTotal scale so all accounting
// fields (trust line / MPT, sfAssetsAvailable, sfAssetsTotal) change by the same
// representable delta.
if (fix340Enabled)
if (fix340Enabled || getVaultVersion(vault) == VaultVersion::FixedPrecision)
{
// Round down at the posterior sfAssetsTotal scale so the vault is credited by no more
// than the depositor paid. Keep the share count from the first round trip: the clamp
@@ -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

@@ -450,7 +450,8 @@ VaultWithdraw::doApply()
// permits fixed-share zero-asset withdrawals in a fully-impaired vault (where
// assetsTotalForWithdrawal == 0), and clamping-then-rejecting would undo that. Also skip on
// the final-withdrawal path, which overwrites assetsWithdrawn with sfAssetsAvailable below.
if (fix340Enabled && !isFinalWithdrawal && assetsWithdrawn > beast::kZero)
if ((fix340Enabled || getVaultVersion(vault) == VaultVersion::FixedPrecision) &&
!isFinalWithdrawal && assetsWithdrawn > beast::kZero)
{
// Check availability against the unclamped amount first, so a withdrawal that is both
// over the vault's available balance and sub-ULP at the posterior sfAssetsTotal scale
@@ -471,7 +472,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

@@ -20,6 +20,7 @@
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STTakesAsset.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
@@ -1666,6 +1667,32 @@ class LendingHelpers_test : public beast::unit_test::Suite
}
}
void
testFixedPrecisionLoanPaymentDeltas()
{
testcase("fixed_precision::loanPaymentDeltas floors posterior AssetsTotal");
using namespace jtx;
Env const env{*this};
Account const issuer{"issuer"};
PrettyAsset const asset = issuer["USD"];
auto vault = std::make_shared<SLE>(ltVAULT, uint256{2u});
vault->setFieldIssue(sfAsset, STIssue{sfAsset, asset});
vault->at(sfAssetsTotal) = Number{9'999'999'999'999'999, -6};
vault->at(sfAssetsAvailable) = Number{9'999'999'999'999'999, -6};
vault->at(sfScale) = 6;
vault->at(sfLEVersion) = std::to_underlying(VaultVersion::FixedPrecision);
associateAsset(*vault, asset);
LoanPaymentParts const parts{.principalPaid = Number{1, -5}, .interestPaid = Number{5, -6}};
auto const deltas = fixed_precision::loanPaymentDeltas(vault, parts);
BEAST_EXPECT((deltas.assetsTotalDelta == Number{1, -6}));
BEAST_EXPECT(deltas.debtTotalDelta == parts.principalPaid);
BEAST_EXPECT((deltas.vaultCredit == Number{1, -5}));
}
void
testLoanOriginationDeltasDispatcher()
{
@@ -1675,7 +1702,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 +1717,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 +1742,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 +1756,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 +1775,6 @@ class LendingHelpers_test : public beast::unit_test::Suite
using namespace jtx;
auto const legacyVault = makeVaultSle();
auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis);
{
testcase(
@@ -1758,13 +1789,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));
}
}
}
@@ -1780,7 +1814,6 @@ class LendingHelpers_test : public beast::unit_test::Suite
.feePaid = Number{3}};
auto const legacyVault = makeVaultSle();
auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis);
{
testcase(
@@ -1795,14 +1828,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);
}
}
}
@@ -1854,6 +1889,17 @@ public:
.amount = STAmount{iou, Number{1, -13}},
.expected = tesSUCCESS,
},
{
// CoverAvailable 1e10 is exponent -5. 1e-6 is non-zero at
// FixedPrecision P=6, but ToNearest at the live cover
// exponent rounds it to zero. Withdraw/clawback skip this
// helper for FixedPrecision so a re-fining outflow can
// succeed.
.name = "Coarsened live scale rejects re-fining amount",
.coverAvailable = Number{1, 10},
.amount = STAmount{iou, Number{1, -6}},
.expected = tecPRECISION_LOSS,
},
};
Env const env{*this};
@@ -1883,6 +1929,26 @@ public:
envOff.journal,
"test") == tesSUCCESS);
}
testcase("minimumBrokerCover: FixedPrecision uses base scale");
auto const makeVault = [&](VaultVersion version) {
auto vault = std::make_shared<SLE>(ltVAULT, uint256{2u});
vault->setFieldIssue(sfAsset, STIssue{sfAsset, iou});
vault->at(sfAssetsTotal) = Number{1};
vault->at(sfScale) = 6;
vault->at(sfLEVersion) = std::to_underlying(version);
associateAsset(*vault, iou);
return vault;
};
Number const debtTotal{15, -2};
TenthBips32 const coverRate{1};
BEAST_EXPECT(
(minimumBrokerCover(debtTotal, coverRate, makeVault(VaultVersion::FixedPrecision)) ==
Number{2, -6}));
BEAST_EXPECT(
(minimumBrokerCover(debtTotal, coverRate, makeVault(VaultVersion::CashBasis)) ==
Number{15, -7}));
}
// Targeted unit test for getLoanDefaultFreezeExemptAccounts(): builds a real
@@ -2010,6 +2076,7 @@ public:
testInstantRecognitionLoanVaultExposure();
testCashBasisLoanVaultExposure();
testLoanPaymentDeltas();
testFixedPrecisionLoanPaymentDeltas();
testLoanOriginationDeltasDispatcher();
testLoanOriginationExceedsVaultMaximumDispatcher();
testLoanVaultExposureDispatcher();

View File

@@ -73,12 +73,12 @@ class LoanBroker_test : public beast::unit_test::Suite
// 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 adds
// the closed-ended vault gate on LoanBrokerSet::preclaim (see
// LoanBrokerSet.cpp), but this suite exercises loan-broker mechanics on
// plain open-ended vaults. Tests that specifically exercise the
// amendment opt it back in explicitly and use closed-ended vaults.
FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1};
// V1.1 and V1.2 are excluded from the default set: they add the
// closed-ended Vault gate and fixed-precision behavior, while this suite
// primarily exercises legacy LoanBroker mechanics on open-ended Vaults.
// Tests for the new behavior opt both amendments back in explicitly.
FeatureBitset const all_{
jtx::testableAmendments() - featureLendingProtocolV1_1 - featureLendingProtocolV1_2};
void
testDisabled()
@@ -2757,6 +2757,135 @@ class LoanBroker_test : public beast::unit_test::Suite
BEAST_EXPECT(!env.le(credKeylet));
}
void
testFixedPrecisionCover()
{
using namespace jtx;
using namespace loan_broker;
testcase("FixedPrecision LoanBroker cover");
FeatureBitset const v12{all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2};
Account const issuer{"issuer"};
Account const alice{"alice"};
Account const borrower{"borrower"};
Env env{*this, v12};
env.fund(XRP(100'000), issuer, alice, borrower);
env.close();
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
PrettyAsset const iou = issuer["IOU"];
env(trust(alice, iou(Number{10, 10})));
env(trust(borrower, iou(Number{10, 10})));
env(pay(issuer, alice, iou(Number{10, 9})));
env(pay(issuer, borrower, iou(Number{10, 2})));
Vault const vault{env};
[[maybe_unused]] auto [createTx, vaultKeylet, subscriptionDate] =
vault.createClosedEnded({.owner = alice, .asset = iou});
createTx[sfScale] = 6;
env(createTx);
env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = iou(100)}));
vault.closePastSubscription(subscriptionDate);
env(set(alice, vaultKeylet.key), kDebtMaximum(Number{15, -7}), Ter(tecPRECISION_LOSS));
auto const brokerKeylet =
keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
env(set(alice, vaultKeylet.key), kDebtMaximum(Number{1, -6}));
env(coverDeposit(alice, brokerKeylet.key, iou(Number{1, -7})), Ter(tecPRECISION_LOSS));
Number const openLimit{9, 9};
env(coverDeposit(alice, brokerKeylet.key, iou(Number{18, -7})));
{
auto const broker = env.le(brokerKeylet);
BEAST_EXPECT(broker);
if (broker)
BEAST_EXPECT((broker->at(sfCoverAvailable) == Number{1, -6}));
}
env(coverDeposit(alice, brokerKeylet.key, iou(openLimit - Number{1, -6})));
env(coverDeposit(alice, brokerKeylet.key, iou(Number{1, -6})), Ter(tecLIMIT_EXCEEDED));
auto const coverAvailable = [&]() {
auto const broker = env.le(brokerKeylet);
BEAST_EXPECT(broker);
return broker ? broker->at(sfCoverAvailable) : Number{0};
};
BEAST_EXPECT(coverAvailable() == openLimit);
env(coverWithdraw(alice, brokerKeylet.key, iou(Number{18, -7})));
BEAST_EXPECT((coverAvailable() == openLimit - Number{1, -6}));
env(coverClawback(issuer), kLoanBrokerId(brokerKeylet.key), kAmount(iou(Number{18, -7})));
BEAST_EXPECT((coverAvailable() == openLimit - Number{2, -6}));
env(coverWithdraw(alice, brokerKeylet.key, iou(Number{1, -7})), Ter(tecPRECISION_LOSS));
env(coverClawback(issuer),
kLoanBrokerId(brokerKeylet.key),
kAmount(iou(Number{1, -7})),
Ter(tecPRECISION_LOSS));
env(coverClawback(issuer), kLoanBrokerId(brokerKeylet.key));
BEAST_EXPECT((coverAvailable() == Number{0}));
auto const minCoverBroker =
keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
env(set(alice, vaultKeylet.key),
kDebtMaximum(Number{100}),
kCoverRateMinimum(percentageToTenthBips(10)),
kCoverRateLiquidation(percentageToTenthBips(25)));
env(coverDeposit(alice, minCoverBroker.key, iou(Number{1, -1})));
env(loan::set(borrower, minCoverBroker.key, Number{1}),
Sig(sfCounterpartySignature, alice),
Fee(env.current()->fees().base * 2));
{
auto const broker = env.le(minCoverBroker);
BEAST_EXPECT(broker);
if (broker)
BEAST_EXPECT((broker->at(sfDebtTotal) == Number{1}));
}
env(coverWithdraw(alice, minCoverBroker.key, iou(Number{1, -6})),
Ter(tecINSUFFICIENT_FUNDS));
env(loan::set(borrower, minCoverBroker.key, Number{1}),
Sig(sfCounterpartySignature, alice),
Fee(env.current()->fees().base * 2),
Ter(tecINSUFFICIENT_FUNDS));
auto const loanKeylet = keylet::loan(minCoverBroker.key, SeqProxy::rawSequence(1));
env(loan::pay(borrower, loanKeylet.key, iou(1).value()));
{
testcase("FixedPrecision LoanBroker cover: XRP");
[[maybe_unused]] auto [xrpTx, xrpVault, xrpSub] =
vault.createClosedEnded({.owner = alice, .asset = xrpIssue()});
env(xrpTx);
auto const xrpBroker =
keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
env(set(alice, xrpVault.key));
env(coverDeposit(alice, xrpBroker.key, XRP(10)));
env(coverWithdraw(alice, xrpBroker.key, XRP(1)));
}
{
testcase("FixedPrecision LoanBroker cover: MPT");
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
PrettyAsset const mpt = mptt["MPT"];
mptt.authorize({.account = alice});
env(pay(issuer, alice, mpt(100)));
[[maybe_unused]] auto [mptTx, mptVault, mptSub] =
vault.createClosedEnded({.owner = alice, .asset = mpt});
env(mptTx);
auto const mptBroker =
keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
env(set(alice, mptVault.key));
env(coverDeposit(alice, mptBroker.key, mpt(10).value()));
env(coverWithdraw(alice, mptBroker.key, mpt(1).value()));
env(coverClawback(issuer), kLoanBrokerId(mptBroker.key), kAmount(mpt(1)));
}
}
// Exercises canApplyToBrokerCover (fixCleanup3_2_0): a deposit, withdraw,
// or clawback whose amount rounds to zero at sfCoverAvailable's precision
// scale must be rejected with tecPRECISION_LOSS once the amendment is on,
@@ -2979,6 +3108,7 @@ public:
testCoverWithdrawFreezes();
testCoverWithdrawSelfWhileFrozen();
testFixedPrecisionCover();
testCoverPrecisionGuard();
testLoanBrokerSetDebtMaximum();

View File

@@ -12,6 +12,7 @@
#include <test/jtx/trust.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
@@ -19,6 +20,7 @@
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
@@ -34,11 +36,14 @@
#include <xrpl/tx/transactors/lending/LoanSet.h>
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <memory>
#include <optional>
#include <string>
#include <type_traits>
#include <vector>
namespace xrpl::test {
@@ -1483,6 +1488,788 @@ private:
run(all_, tesSUCCESS);
}
void
testFixedPrecisionScheduledPayment()
{
testcase("FixedPrecision scheduled LoanPay accounting");
using namespace jtx;
using namespace loan;
FeatureBitset const features{
all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2};
Env env{*this, features};
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(100'000), issuer, lender, borrower);
env.close();
PrettyAsset const asset = issuer["USD"];
env(trust(lender, asset(10'000'000)));
env(trust(borrower, asset(10'000'000)));
env(pay(issuer, lender, asset(2'000'000)));
env(pay(issuer, borrower, asset(100)));
env.close();
BrokerParameters brokerParams;
brokerParams.vaultScale = 6;
brokerParams.managementFeeRate = TenthBips16{100};
auto const broker = createVaultAndBroker(env, asset, lender, brokerParams);
auto const loanKeylet = nextLoanKeylet(env, broker);
env(set(borrower, broker.brokerID, asset(1'000).value()),
Sig(sfCounterpartySignature, lender),
kInterestRate(percentageToTenthBips(12)),
kPaymentTotal(3),
kPaymentInterval(24 * 60 * 60),
Fee(env.current()->fees().base * 2));
env.close();
auto const state = getCurrentState(env, broker, loanKeylet);
STAmount const payment{
asset, roundPeriodicPayment(asset, state.periodicPayment, state.loanScale)};
auto const vaultBefore = env.le(broker.vaultKeylet());
auto const brokerBefore = env.le(broker.brokerKeylet());
auto const loanBefore = env.le(loanKeylet);
if (!BEAST_EXPECT(vaultBefore && brokerBefore && loanBefore))
return;
Number const assetsAvailableBefore = vaultBefore->at(sfAssetsAvailable);
Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal);
Number const yieldBefore = vaultBefore->at(sfYieldUnrealized);
Number const debtBefore = brokerBefore->at(sfDebtTotal);
Number const principalBefore = loanBefore->at(sfPrincipalOutstanding);
Number const managementFeeBefore = loanBefore->at(sfManagementFeeOutstanding);
Number const scheduledInterestBefore =
loanBefore->at(sfTotalValueOutstanding) - principalBefore - managementFeeBefore;
Number const lenderBalanceBefore = env.balance(lender, asset).number();
BEAST_EXPECT(managementFeeBefore > beast::kZero);
env(pay(borrower, loanKeylet.key, payment));
env.close();
auto const vaultAfter = env.le(broker.vaultKeylet());
auto const brokerAfter = env.le(broker.brokerKeylet());
auto const loanAfter = env.le(loanKeylet);
if (!BEAST_EXPECT(vaultAfter && brokerAfter && loanAfter))
return;
Number const principalAfter = loanAfter->at(sfPrincipalOutstanding);
Number const principalPaid = principalBefore - principalAfter;
Number const credit = vaultAfter->at(sfAssetsAvailable) - assetsAvailableBefore;
Number const managementFeeAfter = loanAfter->at(sfManagementFeeOutstanding);
Number const scheduledInterestAfter =
loanAfter->at(sfTotalValueOutstanding) - principalAfter - managementFeeAfter;
Number const interestPaid = scheduledInterestBefore - scheduledInterestAfter;
Number const expectedAssetsTotal = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::Downward);
return Number{STAmount{asset, assetsTotalBefore + interestPaid}};
}();
Number const interestActual = expectedAssetsTotal - assetsTotalBefore;
BEAST_EXPECT(brokerAfter->at(sfDebtTotal) == debtBefore - principalPaid);
BEAST_EXPECTS(
vaultAfter->at(sfAssetsTotal) == expectedAssetsTotal,
"AssetsTotal expected " + to_string(expectedAssetsTotal) + ", got " +
to_string(vaultAfter->at(sfAssetsTotal)));
BEAST_EXPECT(expectedAssetsTotal == assetsTotalBefore + interestPaid);
BEAST_EXPECT(credit == principalPaid + interestActual);
BEAST_EXPECT(
env.balance(lender, asset).number() - lenderBalanceBefore ==
managementFeeBefore - managementFeeAfter);
BEAST_EXPECT(
vaultAfter->at(sfYieldUnrealized) ==
yieldBefore + scheduledInterestAfter - scheduledInterestBefore);
BEAST_EXPECTS(
vaultAfter->at(sfAssetsTotal) + vaultAfter->at(sfYieldUnrealized) ==
assetsTotalBefore + yieldBefore,
"capacity before " + to_string(assetsTotalBefore + yieldBefore) + ", after " +
to_string(vaultAfter->at(sfAssetsTotal) + vaultAfter->at(sfYieldUnrealized)));
Number const yieldBeforeFull = vaultAfter->at(sfYieldUnrealized);
BEAST_EXPECT(scheduledInterestAfter > beast::kZero);
BEAST_EXPECT(yieldBeforeFull > beast::kZero);
Number const fullPaymentMaximum = env.balance(borrower, asset).number();
env(pay(borrower, loanKeylet.key, asset(fullPaymentMaximum), tfLoanFullPayment));
env.close();
auto const vaultAfterFull = env.le(broker.vaultKeylet());
auto const loanAfterFull = env.le(loanKeylet);
if (!BEAST_EXPECT(vaultAfterFull && loanAfterFull))
return;
BEAST_EXPECT(loanAfterFull->at(sfPaymentRemaining) == 0);
BEAST_EXPECT(loanAfterFull->at(sfTotalValueOutstanding) == beast::kZero);
BEAST_EXPECT(loanAfterFull->at(sfPrincipalOutstanding) == beast::kZero);
BEAST_EXPECT(loanAfterFull->at(sfManagementFeeOutstanding) == beast::kZero);
BEAST_EXPECT(vaultAfterFull->at(sfYieldUnrealized) == beast::kZero);
}
void
testFixedPrecisionRedirectedFeeRounding()
{
testcase("FixedPrecision redirected fee rounds at posterior cover scale");
using namespace jtx;
using namespace loan;
FeatureBitset const features{
all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2};
Env env{*this, features};
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(100'000), issuer, lender, borrower);
env.close();
PrettyAsset const asset = issuer["USD"];
Number const trustLimit{2, 10};
env(trust(lender, asset(trustLimit)));
env(trust(borrower, asset(trustLimit)));
env(pay(issuer, lender, asset(Number{1, 10})));
env(pay(issuer, borrower, asset(Number{2, 9})));
env.close();
BrokerParameters brokerParams;
brokerParams.vaultScale = 6;
brokerParams.coverDeposit = 0;
brokerParams.managementFeeRate = TenthBips16{0};
auto const broker = createVaultAndBroker(env, asset, lender, brokerParams);
// Optional cover can fill the Open zone at P=6. The first redirected
// fee below is mandatory growth and takes CoverAvailable into the
// coarsened state.
env(loan_broker::coverDeposit(lender, broker.brokerID, asset(Number{9, 9})));
env.close();
auto const makeLoan = [&](Number const& serviceFee) {
auto const loanKeylet = nextLoanKeylet(env, broker);
env(set(borrower, broker.brokerID, asset(1'000).value()),
Sig(sfCounterpartySignature, lender),
kLoanServiceFee(serviceFee),
kPaymentTotal(2),
kPaymentInterval(24 * 60 * 60),
Fee(env.current()->fees().base * 2));
env.close();
return loanKeylet;
};
auto const payOnce = [&](Keylet const& loanKeylet, Number const& serviceFee) {
auto const state = getCurrentState(env, broker, loanKeylet);
STAmount const payment{
asset,
roundPeriodicPayment(asset, state.periodicPayment + serviceFee, state.loanScale)};
env(pay(borrower, loanKeylet.key, payment));
env.close();
};
Number const coarseningFee{1, 9};
auto const firstLoan = makeLoan(coarseningFee);
Number const roundedFeeRaw{17, -6};
auto const roundedFeeLoan = makeLoan(roundedFeeRaw);
Number const dustFee{1, -6};
auto const secondLoan = makeLoan(dustFee);
// A deep-frozen owner cannot receive broker fees, so LoanPay redirects
// them to the broker pseudo-account and CoverAvailable. Originate both
// loans first because the freeze also blocks LoanSet.
env(trust(issuer, asset(0), lender, tfSetFreeze | tfSetDeepFreeze));
env.close();
payOnce(firstLoan, coarseningFee);
auto const brokerCoarsened = env.le(broker.brokerKeylet());
if (!BEAST_EXPECT(brokerCoarsened))
return;
Number const coverBeforeDust = brokerCoarsened->at(sfCoverAvailable);
Number const expectedCoarsenedCover{1, 10};
BEAST_EXPECT(coverBeforeDust == expectedCoarsenedCover);
payOnce(roundedFeeLoan, roundedFeeRaw);
auto const brokerAfterRoundedFee = env.le(broker.brokerKeylet());
if (!BEAST_EXPECT(brokerAfterRoundedFee))
return;
Number const roundedFee = brokerAfterRoundedFee->at(sfCoverAvailable) - coverBeforeDust;
BEAST_EXPECT(roundedFee > beast::kZero);
BEAST_EXPECT(roundedFee < roundedFeeRaw);
auto const vaultBeforeDust = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(vaultBeforeDust))
return;
Number const availableBeforeDust = vaultBeforeDust->at(sfAssetsAvailable);
Number const borrowerBeforeDust = env.balance(borrower, asset).number();
payOnce(secondLoan, dustFee);
auto const brokerAfterDust = env.le(broker.brokerKeylet());
auto const vaultAfterDust = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(brokerAfterDust && vaultAfterDust))
return;
BEAST_EXPECT(
brokerAfterDust->at(sfCoverAvailable) == brokerAfterRoundedFee->at(sfCoverAvailable));
Number const vaultCredit = vaultAfterDust->at(sfAssetsAvailable) - availableBeforeDust;
BEAST_EXPECT(borrowerBeforeDust - env.balance(borrower, asset).number() == vaultCredit);
}
void
testFixedPrecisionCoarsenedPayments()
{
testcase("FixedPrecision coarsened LoanPay transfers principal in full");
using namespace jtx;
using namespace loan;
FeatureBitset const features{
all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2};
Env env{*this, features};
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(100'000), issuer, lender, borrower);
env.close();
PrettyAsset const asset = issuer["USD"];
Number const trustLimit{3, 10};
env(trust(lender, asset(trustLimit)));
env(trust(borrower, asset(trustLimit)));
env(pay(issuer, lender, asset(Number{11, 9})));
env(pay(issuer, borrower, asset(Number{15, 9})));
env.close();
BrokerParameters brokerParams;
brokerParams.vaultDeposit = Number{9, 9};
brokerParams.debtMax = Number{9, 9};
brokerParams.coverDeposit = 1'000'000'000;
brokerParams.vaultScale = 6;
brokerParams.managementFeeRate = TenthBips16{0};
auto const broker = createVaultAndBroker(env, asset, lender, brokerParams);
auto const makeLoan = [&](Number const& principal,
std::uint32_t paymentTotal,
TenthBips32 closeInterestRate = TenthBips32{0}) {
auto const loanKeylet = nextLoanKeylet(env, broker);
env(set(borrower, broker.brokerID, principal),
Sig(sfCounterpartySignature, lender),
kCloseInterestRate(closeInterestRate),
kPaymentTotal(paymentTotal),
kPaymentInterval(2 * 365 * 24 * 60 * 60),
Fee(env.current()->fees().base * 2));
env.close();
return loanKeylet;
};
Number const terminalRemainder{7, -6};
auto const terminalLoan = makeLoan(terminalRemainder, 1);
makeLoan(Number{7, 9}, 2);
auto const growthLoan = makeLoan(Number{1, 9}, 2, lending::kMaxCloseInterestRate);
auto const vaultBeforeGrowth = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(vaultBeforeGrowth))
return;
Number const assetsAvailableBeforeGrowth = vaultBeforeGrowth->at(sfAssetsAvailable);
Number const assetsTotalBeforeGrowth = vaultBeforeGrowth->at(sfAssetsTotal);
Number const yieldBeforeGrowth = vaultBeforeGrowth->at(sfYieldUnrealized);
auto const growthState = getCurrentState(env, broker, growthLoan);
using d = NetClock::duration;
env.close(growthState.startDate + d{366 * 24 * 60 * 60});
env(pay(borrower, growthLoan.key, asset(Number{1, 10}), tfLoanFullPayment));
env.close();
auto const coarsenedVault = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(coarsenedVault))
return;
int const coarsenedScale = getVaultScale(coarsenedVault);
BEAST_EXPECTS(
coarsenedScale > getVaultBaseScale(coarsenedVault),
"expected coarsened scale; base " + std::to_string(getVaultBaseScale(coarsenedVault)) +
", live " + std::to_string(coarsenedScale) + ", total " +
to_string(coarsenedVault->at(sfAssetsTotal)));
BEAST_EXPECTS(
coarsenedVault->at(sfAssetsTotal) > assetsTotalBeforeGrowth,
"AssetsTotal before " + to_string(assetsTotalBeforeGrowth) + ", after " +
to_string(coarsenedVault->at(sfAssetsTotal)));
BEAST_EXPECT(coarsenedVault->at(sfYieldUnrealized) == yieldBeforeGrowth);
Number const vaultCredit =
coarsenedVault->at(sfAssetsAvailable) - assetsAvailableBeforeGrowth;
BEAST_EXPECT(isRounded(asset, vaultCredit, coarsenedScale));
BEAST_EXPECT(isRounded(asset, coarsenedVault->at(sfAssetsTotal), coarsenedScale));
auto const vaultBeforeTerminal = env.le(broker.vaultKeylet());
auto const brokerBeforeTerminal = env.le(broker.brokerKeylet());
if (!BEAST_EXPECT(vaultBeforeTerminal && brokerBeforeTerminal))
return;
Number const availableBeforeTerminal = vaultBeforeTerminal->at(sfAssetsAvailable);
Number const assetsTotalBeforeTerminal = vaultBeforeTerminal->at(sfAssetsTotal);
Number const yieldBeforeTerminal = vaultBeforeTerminal->at(sfYieldUnrealized);
Number const debtBeforeTerminal = brokerBeforeTerminal->at(sfDebtTotal);
Number const borrowerBeforeTerminal = env.balance(borrower, asset).number();
env(pay(borrower, terminalLoan.key, asset(terminalRemainder)));
env.close();
auto const terminalAfter = env.le(terminalLoan);
auto const vaultAfterTerminal = env.le(broker.vaultKeylet());
auto const brokerAfterTerminal = env.le(broker.brokerKeylet());
if (!BEAST_EXPECT(terminalAfter && vaultAfterTerminal && brokerAfterTerminal))
return;
BEAST_EXPECT(terminalAfter->at(sfPaymentRemaining) == 0);
BEAST_EXPECT(terminalAfter->at(sfPrincipalOutstanding) == beast::kZero);
BEAST_EXPECT(
vaultAfterTerminal->at(sfAssetsAvailable) ==
availableBeforeTerminal + terminalRemainder);
BEAST_EXPECT(borrowerBeforeTerminal > env.balance(borrower, asset).number());
BEAST_EXPECT(vaultAfterTerminal->at(sfAssetsTotal) == assetsTotalBeforeTerminal);
BEAST_EXPECT(vaultAfterTerminal->at(sfYieldUnrealized) == yieldBeforeTerminal);
BEAST_EXPECT(
brokerAfterTerminal->at(sfDebtTotal) == debtBeforeTerminal - terminalRemainder);
}
void
testFixedPrecisionCoarsenedSingleLoanTerminal()
{
testcase("FixedPrecision coarsened LoanPay single-loan terminal remainder");
using namespace jtx;
using namespace loan;
FeatureBitset const features{
all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2};
Env env{*this, features};
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(100'000), issuer, lender, borrower);
env.close();
PrettyAsset const asset = issuer["USD"];
Number const trustLimit{3, 10};
env(trust(lender, asset(trustLimit)));
env(trust(borrower, asset(trustLimit)));
env(pay(issuer, lender, asset(Number{11, 9})));
env(pay(issuer, borrower, asset(Number{5, 9})));
env.close();
BrokerParameters brokerParams;
brokerParams.vaultDeposit = Number{9, 9};
brokerParams.debtMax = Number{2, 9};
brokerParams.coverDeposit = 200'000'000;
brokerParams.vaultScale = 6;
brokerParams.managementFeeRate = TenthBips16{0};
auto const broker = createVaultAndBroker(env, asset, lender, brokerParams);
auto const makeLoan = [&](Number const& principal, TenthBips32 closeInterestRate) {
auto const loanKeylet = nextLoanKeylet(env, broker);
env(set(borrower, broker.brokerID, principal),
Sig(sfCounterpartySignature, lender),
kCloseInterestRate(closeInterestRate),
kPaymentTotal(2),
kPaymentInterval(2 * 365 * 24 * 60 * 60),
Fee(env.current()->fees().base * 2));
env.close();
return loanKeylet;
};
Number const terminalPrincipal{1'000'000'000'007, -6};
auto const terminalLoan = makeLoan(terminalPrincipal, TenthBips32{0});
auto const growthLoan = makeLoan(Number{1, 9}, lending::kMaxCloseInterestRate);
auto const growthState = getCurrentState(env, broker, growthLoan);
using d = NetClock::duration;
env.close(growthState.startDate + d{366 * 24 * 60 * 60});
env(pay(borrower, growthLoan.key, asset(Number{4, 9}), tfLoanFullPayment));
env.close();
auto const vaultBefore = env.le(broker.vaultKeylet());
auto const brokerBefore = env.le(broker.brokerKeylet());
if (!BEAST_EXPECT(vaultBefore && brokerBefore))
return;
BEAST_EXPECT(getVaultScale(vaultBefore) > getVaultBaseScale(vaultBefore));
BEAST_EXPECT(
vaultBefore->at(sfAssetsTotal) - vaultBefore->at(sfAssetsAvailable) ==
terminalPrincipal);
Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal);
Number const assetsAvailableBefore = vaultBefore->at(sfAssetsAvailable);
Number const borrowerBefore = env.balance(borrower, asset).number();
env(pay(borrower, terminalLoan.key, asset(terminalPrincipal)));
env.close();
auto const loanAfter = env.le(terminalLoan);
auto const vaultAfter = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(loanAfter && vaultAfter))
return;
BEAST_EXPECT(loanAfter->at(sfPaymentRemaining) == 0);
BEAST_EXPECT(loanAfter->at(sfPrincipalOutstanding) == beast::kZero);
BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore);
Number const credit = vaultAfter->at(sfAssetsAvailable) - assetsAvailableBefore;
BEAST_EXPECT(borrowerBefore - env.balance(borrower, asset).number() == credit);
Number const residual = vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable);
BEAST_EXPECT(residual == terminalPrincipal - credit);
BEAST_EXPECT(residual > beast::kZero);
Number const availableCandidate = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
return assetsAvailableBefore + terminalPrincipal;
}();
int const availableScale =
std::max(getVaultBaseScale(vaultAfter), scale(availableCandidate, asset));
Number const availableUnit{1, availableScale};
BEAST_EXPECT(residual < availableUnit);
}
void
testFixedPrecisionCoarsenedInterestPayment()
{
testcase("FixedPrecision coarsened LoanPay records interest before credit");
using namespace jtx;
using namespace loan;
FeatureBitset const features{
all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2};
Env env{*this, features};
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(100'000), issuer, lender, borrower);
env.close();
PrettyAsset const asset = issuer["USD"];
Number const trustLimit{3, 10};
env(trust(lender, asset(trustLimit)));
env(trust(borrower, asset(trustLimit)));
env(pay(issuer, lender, asset(Number{1, 10})));
env(pay(issuer, borrower, asset(Number{5, 9})));
env.close();
BrokerParameters brokerParams;
brokerParams.vaultDeposit = Number{85, 8};
brokerParams.debtMax = Number{2, 9};
brokerParams.coverDeposit = 200'000'000;
brokerParams.vaultScale = 6;
brokerParams.managementFeeRate = TenthBips16{0};
auto const broker = createVaultAndBroker(env, asset, lender, brokerParams);
auto const targetLoan = nextLoanKeylet(env, broker);
env(set(borrower, broker.brokerID, asset(1'000'000).value()),
Sig(sfCounterpartySignature, lender),
kInterestRate(percentageToTenthBips(12)),
kPaymentTotal(2),
kPaymentInterval(2 * 365 * 24 * 60 * 60),
Fee(env.current()->fees().base * 2));
env.close();
auto const growthLoan = nextLoanKeylet(env, broker);
env(set(borrower, broker.brokerID, asset(Number{15, 8}).value()),
Sig(sfCounterpartySignature, lender),
kCloseInterestRate(lending::kMaxCloseInterestRate),
kPaymentTotal(2),
kPaymentInterval(2 * 365 * 24 * 60 * 60),
Fee(env.current()->fees().base * 2));
env.close();
auto const growthState = getCurrentState(env, broker, growthLoan);
using d = NetClock::duration;
env.close(growthState.startDate + d{366 * 24 * 60 * 60});
env(pay(borrower, growthLoan.key, asset(Number{4, 9}), tfLoanFullPayment));
env.close();
auto const targetBefore = env.le(targetLoan);
auto const vaultBefore = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(targetBefore && vaultBefore))
return;
BEAST_EXPECT(getVaultScale(vaultBefore) > getVaultBaseScale(vaultBefore));
Number const principalBefore = targetBefore->at(sfPrincipalOutstanding);
Number const scheduledBefore = targetBefore->at(sfTotalValueOutstanding) - principalBefore -
targetBefore->at(sfManagementFeeOutstanding);
Number const yieldBefore = vaultBefore->at(sfYieldUnrealized);
Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal);
Number const assetsAvailableBefore = vaultBefore->at(sfAssetsAvailable);
Number const borrowerBefore = env.balance(borrower, asset).number();
auto const targetState = getCurrentState(env, broker, targetLoan);
Number const payment =
roundPeriodicPayment(asset, targetState.periodicPayment, targetState.loanScale);
env(pay(borrower, targetLoan.key, asset(payment)));
env.close();
auto const targetAfter = env.le(targetLoan);
auto const vaultAfter = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(targetAfter && vaultAfter))
return;
Number const principalAfter = targetAfter->at(sfPrincipalOutstanding);
Number const scheduledAfter = targetAfter->at(sfTotalValueOutstanding) - principalAfter -
targetAfter->at(sfManagementFeeOutstanding);
Number const interestActual = vaultAfter->at(sfAssetsTotal) - assetsTotalBefore;
Number const interestPaid = scheduledBefore - scheduledAfter;
Number const credit = vaultAfter->at(sfAssetsAvailable) - assetsAvailableBefore;
BEAST_EXPECT(interestActual < interestPaid);
BEAST_EXPECT(isRounded(asset, interestActual, getVaultScale(vaultAfter)));
BEAST_EXPECT(credit == principalBefore - principalAfter + interestActual);
BEAST_EXPECT(borrowerBefore - env.balance(borrower, asset).number() == credit);
BEAST_EXPECT(scheduledAfter < scheduledBefore);
BEAST_EXPECT(
vaultAfter->at(sfYieldUnrealized) == yieldBefore + scheduledAfter - scheduledBefore);
BEAST_EXPECT(
vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) == principalAfter);
}
void
testFixedPrecisionSpecialPayments()
{
using namespace jtx;
using namespace loan;
FeatureBitset const features{
all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2};
{
testcase("FixedPrecision base-scale overpayment");
Env env{*this, features};
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(100'000), issuer, lender, borrower);
env.close();
PrettyAsset const asset = issuer["USD"];
env(trust(lender, asset(5'000'000)));
env(trust(borrower, asset(5'000'000)));
env(pay(issuer, lender, asset(2'000'000)));
env(pay(issuer, borrower, asset(500'000)));
env.close();
BrokerParameters brokerParams;
brokerParams.debtMax = 500'000;
brokerParams.coverDeposit = 50'000;
brokerParams.vaultScale = 6;
brokerParams.managementFeeRate = TenthBips16{0};
auto const broker = createVaultAndBroker(env, asset, lender, brokerParams);
auto const loanKeylet = nextLoanKeylet(env, broker);
env(set(borrower, broker.brokerID, asset(100'000).value(), tfLoanOverpayment),
Sig(sfCounterpartySignature, lender),
kInterestRate(percentageToTenthBips(12)),
kOverpaymentInterestRate(percentageToTenthBips(20)),
kPaymentTotal(3),
kPaymentInterval(24 * 60 * 60),
Fee(env.current()->fees().base * 2));
env.close();
env(pay(borrower, loanKeylet.key, asset(50'000), tfLoanOverpayment));
env.close();
auto const loanAfter = env.le(loanKeylet);
auto const vaultAfter = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(loanAfter && vaultAfter))
return;
Number const scheduledAfter = loanAfter->at(sfTotalValueOutstanding) -
loanAfter->at(sfPrincipalOutstanding) - loanAfter->at(sfManagementFeeOutstanding);
BEAST_EXPECT(getVaultVersion(vaultAfter) == VaultVersion::FixedPrecision);
BEAST_EXPECT(vaultAfter->at(sfYieldUnrealized) == scheduledAfter);
BEAST_EXPECT(
vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) ==
loanAfter->at(sfPrincipalOutstanding));
}
{
testcase("FixedPrecision base-scale late payment");
Env env{*this, features};
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(100'000), issuer, lender, borrower);
env.close();
PrettyAsset const asset = issuer["USD"];
env(trust(lender, asset(5'000'000)));
env(trust(borrower, asset(5'000'000)));
env(pay(issuer, lender, asset(2'000'000)));
env(pay(issuer, borrower, asset(500'000)));
env.close();
BrokerParameters brokerParams;
brokerParams.debtMax = 500'000;
brokerParams.coverDeposit = 50'000;
brokerParams.vaultScale = 6;
brokerParams.managementFeeRate = TenthBips16{0};
auto const broker = createVaultAndBroker(env, asset, lender, brokerParams);
auto const loanKeylet = nextLoanKeylet(env, broker);
env(set(borrower, broker.brokerID, asset(100'000).value()),
Sig(sfCounterpartySignature, lender),
kLateInterestRate(percentageToTenthBips(24)),
kLatePaymentFee(asset(1).value()),
kPaymentTotal(12),
kPaymentInterval(600),
Fee(env.current()->fees().base * 2));
env.close();
auto const state = getCurrentState(env, broker, loanKeylet);
using d = NetClock::duration;
env.close(NetClock::time_point{d{state.nextPaymentDate + 1}});
Number const generousAmount =
roundPeriodicPayment(asset, state.periodicPayment, state.loanScale) * 3 +
asset(100).value();
env(pay(borrower, loanKeylet.key, asset(generousAmount), tfLoanLatePayment));
env.close();
auto const loanAfter = env.le(loanKeylet);
auto const vaultAfter = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(loanAfter && vaultAfter))
return;
Number const scheduledAfter = loanAfter->at(sfTotalValueOutstanding) -
loanAfter->at(sfPrincipalOutstanding) - loanAfter->at(sfManagementFeeOutstanding);
BEAST_EXPECT(getVaultVersion(vaultAfter) == VaultVersion::FixedPrecision);
BEAST_EXPECT(vaultAfter->at(sfYieldUnrealized) == scheduledAfter);
BEAST_EXPECT(
vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) ==
loanAfter->at(sfPrincipalOutstanding));
}
}
void
testFixedPrecisionIntegralPayments()
{
using namespace jtx;
FeatureBitset const features{
all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2};
for (auto const assetType : {AssetType::XRP, AssetType::MPT})
{
testcase << "FixedPrecision regular LoanPay "
<< (assetType == AssetType::XRP ? "XRP" : "MPT");
Env env{*this, features};
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
BrokerParameters brokerParams;
brokerParams.vaultDeposit = 100'000;
brokerParams.debtMax = 25'000;
brokerParams.coverDeposit = 1'000;
brokerParams.managementFeeRate = TenthBips16{0};
LoanParameters const loanParams{
.account = borrower,
.counter = lender,
.principalRequest = 1'000,
.payTotal = 2,
.payInterval = 24 * 60 * 60};
auto const loanOpt =
createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
if (BEAST_EXPECT(loanOpt); !loanOpt)
continue;
auto const& [broker, loanKeylet, unusedBrokerPseudo] = *loanOpt;
auto const state = getCurrentState(env, broker, loanKeylet);
topUpBorrower(env, broker, issuer, borrower, state, std::nullopt);
Number const payment =
roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale);
env(loan::pay(borrower, loanKeylet.key, STAmount{broker.asset, payment}));
env.close();
auto const loanAfter = env.le(loanKeylet);
auto const vaultAfter = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(loanAfter && vaultAfter))
continue;
BEAST_EXPECT(getVaultVersion(vaultAfter) == VaultVersion::FixedPrecision);
BEAST_EXPECT(loanAfter->at(sfPaymentRemaining) == state.paymentRemaining - 1);
BEAST_EXPECT(
vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) ==
loanAfter->at(sfPrincipalOutstanding));
}
}
void
testFixedPrecisionYieldAcrossLoans()
{
testcase("FixedPrecision YieldUnrealized equals three-loan sum");
using namespace jtx;
using namespace loan;
FeatureBitset const features{
all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2};
Env env{*this, features};
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(100'000), issuer, lender, borrower);
env.close();
PrettyAsset const asset = issuer["USD"];
env(trust(lender, asset(10'000'000)));
env(trust(borrower, asset(10'000'000)));
env(pay(issuer, lender, asset(3'000'000)));
env(pay(issuer, borrower, asset(1'000'000)));
env.close();
BrokerParameters brokerParams;
brokerParams.vaultDeposit = 2'000'000;
brokerParams.debtMax = 1'000'000;
brokerParams.coverDeposit = 100'000;
brokerParams.vaultScale = 6;
brokerParams.managementFeeRate = TenthBips16{0};
auto const broker = createVaultAndBroker(env, asset, lender, brokerParams);
std::vector<Keylet> loans;
loans.reserve(3);
for (std::size_t i = 0; i < 3; ++i)
{
loans.push_back(nextLoanKeylet(env, broker));
env(set(borrower, broker.brokerID, asset(100'000).value()),
Sig(sfCounterpartySignature, lender),
kInterestRate(percentageToTenthBips(12)),
kPaymentTotal(2),
kPaymentInterval(24 * 60 * 60),
Fee(env.current()->fees().base * 2));
env.close();
}
auto const loanYield = [&](Keylet const& keylet) {
auto const loan = env.le(keylet);
if (!BEAST_EXPECT(loan))
return kNumZero;
return loan->at(sfTotalValueOutstanding) - loan->at(sfPrincipalOutstanding) -
loan->at(sfManagementFeeOutstanding);
};
auto expectedYield = [&] {
Number result;
for (auto const& loan : loans)
result += loanYield(loan);
return result;
};
auto vault = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(vault))
return;
BEAST_EXPECT(vault->at(sfYieldUnrealized) == expectedYield());
for (std::size_t i = 0; i < 2; ++i)
{
auto const state = getCurrentState(env, broker, loans[i]);
Number const payment =
roundPeriodicPayment(asset, state.periodicPayment, state.loanScale);
env(pay(borrower, loans[i].key, asset(payment)));
env.close();
}
vault = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(vault))
return;
BEAST_EXPECT(vault->at(sfYieldUnrealized) == expectedYield());
}
void
runAmendmentIndependent()
{
@@ -1494,6 +2281,16 @@ private:
testLoanPayCatchUpFeeAtExactDueDatePreAmendment();
testRepayIntoUnauthorizedVault();
testLoanPaySelfBrokerExistingLineDefaultRipple();
testFixedPrecisionScheduledPayment();
testFixedPrecisionRedirectedFeeRounding();
testFixedPrecisionCoarsenedPayments();
testFixedPrecisionCoarsenedSingleLoanTerminal();
testFixedPrecisionCoarsenedInterestPayment();
testFixedPrecisionSpecialPayments();
testFixedPrecisionIntegralPayments();
testFixedPrecisionYieldAcrossLoans();
testOverpaymentManagementFee(
all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2);
}
// Tests run under each entry in amendmentCombinations().

View File

@@ -15,7 +15,9 @@
#include <xrpl/basics/Number.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
@@ -928,6 +930,118 @@ private:
run(all_, tesSUCCESS);
}
void
testFixedPrecisionLoanSet()
{
using namespace jtx;
using namespace loan;
testcase("FixedPrecision LoanSet");
FeatureBitset const features{
all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2};
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
Env env{*this, features};
env.fund(XRP(100'000), issuer, lender, borrower);
env.close();
PrettyAsset const iou = issuer["IOU"];
env(trust(lender, iou(Number{10, 10})));
env(trust(borrower, iou(Number{10, 10})));
Number const openLimit{9, 9};
env(pay(issuer, lender, iou(openLimit)));
Vault const vault{env};
auto [createTx, vaultKeylet, subscriptionDate] =
vault.createClosedEnded({.owner = lender, .asset = iou});
createTx[sfScale] = 6;
env(createTx);
Number const principal{100};
TenthBips32 const interestRate{100'000};
constexpr std::uint32_t paymentTotal = 2;
constexpr std::uint32_t paymentInterval = 24 * 60 * 60;
auto const properties = computeLoanProperties(
env.current()->rules(),
iou.raw(),
principal,
interestRate,
paymentInterval,
paymentTotal,
TenthBips16{0},
-6);
Number const interestDue = properties.loanState.interestDue;
BEAST_EXPECT(interestDue > beast::kZero);
BEAST_EXPECT(properties.loanScale == -6);
Number const deposit = openLimit - interestDue;
env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = iou(deposit)}));
vault.closePastSubscription(subscriptionDate);
auto const brokerKeylet =
keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
env(loan_broker::set(lender, vaultKeylet.key));
env.close();
auto const fee = Fee(env.current()->fees().base * 2);
Number const offGrid{1, -7};
auto const rejectedOffGrid = [&](auto const& field) {
env(set(borrower, brokerKeylet.key, principal),
field(offGrid),
Sig(sfCounterpartySignature, lender),
fee,
Ter(tecPRECISION_LOSS));
};
env(set(borrower, brokerKeylet.key, offGrid),
Sig(sfCounterpartySignature, lender),
fee,
Ter(tecPRECISION_LOSS));
rejectedOffGrid(kLoanOriginationFee);
rejectedOffGrid(kLoanServiceFee);
rejectedOffGrid(kLatePaymentFee);
rejectedOffGrid(kClosePaymentFee);
auto const makeLoan = [&](TER expected) {
env(set(borrower, brokerKeylet.key, principal),
kInterestRate(interestRate),
kPaymentTotal(paymentTotal),
kPaymentInterval(paymentInterval),
Sig(sfCounterpartySignature, lender),
fee,
Ter(expected));
env.close();
};
makeLoan(tesSUCCESS);
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
{
auto const loan = env.le(loanKeylet);
BEAST_EXPECT(loan);
if (loan)
{
BEAST_EXPECT(loan->at(sfLoanScale) == -6);
BEAST_EXPECT(loan->at(sfPeriodicPayment) == properties.periodicPayment);
}
}
{
auto const vaultSle = env.le(vaultKeylet);
BEAST_EXPECT(vaultSle);
if (vaultSle)
{
BEAST_EXPECT(vaultSle->at(sfAssetsTotal) == deposit);
BEAST_EXPECT(vaultSle->at(sfYieldUnrealized) == interestDue);
}
}
// The first LoanSet puts AssetsTotal + YieldUnrealized exactly at the
// Open-zone ceiling. A second loan's InterestDue is therefore rejected.
makeLoan(tecLIMIT_EXCEEDED);
}
public:
void
run() override
@@ -937,6 +1051,7 @@ public:
testLoanSet(features);
testLoanSetClosedEnded();
testFixedPrecisionLoanSet();
testLoanSetExistingLineAfterIssuerClearsDefaultRipple();
testLoanSetOriginationFeeTwoMptCreates(all_);
testLoanSetOriginationFeeTwoMptCreates(all_ - fixCleanup3_4_0);

View File

@@ -88,7 +88,9 @@ protected:
// most of this file's tests assert instant-interest-recognition-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};
// featureLendingProtocolV1_2 is also excluded: it changes vault precision.
FeatureBitset const all_{
jtx::testableAmendments() - featureLendingProtocolV1_1 - featureLendingProtocolV1_2};
std::string const iouCurrency_{"IOU"};
struct BrokerParameters
@@ -346,7 +348,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 +453,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 +668,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

@@ -65,7 +65,26 @@ private:
auto const maxPeriod = kMaxInvestmentPeriod;
auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
// Gate: the three new fields require featureLendingProtocolV1_1.
// Gate: the three new fields require featureLendingProtocolV1_1 OR
// featureLendingProtocolV1_2 (VaultCreate.cpp treats V1.2 as
// implying V1.1). Only disabled when BOTH are absent.
withEnv(
testableAmendments() - featureLendingProtocolV1_1 - featureLendingProtocolV1_2,
[&](Env& env, Account const& owner, Vault& vault) {
auto const sub = env.now().time_since_epoch().count() + 60;
auto [tx, keylet] = vault.create(
{.owner = owner,
.asset = asset,
.vaultKind = closedEnded,
.subscriptionDate = sub,
.redemptionDate = sub + minPeriod});
env(tx, Ter{temDISABLED});
});
// V1.2 alone (no V1.1) still satisfies the gate for closed-ended
// vaults, same as it does for open-ended vaults in
// VaultFixedPrecision_test.cpp's "VaultCreate treats V1.2 as
// implying V1.1" case.
withEnv(
testableAmendments() - featureLendingProtocolV1_1,
[&](Env& env, Account const& owner, Vault& vault) {
@@ -76,7 +95,11 @@ private:
.vaultKind = closedEnded,
.subscriptionDate = sub,
.redemptionDate = sub + minPeriod});
env(tx, Ter{temDISABLED});
env(tx);
env.close();
auto const sle = env.le(keylet);
if (BEAST_EXPECT(sle))
BEAST_EXPECT(sle->at(sfVaultKind) == closedEnded);
});
/*

View File

@@ -0,0 +1,454 @@
#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;
}
// Submits a VaultCreate for an open-ended vault at the given fixed
// Scale and closes the ledger. Shared by every scenario below that
// needs a Scale-6 vault rather than the protocol default.
static std::pair<test::jtx::Vault, Keylet>
createScaledVault(
test::jtx::Env& env,
test::jtx::Account const& owner,
Asset const& asset,
std::uint8_t scale)
{
test::jtx::Vault const vault{env};
auto [create, keylet] = vault.create({.owner = owner, .asset = asset});
create[sfScale] = scale;
env(create);
env.close();
return {vault, keylet};
}
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);
if (!BEAST_EXPECT(sle))
return;
BEAST_EXPECT(sle->at(sfLEVersion) == std::to_underlying(VaultVersion::FixedPrecision));
BEAST_EXPECT(sle->at(sfScale) == kVaultDefaultIouScale);
BEAST_EXPECT(sle->at(sfYieldUnrealized) == beast::kZero);
}
{
testcase("VaultCreate treats V1.2 as implying V1.1");
Env env(*this, features() - featureLendingProtocolV1_1);
env.fund(XRP(1'000'000), issuer, owner);
env.close();
Vault const vault{env};
auto [tx, keylet] = vault.create(
{.owner = owner,
.asset = asset,
.vaultKind = std::to_underlying(VaultKind::OpenEnded)});
tx[sfScale] = kVaultMaximumFixedIouScale;
env(tx);
env.close();
auto const sle = env.le(keylet);
if (!BEAST_EXPECT(sle))
return;
BEAST_EXPECT(sle->at(sfLEVersion) == std::to_underlying(VaultVersion::FixedPrecision));
BEAST_EXPECT(sle->at(sfVaultKind) == std::to_underlying(VaultKind::OpenEnded));
auto [invalid, invalidKeylet] = vault.create({.owner = owner, .asset = asset});
invalid[sfScale] = static_cast<std::uint8_t>(kVaultMaximumFixedIouScale + 1);
env(invalid, Ter(temMALFORMED));
BEAST_EXPECT(!env.le(invalidKeylet));
}
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] = kVaultMaximumLegacyIouScale;
env(tx);
env.close();
auto const sle = env.le(keylet);
if (!BEAST_EXPECT(sle))
return;
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();
auto [vault, keylet] = createScaledVault(env, owner, asset, 6);
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);
if (!BEAST_EXPECT(atOpen))
return;
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);
if (!BEAST_EXPECT(afterRejected))
return;
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);
if (!BEAST_EXPECT(before))
return;
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);
if (!BEAST_EXPECT(after))
return;
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();
auto [vault, keylet] = createScaledVault(env, owner, asset, 6);
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);
if (!BEAST_EXPECT(afterDeposit))
return;
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);
if (!BEAST_EXPECT(afterWithdraw))
return;
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);
if (!BEAST_EXPECT(afterClawback))
return;
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);
if (!BEAST_EXPECT(sle))
return;
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();
auto [vault, keylet] = createScaledVault(env, owner, asset, 6);
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();
auto [vault, keylet] = createScaledVault(env, owner, asset, 6);
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();
auto [vault, keylet] = createScaledVault(env, owner, asset, 6);
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

@@ -24,13 +24,14 @@
#include <memory>
#include <optional>
#include <string>
#include <utility>
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,27 +59,40 @@ 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 `fixedScale` absent, sfLEVersion stays
// absent too, preserving the pre-V1.2 Legacy behavior exercised by the
// existing clamp table. With `fixedScale` set, the SLE is stamped
// FixedPrecision with that Scale, exercising clampToAssetsTotalScale's
// roundToPosteriorVaultScale branch instead.
static std::shared_ptr<SLE>
makeVault(Asset const& asset, Number const& assetsTotal)
makeVault(
Asset const& asset,
Number const& assetsTotal,
std::optional<std::uint8_t> fixedScale = std::nullopt)
{
auto vault = std::make_shared<SLE>(keylet::vault(uint256(1)));
vault->setFieldIssue(sfAsset, STIssue{sfAsset, asset});
vault->at(sfAssetsTotal) = assetsTotal;
associateAsset(*vault, asset);
if (fixedScale)
{
vault->at(sfLEVersion) = std::to_underlying(VaultVersion::FixedPrecision);
vault->at(sfScale) = *fixedScale;
}
return vault;
}
// Runs every case in `cases` against `asset`, once per ambient rounding
// mode. The function must give the same answer under all four modes,
// and its answer must match the hand-derived `expected` value.
// `fixedScale`, when set, builds a FixedPrecision vault at that Scale
// instead of the default Legacy vault.
template <std::size_t N>
void
runCases(Asset const& asset, std::array<Case, N> const& cases)
runCases(
Asset const& asset,
std::array<Case, N> const& cases,
std::optional<std::uint8_t> fixedScale = std::nullopt)
{
std::array<Number::RoundingMode, 4> const modes{
Number::RoundingMode::ToNearest,
@@ -90,7 +104,7 @@ private:
{
testcase(c.name);
auto const vault = makeVault(asset, c.assetsTotal);
auto const vault = makeVault(asset, c.assetsTotal, fixedScale);
BEAST_EXPECTS(
Number(vault->at(sfAssetsTotal)) == c.assetsTotal,
std::string(c.name) +
@@ -462,6 +476,80 @@ private:
runCases(xrp, xrpCases);
}
// -------------------------------------------------------------------
// FixedPrecision vaults: clampToAssetsTotalScale takes the
// roundToPosteriorVaultScale branch instead of the Legacy/CashBasis
// scale()-of-the-sum branch. All rows below use a Scale-6 vault
// (baseScale -6) with assetsTotal already sitting on that grid, so
// TowardsZero-truncating `delta` itself to scale -6 is the whole
// story: no case here forces liveScale to coarsen past baseScale
// (that scenario needs a non-unit share price and is exercised by
// VaultFixedPrecision_test.cpp's testPartialTowardZeroRounding /
// dust tests through real transactions instead).
// -------------------------------------------------------------------
void
testFixedPrecisionClamp(Asset const& iou)
{
std::uint8_t const fixedScale = 6;
Number const onGrid{3'234'567, -6}; // 3.234567, exact at scale -6.
std::array<Case, 6> const cases{
Case{
// delta already exact at the base grid: passes through
// unchanged, same as a Legacy on-grid debit.
.name = "FixedPrecision debit: exact on the base grid",
.assetsTotal = onGrid,
.delta = Number{-1, -6},
.expected = Number{1, -6},
},
Case{
// delta already exact at the base grid: passes through
// unchanged, same as a Legacy on-grid credit.
.name = "FixedPrecision credit: exact on the base grid",
.assetsTotal = onGrid,
.delta = Number{2, -6},
.expected = Number{2, -6},
},
Case{
// delta = -1.7 base units. TowardsZero truncates the
// magnitude to 1 base unit -- this is the "books the
// truncated amount" behavior VaultFixedPrecision_test's
// testPartialTowardZeroRounding exercises end-to-end via
// VaultWithdraw; this row pins it at the helper level.
.name = "FixedPrecision debit: truncated toward zero on the base grid",
.assetsTotal = onGrid,
.delta = Number{-17, -7},
.expected = Number{1, -6},
},
Case{
// delta = +2.3 base units, truncates to 2 base units for
// the same reason as the row above.
.name = "FixedPrecision credit: truncated toward zero on the base grid",
.assetsTotal = onGrid,
.delta = Number{23, -7},
.expected = Number{2, -6},
},
Case{
// delta = -0.3 base units: sub-ULP at the fixed grid, so
// TowardsZero truncates it entirely to zero.
.name = "FixedPrecision debit: sub-ULP dust rejected at the base grid",
.assetsTotal = onGrid,
.delta = Number{-3, -7},
.expected = std::nullopt,
},
Case{
// delta = +0.4 base units: same sub-ULP rejection for a
// credit.
.name = "FixedPrecision credit: sub-ULP dust rejected at the base grid",
.assetsTotal = onGrid,
.delta = Number{4, -7},
.expected = std::nullopt,
},
};
runCases(iou, cases, fixedScale);
}
public:
void
run() override
@@ -476,6 +564,7 @@ public:
testIouDebits(iou);
testIouCredits(iou);
testIntegralAssets(mpt, xrp);
testFixedPrecisionClamp(iou);
}
};

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,13 @@ protected:
return {.vault = vault, .keylet = keylet, .sub = sub, .red = red};
}
FeatureBitset const all_{test::jtx::testableAmendments()};
// The IOU precision-boundary bugs in VaultBugs_test.cpp probe the
// STAmount 16-digit mantissa cliff (~1e16). FixedPrecision's Open-zone
// cap (9e(15-Scale)) makes that value unreachable at any Scale, so
// these scenarios cannot be reproduced under V1.2 by construction.
// 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,283 @@
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/helpers/LendingHelpers.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/LedgerFormats.h>
#include <xrpl/protocol/MPTIssue.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;
}
std::shared_ptr<SLE>
makeBroker(Asset const& asset, Number const& coverAvailable)
{
auto broker = std::make_shared<SLE>(ltLOAN_BROKER, uint256{2u});
broker->at(sfCoverAvailable) = coverAvailable;
associateAsset(*broker, asset);
return broker;
}
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);
}
TEST(VaultGrid, BrokerCoverScaleAndRounding)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto const vault = makeVault(iou, Number{0}, VaultVersion::FixedPrecision, 6);
auto broker = makeBroker(iou, Number{9'999'999'999'999'999, -6});
STAmount const inflow{iou, Number{21, -6}};
EXPECT_EQ(getBrokerCoverScale(vault, broker), -6);
EXPECT_EQ(getPosteriorBrokerCoverScale(vault, broker, inflow), -5);
EXPECT_EQ(
roundToPosteriorBrokerCoverScale(vault, broker, inflow, Number::RoundingMode::TowardsZero),
STAmount(iou, Number{20, -6}));
broker->at(sfCoverAvailable) = Number{1'000'000'000'000'001, -5};
associateAsset(*broker, iou);
STAmount const outflow{iou, -Number{11, -6}};
EXPECT_EQ(getBrokerCoverScale(vault, broker), -5);
EXPECT_EQ(getPosteriorBrokerCoverScale(vault, broker, outflow), -6);
EXPECT_EQ(
roundToPosteriorBrokerCoverScale(vault, broker, outflow, Number::RoundingMode::TowardsZero),
outflow);
// CoverAvailable exactly 1e10 (exponent -5). Withdrawing 1e-6 re-fines
// to -6; the posterior rounded amount is 1e-6, which isZeroAtScale(-5)
// would treat as zero.
broker->at(sfCoverAvailable) = Number{1, 10};
associateAsset(*broker, iou);
STAmount const refine{iou, -Number{1, -6}};
EXPECT_EQ(getBrokerCoverScale(vault, broker), -5);
EXPECT_EQ(getPosteriorBrokerCoverScale(vault, broker, refine), -6);
EXPECT_EQ(
roundToPosteriorBrokerCoverScale(vault, broker, refine, Number::RoundingMode::TowardsZero),
refine);
}
TEST(VaultGrid, BrokerCoverOptionalInflowBoundaries)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto fixedIou = makeVault(iou, Number{0}, VaultVersion::FixedPrecision, 10);
auto iouBroker = makeBroker(iou, Number{9, 5});
STAmount const iouUnit{iou, Number{1, -10}};
EXPECT_EQ(checkOptionalBrokerCoverInflow(fixedIou, iouBroker, STAmount{iou}), tesSUCCESS);
EXPECT_EQ(checkOptionalBrokerCoverInflow(fixedIou, iouBroker, iouUnit), tecLIMIT_EXCEEDED);
auto legacy = makeVault(iou, Number{0}, VaultVersion::CashBasis, 10);
EXPECT_EQ(checkOptionalBrokerCoverInflow(legacy, iouBroker, iouUnit), tesSUCCESS);
for (Asset const asset : {Asset{xrpIssue()}, Asset{MPTIssue{makeMptID(1, issuer.id())}}})
{
auto vault = makeVault(asset, Number{0}, VaultVersion::FixedPrecision, 0);
auto broker = makeBroker(asset, Number{9, 15});
EXPECT_EQ(
checkOptionalBrokerCoverInflow(vault, broker, STAmount{asset, std::uint64_t{1}}),
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());