mirror of
https://github.com/XRPLF/rippled.git
synced 2026-09-24 06:00:17 +00:00
Compare commits
10 Commits
develop
...
tapanito/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19519c6b19 | ||
|
|
4e83215457 | ||
|
|
5157a2866b | ||
|
|
663229cfdf | ||
|
|
89028a2ec6 | ||
|
|
bb820683cb | ||
|
|
5e0e36f87d | ||
|
|
9ae863d89e | ||
|
|
b17c737e74 | ||
|
|
71d271ebed |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -513,6 +513,7 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({
|
||||
{sfAssetsAvailable, SoeDefault},
|
||||
{sfAssetsMaximum, SoeDefault},
|
||||
{sfLossUnrealized, SoeDefault},
|
||||
{sfYieldUnrealized, SoeDefault},
|
||||
{sfShareMPTID, SoeRequired},
|
||||
{sfWithdrawalPolicy, SoeRequired},
|
||||
{sfScale, SoeDefault},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) << ".";
|
||||
|
||||
@@ -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: " //
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
454
src/test/app/vault/VaultFixedPrecision_test.cpp
Normal file
454
src/test/app/vault/VaultFixedPrecision_test.cpp
Normal 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
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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"};
|
||||
|
||||
|
||||
@@ -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"};
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
283
src/tests/libxrpl/protocol/VaultGridTests.cpp
Normal file
283
src/tests/libxrpl/protocol/VaultGridTests.cpp
Normal 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
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user