From 71d271ebed550284dd7151ff9c3532caff69cfa3 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:55:56 +0200 Subject: [PATCH] feat: Add FixedPrecision vault scale helpers and Open-zone deposit checks New vaults created under V1.2 stay on a lifetime base grid so optional inflows cannot coarsen AssetsTotal, while pre-V1.2 vaults keep the dynamic scale they were created with. --- include/xrpl/ledger/helpers/VaultHelpers.h | 70 ++- include/xrpl/protocol/Protocol.h | 12 +- include/xrpl/protocol/detail/features.macro | 1 + .../xrpl/protocol/detail/ledger_entries.macro | 1 + include/xrpl/protocol/detail/sfields.macro | 1 + .../protocol_autogen/ledger_entries/Vault.h | 35 ++ src/libxrpl/ledger/helpers/LendingHelpers.cpp | 9 +- src/libxrpl/ledger/helpers/VaultHelpers.cpp | 235 ++++++++-- .../tx/transactors/lending/LoanSet.cpp | 4 +- .../tx/transactors/vault/VaultCreate.cpp | 21 +- .../tx/transactors/vault/VaultDeposit.cpp | 32 +- .../tx/transactors/vault/VaultWithdraw.cpp | 2 +- src/test/app/lending/LendingHelpers_test.cpp | 64 +-- src/test/app/lending/LoanTestBase.h | 17 +- src/test/app/vault/VaultBugs_test.cpp | 47 +- .../app/vault/VaultFixedPrecision_test.cpp | 419 ++++++++++++++++++ src/test/app/vault/VaultHelpers_test.cpp | 15 +- src/test/app/vault/VaultScale_test.cpp | 4 +- src/test/app/vault/VaultTestBase.h | 4 +- src/test/app/vault/VaultValidation_test.cpp | 10 +- src/tests/libxrpl/protocol/VaultGridTests.cpp | 211 +++++++++ .../ledger_entries/VaultTests.cpp | 27 ++ 22 files changed, 1089 insertions(+), 152 deletions(-) create mode 100644 src/test/app/vault/VaultFixedPrecision_test.cpp create mode 100644 src/tests/libxrpl/protocol/VaultGridTests.cpp diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index b42f349b95..f7795eb332 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -17,6 +17,69 @@ namespace xrpl { class STTx; +/** + * Return the Vault's current live exponent. + * + * Legacy and CashBasis Vaults use the exponent of AssetsTotal. FixedPrecision + * Vaults floor that exponent at their lifetime base exponent. + */ +[[nodiscard]] int +getVaultScale(SLE::const_ref vault); + +/** + * Return the Vault's base exponent. + * + * Legacy and CashBasis Vaults use their current live exponent. FixedPrecision + * Vaults use -Scale, or 0 for integral assets. + */ +[[nodiscard]] int +getVaultBaseScale(SLE::const_ref vault); + +/** + * Return the Vault's posterior live exponent after applying an unrounded delta. + */ +[[nodiscard]] int +getPosteriorVaultScale(SLE::const_ref vault, STAmount const& delta); + +/** + * Round an amount at the Vault's current live exponent. + * + * Reserved for LoanPay. Vault deposit, withdraw, and clawback round at the + * posterior live exponent instead. + */ +[[nodiscard]] STAmount +roundToVaultScale(SLE::const_ref vault, STAmount const& amount, Number::RoundingMode roundingMode); + +/** + * Round an amount at the Vault's posterior live exponent. + */ +[[nodiscard]] STAmount +roundToPosteriorVaultScale( + SLE::const_ref vault, + STAmount const& amount, + Number::RoundingMode roundingMode); + +/** + * Open-zone capacity ceiling: 9 * 10^(15 + baseScale). + * + * Defined only for FixedPrecision Vaults, where this is 9 * 10^(15 - P). + */ +[[nodiscard]] Number +getVaultOpenLimit(SLE::const_ref vault); + +/** + * Check whether `amount` is an admissible optional inflow. + * + * Legacy and CashBasis Vaults always succeed. FixedPrecision Vaults must + * remain at their base scale after applying the rounded amount, and the + * posterior capacity (AssetsTotal + YieldUnrealized + rounded amount) must + * stay within the Open zone. + * + * The amount is rounded toward zero at the posterior live exponent. + */ +[[nodiscard]] TER +checkOptionalVaultInflow(SLE::const_ref vault, STAmount const& amount); + /** * From the perspective of a vault, return the number of shares to give * depositor when they offer a fixed amount of assets. Note, since shares are @@ -53,9 +116,10 @@ sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co * Rounding strategy: * - Debits (withdrawals): Rounds down `|delta|` on the new scale to prevent * paying out more than requested. - * - Credits (deposits): Floors the resulting total asset balance and returns the - * difference from the current total. This prevents crediting the vault with - * more assets than the user deposited. + * - Legacy/CashBasis credits: Floors the resulting total asset balance and + * returns the difference from the current total. + * - FixedPrecision credits: Rounds the delta toward zero at the posterior live + * scale. * * Key rules: * - The returned magnitude never exceeds `|delta|`. diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 1b88eea456..12e242b788 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -319,14 +319,20 @@ constexpr std::uint8_t kVaultDefaultIouScale = 6; constexpr std::uint8_t kVaultMaximumIouScale = 18; /** - * Vault ledger-entry schema versions. Assigned to newly created - * Vaults once featureLendingProtocolV1_1 is enabled. Vaults created before - * activation are left without LEVersion (implicit legacy version 0, + * Maximum fixed-precision IOU scale factor for a Vault. + */ +constexpr std::uint8_t kVaultMaximumFixedIouScale = 10; + +/** + * Vault ledger-entry schema versions. Assigned to newly created Vaults by + * featureLendingProtocolV1_1 and later protocol amendments. Vaults created + * before activation are left without LEVersion (implicit legacy version 0, * accrual-basis accounting). */ enum class VaultVersion : uint8_t { Legacy = 0, CashBasis, + FixedPrecision, }; /** diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index e63a7f515d..e4f37ef756 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -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) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 04b390a7a4..482c6b1cb0 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -513,6 +513,7 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ {sfAssetsAvailable, SoeDefault}, {sfAssetsMaximum, SoeDefault}, {sfLossUnrealized, SoeDefault}, + {sfYieldUnrealized, SoeDefault}, {sfShareMPTID, SoeRequired}, {sfWithdrawalPolicy, SoeRequired}, {sfScale, SoeDefault}, diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 2cf35743ae..fbb8c959f1 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -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) diff --git a/include/xrpl/protocol_autogen/ledger_entries/Vault.h b/include/xrpl/protocol_autogen/ledger_entries/Vault.h index 389ffb4c46..1bcc230dab 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Vault.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Vault.h @@ -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 + 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 const& value) + { + object_[sfYieldUnrealized] = value; + return *this; + } + /** * @brief Set sfShareMPTID (SoeRequired) * @return Reference to this builder for method chaining. diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 10c7e62c6c..cb41b30e88 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -248,14 +248,13 @@ loanPaymentDeltas(LoanPaymentParts const& parts) namespace { -// Cash-basis accounting applies only when featureLendingProtocolV1_1 is -// enabled AND the specific Vault was created under it (LEVersion == -// VaultVersion::CashBasis). Vaults created before activation keep accrual-basis -// accounting forever, even after the amendment later turns on. +// Cash-basis accounting applies to Vaults created under +// featureLendingProtocolV1_1 or a later version. Vaults created before +// activation keep accrual-basis accounting forever. bool cashBasisEnabled(SLE::const_ref vaultSle) { - return getVaultVersion(vaultSle) == VaultVersion::CashBasis; + return getVaultVersion(vaultSle) >= VaultVersion::CashBasis; } } // namespace diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index 941b94143d..6a2838b93f 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include // IWYU pragma: keep #include @@ -17,6 +18,7 @@ #include #include +#include #include #include #include @@ -24,6 +26,169 @@ namespace xrpl { +namespace { + +[[nodiscard]] int +fixedBaseScale(SLE::const_ref vault) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::fixedBaseScale : valid Vault sle"); + if (vault->at(sfAsset).integral()) + return 0; + return -static_cast(vault->at(sfScale)); +} + +[[nodiscard]] int +liveScale(Number const& reference, Asset const& asset, int baseScale) +{ + if (reference == beast::kZero) + return baseScale; + return std::max(baseScale, scale(reference, asset)); +} + +[[nodiscard]] VaultKind +decodeVaultKind(std::optional vaultKind) +{ + if (vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded)) + return VaultKind::ClosedEnded; + return VaultKind::OpenEnded; +} + +} // namespace + +[[nodiscard]] int +getVaultScale(SLE::const_ref vault) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultScale : valid Vault sle"); + + switch (getVaultVersion(vault)) + { + case VaultVersion::Legacy: + case VaultVersion::CashBasis: + return scale(vault->at(sfAssetsTotal), vault->at(sfAsset)); + case VaultVersion::FixedPrecision: + return liveScale(vault->at(sfAssetsTotal), vault->at(sfAsset), fixedBaseScale(vault)); + } + // LCOV_EXCL_START + UNREACHABLE("xrpl::getVaultScale : valid VaultVersion"); + return Number::kMinExponent - 1; + // LCOV_EXCL_STOP +} + +[[nodiscard]] int +getVaultBaseScale(SLE::const_ref vault) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultBaseScale : valid Vault sle"); + + switch (getVaultVersion(vault)) + { + case VaultVersion::Legacy: + case VaultVersion::CashBasis: + return getVaultScale(vault); + case VaultVersion::FixedPrecision: + return fixedBaseScale(vault); + } + // LCOV_EXCL_START + UNREACHABLE("xrpl::getVaultBaseScale : valid VaultVersion"); + return Number::kMinExponent - 1; + // LCOV_EXCL_STOP +} + +[[nodiscard]] int +getPosteriorVaultScale(SLE::const_ref vault, STAmount const& delta) +{ + XRPL_ASSERT( + vault && vault->getType() == ltVAULT, "xrpl::getPosteriorVaultScale : valid Vault sle"); + XRPL_ASSERT( + delta.asset() == vault->at(sfAsset), + "xrpl::getPosteriorVaultScale : delta and Vault asset match"); + + Number const posterior = [&] { + NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest); + return vault->at(sfAssetsTotal) + delta; + }(); + + switch (getVaultVersion(vault)) + { + case VaultVersion::Legacy: + case VaultVersion::CashBasis: + return scale(posterior, vault->at(sfAsset)); + case VaultVersion::FixedPrecision: + return liveScale(posterior, vault->at(sfAsset), fixedBaseScale(vault)); + } + // LCOV_EXCL_START + UNREACHABLE("xrpl::getPosteriorVaultScale : valid VaultVersion"); + return Number::kMinExponent - 1; + // LCOV_EXCL_STOP +} + +[[nodiscard]] STAmount +roundToVaultScale(SLE::const_ref vault, STAmount const& amount, Number::RoundingMode roundingMode) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::roundToVaultScale : valid Vault sle"); + XRPL_ASSERT( + amount.asset() == vault->at(sfAsset), + "xrpl::roundToVaultScale : amount and Vault asset match"); + if (amount.integral()) + return amount; + return roundToScale(amount, getVaultScale(vault), roundingMode); +} + +[[nodiscard]] STAmount +roundToPosteriorVaultScale( + SLE::const_ref vault, + STAmount const& amount, + Number::RoundingMode roundingMode) +{ + XRPL_ASSERT( + vault && vault->getType() == ltVAULT, "xrpl::roundToPosteriorVaultScale : valid Vault sle"); + XRPL_ASSERT( + amount.asset() == vault->at(sfAsset), + "xrpl::roundToPosteriorVaultScale : amount and Vault asset match"); + if (amount.integral()) + return amount; + return roundToScale(amount, getPosteriorVaultScale(vault, amount), roundingMode); +} + +[[nodiscard]] Number +getVaultOpenLimit(SLE::const_ref vault) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultOpenLimit : valid Vault sle"); + XRPL_ASSERT( + getVaultVersion(vault) == VaultVersion::FixedPrecision, + "xrpl::getVaultOpenLimit : FixedPrecision Vault"); + return Number{9, 15 + getVaultBaseScale(vault)}; +} + +[[nodiscard]] TER +checkOptionalVaultInflow(SLE::const_ref vault, STAmount const& amount) +{ + XRPL_ASSERT( + vault && vault->getType() == ltVAULT, "xrpl::checkOptionalVaultInflow : valid Vault sle"); + XRPL_ASSERT( + amount.asset() == vault->at(sfAsset), + "xrpl::checkOptionalVaultInflow : amount and Vault asset match"); + XRPL_ASSERT(!amount.negative(), "xrpl::checkOptionalVaultInflow : non-negative amount"); + if (getVaultVersion(vault) != VaultVersion::FixedPrecision) + return tesSUCCESS; + + STAmount const rounded = + roundToPosteriorVaultScale(vault, amount, Number::RoundingMode::TowardsZero); + int const baseScale = getVaultBaseScale(vault); + // Keep this explicit even though a non-negative YieldUnrealized makes the + // Open-zone capacity ceiling reject every coarsening transition too. The + // protocol defines both conditions independently. + if (getPosteriorVaultScale(vault, rounded) != baseScale) + return tecLIMIT_EXCEEDED; + + Number const capacity = [&] { + NumberRoundModeGuard const rg(Number::RoundingMode::TowardsZero); + return vault->at(sfAssetsTotal) + vault->at(sfYieldUnrealized) + rounded; + }(); + if (capacity > getVaultOpenLimit(vault)) + return tecLIMIT_EXCEEDED; + return tesSUCCESS; +} + [[nodiscard]] std::optional assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& assets) { @@ -85,37 +250,47 @@ clampToAssetsTotalScale(SLE::const_ref vault, STAmount const& delta) { return magnitude; } - Number const assetsTotal = vault->at(sfAssetsTotal); - - // Calculate the scale after applying the delta using ToNearest rounding. - // This aligns the delta with scale checks used by vault invariants. - int const postScale = [&] { - NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest); - return scale(assetsTotal + delta, asset); - }(); STAmount actualDelta; - if (delta.negative()) + if (getVaultVersion(vault) == VaultVersion::FixedPrecision) { - // For withdrawals (debits), floor the magnitude to the target scale - // to ensure exact grid alignment without paying out extra assets. - actualDelta = roundToScale(magnitude, postScale, Number::RoundingMode::Downward); + STAmount const rounded = + roundToPosteriorVaultScale(vault, delta, Number::RoundingMode::TowardsZero); + actualDelta = rounded.negative() ? -rounded : rounded; } else { - // For deposits (credits), derive actualDelta from the floored posterior total. - // This prevents grid alignment issues from crediting the vault more than deposited. - // - // Sum using Downward rounding so intermediate precision doesn't round up - // and exceed the original requested amount. - Number const posterior = [&] { - NumberRoundModeGuard const rg(Number::RoundingMode::Downward); - return assetsTotal + magnitude; + Number const assetsTotal = vault->at(sfAssetsTotal); + + // Calculate the scale after applying the delta using ToNearest rounding. + // This aligns the delta with scale checks used by vault invariants. + int const postScale = [&] { + NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest); + return scale(assetsTotal + delta, asset); }(); - Number const roundedPosterior = - roundToAsset(asset, posterior, postScale, Number::RoundingMode::Downward); - actualDelta = STAmount{asset, roundedPosterior - assetsTotal}; + if (delta.negative()) + { + // For withdrawals (debits), floor the magnitude to the target scale + // to ensure exact grid alignment without paying out extra assets. + actualDelta = roundToScale(magnitude, postScale, Number::RoundingMode::Downward); + } + else + { + // For deposits (credits), derive actualDelta from the floored posterior total. + // This prevents grid alignment issues from crediting the vault more than deposited. + // + // Sum using Downward rounding so intermediate precision doesn't round up + // and exceed the original requested amount. + Number const posterior = [&] { + NumberRoundModeGuard const rg(Number::RoundingMode::Downward); + return assetsTotal + magnitude; + }(); + + Number const roundedPosterior = + roundToAsset(asset, posterior, postScale, Number::RoundingMode::Downward); + actualDelta = STAmount{asset, roundedPosterior - assetsTotal}; + } } XRPL_ASSERT( @@ -225,7 +400,7 @@ getVaultVersion(SLE::const_ref vault) return VaultVersion::Legacy; auto const version = vault->at(sfLEVersion); - if (version > std::to_underlying(VaultVersion::CashBasis)) + if (version > std::to_underlying(VaultVersion::FixedPrecision)) { // LCOV_EXCL_START UNREACHABLE("xrpl::getVaultVersion : invalid vault version"); @@ -235,18 +410,6 @@ getVaultVersion(SLE::const_ref vault) return static_cast(version); } -namespace { - -[[nodiscard]] VaultKind -decodeVaultKind(std::optional vaultKind) -{ - if (vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded)) - return VaultKind::ClosedEnded; - return VaultKind::OpenEnded; -} - -} // namespace - [[nodiscard]] VaultKind getVaultKind(SLE::const_ref vault) { diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index f7b97dfedf..4d20fc7fe2 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -347,7 +347,7 @@ LoanSet::preclaim(PreclaimContext const& ctx) // already at AssetsMaximum cannot take another loan. Cash-basis origination // does not change AssetsTotal (see cash_basis::loanOriginationDeltas), so // this leftover accrual gate must not apply there. - if (getVaultVersion(vault) != VaultVersion::CashBasis && vault->at(sfAssetsMaximum) != 0 && + if (getVaultVersion(vault) < VaultVersion::CashBasis && vault->at(sfAssetsMaximum) != 0 && vault->at(sfAssetsTotal) >= vault->at(sfAssetsMaximum)) { JLOG(ctx.j.warn()) << "Vault at maximum assets limit. Can't add another loan."; @@ -496,7 +496,7 @@ LoanSet::doApply() XRPL_ASSERT_PARTS( *vaultSle->at(sfAssetsMaximum) == 0 || - getVaultVersion(vaultSle) == VaultVersion::CashBasis || + getVaultVersion(vaultSle) >= VaultVersion::CashBasis || *vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy, "xrpl::LoanSet::doApply", "accrual vault is below maximum limit"); diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index 7ade4ed5ab..49105e5d46 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -101,7 +101,10 @@ VaultCreate::preflight(PreflightContext const& ctx) if (vaultAsset.holds() || vaultAsset.native()) return temMALFORMED; - if (scale > kVaultMaximumIouScale) + auto const maximumScale = ctx.rules.enabled(featureLendingProtocolV1_2) + ? kVaultMaximumFixedIouScale + : kVaultMaximumIouScale; + if (scale > maximumScale) return temMALFORMED; } @@ -273,10 +276,24 @@ VaultCreate::doApply() } if (scale != 0u) vault->at(sfScale) = scale; - if (view().rules().enabled(featureLendingProtocolV1_1)) + // featureLendingProtocolV1_2 is defined to require V1.1: new vaults get + // FixedPrecision plus the V1.1 VaultKind fields even if a test enables + // only V1.2. YieldUnrealized is SoeDefault, so writing zero stores the + // field as absent, matching LossUnrealized. + bool const fixedPrecision = view().rules().enabled(featureLendingProtocolV1_2); + bool const cashBasis = view().rules().enabled(featureLendingProtocolV1_1); + if (fixedPrecision) + { + vault->at(sfLEVersion) = std::to_underlying(VaultVersion::FixedPrecision); + vault->at(sfYieldUnrealized) = Number(0); + } + else if (cashBasis) { vault->at(sfLEVersion) = std::to_underlying(VaultVersion::CashBasis); + } + if (fixedPrecision || cashBasis) + { auto const kind = getVaultKind(tx); vault->at(sfVaultKind) = std::to_underlying(kind); if (kind == VaultKind::ClosedEnded) diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp index fc72159444..efb117b720 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp @@ -31,24 +31,6 @@ namespace xrpl { -[[nodiscard]] -static STAmount -roundToVaultScale(STAmount const& amount, SLE::const_ref vault) -{ - XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::roundToVaultScale : valid vault sle"); - XRPL_ASSERT( - amount.asset() == vault->at(sfAsset), "xrpl::roundToVaultScale : valid vault asset"); - - if (amount.integral()) - return amount; - - int const postScale = [&]() { - NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest); - return scale(vault->at(sfAssetsTotal) + amount, vault->at(sfAsset)); - }(); - return roundToScale(amount, postScale, Number::RoundingMode::Downward); -} - // True if debiting `assets` would leave the depositor's balance where it started, so the deposit // would mint shares against a transfer that never happened. Asking the balance directly whether it // notices the debit avoids having to infer the rounding step: it has to be the stored balance that @@ -187,7 +169,9 @@ VaultDeposit::preclaim(PreclaimContext const& ctx) if (auto const ter = requireAuth(ctx.view, vaultAsset, account); !isTesSuccess(ter)) return ter; - auto const roundedAmount = fix320Enabled ? roundToVaultScale(amount, vault) : amount; + auto const roundedAmount = fix320Enabled + ? roundToPosteriorVaultScale(vault, amount, Number::RoundingMode::TowardsZero) + : amount; if (fix320Enabled && roundedAmount == beast::kZero) { @@ -237,10 +221,11 @@ VaultDeposit::doApply() return tefINTERNAL; // LCOV_EXCL_LINE auto const vaultAsset = vault->at(sfAsset); - // Post-amendment IOU only: round Downward to the AssetsTotal precision so + // Post-amendment IOU only: round toward zero to the AssetsTotal precision so // a sub-ULP tail can't be silently absorbed by one rail and not the other. - auto const amount = - fix320Enabled ? roundToVaultScale(ctx_.tx[sfAmount], vault) : ctx_.tx[sfAmount]; + auto const amount = fix320Enabled + ? roundToPosteriorVaultScale(vault, ctx_.tx[sfAmount], Number::RoundingMode::TowardsZero) + : ctx_.tx[sfAmount]; // We validated zero-amount in preclaim, if we ended up with zero now, fail hard. if (amount == beast::kZero) @@ -374,6 +359,9 @@ VaultDeposit::doApply() return tecPATH_DRY; } + if (auto const ter = checkOptionalVaultInflow(vault, assetsDeposited); !isTesSuccess(ter)) + return ter; + XRPL_ASSERT( sharesCreated.asset() != assetsDeposited.asset(), "xrpl::VaultDeposit::doApply : assets are not shares"); diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index 4dc5b95c89..4f6f98325a 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -471,7 +471,7 @@ VaultWithdraw::doApply() // re-derived: any trimmed residue stays with remaining shareholders. auto const maybeClamped = clampToAssetsTotalScale(vault, -assetsWithdrawn); if (!maybeClamped) - return maybeClamped.error(); // LCOV_EXCL_LINE + return maybeClamped.error(); assetsWithdrawn = *maybeClamped; } // LCOV_EXCL_START diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp index 96adfd5254..608965c888 100644 --- a/src/test/app/lending/LendingHelpers_test.cpp +++ b/src/test/app/lending/LendingHelpers_test.cpp @@ -1675,7 +1675,6 @@ class LendingHelpers_test : public beast::unit_test::Suite Number const interestDue{75}; auto const legacyVault = makeVaultSle(); - auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); { testcase( @@ -1691,14 +1690,17 @@ class LendingHelpers_test : public beast::unit_test::Suite { testcase( - "loanOriginationDeltas dispatcher: amendment enabled, LEVersion == " - "VaultVersion::CashBasis picks CashBasis"); + "loanOriginationDeltas dispatcher: CashBasis and FixedPrecision " + "Vaults pick cash-basis accounting"); Env const env{*this}; - auto const deltas = - loanOriginationDeltas(cashBasisVault, principalRequested, interestDue); - auto const expected = xrpl::cash_basis::loanOriginationDeltas(principalRequested); - BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); - BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); + for (auto const version : {VaultVersion::CashBasis, VaultVersion::FixedPrecision}) + { + auto const deltas = + loanOriginationDeltas(makeVaultSle(version), principalRequested, interestDue); + auto const expected = xrpl::cash_basis::loanOriginationDeltas(principalRequested); + BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); + BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); + } } } @@ -1713,7 +1715,6 @@ class LendingHelpers_test : public beast::unit_test::Suite Number const interestDue{101}; auto const legacyVault = makeVaultSle(std::nullopt, vaultMaximum, vaultTotal); - auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis, vaultMaximum, vaultTotal); { testcase( @@ -1728,12 +1729,16 @@ class LendingHelpers_test : public beast::unit_test::Suite { testcase( - "loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, LEVersion == " - "VaultVersion::CashBasis picks CashBasis"); + "loanOriginationExceedsVaultMaximum dispatcher: CashBasis and " + "FixedPrecision Vaults pick cash-basis accounting"); Env const env{*this}; - BEAST_EXPECT( - loanOriginationExceedsVaultMaximum(cashBasisVault, vaultTotal, interestDue) == - false); + for (auto const version : {VaultVersion::CashBasis, VaultVersion::FixedPrecision}) + { + BEAST_EXPECT( + loanOriginationExceedsVaultMaximum( + makeVaultSle(version, vaultMaximum, vaultTotal), vaultTotal, interestDue) == + false); + } } } @@ -1743,7 +1748,6 @@ class LendingHelpers_test : public beast::unit_test::Suite using namespace jtx; auto const legacyVault = makeVaultSle(); - auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); { testcase("loanVaultExposure dispatcher: amendment enabled, legacy vault picks Accrual"); @@ -1755,13 +1759,16 @@ class LendingHelpers_test : public beast::unit_test::Suite { testcase( - "loanVaultExposure dispatcher: amendment enabled, LEVersion == " - "VaultVersion::CashBasis " - "picks CashBasis"); + "loanVaultExposure dispatcher: CashBasis and FixedPrecision " + "Vaults pick cash-basis accounting"); Env const env{*this}; auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); - BEAST_EXPECT( - loanVaultExposure(cashBasisVault, sle) == xrpl::cash_basis::loanVaultExposure(sle)); + for (auto const version : {VaultVersion::CashBasis, VaultVersion::FixedPrecision}) + { + BEAST_EXPECT( + loanVaultExposure(makeVaultSle(version), sle) == + xrpl::cash_basis::loanVaultExposure(sle)); + } } } @@ -1777,7 +1784,6 @@ class LendingHelpers_test : public beast::unit_test::Suite .feePaid = Number{3}}; auto const legacyVault = makeVaultSle(); - auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); { testcase("loanPaymentDeltas dispatcher: amendment enabled, legacy vault picks Accrual"); @@ -1790,14 +1796,16 @@ class LendingHelpers_test : public beast::unit_test::Suite { testcase( - "loanPaymentDeltas dispatcher: amendment enabled, LEVersion == " - "VaultVersion::CashBasis " - "picks CashBasis"); + "loanPaymentDeltas dispatcher: CashBasis and FixedPrecision " + "Vaults pick cash-basis accounting"); Env const env{*this}; - auto const deltas = loanPaymentDeltas(cashBasisVault, parts); - auto const expected = xrpl::cash_basis::loanPaymentDeltas(parts); - BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); - BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); + for (auto const version : {VaultVersion::CashBasis, VaultVersion::FixedPrecision}) + { + auto const deltas = loanPaymentDeltas(makeVaultSle(version), parts); + auto const expected = xrpl::cash_basis::loanPaymentDeltas(parts); + BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); + BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); + } } } diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index 13dffb6b9e..22b5fd4c33 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -83,12 +83,11 @@ protected: // Ensure that all the features needed for Lending Protocol are included, // even if they are set to unsupported. // - // featureLendingProtocolV1_1 is excluded from the default set: it changes - // Vault/LoanBroker accounting (AssetsTotal/DebtTotal/LossUnrealized), and - // most of this file's tests assert whole-life-specific expected values - // for those fields. Tests that specifically exercise the amendment opt - // it back in explicitly (e.g. `all_ | featureLendingProtocolV1_1`). - FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1}; + // Later Lending amendments are excluded from the default set because + // they change accounting and precision behavior. Tests that exercise an + // amendment opt it back in explicitly. + FeatureBitset const all_{ + jtx::testableAmendments() - featureLendingProtocolV1_1 - featureLendingProtocolV1_2}; std::string const iouCurrency_{"IOU"}; struct BrokerParameters @@ -346,7 +345,7 @@ protected: { auto const expectedDebt = env.current()->rules().enabled(featureLendingProtocolV1_1) && - getVaultVersion(vaultSle) == VaultVersion::CashBasis + getVaultVersion(vaultSle) >= VaultVersion::CashBasis ? principalOutstanding : principalOutstanding + interestOwed; env.test.BEAST_EXPECT(brokerDebt == expectedDebt); @@ -451,7 +450,7 @@ protected: env.test.BEAST_EXPECT( vaultSle->at(sfLossUnrealized) == (env.current()->rules().enabled(featureLendingProtocolV1_1) && - getVaultVersion(vaultSle) == VaultVersion::CashBasis + getVaultVersion(vaultSle) >= VaultVersion::CashBasis ? principalOutstanding : totalValue - managementFeeOutstanding)); } @@ -666,7 +665,7 @@ protected: vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable); auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + (env.current()->rules().enabled(featureLendingProtocolV1_1) && - getVaultVersion(vaultSle) == VaultVersion::CashBasis + getVaultVersion(vaultSle) >= VaultVersion::CashBasis ? state.principalOutstanding : state.totalValue - state.managementFeeOutstanding); diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp index cc30bd6091..8fd7a26c6d 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -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); } } diff --git a/src/test/app/vault/VaultFixedPrecision_test.cpp b/src/test/app/vault/VaultFixedPrecision_test.cpp new file mode 100644 index 0000000000..ac2c89dd9c --- /dev/null +++ b/src/test/app/vault/VaultFixedPrecision_test.cpp @@ -0,0 +1,419 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include // IWYU pragma: keep +#include +#include + +#include +#include + +namespace xrpl { + +class VaultFixedPrecision_test : public VaultTestBase +{ + static FeatureBitset + features() + { + return test::jtx::testableAmendments() | featureLendingProtocolV1_1 | + featureLendingProtocolV1_2; + } + + void + testCreate() + { + using namespace test::jtx; + + Account const issuer{"issuer"}; + Account const owner{"owner"}; + PrettyAsset const asset{issuer["USD"]}; + + { + testcase("VaultCreate writes FixedPrecision fields"); + Env env(*this, features()); + env.fund(XRP(1'000'000), issuer, owner); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + auto const sle = env.le(keylet); + BEAST_EXPECT(sle); + BEAST_EXPECT(sle->at(sfLEVersion) == std::to_underlying(VaultVersion::FixedPrecision)); + BEAST_EXPECT(sle->at(sfScale) == kVaultDefaultIouScale); + BEAST_EXPECT(sle->at(sfYieldUnrealized) == beast::kZero); + } + + for (std::uint8_t const scaleValue : + {kVaultMaximumFixedIouScale, + static_cast(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(env.le(keylet)) == (scaleValue == kVaultMaximumFixedIouScale)); + } + + { + testcase("CashBasis Vault retains legacy Scale maximum"); + auto const legacyFeatures = features() - featureLendingProtocolV1_2; + Env env(*this, legacyFeatures); + env.fund(XRP(1'000'000), issuer, owner); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[sfScale] = kVaultMaximumIouScale; + env(tx); + env.close(); + + auto const sle = env.le(keylet); + BEAST_EXPECT(sle); + BEAST_EXPECT(sle->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis)); + BEAST_EXPECT(!sle->isFieldPresent(sfYieldUnrealized)); + } + } + + void + testDepositAdmission() + { + using namespace test::jtx; + + Account const issuer{"issuer"}; + Account const owner{"owner"}; + PrettyAsset const asset{issuer["USD"]}; + Number const open{9, 9}; + Number const baseUnit{1, -6}; + + Env env(*this, features()); + env.fund(XRP(1'000'000), issuer, owner); + env.close(); + env(trust(owner, asset(open + Number{1}))); + env.close(); + env(pay(issuer, owner, asset(open + Number{1}))); + env.close(); + + Vault const vault{env}; + auto [create, keylet] = vault.create({.owner = owner, .asset = asset}); + create[sfScale] = 6; + env(create); + env.close(); + + testcase("VaultDeposit admits the Open boundary"); + env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(open)})); + env.close(); + + auto const atOpen = env.le(keylet); + BEAST_EXPECT(atOpen); + BEAST_EXPECT(atOpen->at(sfAssetsTotal) == open); + BEAST_EXPECT(atOpen->at(sfAssetsAvailable) == open); + + testcase("VaultDeposit rejects one base unit above Open"); + env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(baseUnit)}), + Ter(tecLIMIT_EXCEEDED)); + env.close(); + + auto const afterRejected = env.le(keylet); + BEAST_EXPECT(afterRejected); + BEAST_EXPECT(afterRejected->at(sfAssetsTotal) == open); + BEAST_EXPECT(afterRejected->at(sfAssetsAvailable) == open); + } + + void + testExistingCashBasisVault() + { + using namespace test::jtx; + + testcase("V1.2 does not migrate an existing CashBasis Vault"); + + Account const issuer{"issuer"}; + Account const owner{"owner"}; + PrettyAsset const asset{issuer["USD"]}; + Number const deposit{9'999'999'999'999'999LL}; + + Env env(*this, features() - featureLendingProtocolV1_2); + env.fund(XRP(1'000'000), issuer, owner); + env.close(); + env(trust(owner, STAmount{asset.raw(), 2, 16})); + env.close(); + env(pay(issuer, owner, asset(deposit))); + env.close(); + + Vault const vault{env}; + auto [create, keylet] = vault.create({.owner = owner, .asset = asset}); + create[sfScale] = 0; + env(create); + env.close(); + + auto const before = env.le(keylet); + BEAST_EXPECT(before); + BEAST_EXPECT(before->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis)); + + env.enableFeature(featureLendingProtocolV1_2); + env.close(); + env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(deposit)})); + env.close(); + + auto const after = env.le(keylet); + BEAST_EXPECT(after); + BEAST_EXPECT(after->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis)); + BEAST_EXPECT(!after->isFieldPresent(sfYieldUnrealized)); + BEAST_EXPECT(after->at(sfAssetsTotal) == deposit); + } + + void + testPartialTowardZeroRounding() + { + using namespace test::jtx; + + // On a fresh vault the share price is one base unit, so the share + // round-trip already lands on the Scale-6 grid before + // clampToAssetsTotalScale. These cases check that the deposit, + // withdraw, and clawback paths still book that truncated amount. + // A non-unit share price (loan yield) is needed to exercise the + // clamp itself; that arrives with the lending PR. + + Account const issuer{"issuer"}; + Account const owner{"owner"}; + PrettyAsset const asset{issuer["USD"]}; + Number const depositRequested{32'345'678, -7}; // 3.2345678 + Number const outflowRequested{10'000'005, -7}; // 1.0000005 + + Env env(*this, features()); + env.fund(XRP(1'000'000), issuer, owner); + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + env(trust(owner, asset(4))); + env.close(); + env(pay(issuer, owner, asset(4))); + env.close(); + + Vault const vault{env}; + auto [create, keylet] = vault.create({.owner = owner, .asset = asset}); + create[sfScale] = 6; + env(create); + env.close(); + + testcase("VaultDeposit books the truncated amount on the base grid"); + env(vault.deposit( + {.depositor = owner, .id = keylet.key, .amount = asset(depositRequested)})); + env.close(); + + auto afterDeposit = env.le(keylet); + BEAST_EXPECT(afterDeposit); + BEAST_EXPECT(afterDeposit->at(sfAssetsTotal) == (Number{3'234'567, -6})); + BEAST_EXPECT(afterDeposit->at(sfAssetsAvailable) == (Number{3'234'567, -6})); + BEAST_EXPECT(env.balance(owner, asset) == asset(Number{765'433, -6})); + + testcase("VaultWithdraw books the truncated amount on the base grid"); + env(vault.withdraw( + {.depositor = owner, .id = keylet.key, .amount = asset(outflowRequested)})); + env.close(); + + auto afterWithdraw = env.le(keylet); + BEAST_EXPECT(afterWithdraw); + BEAST_EXPECT(afterWithdraw->at(sfAssetsTotal) == (Number{2'234'567, -6})); + BEAST_EXPECT(afterWithdraw->at(sfAssetsAvailable) == (Number{2'234'567, -6})); + BEAST_EXPECT(env.balance(owner, asset) == asset(Number{1'765'433, -6})); + + testcase("VaultClawback books the truncated amount on the base grid"); + env(vault.clawback( + {.issuer = issuer, + .id = keylet.key, + .holder = owner, + .amount = asset(outflowRequested).value()})); + env.close(); + + auto const afterClawback = env.le(keylet); + BEAST_EXPECT(afterClawback); + BEAST_EXPECT(afterClawback->at(sfAssetsTotal) == (Number{1'234'567, -6})); + BEAST_EXPECT(afterClawback->at(sfAssetsAvailable) == (Number{1'234'567, -6})); + } + + void + testIntegralAssetCapacity() + { + using namespace test::jtx; + + testcase("FixedPrecision MPT Vault enforces integral Open zone"); + + Account const issuer{"issuer"}; + Account const owner{"owner"}; + constexpr std::uint64_t open = 9'000'000'000'000'000; + constexpr std::uint64_t maximum = open + 1; + + Env env(*this, features()); + env.fund(XRP(1'000'000), issuer, owner); + env.close(); + + MPTTester mpt{env, issuer, kMptInitNoFund}; + mpt.create({.maxAmt = maximum, .flags = tfMPTCanTransfer}); + PrettyAsset const asset = mpt.issuanceID(); + mpt.authorize({.account = owner}); + env(pay(issuer, owner, asset(maximum))); + env.close(); + + Vault const vault{env}; + auto [create, keylet] = vault.create({.owner = owner, .asset = asset}); + env(create); + env.close(); + + env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(open)})); + env.close(); + env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}), + Ter(tecLIMIT_EXCEEDED)); + + auto const sle = env.le(keylet); + BEAST_EXPECT(sle); + BEAST_EXPECT(sle->at(sfAssetsTotal) == Number{open}); + } + + void + testDepositDust() + { + using namespace test::jtx; + + testcase("VaultDeposit rejects sub-base-unit dust"); + + Account const issuer{"issuer"}; + Account const owner{"owner"}; + PrettyAsset const asset{issuer["USD"]}; + + Env env(*this, features()); + env.fund(XRP(1'000'000), issuer, owner); + env.close(); + env(trust(owner, asset(1))); + env.close(); + env(pay(issuer, owner, asset(1))); + env.close(); + + Vault const vault{env}; + auto [create, keylet] = vault.create({.owner = owner, .asset = asset}); + create[sfScale] = 6; + env(create); + env.close(); + + env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(Number{1, -7})}), + Ter(tecPRECISION_LOSS)); + } + + void + testWithdrawDust() + { + using namespace test::jtx; + + testcase("VaultWithdraw rejects sub-base-unit dust"); + + Account const issuer{"issuer"}; + Account const owner{"owner"}; + PrettyAsset const asset{issuer["USD"]}; + + Env env(*this, features()); + env.fund(XRP(1'000'000), issuer, owner); + env.close(); + env(trust(owner, asset(2))); + env.close(); + env(pay(issuer, owner, asset(2))); + env.close(); + + Vault const vault{env}; + auto [create, keylet] = vault.create({.owner = owner, .asset = asset}); + create[sfScale] = 6; + env(create); + env.close(); + env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)})); + env.close(); + + env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(Number{1, -7})}), + Ter(tecPRECISION_LOSS)); + } + + void + testClawbackDust() + { + using namespace test::jtx; + + testcase("VaultClawback rejects sub-base-unit dust"); + + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + PrettyAsset const asset{issuer["USD"]}; + + Env env(*this, features()); + env.fund(XRP(1'000'000), issuer, owner, depositor); + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + env(trust(depositor, asset(2))); + env.close(); + env(pay(issuer, depositor, asset(2))); + env.close(); + + Vault const vault{env}; + auto [create, keylet] = vault.create({.owner = owner, .asset = asset}); + create[sfScale] = 6; + env(create); + env.close(); + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1)})); + env.close(); + + env(vault.clawback( + {.issuer = issuer, + .id = keylet.key, + .holder = depositor, + .amount = asset(Number{1, -7}).value()}), + Ter(tecPRECISION_LOSS)); + } + +public: + void + run() override + { + testCreate(); + testDepositAdmission(); + testExistingCashBasisVault(); + testPartialTowardZeroRounding(); + testIntegralAssetCapacity(); + testDepositDust(); + testWithdrawDust(); + testClawbackDust(); + } +}; + +BEAST_DEFINE_TESTSUITE(VaultFixedPrecision, app, xrpl); + +} // namespace xrpl diff --git a/src/test/app/vault/VaultHelpers_test.cpp b/src/test/app/vault/VaultHelpers_test.cpp index d52b732a60..3d6ff6fc6a 100644 --- a/src/test/app/vault/VaultHelpers_test.cpp +++ b/src/test/app/vault/VaultHelpers_test.cpp @@ -27,10 +27,10 @@ namespace xrpl { -// True unit test of `clampToAssetsTotalScale`. The function under test only -// reads sfAsset and sfAssetsTotal from the vault SLE and never touches a -// ledger view or Rules, so a bare in-memory ltVAULT SLE is enough; there is -// no jtx::Env and no transaction submitted anywhere in this file. +// True unit test of `clampToAssetsTotalScale`. The function under test reads +// only fields from the vault SLE and never touches a ledger view or Rules, so +// a bare in-memory ltVAULT SLE is enough; there is no jtx::Env and no +// transaction submitted anywhere in this file. // // Number regime: this suite relies on the default thread_local Number // mantissa range, which src/libxrpl/basics/Number.cpp initializes to @@ -58,11 +58,8 @@ private: std::optional expected; // nullopt means tecPRECISION_LOSS }; - // Builds a bare ltVAULT SLE with only sfAsset and sfAssetsTotal set, - // mirroring what a transactor does: set the STNumber field, then call - // associateAsset() so it is quantized to the asset's STAmount grid, the - // same way VaultDeposit::doApply does for a real vault (see - // src/libxrpl/tx/transactors/vault/VaultDeposit.cpp). + // Builds a bare ltVAULT SLE with sfLEVersion absent, preserving the + // pre-V1.2 Legacy behavior exercised by this existing clamp table. static std::shared_ptr makeVault(Asset const& asset, Number const& assetsTotal) { diff --git a/src/test/app/vault/VaultScale_test.cpp b/src/test/app/vault/VaultScale_test.cpp index c2858a204d..c9de83537d 100644 --- a/src/test/app/vault/VaultScale_test.cpp +++ b/src/test/app/vault/VaultScale_test.cpp @@ -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"}; diff --git a/src/test/app/vault/VaultTestBase.h b/src/test/app/vault/VaultTestBase.h index 538f3b72d8..93225b3794 100644 --- a/src/test/app/vault/VaultTestBase.h +++ b/src/test/app/vault/VaultTestBase.h @@ -113,7 +113,9 @@ protected: return {.vault = vault, .keylet = keylet, .sub = sub, .red = red}; } - FeatureBitset const all_{test::jtx::testableAmendments()}; + // Keep legacy Vault suites on their pre-V1.2 behavior. Tests for the + // fixed-precision protocol enable featureLendingProtocolV1_2 explicitly. + FeatureBitset const all_{test::jtx::testableAmendments() - featureLendingProtocolV1_2}; std::string const iouCurrency_{"IOU"}; }; diff --git a/src/test/app/vault/VaultValidation_test.cpp b/src/test/app/vault/VaultValidation_test.cpp index 45f6d1deaf..08f1662cb6 100644 --- a/src/test/app/vault/VaultValidation_test.cpp +++ b/src/test/app/vault/VaultValidation_test.cpp @@ -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(); diff --git a/src/tests/libxrpl/protocol/VaultGridTests.cpp b/src/tests/libxrpl/protocol/VaultGridTests.cpp new file mode 100644 index 0000000000..f3f155664c --- /dev/null +++ b/src/tests/libxrpl/protocol/VaultGridTests.cpp @@ -0,0 +1,211 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // IWYU pragma: keep +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace xrpl { +namespace { + +std::shared_ptr +makeVault( + Asset const& asset, + Number const& assetsTotal, + std::optional version, + std::uint8_t scaleValue = kVaultDefaultIouScale) +{ + auto vault = std::make_shared(keylet::vault(uint256(1))); + vault->setFieldIssue(sfAsset, STIssue{sfAsset, asset}); + vault->at(sfAssetsTotal) = assetsTotal; + if (!asset.integral()) + vault->at(sfScale) = scaleValue; + if (version) + vault->at(sfLEVersion) = std::to_underlying(*version); + associateAsset(*vault, asset); + return vault; +} + +TEST(VaultGrid, BaseAndLiveScale) +{ + test::Account const issuer{"issuer"}; + Issue const iou{toCurrency("USD"), issuer.id()}; + + auto const legacy = makeVault(iou, Number{1'000'000}, VaultVersion::Legacy, 6); + EXPECT_EQ(getVaultScale(legacy), -9); + EXPECT_EQ(getVaultBaseScale(legacy), getVaultScale(legacy)); + + auto const empty = makeVault(iou, Number{0}, VaultVersion::FixedPrecision, 6); + EXPECT_EQ(getVaultScale(empty), -6); + EXPECT_EQ(getVaultBaseScale(empty), -6); + + auto const small = makeVault(iou, Number{1'000'000}, VaultVersion::FixedPrecision, 6); + EXPECT_EQ(getVaultScale(small), -6); + EXPECT_EQ(getVaultBaseScale(small), -6); + + auto const coarsened = makeVault(iou, Number{10'000'000'000}, VaultVersion::FixedPrecision, 6); + EXPECT_GT(getVaultScale(coarsened), getVaultBaseScale(coarsened)); + EXPECT_EQ(getVaultBaseScale(coarsened), -6); +} + +TEST(VaultGrid, PreV12BehaviorIsPreserved) +{ + test::Account const issuer{"issuer"}; + Issue const iou{toCurrency("USD"), issuer.id()}; + Number const assetsTotal{1'000'000}; + STAmount const onGrid{iou, Number{2, -9}}; + STAmount const dust{iou, Number{4, -10}}; + STAmount const overOpen{iou, Number{10, 9}}; + auto const downward = Number::RoundingMode::Downward; + + auto const legacy = makeVault(iou, assetsTotal, VaultVersion::Legacy); + int const expectedLive = getVaultScale(legacy); + int const expectedPosterior = getPosteriorVaultScale(legacy, onGrid); + STAmount const expectedLiveRound = roundToVaultScale(legacy, onGrid, downward); + STAmount const expectedPosteriorRound = roundToPosteriorVaultScale(legacy, dust, downward); + + for (auto const version : + {std::optional{}, + 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{}, + std::optional{VaultVersion::Legacy}, + std::optional{VaultVersion::CashBasis}}) + { + auto const result = clampToAssetsTotalScale(makeVault(iou, assetsTotal, version), delta); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, expected); + } +} + +TEST(VaultGrid, IntegralScaleIsZero) +{ + auto const vault = makeVault(xrpIssue(), Number{1'000}, VaultVersion::FixedPrecision, 0); + STAmount const delta{xrpIssue(), 7}; + + EXPECT_EQ(getVaultScale(vault), 0); + EXPECT_EQ(getVaultBaseScale(vault), 0); + EXPECT_EQ(getPosteriorVaultScale(vault, delta), 0); + EXPECT_EQ(roundToPosteriorVaultScale(vault, delta, Number::RoundingMode::TowardsZero), delta); +} + +TEST(VaultGrid, PosteriorScaleRoundsDelta) +{ + test::Account const issuer{"issuer"}; + Issue const iou{toCurrency("USD"), issuer.id()}; + auto const vault = + makeVault(iou, Number{9'999'999'999'999'999, -6}, VaultVersion::FixedPrecision, 6); + STAmount const delta{iou, Number{21, -6}}; + + EXPECT_EQ(getVaultScale(vault), -6); + EXPECT_EQ(getPosteriorVaultScale(vault, delta), -5); + EXPECT_EQ(roundToVaultScale(vault, delta, Number::RoundingMode::TowardsZero), delta); + EXPECT_EQ( + roundToPosteriorVaultScale(vault, delta, Number::RoundingMode::TowardsZero), + STAmount(iou, Number{20, -6})); +} + +TEST(VaultGrid, PosteriorScaleRejectsDustAtCallSite) +{ + test::Account const issuer{"issuer"}; + Issue const iou{toCurrency("USD"), issuer.id()}; + auto const vault = makeVault(iou, Number{10'000'000'000}, VaultVersion::FixedPrecision, 6); + STAmount const dust{iou, Number{1, -6}}; + + EXPECT_EQ( + roundToPosteriorVaultScale(vault, dust, Number::RoundingMode::TowardsZero), beast::kZero); +} + +TEST(VaultGrid, PosteriorOutflowCanRefineScale) +{ + test::Account const issuer{"issuer"}; + Issue const iou{toCurrency("USD"), issuer.id()}; + auto const vault = + makeVault(iou, Number{1'000'000'000'000'001, -5}, VaultVersion::FixedPrecision, 6); + STAmount const delta{iou, -Number{11, -6}}; + + EXPECT_EQ(getVaultScale(vault), -5); + EXPECT_EQ(getPosteriorVaultScale(vault, delta), -6); + EXPECT_EQ(roundToPosteriorVaultScale(vault, delta, Number::RoundingMode::TowardsZero), delta); +} + +TEST(VaultGrid, OptionalInflowCapacityBoundaries) +{ + test::Account const issuer{"issuer"}; + Issue const iou{toCurrency("USD"), issuer.id()}; + + auto fixedIou = makeVault(iou, Number{9, 5}, VaultVersion::FixedPrecision, 10); + STAmount const iouDust{iou, Number{1, -10}}; + EXPECT_EQ(getVaultOpenLimit(fixedIou), (Number{9, 5})); + EXPECT_EQ(checkOptionalVaultInflow(fixedIou, STAmount{iou}), tesSUCCESS); + EXPECT_EQ(checkOptionalVaultInflow(fixedIou, iouDust), tecLIMIT_EXCEEDED); + + auto fixedXrp = makeVault(xrpIssue(), Number{9, 15}, VaultVersion::FixedPrecision, 0); + STAmount const xrpUnit{xrpIssue(), 1}; + EXPECT_EQ(getVaultOpenLimit(fixedXrp), (Number{9, 15})); + EXPECT_EQ(checkOptionalVaultInflow(fixedXrp, STAmount{xrpIssue()}), tesSUCCESS); + EXPECT_EQ(checkOptionalVaultInflow(fixedXrp, xrpUnit), tecLIMIT_EXCEEDED); + + auto legacy = makeVault(iou, Number{10, 5}, VaultVersion::Legacy, 10); + EXPECT_EQ(checkOptionalVaultInflow(legacy, iouDust), tesSUCCESS); + + auto coarsening = + makeVault(iou, Number{9'999'999'999'999'999, -6}, VaultVersion::FixedPrecision, 6); + STAmount const coarseningDelta{iou, Number{21, -6}}; + EXPECT_EQ(checkOptionalVaultInflow(coarsening, coarseningDelta), tecLIMIT_EXCEEDED); +} + +TEST(VaultGrid, OptionalInflowIncludesYieldUnrealized) +{ + test::Account const issuer{"issuer"}; + Issue const iou{toCurrency("USD"), issuer.id()}; + auto vault = makeVault(iou, Number{8'999'999'999}, VaultVersion::FixedPrecision, 6); + STAmount const amount{iou, Number{1}}; + + EXPECT_EQ(checkOptionalVaultInflow(vault, amount), tesSUCCESS); + + vault->at(sfYieldUnrealized) = Number{1}; + associateAsset(*vault, iou); + EXPECT_EQ(checkOptionalVaultInflow(vault, amount), tecLIMIT_EXCEEDED); +} + +} // namespace +} // namespace xrpl diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp index 26dde55563..e93461fccf 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp @@ -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());