diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index f3fc82eacbc..c625c5bffd5 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -101,6 +101,24 @@ static constexpr std::uint32_t kSecondsInYear = 365 * 24 * 60 * 60; Number loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval); +/** + * Assets a loan earns per second at this principal outstanding. + * + * Equation (27) of XLS-66 is linear in elapsed time, and sfPaymentInterval + * cancels out of it, so a loan's accrual rate depends only on its principal + * and interest rate. Principal is flat between payments, which makes the rate + * piecewise-constant with breakpoints exactly at the events that update it — + * summing it across a vault's loans is therefore exact, not an approximation. + */ +inline Number +loanAccrualRate(Number const& principalOutstanding, TenthBips32 interestRate) +{ + if (interestRate == TenthBips32{0} || principalOutstanding <= Number{}) + return Number{}; + + return tenthBipsOfValue(principalOutstanding, interestRate) / Number{kSecondsInYear}; +} + /** * Ensure the periodic payment is always rounded consistently */ diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index b42f349b951..e892e70edd9 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -16,20 +17,111 @@ namespace xrpl { class STTx; +/** + * Interest the vault's loans have earned since sfLastAccrualTime, capped by + * the sfUnearnedInterest budget still left to recognize. + * + * sfAssetsTotal is only credited when a loan event settles the vault, so + * between those events it lags by this amount. Adding it back at read time + * recognizes interest continuously as it is earned, without writing to + * sfAssetsTotal outside the loan transactions. + * + * Returns zero when nothing is accruing — no rate, no budget left, or a vault + * created before featureLendingProtocolV1_1. + */ +[[nodiscard]] Number +vaultAccruedInterest(ReadView const& view, SLE::const_ref vault); + +/** + * Whether a rolling vault is inside a dealing window at this close time. + * + * A rolling vault deals in [SubscriptionDate + k * DealingInterval, + * SubscriptionDate + k * DealingInterval + DealingWindow) for integer k >= 0. + * Returns true for any vault that is not rolling, which has no windows to be + * outside of, and false before the first window opens. + */ +[[nodiscard]] bool +inDealingWindow(ReadView const& view, SLE::const_ref vault); + +/** + * End of the dealing window containing this close time. + * + * Only meaningful when inDealingWindow is true for a rolling vault; it is the + * sfStruckUntil written when a window's price is struck. + */ +[[nodiscard]] std::uint32_t +dealingWindowEnd(ReadView const& view, SLE::const_ref vault); + +/** + * The price every deal in the current window converts at, in vault asset per + * share, or nullopt when no struck price governs this ledger. + * + * Returns a price only for a rolling vault inside a window whose sfStruckUntil + * matches that window's end. Accrual continues underneath it: the dealing price + * is frozen for the window, the accounting is not. + */ +/** + * The interest recognition method of a vault. + * + * Returns sfAccountingMethod where it is present. A vault created before + * featureVaultContinuousAccrual carries no such field, so the method is derived + * from its schema version instead: CashBasis recognizes interest as it is + * collected, and anything older is Legacy, which recognizes a loan's whole-life + * interest at origination. Every vault that exists today therefore resolves + * without being touched. + */ +[[nodiscard]] std::uint8_t +getAccountingMethod(SLE::const_ref vault); + +[[nodiscard]] std::optional +struckPriceInForce(ReadView const& view, SLE::const_ref vault); + +/** + * Strike the price for the current window if it has not been struck yet. + * + * Called by the first deposit or withdrawal of a window. Does nothing for a + * vault that is not rolling, outside a window, or where this window's price is + * already struck, so it is safe to call unconditionally. + */ +void +strikeWindowPrice(ApplyView& view, SLE::ref vault, SLE::const_ref issuance); + +/** + * Credit interest earned since the last settlement into sfAssetsTotal, draw + * it out of the sfUnearnedInterest budget, and stamp the current close time. + * + * Must be called before adjusting sfAccrualRate, so the elapsed period is + * charged at the rate that was in effect over it. Only the ttLOAN_* + * transactions may call this: ValidVault requires sfAssetsTotal to move with + * the vault balance on deposit and withdraw, which settling would violate. + * Pricing does not need it — the conversion helpers add elapsed interest + * themselves. + */ +void +accrueVault(ApplyView& view, SLE::ref vault); /** * 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 * MPT, this number is integral and always truncated in this calculation. * - * @param vault The vault SLE. - * @param issuance The MPTokenIssuance SLE for the vault's shares. - * @param assets The amount of assets to convert. - * - * @return The number of shares, or nullopt on error. + * /** + * * 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 + * * MPT, this number is integral and always truncated in this calculation. + * * + * * @param vault The vault SLE. + * * @param issuance The MPTokenIssuance SLE for the vault's shares. + * * @param assets The amount of assets to convert. + * * + * * @return The number of shares, or nullopt on error. */ [[nodiscard]] std::optional -assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& assets); +assetsToSharesDeposit( + ReadView const& view, + SLE::const_ref vault, + SLE::const_ref issuance, + STAmount const& assets); /** * From the perspective of a vault, return the number of assets to take from @@ -43,7 +135,11 @@ assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co * @return The number of assets, or nullopt on error. */ [[nodiscard]] std::optional -sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares); +sharesToAssetsDeposit( + ReadView const& view, + SLE::const_ref vault, + SLE::const_ref issuance, + STAmount const& shares); /** * Adjusts a requested asset change (`delta`) to match the decimal scale of the @@ -91,11 +187,12 @@ enum class WaiveUnrealizedLoss : bool { No = false, Yes = true }; * unrealized loss is waived. Used by assetsToSharesWithdraw and * sharesToAssetsWithdraw as the numerator of the share/asset exchange rate. * + * @param view The ledger view, for interest accrued since the last settlement. * @param vault The vault SLE. * @param waive Whether to skip subtracting the unrealized loss. */ [[nodiscard]] Number -assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive); +assetsTotalForWithdrawal(ReadView const& view, SLE::const_ref vault, WaiveUnrealizedLoss waive); /** * Returns true if debiting `amount` from `total` (the current value of a @@ -131,6 +228,7 @@ debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount */ [[nodiscard]] std::optional assetsToSharesWithdraw( + ReadView const& view, SLE::const_ref vault, SLE::const_ref issuance, STAmount const& assets, @@ -152,6 +250,7 @@ assetsToSharesWithdraw( */ [[nodiscard]] std::optional sharesToAssetsWithdraw( + ReadView const& view, SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares, diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 1b88eea4563..aa4d39e598c 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -307,6 +307,19 @@ constexpr std::size_t kMaxDataPayloadLength = 256; */ constexpr std::uint8_t kVaultStrategyFirstComeFirstServe = 1; +/** + * Vault interest recognition methods. + * + * Legacy recognizes a loan's whole-life interest at origination and is the + * implicit method for vaults created before featureLendingProtocolV1_1. Cash + * recognizes interest as it is collected; accrual recognizes it continuously + * as it is earned. Fixed at VaultCreate — changing it would reprice every + * outstanding share in a single step. + */ +constexpr std::uint8_t kVaultAccountingLegacy = 0; +constexpr std::uint8_t kVaultAccountingCash = 1; +constexpr std::uint8_t kVaultAccountingAccrual = 2; + /** * Default IOU scale factor for a Vault */ @@ -316,7 +329,13 @@ constexpr std::uint8_t kVaultDefaultIouScale = 6; * 1 IOU can be always converted to shares. * 10^19 > maxMPTokenAmount (2^64-1) > 10^18 */ -constexpr std::uint8_t kVaultMaximumIouScale = 18; +constexpr std::uint8_t kVaultMaximumIouScale = + 18; /** Largest deposit or redemption fee a vault may charge, in 1/10 bips. + +Matches kMaxTransferFee: half of what is moved is the most any fee may +retain. +*/ +constexpr std::uint32_t kMaxVaultFee = 50'000; /** * Vault ledger-entry schema versions. Assigned to newly created @@ -330,12 +349,16 @@ enum class VaultVersion : uint8_t { }; /** - * Vault kind. Distinguishes closed-ended vaults from the default open-ended - * kind. Persisted as sfVaultKind (UINT8); absent means OpenEnded. + * Vault kind. Distinguishes closed-ended and rolling vaults from the default + * open-ended kind. Persisted as sfVaultKind (UINT8); absent means OpenEnded. + * + * A rolling vault deals in a window that reopens every sfDealingInterval + * seconds and stays open for sfDealingWindow of them. */ enum class VaultKind : std::uint8_t { OpenEnded = 0, ClosedEnded = 1, + Rolling = 2, }; /** diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index e63a7f515dc..20844f85d3a 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -15,6 +15,7 @@ // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. +XRPL_FEATURE(VaultContinuousAccrual, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(SmartEscrow, Supported::No, VoteBehavior::DefaultNo) XRPL_FEATURE(LendingProtocolV1_2, Supported::No, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_5_0, Supported::Yes, VoteBehavior::DefaultNo) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 18c71b572cd..44f87266bb2 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -437,6 +437,7 @@ LEDGER_ENTRY(ltMPTOKEN, 0x007f, MPToken, mptoken, ({ {sfIssuerEncryptedBalance, SoeOptional}, {sfAuditorEncryptedBalance, SoeOptional}, {sfHolderEncryptionKey, SoeOptional}, + {sfRedemptionAfter, SoeDefault}, })) /** A ledger object which tracks Oracle @@ -511,13 +512,24 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ {sfAssetsAvailable, SoeDefault}, {sfAssetsMaximum, SoeDefault}, {sfLossUnrealized, SoeDefault}, + {sfUnearnedInterest, SoeDefault}, + {sfAccrualRate, SoeDefault}, + {sfLastAccrualTime, SoeDefault}, {sfShareMPTID, SoeRequired}, {sfWithdrawalPolicy, SoeRequired}, + {sfAccountingMethod, SoeDefault}, {sfScale, SoeDefault}, {sfLEVersion, SoeDefault}, {sfVaultKind, SoeDefault}, {sfSubscriptionDate, SoeOptional}, {sfRedemptionDate, SoeOptional}, + {sfDealingInterval, SoeDefault}, + {sfDealingWindow, SoeDefault}, + {sfStruckPrice, SoeDefault}, + {sfStruckUntil, SoeDefault}, + {sfDepositFee, SoeDefault}, + {sfRedemptionFee, SoeDefault}, + {sfRedemptionPeriod, SoeDefault}, // no SharesTotal ever (use MPTIssuance.sfOutstandingAmount) // no PermissionedDomainID ever (use MPTIssuance.sfDomainID) })) diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 2cf35743aea..ec79c38792b 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -28,6 +28,7 @@ TYPED_SFIELD(sfWasLockingChainSend, UINT8, 19) TYPED_SFIELD(sfWithdrawalPolicy, UINT8, 20) TYPED_SFIELD(sfContractResult, UINT8, 21) TYPED_SFIELD(sfVaultKind, UINT8, 22) +TYPED_SFIELD(sfAccountingMethod, UINT8, 23) // 16-bit integers (common) TYPED_SFIELD(sfLedgerEntryType, UINT16, 1, SField::kSmdNever) @@ -128,6 +129,14 @@ TYPED_SFIELD(sfBytecodeSizeLimit, UINT32, 82) TYPED_SFIELD(sfGasPrice, UINT32, 83) TYPED_SFIELD(sfGas, UINT32, 84) TYPED_SFIELD(sfGasUsed, UINT32, 85) +TYPED_SFIELD(sfLastAccrualTime, UINT32, 86) +TYPED_SFIELD(sfDealingInterval, UINT32, 87) +TYPED_SFIELD(sfDealingWindow, UINT32, 88) +TYPED_SFIELD(sfStruckUntil, UINT32, 89) +TYPED_SFIELD(sfDepositFee, UINT32, 90) +TYPED_SFIELD(sfRedemptionFee, UINT32, 91) +TYPED_SFIELD(sfRedemptionPeriod, UINT32, 92) +TYPED_SFIELD(sfRedemptionAfter, UINT32, 93) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) @@ -239,6 +248,11 @@ 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(sfUnearnedInterest, NUMBER, 18, SField::kSmdNeedsAsset | SField::kSmdDefault) +// Assets earned per second, summed over a vault's performing loans. A rate, +// not an amount, so it carries no asset association. +TYPED_SFIELD(sfAccrualRate, NUMBER, 19, SField::kSmdDefault) +TYPED_SFIELD(sfStruckPrice, NUMBER, 20, SField::kSmdDefault) // 32-bit signed (common) TYPED_SFIELD(sfLoanScale, INT32, 1) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 454aa85ffd0..d6d7d0f7ed4 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -788,6 +788,12 @@ TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, {sfVaultKind, SoeOptional}, {sfSubscriptionDate, SoeOptional}, {sfRedemptionDate, SoeOptional}, + {sfDealingInterval, SoeOptional}, + {sfDealingWindow, SoeOptional}, + {sfDepositFee, SoeOptional}, + {sfRedemptionFee, SoeOptional}, + {sfRedemptionPeriod, SoeOptional}, + {sfAccountingMethod, SoeOptional}, })) /** This transaction updates a single asset vault. */ @@ -804,6 +810,9 @@ TRANSACTION(ttVAULT_SET, 66, VaultSet, {sfAssetsMaximum, SoeOptional}, {sfDomainID, SoeOptional}, {sfData, SoeOptional}, + {sfDepositFee, SoeOptional}, + {sfRedemptionFee, SoeOptional}, + {sfRedemptionPeriod, SoeOptional}, })) /** This transaction deletes a single asset vault. */ diff --git a/include/xrpl/protocol_autogen/ledger_entries/Vault.h b/include/xrpl/protocol_autogen/ledger_entries/Vault.h index 389ffb4c460..590e0b56a79 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Vault.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Vault.h @@ -242,6 +242,78 @@ class Vault : public LedgerEntryBase return this->sle_->isFieldPresent(sfLossUnrealized); } + /** + * @brief Get sfUnearnedInterest (SoeDefault) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getUnearnedInterest() const + { + if (hasUnearnedInterest()) + return this->sle_->at(sfUnearnedInterest); + return std::nullopt; + } + + /** + * @brief Check if sfUnearnedInterest is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasUnearnedInterest() const + { + return this->sle_->isFieldPresent(sfUnearnedInterest); + } + + /** + * @brief Get sfAccrualRate (SoeDefault) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAccrualRate() const + { + if (hasAccrualRate()) + return this->sle_->at(sfAccrualRate); + return std::nullopt; + } + + /** + * @brief Check if sfAccrualRate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAccrualRate() const + { + return this->sle_->isFieldPresent(sfAccrualRate); + } + + /** + * @brief Get sfLastAccrualTime (SoeDefault) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getLastAccrualTime() const + { + if (hasLastAccrualTime()) + return this->sle_->at(sfLastAccrualTime); + return std::nullopt; + } + + /** + * @brief Check if sfLastAccrualTime is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasLastAccrualTime() const + { + return this->sle_->isFieldPresent(sfLastAccrualTime); + } + /** * @brief Get sfShareMPTID (SoeRequired) * @return The field value. @@ -264,6 +336,30 @@ class Vault : public LedgerEntryBase return this->sle_->at(sfWithdrawalPolicy); } + /** + * @brief Get sfAccountingMethod (SoeDefault) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAccountingMethod() const + { + if (hasAccountingMethod()) + return this->sle_->at(sfAccountingMethod); + return std::nullopt; + } + + /** + * @brief Check if sfAccountingMethod is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAccountingMethod() const + { + return this->sle_->isFieldPresent(sfAccountingMethod); + } + /** * @brief Get sfScale (SoeDefault) * @return The field value, or std::nullopt if not present. @@ -571,6 +667,39 @@ class VaultBuilder : public LedgerEntryBuilderBase return *this; } + /** + * @brief Set sfUnearnedInterest (SoeDefault) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setUnearnedInterest(std::decay_t const& value) + { + object_[sfUnearnedInterest] = value; + return *this; + } + + /** + * @brief Set sfAccrualRate (SoeDefault) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setAccrualRate(std::decay_t const& value) + { + object_[sfAccrualRate] = value; + return *this; + } + + /** + * @brief Set sfLastAccrualTime (SoeDefault) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setLastAccrualTime(std::decay_t const& value) + { + object_[sfLastAccrualTime] = value; + return *this; + } + /** * @brief Set sfShareMPTID (SoeRequired) * @return Reference to this builder for method chaining. @@ -593,6 +722,17 @@ class VaultBuilder : public LedgerEntryBuilderBase return *this; } + /** + * @brief Set sfAccountingMethod (SoeDefault) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setAccountingMethod(std::decay_t const& value) + { + object_[sfAccountingMethod] = value; + return *this; + } + /** * @brief Set sfScale (SoeDefault) * @return Reference to this builder for method chaining. diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index e8dafbd3017..b5f03195a98 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -457,6 +458,7 @@ using InvariantChecks = std::tuple< ValidLoanBroker, ValidLoan, ValidVault, + ValidVaultAccrual, ValidConfidentialMPToken, ValidMPTBalanceChanges, ValidAmounts, diff --git a/include/xrpl/tx/invariants/VaultAccrualInvariant.h b/include/xrpl/tx/invariants/VaultAccrualInvariant.h new file mode 100644 index 00000000000..123dcb97130 --- /dev/null +++ b/include/xrpl/tx/invariants/VaultAccrualInvariant.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +/** + * @brief Invariant: the continuous-accrual state of a vault moves only where it + * is allowed to. + * + * Enforces XLS Vault Continuous Accrual section 7 for every Vault entry the + * transaction touches: + * + * 1. sfAccrualRate and sfUnearnedInterest are never negative. + * 2. sfLastAccrualTime never decreases, and changes only in a ttLOAN_* + * transaction, which is where the vault settles. + * 4. VaultDeposit, VaultWithdraw and VaultClawback leave sfAccrualRate, + * sfUnearnedInterest and sfLastAccrualTime untouched: they price against the + * accrued value but never settle it. + * 6. sfStruckPrice changes at most once per window and only in a dealing + * transaction, and sfStruckUntil only ever moves forward. + * + * Numbers 3 and 5 of that section need the loan book and the fee split + * respectively, and are not enforced here. + */ +class ValidVaultAccrual +{ + struct Accrual final + { + Number accrualRate = Number{}; + Number unearnedInterest = Number{}; + std::uint32_t lastAccrualTime = 0; + Number struckPrice = Number{}; + std::uint32_t struckUntil = 0; + }; + + std::vector before_; + std::vector after_; + +public: + void + visitEntry(bool, SLE::const_ref, SLE::const_ref); + + bool + finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); +}; + +} // namespace xrpl diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 10c7e62c6c9..63ecf778913 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -286,9 +286,48 @@ loanOriginationExceedsVaultMaximum( return accrual::loanOriginationExceedsVaultMaximum(vaultMaximum, vaultTotal, interestDue); } +namespace continuous_accrual { + +/* + * Under continuous accrual the vault has recognized this loan's interest only + * as far as the clock has reached, so the paper loss is the principal plus that + * much interest, and neither of the other two formulas gives it: cash basis + * books principal alone and leaves recognized interest inside NAV, while Legacy + * books the loan's whole remaining interest, which was never recognized. + * + * A loan can only be impaired once it is late, and lateness means the period has + * fully elapsed, so the recognized amount has already saturated at the period's + * whole schedule. That makes the exposure a function of the loan alone, and + * identical whether it is being booked on impair or reversed on unimpair. + */ +Number +loanVaultExposure(SLE::const_ref vaultSle, SLE::const_ref loanSle) +{ + TenthBips32 const interestRate{loanSle->at(sfInterestRate)}; + Number const rate = loanAccrualRate(loanSle->at(sfPrincipalOutstanding), interestRate); + std::uint32_t const interval = loanSle->at(sfPaymentInterval); + + // Round the interest term to the scale the vault stores its loss at, so the + // amount booked on impair is the amount reversed on unimpair. The other two + // formulas return the principal alone, which is already representable. + Asset const vaultAsset = vaultSle->at(sfAsset); + Number const recognized = roundToAsset( + vaultAsset, + rate * Number{interval}, + getAssetsTotalScale(vaultSle), + Number::RoundingMode::Downward); + + return Number{loanSle->at(sfPrincipalOutstanding)} + recognized; +} + +} // namespace continuous_accrual + Number loanVaultExposure(SLE::const_ref vaultSle, SLE::const_ref loanSle) { + if (getAccountingMethod(vaultSle) == kVaultAccountingAccrual) + return continuous_accrual::loanVaultExposure(vaultSle, loanSle); + return cashBasisEnabled(vaultSle) ? cash_basis::loanVaultExposure(loanSle) : accrual::loanVaultExposure(loanSle); } diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index 941b94143d8..8178405e3e1 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 @@ -24,8 +25,72 @@ namespace xrpl { +[[nodiscard]] Number +vaultAccruedInterest(ReadView const& view, SLE::const_ref vault) +{ + Number const unearned = vault->at(sfUnearnedInterest); + if (unearned <= Number{}) + return Number{}; + + Number const rate = vault->at(sfAccrualRate); + if (rate <= Number{}) + return Number{}; + + // A rate that was never stamped has no measurable elapsed period; accruing + // from the epoch would recognize the whole budget at once. + std::uint32_t const stamped = vault->at(sfLastAccrualTime); + if (stamped == 0) + return Number{}; + + auto const now = view.parentCloseTime().time_since_epoch().count(); + if (now <= stamped) + return Number{}; + + // Round down: never recognize more than certainly earned. + NumberRoundModeGuard const guard(Number::RoundingMode::Downward); + Number const earned = rate * Number{now - stamped}; + return earned >= unearned ? unearned : earned; +} + +void +accrueVault(ApplyView& view, SLE::ref vault) +{ + Number const earned = vaultAccruedInterest(view, vault); + if (earned > Number{}) + { + vault->at(sfAssetsTotal) += earned; + vault->at(sfUnearnedInterest) -= earned; + } + vault->at(sfLastAccrualTime) = view.parentCloseTime().time_since_epoch().count(); +} + +/* The vault's assets including interest earned since the last settlement. + * + * sfAssetsTotal only holds interest that has been recognized, so between loan + * events it lags by the amount accrued since sfLastAccrualTime. Pricing adds + * that back rather than writing it, which keeps sfAssetsTotal equal to the + * vault's cash-plus-receivables and leaves the deposit/withdraw invariants + * (which require sfAssetsTotal to move only with the vault balance) intact. + * + * Before featureLendingProtocolV1_1 the whole of a loan's interest is + * recognized at origination, so sfAssetsTotal stands alone. + */ +static Number +netAssetsTotal(ReadView const& view, SLE::const_ref vault) +{ + Number const assetTotal = vault->at(sfAssetsTotal); + if (!view.rules().enabled(featureLendingProtocolV1_1)) + return assetTotal; + + return assetTotal + vaultAccruedInterest(view, vault); +} + [[nodiscard]] std::optional -assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& assets) +assetsToSharesDeposit( + ReadView const& view, + SLE::const_ref vault, + SLE::const_ref issuance, + STAmount const& assets) { XRPL_ASSERT(!assets.negative(), "xrpl::assetsToSharesDeposit : non-negative assets"); XRPL_ASSERT( @@ -34,9 +99,13 @@ assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co if (assets.negative() || assets.asset() != vault->at(sfAsset)) return std::nullopt; // LCOV_EXCL_LINE - Number const assetTotal = vault->at(sfAssetsTotal); + // Inside a struck window every deal converts at the one price. + if (auto const struck = struckPriceInForce(view, vault)) + return STAmount{vault->at(sfShareMPTID), (Number{assets} / *struck).truncate()}; + + Number const assetTotal = netAssetsTotal(view, vault); STAmount shares{vault->at(sfShareMPTID)}; - if (assetTotal == 0) + if (assetTotal <= Number{}) { return STAmount{ shares.asset(), @@ -49,7 +118,11 @@ assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co } [[nodiscard]] std::optional -sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares) +sharesToAssetsDeposit( + ReadView const& view, + SLE::const_ref vault, + SLE::const_ref issuance, + STAmount const& shares) { XRPL_ASSERT(!shares.negative(), "xrpl::sharesToAssetsDeposit : non-negative shares"); XRPL_ASSERT( @@ -58,9 +131,12 @@ sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co if (shares.negative() || shares.asset() != vault->at(sfShareMPTID)) return std::nullopt; // LCOV_EXCL_LINE - Number const assetTotal = vault->at(sfAssetsTotal); + if (auto const struck = struckPriceInForce(view, vault)) + return STAmount{vault->at(sfAsset), Number{shares} * *struck}; + + Number const assetTotal = netAssetsTotal(view, vault); STAmount assets{vault->at(sfAsset)}; - if (assetTotal == 0) + if (assetTotal <= Number{}) { return STAmount{ assets.asset(), shares.mantissa(), shares.exponent() - vault->at(sfScale), false}; @@ -131,9 +207,9 @@ clampToAssetsTotalScale(SLE::const_ref vault, STAmount const& delta) } [[nodiscard]] Number -assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive) +assetsTotalForWithdrawal(ReadView const& view, SLE::const_ref vault, WaiveUnrealizedLoss waive) { - Number assetTotal = vault->at(sfAssetsTotal); + Number assetTotal = netAssetsTotal(view, vault); if (waive == WaiveUnrealizedLoss::No) assetTotal -= vault->at(sfLossUnrealized); return assetTotal; @@ -149,6 +225,7 @@ debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount [[nodiscard]] std::optional assetsToSharesWithdraw( + ReadView const& view, SLE::const_ref vault, SLE::const_ref issuance, STAmount const& assets, @@ -162,9 +239,17 @@ assetsToSharesWithdraw( if (assets.negative() || assets.asset() != vault->at(sfAsset)) return std::nullopt; // LCOV_EXCL_LINE - Number const assetTotal = assetsTotalForWithdrawal(vault, waive); + if (auto const struck = struckPriceInForce(view, vault)) + { + Number struckShares = Number{assets} / *struck; + if (truncate == TruncateShares::Yes) + struckShares = struckShares.truncate(); + return STAmount{vault->at(sfShareMPTID), struckShares}; + } + + Number const assetTotal = assetsTotalForWithdrawal(view, vault, waive); STAmount shares{vault->at(sfShareMPTID)}; - if (assetTotal == 0) + if (assetTotal <= Number{}) return shares; Number const shareTotal = issuance->at(sfOutstandingAmount); Number result = (shareTotal * assets) / assetTotal; @@ -176,6 +261,7 @@ assetsToSharesWithdraw( [[nodiscard]] std::optional sharesToAssetsWithdraw( + ReadView const& view, SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares, @@ -188,9 +274,12 @@ sharesToAssetsWithdraw( if (shares.negative() || shares.asset() != vault->at(sfShareMPTID)) return std::nullopt; // LCOV_EXCL_LINE - Number const assetTotal = assetsTotalForWithdrawal(vault, waive); + if (auto const struck = struckPriceInForce(view, vault)) + return STAmount{vault->at(sfAsset), Number{shares} * *struck}; + + Number const assetTotal = assetsTotalForWithdrawal(view, vault, waive); STAmount assets{vault->at(sfAsset)}; - if (assetTotal == 0) + if (assetTotal <= Number{}) return assets; Number const shareTotal = issuance->at(sfOutstandingAmount); assets = (assetTotal * shares) / shareTotal; @@ -242,11 +331,131 @@ decodeVaultKind(std::optional vaultKind) { if (vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded)) return VaultKind::ClosedEnded; + if (vaultKind && *vaultKind == std::to_underlying(VaultKind::Rolling)) + return VaultKind::Rolling; return VaultKind::OpenEnded; } } // namespace +namespace { + +/** + * Seconds from the first window's open to this close time, or nullopt when the + * vault is not rolling or the first window has not opened yet. + */ +[[nodiscard]] std::optional +sinceFirstWindow(ReadView const& view, SLE::const_ref vault) +{ + if (decodeVaultKind(vault->at(~sfVaultKind)) != VaultKind::Rolling) + return std::nullopt; + + auto const start = vault->at(~sfSubscriptionDate); + auto const interval = vault->at(~sfDealingInterval); + if (!start || !interval || *interval == 0) + return std::nullopt; + + auto const now = view.header().parentCloseTime.time_since_epoch().count(); + if (now < *start) + return std::nullopt; + + return static_cast(now) - static_cast(*start); +} + +} // namespace + +[[nodiscard]] bool +inDealingWindow(ReadView const& view, SLE::const_ref vault) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::inDealingWindow : valid Vault sle"); + + if (decodeVaultKind(vault->at(~sfVaultKind)) != VaultKind::Rolling) + return true; + + auto const elapsed = sinceFirstWindow(view, vault); + if (!elapsed) + return false; + + return *elapsed % vault->at(sfDealingInterval) < vault->at(sfDealingWindow); +} + +[[nodiscard]] std::uint32_t +dealingWindowEnd(ReadView const& view, SLE::const_ref vault) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::dealingWindowEnd : valid Vault sle"); + + auto const elapsed = sinceFirstWindow(view, vault); + if (!elapsed) + return 0; + + std::uint64_t const interval = vault->at(sfDealingInterval); + std::uint64_t const opened = *elapsed - (*elapsed % interval); + return static_cast( + vault->at(sfSubscriptionDate) + opened + vault->at(sfDealingWindow)); +} + +[[nodiscard]] std::uint8_t +getAccountingMethod(SLE::const_ref vault) +{ + XRPL_ASSERT( + vault && vault->getType() == ltVAULT, "xrpl::getAccountingMethod : valid Vault sle"); + + if (auto const method = vault->at(~sfAccountingMethod)) + return *method; + + return vault->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis) + ? kVaultAccountingCash + : kVaultAccountingLegacy; +} + +[[nodiscard]] std::optional +struckPriceInForce(ReadView const& view, SLE::const_ref vault) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::struckPriceInForce : valid Vault sle"); + + if (decodeVaultKind(vault->at(~sfVaultKind)) != VaultKind::Rolling) + return std::nullopt; + if (!inDealingWindow(view, vault)) + return std::nullopt; + + // A stamp from an earlier window does not govern this one. + if (vault->at(sfStruckUntil) != dealingWindowEnd(view, vault)) + return std::nullopt; + + Number const price = vault->at(sfStruckPrice); + if (price <= Number{}) + return std::nullopt; + + return price; +} + +void +strikeWindowPrice(ApplyView& view, SLE::ref vault, SLE::const_ref issuance) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::strikeWindowPrice : valid Vault sle"); + + if (decodeVaultKind(vault->at(~sfVaultKind)) != VaultKind::Rolling) + return; + if (!inDealingWindow(view, vault)) + return; + + auto const windowEnd = dealingWindowEnd(view, vault); + if (vault->at(sfStruckUntil) == windowEnd) + return; // already struck for this window + + Number const shareTotal = issuance->at(sfOutstandingAmount); + if (shareTotal <= Number{}) + return; // no shares yet, so nothing to price against + + Number const assetTotal = netAssetsTotal(view, vault) - vault->at(sfLossUnrealized); + if (assetTotal <= Number{}) + return; + + vault->at(sfStruckPrice) = assetTotal / shareTotal; + vault->at(sfStruckUntil) = windowEnd; + view.update(vault); +} + [[nodiscard]] VaultKind getVaultKind(SLE::const_ref vault) { @@ -267,7 +476,8 @@ isValidVaultKind(STTx const& tx) if (!kindField) return true; return *kindField == std::to_underlying(VaultKind::OpenEnded) || - *kindField == std::to_underlying(VaultKind::ClosedEnded); + *kindField == std::to_underlying(VaultKind::ClosedEnded) || + *kindField == std::to_underlying(VaultKind::Rolling); } [[nodiscard]] bool diff --git a/src/libxrpl/tx/invariants/VaultAccrualInvariant.cpp b/src/libxrpl/tx/invariants/VaultAccrualInvariant.cpp new file mode 100644 index 00000000000..5290299a661 --- /dev/null +++ b/src/libxrpl/tx/invariants/VaultAccrualInvariant.cpp @@ -0,0 +1,152 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +namespace { + +[[nodiscard]] bool +isLoanTransaction(TxType const txType) +{ + switch (txType) + { + case ttLOAN_SET: + case ttLOAN_PAY: + case ttLOAN_MANAGE: + case ttLOAN_DELETE: + return true; + default: + return false; + } +} + +[[nodiscard]] bool +isDealingTransaction(TxType const txType) +{ + switch (txType) + { + case ttVAULT_DEPOSIT: + case ttVAULT_WITHDRAW: + case ttVAULT_CLAWBACK: + return true; + default: + return false; + } +} + +} // namespace + +void +ValidVaultAccrual::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) +{ + auto const read = [](SLE::const_ref sle) { + return Accrual{ + .accrualRate = sle->at(sfAccrualRate), + .unearnedInterest = sle->at(sfUnearnedInterest), + .lastAccrualTime = sle->at(sfLastAccrualTime), + .struckPrice = sle->at(sfStruckPrice), + .struckUntil = sle->at(sfStruckUntil)}; + }; + + if (before && before->getType() == ltVAULT) + before_.push_back(read(before)); + + // A deleted vault constrains nothing about the state it no longer has. + if (!isDelete && after && after->getType() == ltVAULT) + after_.push_back(read(after)); +} + +bool +ValidVaultAccrual::finalize( + STTx const& tx, + TER const result, + XRPAmount const, + ReadView const&, + beast::Journal const& j) +{ + if (!isTesSuccess(result)) + return true; + + for (auto const& accrual : after_) + { + if (accrual.accrualRate < Number{}) + { + JLOG(j.fatal()) << "Invariant failed: vault accrual rate is negative"; + return false; + } + + if (accrual.unearnedInterest < Number{}) + { + JLOG(j.fatal()) << "Invariant failed: vault unearned interest is negative"; + return false; + } + } + + // Pair the states only when the transaction touched a single vault, which is + // every transaction that can move these fields. + if (before_.size() != 1 || after_.size() != 1) + return true; + + auto const& was = before_.front(); + auto const& is = after_.front(); + auto const txType = tx.getTxnType(); + + if (is.lastAccrualTime < was.lastAccrualTime) + { + JLOG(j.fatal()) << "Invariant failed: vault last accrual time moved backwards"; + return false; + } + + if (is.lastAccrualTime != was.lastAccrualTime && !isLoanTransaction(txType)) + { + JLOG(j.fatal()) << "Invariant failed: vault settled outside a loan transaction"; + return false; + } + + if (isDealingTransaction(txType) && + (is.accrualRate != was.accrualRate || is.unearnedInterest != was.unearnedInterest || + is.lastAccrualTime != was.lastAccrualTime)) + { + JLOG(j.fatal()) << "Invariant failed: dealing transaction changed vault accrual state"; + return false; + } + + // A window's price, once struck, stands until the window it was struck for + // has passed. Only a deal inside a window may strike one. + if (is.struckUntil < was.struckUntil) + { + JLOG(j.fatal()) << "Invariant failed: vault struck window moved backwards"; + return false; + } + + bool const struck = is.struckPrice != was.struckPrice || is.struckUntil != was.struckUntil; + if (struck && !isDealingTransaction(txType)) + { + JLOG(j.fatal()) << "Invariant failed: vault price struck outside a dealing transaction"; + return false; + } + + // Striking again within the same window would let a later deal reprice an + // earlier one, which is the timing problem the struck price exists to close. + if (is.struckPrice != was.struckPrice && is.struckUntil == was.struckUntil) + { + JLOG(j.fatal()) << "Invariant failed: vault price struck twice in one window"; + return false; + } + + return true; +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp index 1ab4eb2ce02..67f54f0041a 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp @@ -153,7 +153,14 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx) // stays unrestricted so existing open-ended flows keep working; // the constraint is enforced here, at the point where the vault // is first bound to the lending protocol. - if (ctx.view.rules().enabled(featureLendingProtocolV1_1) && + // LP V1.2 lifts this for accrual vaults: a continuous price has no step + // to front-run, so an open-ended or rolling vault is safe to deal on + // while interest is being earned. Cash basis keeps the restriction, + // because its price still moves in a step at each payment. + bool const accrualPriced = ctx.view.rules().enabled(featureVaultContinuousAccrual) && + getAccountingMethod(sleVault) == kVaultAccountingAccrual; + + if (ctx.view.rules().enabled(featureLendingProtocolV1_1) && !accrualPriced && getVaultKind(sleVault) != VaultKind::ClosedEnded) { JLOG(ctx.j.warn()) << "LoanBroker requires a closed-ended Vault."; diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp index 2d710ceebed..9c3fda7e7f4 100644 --- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -299,6 +300,22 @@ LoanManage::impairLoan( return tecTOO_SOON; } + // Settle before the loss is measured, so the interest the clock earned over + // the period is already in AssetsTotal and the paper loss can cover it, then + // stop this loan accruing. Continuing to accrue on a loan that may never pay + // is the Legacy defect in miniature. + bool const continuousAccrual = view.rules().enabled(featureVaultContinuousAccrual) && + getAccountingMethod(vaultSle) == kVaultAccountingAccrual; + if (continuousAccrual) + { + accrueVault(view, vaultSle); + TenthBips32 const interestRate{loanSle->at(sfInterestRate)}; + Number const loanRate = loanAccrualRate(loanSle->at(sfPrincipalOutstanding), interestRate); + auto rateProxy = vaultSle->at(sfAccrualRate); + Number const remaining = *rateProxy - loanRate; + rateProxy = remaining > Number{} ? remaining : Number{}; + } + Number const lossUnrealized = loanVaultExposure(vaultSle, loanSle); // The vault may be at a different scale than the loan. Reduce rounding @@ -316,6 +333,7 @@ LoanManage::impairLoan( JLOG(j.warn()) << "Vault unrealized loss is too large, and will corrupt the vault."; return tecLIMIT_EXCEEDED; } + view.update(vaultSle); // Update the Loan object @@ -361,6 +379,17 @@ LoanManage::unimpairLoan( // Reverse the "paper loss" adjustImpreciseNumber(vaultLossUnrealizedProxy, -lossReversed, vaultAsset, vaultScale); + // Settle while this loan is still excluded, so the impaired interval earns + // nothing, then take its rate back on. + if (view.rules().enabled(featureVaultContinuousAccrual) && + getAccountingMethod(vaultSle) == kVaultAccountingAccrual) + { + accrueVault(view, vaultSle); + TenthBips32 const interestRate{loanSle->at(sfInterestRate)}; + vaultSle->at(sfAccrualRate) += + loanAccrualRate(loanSle->at(sfPrincipalOutstanding), interestRate); + } + view.update(vaultSle); // Update the Loan object diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 18886b26822..5e37bf6ba04 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -413,6 +414,32 @@ LoanPay::doApply() return LoanPaymentType::Regular; }(); + // An accrual vault has already recognized part of this period's interest by + // the clock. Capture what the clock earned, and the period's whole schedule, + // before loanMakePayment moves the loan on. + bool const accrualVault = view.rules().enabled(featureVaultContinuousAccrual) && + getAccountingMethod(vaultSle) == kVaultAccountingAccrual; + Number rateBefore{}; + Number periodScheduled{}; + Number recognizedPeriod{}; + if (accrualVault) + { + TenthBips32 const interestRate{loanSle->at(sfInterestRate)}; + rateBefore = loanAccrualRate(loanSle->at(sfPrincipalOutstanding), interestRate); + + std::uint32_t const interval = loanSle->at(sfPaymentInterval); + periodScheduled = rateBefore * Number{interval}; + + std::uint32_t const nextDue = loanSle->at(sfNextPaymentDueDate); + std::uint32_t const prevDue = nextDue > interval ? nextDue - interval : 0; + auto const now = view.parentCloseTime().time_since_epoch().count(); + Number const earned = + rateBefore * Number{now > prevDue ? static_cast(now) - prevDue : 0}; + // Late payments are capped at the schedule: the vault never recognizes + // more than the period was ever going to earn. + recognizedPeriod = earned >= periodScheduled ? periodScheduled : earned; + } + std::expected const paymentParts = loanMakePayment(asset, view, loanSle, brokerSle, amount, paymentType, j_); @@ -453,7 +480,28 @@ LoanPay::doApply() // LCOV_EXCL_STOP } - auto const [assetsTotalDelta, debtTotalDelta] = loanPaymentDeltas(vaultSle, *paymentParts); + auto const [rawAssetsTotalDelta, debtTotalDelta] = loanPaymentDeltas(vaultSle, *paymentParts); + + // Settle at the rate that was in force over the period just ended, then take + // the loan's new rate. The interest the clock already recognized is not + // credited again: doing so double-counts on every on-time payment. + Number assetsTotalDelta = rawAssetsTotalDelta; + if (accrualVault) + { + accrueVault(view, vaultSle); + assetsTotalDelta = rawAssetsTotalDelta - recognizedPeriod; + + Number const stillUnrecognized = periodScheduled - recognizedPeriod; + auto unearnedProxy = vaultSle->at(sfUnearnedInterest); + unearnedProxy = + *unearnedProxy > stillUnrecognized ? *unearnedProxy - stillUnrecognized : Number{}; + + TenthBips32 const interestRate{loanSle->at(sfInterestRate)}; + Number const rateAfter = loanAccrualRate(loanSle->at(sfPrincipalOutstanding), interestRate); + auto rateProxy = vaultSle->at(sfAccrualRate); + Number const nextRate = *rateProxy + rateAfter - rateBefore; + rateProxy = nextRate > Number{} ? nextRate : Number{}; + } JLOG(j_.debug()) << "Loan Pay: principal paid: " << paymentParts->principalPaid << ", interest paid: " << paymentParts->interestPaid diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index f7b97dfedff..7c6b31eb0df 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -691,6 +691,18 @@ LoanSet::doApply() loan->at(sfPaymentRemaining) = paymentTotal; view.insert(loan); + // An accrual vault recognizes this loan's interest over its life rather than + // at origination. Settle first, so the period just ended is charged at the + // rate that was in force over it, then take on the new loan's budget and + // rate. AssetsTotal is untouched here: nothing has been earned yet. + if (view.rules().enabled(featureVaultContinuousAccrual) && + getAccountingMethod(vaultSle) == kVaultAccountingAccrual) + { + accrueVault(view, vaultSle); + vaultSle->at(sfUnearnedInterest) += state.interestDue; + vaultSle->at(sfAccrualRate) += loanAccrualRate(principalRequested, interestRate); + } + // Update the balances in the vault vaultAvailableProxy -= principalRequested; vaultTotalProxy += assetsTotalDelta; diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp index 059da7cc0f7..0040e463ea0 100644 --- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp @@ -259,7 +259,8 @@ VaultClawback::assetsToClawback( { auto const sharesDestroyed = accountHolds( view(), holder, share, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, j_); - auto const maybeAssets = sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed); + auto const maybeAssets = + sharesToAssetsWithdraw(view(), vault, sleShareIssuance, sharesDestroyed); if (!maybeAssets) return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE @@ -297,7 +298,7 @@ VaultClawback::assetsToClawback( AuthHandling::IgnoreAuth, j_); auto const maybeAssets = sharesToAssetsWithdraw( - vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss); + view(), vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss); if (!maybeAssets) return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE @@ -312,13 +313,13 @@ VaultClawback::assetsToClawback( // below). auto const truncate = fix340Enabled ? TruncateShares::Yes : TruncateShares::No; auto const maybeShares = assetsToSharesWithdraw( - vault, sleShareIssuance, clawbackAmount, truncate, waiveUnrealizedLoss); + view(), vault, sleShareIssuance, clawbackAmount, truncate, waiveUnrealizedLoss); if (!maybeShares) return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE sharesDestroyed = *maybeShares; auto const maybeAssets = sharesToAssetsWithdraw( - vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss); + view(), vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss); if (!maybeAssets) return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE assetsRecovered = *maybeAssets; @@ -330,6 +331,7 @@ VaultClawback::assetsToClawback( assetsRecovered = *assetsAvailable; { auto const maybeShares = assetsToSharesWithdraw( + view(), vault, sleShareIssuance, assetsRecovered, @@ -341,7 +343,7 @@ VaultClawback::assetsToClawback( } auto const maybeAssets = sharesToAssetsWithdraw( - vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss); + view(), vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss); if (!maybeAssets) return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE assetsRecovered = *maybeAssets; diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index 7ade4ed5ab5..700ef1e897a 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -49,6 +49,12 @@ VaultCreate::checkExtraFeatures(PreflightContext const& ctx) ctx.tx.isFieldPresent(sfRedemptionDate))) return false; + if (!ctx.rules.enabled(featureVaultContinuousAccrual) && + (ctx.tx.isFieldPresent(sfDealingInterval) || ctx.tx.isFieldPresent(sfDealingWindow) || + ctx.tx.isFieldPresent(sfDepositFee) || ctx.tx.isFieldPresent(sfRedemptionFee) || + ctx.tx.isFieldPresent(sfRedemptionPeriod) || ctx.tx.isFieldPresent(sfAccountingMethod))) + return false; + return true; } @@ -111,7 +117,8 @@ VaultCreate::preflight(PreflightContext const& ctx) auto const hasSubscription = ctx.tx.isFieldPresent(sfSubscriptionDate); auto const hasRedemption = ctx.tx.isFieldPresent(sfRedemptionDate); auto const isClosedEnded = kind == VaultKind::ClosedEnded; - if (!isClosedEnded && (hasSubscription || hasRedemption)) + auto const isRolling = kind == VaultKind::Rolling; + if (!isClosedEnded && !isRolling && (hasSubscription || hasRedemption)) return temMALFORMED; if (isClosedEnded) { @@ -121,6 +128,35 @@ VaultCreate::preflight(PreflightContext const& ctx) return temMALFORMED; } + auto const hasInterval = ctx.tx.isFieldPresent(sfDealingInterval); + auto const hasWindow = ctx.tx.isFieldPresent(sfDealingWindow); + if (!isRolling && (hasInterval || hasWindow)) + return temMALFORMED; + if (isRolling) + { + // A rolling vault deals in a window that reopens every DealingInterval; + // SubscriptionDate is when the first one opens. RedemptionDate belongs + // to the closed-ended structure and has no meaning here. + if (!hasSubscription || hasRedemption || !hasInterval || !hasWindow) + return temMALFORMED; + if (ctx.tx[sfDealingWindow] == 0 || ctx.tx[sfDealingWindow] >= ctx.tx[sfDealingInterval]) + return temMALFORMED; + } + + // Legacy is never assigned to a new vault: it recognizes a loan's whole-life + // interest at origination, which is the defect this amendment closes. + if (auto const method = ctx.tx[~sfAccountingMethod]; + method && (*method == kVaultAccountingLegacy || *method > kVaultAccountingAccrual)) + return temMALFORMED; + + // A redemption period without a fee to gate would never be read. + if (ctx.tx.isFieldPresent(sfRedemptionPeriod) && !ctx.tx.isFieldPresent(sfRedemptionFee)) + return temMALFORMED; + + if (ctx.tx[~sfDepositFee].value_or(0) > kMaxVaultFee || + ctx.tx[~sfRedemptionFee].value_or(0) > kMaxVaultFee) + return temMALFORMED; + return tesSUCCESS; } @@ -284,6 +320,23 @@ VaultCreate::doApply() vault->at(sfSubscriptionDate) = tx[sfSubscriptionDate]; vault->at(sfRedemptionDate) = tx[sfRedemptionDate]; } + else if (kind == VaultKind::Rolling) + { + vault->at(sfSubscriptionDate) = tx[sfSubscriptionDate]; + vault->at(sfDealingInterval) = tx[sfDealingInterval]; + vault->at(sfDealingWindow) = tx[sfDealingWindow]; + } + + // Accrual is the default: it is the model the users of this protocol + // report under. Cash basis stays available on request. + vault->at(sfAccountingMethod) = tx[~sfAccountingMethod].value_or(kVaultAccountingAccrual); + + if (tx.isFieldPresent(sfDepositFee)) + vault->at(sfDepositFee) = tx[sfDepositFee]; + if (tx.isFieldPresent(sfRedemptionFee)) + vault->at(sfRedemptionFee) = tx[sfRedemptionFee]; + if (tx.isFieldPresent(sfRedemptionPeriod)) + vault->at(sfRedemptionPeriod) = tx[sfRedemptionPeriod]; } view().insert(vault); diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp index fc72159444b..c93a40f0308 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp @@ -118,6 +118,14 @@ VaultDeposit::preclaim(PreclaimContext const& ctx) } } + // A rolling vault deals only inside its window. + if (ctx.view.rules().enabled(featureVaultContinuousAccrual) && + !inDealingWindow(ctx.view, vault)) + { + JLOG(ctx.j.debug()) << "VaultDeposit: vault is outside its dealing window."; + return tecTOO_SOON; + } + auto const& account = ctx.tx[sfAccount]; auto const amount = ctx.tx[sfAmount]; auto const vaultAsset = vault->at(sfAsset); @@ -306,6 +314,34 @@ VaultDeposit::doApply() } } + // The first deal of a window fixes the price every deal in that window + // converts at, so a participant early in a long window cannot capture a loan + // payment that lands later in it. + strikeWindowPrice(view(), vault, sleIssuance); + + // The deposit fee is taken from the assets in; shares are minted for the net + // and the fee stays in the vault, lifting every existing holder. An empty + // vault has no holders to lift, so the first deposit pays nothing. Rounded + // up so the rounding never favours the depositor over the holders. + STAmount depositFee{amount.asset()}; + if (view().rules().enabled(featureVaultContinuousAccrual)) + { + std::uint32_t const feeRate = vault->at(sfDepositFee); + if (feeRate != 0 && *vault->at(sfAssetsTotal) != beast::kZero) + { + depositFee = STAmount{ + amount.asset(), + roundToAsset( + amount.asset(), + tenthBipsOfValue(Number{amount}, TenthBips32{feeRate}), + scale(amount, amount.asset()), + Number::RoundingMode::Upward)}; + } + } + STAmount const netAmount = amount - depositFee; + if (netAmount <= beast::kZero) + return tecPRECISION_LOSS; + STAmount sharesCreated = {vault->at(sfShareMPTID)}, assetsDeposited; // Number arithmetic can throw overflow_error when Scale and totals are large. Caught below. @@ -313,7 +349,7 @@ VaultDeposit::doApply() { // Compute exchange before transferring any amounts. { - auto const maybeShares = assetsToSharesDeposit(vault, sleIssuance, amount); + auto const maybeShares = assetsToSharesDeposit(view(), vault, sleIssuance, netAmount); if (!maybeShares) return tecINTERNAL; // LCOV_EXCL_LINE sharesCreated = *maybeShares; @@ -325,21 +361,21 @@ VaultDeposit::doApply() // Convert shares back to assets so the depositor is debited for the amount actually minted. // The truncated share count is worth <= amount; without this the difference would be // credited to the vault for free. - auto const maybeAssets = sharesToAssetsDeposit(vault, sleIssuance, sharesCreated); + auto const maybeAssets = sharesToAssetsDeposit(view(), vault, sleIssuance, sharesCreated); if (!maybeAssets) { return tecINTERNAL; // LCOV_EXCL_LINE } // The round-trip must never return more than the original amount. If it does, a conversion // helper is broken. Reject rather than overcharge the depositor. - if (*maybeAssets > amount) + if (*maybeAssets > netAmount) { // LCOV_EXCL_START JLOG(j_.error()) << "VaultDeposit: would take more than offered."; return tecINTERNAL; // LCOV_EXCL_STOP } - assetsDeposited = *maybeAssets; + assetsDeposited = *maybeAssets + depositFee; // Post-fixCleanup3_4_0: round the deposit to the sfAssetsTotal scale so all accounting // fields (trust line / MPT, sfAssetsAvailable, sfAssetsTotal) change by the same @@ -421,6 +457,22 @@ VaultDeposit::doApply() !isTesSuccess(ter)) return ter; + // Start this holder's redemption period. Stamped on their own share MPToken + // so each holder carries their own clock, and pushed out by a later deposit + // rather than kept from the first one. + if (view().rules().enabled(featureVaultContinuousAccrual)) + { + if (std::uint32_t const period = vault->at(sfRedemptionPeriod); period != 0) + { + if (auto sleMpt = view().peek(keylet::mptoken(mptIssuanceID, accountID_))) + { + auto const now = view().header().parentCloseTime.time_since_epoch().count(); + sleMpt->at(sfRedemptionAfter) = static_cast(now) + period; + view().update(sleMpt); + } + } + } + associateAsset(*vault, vaultAsset); return tesSUCCESS; diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index 4dc5b95c890..f8e29703bd1 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -97,6 +97,14 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) } } + // A rolling vault deals only inside its window. + if (ctx.view.rules().enabled(featureVaultContinuousAccrual) && + !inDealingWindow(ctx.view, vault)) + { + JLOG(ctx.j.debug()) << "VaultWithdraw: vault is outside its dealing window."; + return tecTOO_SOON; + } + auto const amount = ctx.tx[sfAmount]; auto const vaultAsset = vault->at(sfAsset); auto const vaultShare = vault->at(sfShareMPTID); @@ -166,7 +174,7 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) try { auto const maybeAssets = - sharesToAssetsWithdraw(vault, sleIssuance, amount, waiveUnrealizedLoss); + sharesToAssetsWithdraw(ctx.view, vault, sleIssuance, amount, waiveUnrealizedLoss); if (!maybeAssets) return tefINTERNAL; // LCOV_EXCL_LINE @@ -305,6 +313,11 @@ VaultWithdraw::doApply() MPTIssue const share{mptIssuanceID}; STAmount sharesRedeemed = {share}; + // The first deal of a window fixes the price every deal in that window + // converts at, so a participant early in a long window cannot capture a loan + // payment that lands later in it. + strikeWindowPrice(view(), vault, sleIssuance); + STAmount assetsWithdrawn; // When the user is the sole shareholder they own both the available and future value. @@ -330,7 +343,7 @@ VaultWithdraw::doApply() view().rules().enabled(fixCleanup3_4_0) ? TruncateShares::Yes : TruncateShares::No; { auto const maybeShares = assetsToSharesWithdraw( - vault, sleIssuance, amount, truncate, waiveUnrealizedLoss); + view(), vault, sleIssuance, amount, truncate, waiveUnrealizedLoss); if (!maybeShares) return tecINTERNAL; // LCOV_EXCL_LINE sharesRedeemed = *maybeShares; @@ -342,8 +355,8 @@ VaultWithdraw::doApply() return tecPRECISION_LOSS; // Convert shares back to assets so the payout matches the shares actually burned, not // the requested amount. The extra would otherwise be paid from the vault for free. - auto const maybeAssets = - sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss); + auto const maybeAssets = sharesToAssetsWithdraw( + view(), vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss); if (!maybeAssets) return tecINTERNAL; // LCOV_EXCL_LINE assetsWithdrawn = *maybeAssets; @@ -353,8 +366,8 @@ VaultWithdraw::doApply() // Fixed shares, variable assets. No round-trip: the share count is exactly what the // caller specified; only the payout amount is derived. sharesRedeemed = amount; - auto const maybeAssets = - sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss); + auto const maybeAssets = sharesToAssetsWithdraw( + view(), vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss); if (!maybeAssets) return tecINTERNAL; // LCOV_EXCL_LINE assetsWithdrawn = *maybeAssets; @@ -394,7 +407,7 @@ VaultWithdraw::doApply() // backing value. Reject rather than burn shares for a zero payout. The fixed-assets branch // above has already rejected zero via the sharesRedeemed check. if (amount.asset() == share && assetsWithdrawn == beast::kZero && - assetsTotalForWithdrawal(vault, waiveUnrealizedLoss) != beast::kZero) + assetsTotalForWithdrawal(view(), vault, waiveUnrealizedLoss) != beast::kZero) { JLOG(j_.debug()) << "VaultWithdraw: fixed-share withdrawal rounds to zero assets"; return tecPRECISION_LOSS; @@ -538,6 +551,42 @@ VaultWithdraw::doApply() } else { + // The redemption fee is taken from the assets out and stays in the vault, + // lifting the holders who remain. It is waived for a sole shareholder and + // for a final withdrawal, neither of which leaves anyone to lift, and with + // a redemption period set it applies only while the holder is inside it. + // Rounded up so the rounding never favours the leaver over the stayers. + if (view().rules().enabled(featureVaultContinuousAccrual) && + waiveUnrealizedLoss == WaiveUnrealizedLoss::No) + { + std::uint32_t const feeRate = vault->at(sfRedemptionFee); + bool charge = feeRate != 0; + if (charge) + { + if (std::uint32_t const period = vault->at(sfRedemptionPeriod); period != 0) + { + auto const sleMpt = view().read(keylet::mptoken(mptIssuanceID, accountID_)); + std::uint32_t const redeemAfter = + sleMpt ? sleMpt->at(sfRedemptionAfter) : std::uint32_t{0}; + auto const now = view().header().parentCloseTime.time_since_epoch().count(); + charge = now < redeemAfter; + } + } + + if (charge) + { + auto const fee = STAmount{ + assetsWithdrawn.asset(), + roundToAsset( + assetsWithdrawn.asset(), + tenthBipsOfValue(Number{assetsWithdrawn}, TenthBips32{feeRate}), + scale(assetsWithdrawn, assetsWithdrawn.asset()), + Number::RoundingMode::Upward)}; + if (fee < assetsWithdrawn) + assetsWithdrawn -= fee; + } + } + // Debit both rails by the same delta so sfAssetsTotal and sfAssetsAvailable stay in step, // as required by the ValidVault invariant. assetsTotal -= assetsWithdrawn; diff --git a/src/test/app/lending/LoanAccrual_test.cpp b/src/test/app/lending/LoanAccrual_test.cpp new file mode 100644 index 00000000000..e64483e79c4 --- /dev/null +++ b/src/test/app/lending/LoanAccrual_test.cpp @@ -0,0 +1,322 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl::test { + +/** + * Continuous accrual, XLS Vault Continuous Accrual sections 4.1 and 4.2. + */ +class LoanAccrual_test : public LoanTestBase +{ +private: + // Origination takes on the loan's interest as a budget and a rate, and + // leaves AssetsTotal alone: nothing has been earned yet. + void + testAccrualLoanSetOrigination() + { + testcase("accrual: LoanSet origination"); + using namespace jtx; + using namespace loan; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}, + .accountingMethod = kVaultAccountingAccrual}; + + Env env{*this, testableAmendments()}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + auto const vaultBefore = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultBefore); + if (!vaultBefore) + return; + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + BEAST_EXPECT(vaultBefore->at(sfUnearnedInterest) == Number{}); + BEAST_EXPECT(vaultBefore->at(sfAccrualRate) == Number{}); + + auto const brokerBefore = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBefore); + if (!brokerBefore) + return; + auto const loanKeylet = + keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerBefore->at(sfLoanSequence))); + + env(set(borrower, broker.brokerID, xrpAsset(10'000).value()), + kCounterparty(lender), + kInterestRate(TenthBips32{percentageToTenthBips(12)}), + kPaymentTotal(4), + kPaymentInterval(600), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + auto const loanSle = env.le(loanKeylet); + auto const vaultAfter = env.le(broker.vaultKeylet()); + BEAST_EXPECT(loanSle && vaultAfter); + if (!loanSle || !vaultAfter) + return; + + Number const interestDue = + Number{loanSle->at(sfTotalValueOutstanding)} - loanSle->at(sfPrincipalOutstanding); + BEAST_EXPECT(interestDue > Number{}); + + // Nothing earned yet, so AssetsTotal has not moved. + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore); + // The whole of the loan's interest is now the budget the clock may draw + // down, and the vault carries the loan's rate. + BEAST_EXPECT(vaultAfter->at(sfUnearnedInterest) == interestDue); + BEAST_EXPECT(vaultAfter->at(sfAccrualRate) > Number{}); + BEAST_EXPECT(vaultAfter->at(sfLastAccrualTime) > 0u); + } + + // The single most important case in the specification: an on-time payment + // must not credit interest the clock has already recognized. + void + testAccrualLoanPayDoesNotDoubleCount() + { + testcase("accrual: on-time LoanPay does not double count"); + using namespace jtx; + using namespace loan; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}, + .accountingMethod = kVaultAccountingAccrual}; + + Env env{*this, testableAmendments()}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + auto const brokerBefore = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBefore); + if (!brokerBefore) + return; + auto const loanKeylet = + keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerBefore->at(sfLoanSequence))); + + std::uint32_t const paymentInterval = 600; + env(set(borrower, broker.brokerID, xrpAsset(10'000).value()), + kCounterparty(lender), + kInterestRate(TenthBips32{percentageToTenthBips(12)}), + kPaymentTotal(4), + kPaymentInterval(paymentInterval), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + if (!loanSle) + return; + Number const interestDue = + Number{loanSle->at(sfTotalValueOutstanding)} - loanSle->at(sfPrincipalOutstanding); + + // Stand just inside the first period, so nearly all of it has been + // recognized by the clock and the payment is still on time. + env.close(NetClock::time_point{NetClock::duration{loanSle->at(sfNextPaymentDueDate) - 30}}); + + auto const vaultAtDue = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultAtDue); + if (!vaultAtDue) + return; + Number const assetsTotalAtDue = vaultAtDue->at(sfAssetsTotal); + Number const unearnedAtDue = vaultAtDue->at(sfUnearnedInterest); + + auto const loanAtDue = env.le(loanKeylet); + BEAST_EXPECT(loanAtDue); + if (!loanAtDue) + return; + Number const principalBefore = loanAtDue->at(sfPrincipalOutstanding); + + // The stored periodic payment is unrounded; a payment must cover it, so + // round up to the loan's scale. + STAmount const paymentAmount{ + broker.asset.raw(), + roundToAsset( + broker.asset.raw(), + Number{loanSle->at(sfPeriodicPayment)}, + loanSle->at(sfLoanScale), + Number::RoundingMode::Upward)}; + env(pay(borrower, loanKeylet.key, paymentAmount), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterPay = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultAfterPay); + if (!vaultAfterPay) + return; + + Number const assetsGained = Number{vaultAfterPay->at(sfAssetsTotal)} - assetsTotalAtDue; + + // The interest this payment actually carried: with no management fee, + // whatever did not reduce the principal. + auto const loanAfterPay = env.le(loanKeylet); + BEAST_EXPECT(loanAfterPay); + if (!loanAfterPay) + return; + Number const principalPaid = + principalBefore - Number{loanAfterPay->at(sfPrincipalOutstanding)}; + Number const interestPaid = Number{paymentAmount} - principalPaid; + BEAST_EXPECT(interestPaid > Number{}); + + // Settling at the payment credits what the clock earned over the period, + // and the payment itself adds only the remainder the clock had not yet + // reached. Were the received interest credited again on top, the vault + // would gain about twice the interest paid: that is the double count + // this accounting exists to avoid. + BEAST_EXPECT(assetsGained > Number{}); + BEAST_EXPECT(assetsGained < interestPaid + (interestPaid / Number{2})); + + // The budget shrinks: the period just paid is no longer owed. + BEAST_EXPECT(vaultAfterPay->at(sfUnearnedInterest) < unearnedAtDue); + // Never negative, whatever the rounding. + BEAST_EXPECT(vaultAfterPay->at(sfUnearnedInterest) >= Number{}); + BEAST_EXPECT(vaultAfterPay->at(sfAccrualRate) >= Number{}); + } + + // Impairing a loan stops it accruing; clearing the impairment takes its rate back on. + void + testAccrualImpairHaltsAccrual() + { + testcase("accrual: impair halts accrual"); + using namespace jtx; + using namespace loan; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}, + .accountingMethod = kVaultAccountingAccrual}; + + Env env{*this, testableAmendments()}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + auto const brokerBefore = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBefore); + if (!brokerBefore) + return; + auto const loanKeylet = + keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerBefore->at(sfLoanSequence))); + + env(set(borrower, broker.brokerID, xrpAsset(10'000).value()), + kCounterparty(lender), + kInterestRate(TenthBips32{percentageToTenthBips(12)}), + kPaymentTotal(4), + kPaymentInterval(600), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + auto const loanSle = env.le(loanKeylet); + auto const vaultLent = env.le(broker.vaultKeylet()); + BEAST_EXPECT(loanSle && vaultLent); + if (!loanSle || !vaultLent) + return; + Number const rateWhileLending = vaultLent->at(sfAccrualRate); + BEAST_EXPECT(rateWhileLending > Number{}); + + // A loan can only be impaired once it is late. + env.close(NetClock::time_point{NetClock::duration{loanSle->at(sfNextPaymentDueDate) + 60}}); + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + auto const vaultImpaired = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultImpaired); + if (!vaultImpaired) + return; + // The vault carries only this loan, so its rate falls to nothing. + BEAST_EXPECT(vaultImpaired->at(sfAccrualRate) < rateWhileLending); + BEAST_EXPECT(vaultImpaired->at(sfAccrualRate) == Number{}); + + // An impaired loan earns nothing, so the budget does not move while it + // stays impaired. + Number const unearnedImpaired = vaultImpaired->at(sfUnearnedInterest); + Number const assetsImpaired = vaultImpaired->at(sfAssetsTotal); + env.close( + NetClock::time_point{NetClock::duration{loanSle->at(sfNextPaymentDueDate) + 100'000}}); + { + auto const vaultIdle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultIdle); + if (!vaultIdle) + return; + BEAST_EXPECT(vaultIdle->at(sfUnearnedInterest) == unearnedImpaired); + BEAST_EXPECT(vaultIdle->at(sfAssetsTotal) == assetsImpaired); + } + + env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS)); + env.close(); + + auto const vaultRestored = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultRestored); + if (!vaultRestored) + return; + BEAST_EXPECT(vaultRestored->at(sfAccrualRate) == rateWhileLending); + } + +public: + void + run() override + { + testAccrualLoanSetOrigination(); + testAccrualLoanPayDoesNotDoubleCount(); + testAccrualImpairHaltsAccrual(); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanAccrual, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanCashBasis_test.cpp b/src/test/app/lending/LoanCashBasis_test.cpp index e238838306d..c32448b1181 100644 --- a/src/test/app/lending/LoanCashBasis_test.cpp +++ b/src/test/app/lending/LoanCashBasis_test.cpp @@ -63,7 +63,8 @@ class LoanCashBasis_test : public LoanTestBase .coverRateMin = TenthBips32{0}, .coverDeposit = 0, .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; + .coverRateLiquidation = TenthBips32{0}, + .accountingMethod = kVaultAccountingCash}; Number const principalRequest{10'000}; TenthBips32 const interestRate{percentageToTenthBips(10)}; @@ -273,7 +274,8 @@ class LoanCashBasis_test : public LoanTestBase .coverRateMin = TenthBips32{0}, .coverDeposit = 0, .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; + .coverRateLiquidation = TenthBips32{0}, + .accountingMethod = kVaultAccountingCash}; Number const principalRequest{12'000}; TenthBips32 const interestRate{percentageToTenthBips(12)}; @@ -515,7 +517,8 @@ class LoanCashBasis_test : public LoanTestBase .coverRateMin = TenthBips32{0}, .coverDeposit = 0, .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; + .coverRateLiquidation = TenthBips32{0}, + .accountingMethod = kVaultAccountingCash}; auto run = [&](FeatureBitset features, TER expectedOverCapSet, bool native, bool vaultPrivate) { @@ -665,7 +668,8 @@ class LoanCashBasis_test : public LoanTestBase .coverRateMin = TenthBips32{0}, .coverDeposit = 0, .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; + .coverRateLiquidation = TenthBips32{0}, + .accountingMethod = kVaultAccountingCash}; Account const lender{"lender"}; Account const borrower{"borrower"}; @@ -758,7 +762,8 @@ class LoanCashBasis_test : public LoanTestBase .coverRateMin = TenthBips32{percentageToTenthBips(10)}, .coverDeposit = 5'000, .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; + .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}, + .accountingMethod = kVaultAccountingCash}; Number const principalRequest{10'000}; TenthBips32 const interestRate{percentageToTenthBips(12)}; @@ -952,7 +957,8 @@ class LoanCashBasis_test : public LoanTestBase .coverRateMin = TenthBips32{percentageToTenthBips(10)}, .coverDeposit = 5'000, .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; + .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}, + .accountingMethod = kVaultAccountingCash}; Number const principalRequest{10'000}; TenthBips32 const interestRate{percentageToTenthBips(12)}; @@ -1145,7 +1151,9 @@ class LoanCashBasis_test : public LoanTestBase PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, .managementFeeRate = TenthBips16{0}}; + .vaultDeposit = 100'000, + .managementFeeRate = TenthBips16{0}, + .accountingMethod = kVaultAccountingCash}; Env env(*this, all_ | featureLendingProtocolV1_1); diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index 13dffb6b9e9..37cdf803c21 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -126,6 +126,11 @@ class LoanTestBase : public beast::unit_test::Suite // Useful for tests that need to observe the vault while it is still in the Subscription // phase. Ignored for open-ended vaults. bool skipPhaseAdvance = false; + // Interest recognition method for the vault. Absent takes the default + // for the amendments in force, which is accrual from + // featureVaultContinuousAccrual onwards. + std::optional accountingMethod = + std::nullopt; // NOLINT(readability-redundant-member-init) [[nodiscard]] Number maxCoveredLoanValue(Number const& currentDebt) const @@ -535,7 +540,8 @@ class LoanTestBase : public beast::unit_test::Suite ? std::optional{} : std::optional{std::to_underlying(effectiveVaultKind)}, .subscriptionDate = subscriptionDate, - .redemptionDate = redemptionDate}); + .redemptionDate = redemptionDate, + .accountingMethod = params.accountingMethod}); if (params.vaultScale) tx[sfScale] = *params.vaultScale; env(tx); diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp index 566633b6903..e00e99baac6 100644 --- a/src/test/app/lending/LoanValidation_test.cpp +++ b/src/test/app/lending/LoanValidation_test.cpp @@ -584,8 +584,17 @@ class LoanValidation_test : public LoanTestBase // Baseline: LP V1.1 disabled -> open-ended vault + broker succeeds. build(all_, tesSUCCESS, tesSUCCESS); - // LP V1.1 enabled -> open-ended vault + broker rejected on create. - build(all_ | featureLendingProtocolV1_1, tecNO_PERMISSION); + // LP V1.1 enabled, V1.2 disabled -> open-ended vault + broker rejected + // on create. + build( + (all_ - featureVaultContinuousAccrual) | featureLendingProtocolV1_1, tecNO_PERMISSION); + + // LP V1.2 -> a vault created under it prices by accrual, which has no + // step to front-run, so an open-ended vault may host a broker again. + build( + all_ | featureLendingProtocolV1_1 | featureVaultContinuousAccrual, + tesSUCCESS, + tesSUCCESS); } void diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp index cc30bd60919..4e9e872bfb5 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -1521,6 +1521,7 @@ class VaultBugs_test : public VaultTestBase // same conversion helper VaultClawback itself uses, rather than // assuming an exact 90/10 split holds under truncation. auto const maybeSharesDestroyed = assetsToSharesWithdraw( + *env.current(), vaultBefore, issuanceBefore, setup.usd(9'000).value(), diff --git a/src/test/app/vault/VaultRolling_test.cpp b/src/test/app/vault/VaultRolling_test.cpp new file mode 100644 index 00000000000..5bf97bbd515 --- /dev/null +++ b/src/test/app/vault/VaultRolling_test.cpp @@ -0,0 +1,426 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +class VaultRolling_test : public VaultTestBase +{ +private: + static constexpr std::uint32_t kInterval = 86'400; // a day between windows + static constexpr std::uint32_t kWindow = 3'600; // open for an hour + + // VaultCreate validation for VaultKind::Rolling and the fee fields, plus + // the featureVaultContinuousAccrual gate. + void + testVaultCreateRolling() + { + testcase("rolling VaultCreate"); + using namespace test::jtx; + + auto const withEnv = [this](FeatureBitset features, auto&& body) { + Env env{*this, features}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + Vault vault{env}; + body(env, owner, vault); + }; + + Asset const asset = xrpIssue(); + auto const rolling = std::to_underlying(VaultKind::Rolling); + auto const openEnded = std::to_underlying(VaultKind::OpenEnded); + + // Gate: the dealing and fee fields require featureVaultContinuousAccrual. + withEnv( + testableAmendments() - featureVaultContinuousAccrual, + [&](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 = rolling, + .subscriptionDate = sub, + .dealingInterval = kInterval, + .dealingWindow = kWindow}); + env(tx, Ter{temDISABLED}); + env.close(); + }); + + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = static_cast(env.now().time_since_epoch().count()) + 60; + + // A rolling vault needs a first window and both durations. + { + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = rolling, + .subscriptionDate = sub, + .dealingInterval = kInterval}); + env(tx, Ter{temMALFORMED}); + env.close(); + } + { + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = rolling, + .subscriptionDate = sub, + .dealingWindow = kWindow}); + env(tx, Ter{temMALFORMED}); + env.close(); + } + { + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = rolling, + .dealingInterval = kInterval, + .dealingWindow = kWindow}); + env(tx, Ter{temMALFORMED}); + env.close(); + } + + // 0 < DealingWindow < DealingInterval. + { + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = rolling, + .subscriptionDate = sub, + .dealingInterval = kInterval, + .dealingWindow = 0}); + env(tx, Ter{temMALFORMED}); + env.close(); + } + { + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = rolling, + .subscriptionDate = sub, + .dealingInterval = kInterval, + .dealingWindow = kInterval}); + env(tx, Ter{temMALFORMED}); + env.close(); + } + + // RedemptionDate belongs to the closed-ended structure. + { + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = rolling, + .subscriptionDate = sub, + .redemptionDate = sub + kInterval, + .dealingInterval = kInterval, + .dealingWindow = kWindow}); + env(tx, Ter{temMALFORMED}); + env.close(); + } + + // The dealing fields mean nothing on a vault that is not rolling. + { + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = openEnded, + .dealingInterval = kInterval, + .dealingWindow = kWindow}); + env(tx, Ter{temMALFORMED}); + env.close(); + } + + // A redemption period with no fee to gate would never be read. + { + auto [tx, keylet] = + vault.create({.owner = owner, .asset = asset, .redemptionPeriod = kInterval}); + env(tx, Ter{temMALFORMED}); + env.close(); + } + + // Neither fee may retain more than half of what is moved. + { + auto [tx, keylet] = + vault.create({.owner = owner, .asset = asset, .depositFee = kMaxVaultFee + 1}); + env(tx, Ter{temMALFORMED}); + env.close(); + } + { + auto [tx, keylet] = vault.create( + {.owner = owner, .asset = asset, .redemptionFee = kMaxVaultFee + 1}); + env(tx, Ter{temMALFORMED}); + env.close(); + } + + // The happy path stores every field. The rejected cases above each + // closed a ledger, so take the first window from the clock as it is + // now rather than the value read before them. + { + auto const subNow = + static_cast(env.now().time_since_epoch().count()) + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = rolling, + .subscriptionDate = subNow, + .dealingInterval = kInterval, + .dealingWindow = kWindow, + .depositFee = 100, + .redemptionFee = 250, + .redemptionPeriod = kInterval}); + env(tx); + env.close(); + + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault != nullptr); + if (!sleVault) + return; + BEAST_EXPECT(sleVault->at(sfVaultKind) == rolling); + BEAST_EXPECT(sleVault->at(sfSubscriptionDate) == subNow); + BEAST_EXPECT(sleVault->at(sfDealingInterval) == kInterval); + BEAST_EXPECT(sleVault->at(sfDealingWindow) == kWindow); + BEAST_EXPECT(sleVault->at(sfDepositFee) == 100); + BEAST_EXPECT(sleVault->at(sfRedemptionFee) == 250); + BEAST_EXPECT(sleVault->at(sfRedemptionPeriod) == kInterval); + BEAST_EXPECT(getVaultKind(sleVault) == VaultKind::Rolling); + } + }); + } + + // VaultDeposit and VaultWithdraw are accepted only inside a dealing window. + void + testDealingWindow() + { + testcase("rolling dealing window"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + env.fund(XRP(10'000), owner, depositor); + env.close(); + + Vault vault{env}; + Asset const asset = xrpIssue(); + auto const start = static_cast(env.now().time_since_epoch().count()) + 60; + + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = std::to_underlying(VaultKind::Rolling), + .subscriptionDate = start, + .dealingInterval = kInterval, + .dealingWindow = kWindow}); + env(tx); + env.close(); + + auto const vaultId = keylet.key; + auto const atTime = [&](std::uint32_t when) { + env.close(NetClock::time_point{NetClock::duration{when}}); + }; + + // Before the first window opens. + atTime(start - 30); + env(vault.deposit({.depositor = depositor, .id = vaultId, .amount = XRP(10)}), + Ter{tecTOO_SOON}); + env.close(); + + // Inside the first window. + atTime(start + 10); + env(vault.deposit({.depositor = depositor, .id = vaultId, .amount = XRP(10)})); + env.close(); + + // After the window has closed, before the next one opens. + atTime(start + kWindow + 10); + env(vault.deposit({.depositor = depositor, .id = vaultId, .amount = XRP(10)}), + Ter{tecTOO_SOON}); + env.close(); + + // The window reopens one interval later. + atTime(start + kInterval + 10); + env(vault.deposit({.depositor = depositor, .id = vaultId, .amount = XRP(10)})); + env.close(); + + // Withdrawal obeys the same window. + atTime(start + kInterval + kWindow + 10); + env(vault.withdraw({.depositor = depositor, .id = vaultId, .amount = XRP(5)}), + Ter{tecTOO_SOON}); + env.close(); + + atTime(start + 2 * kInterval + 10); + env(vault.withdraw({.depositor = depositor, .id = vaultId, .amount = XRP(5)})); + env.close(); + } + + // The deposit fee is retained by the vault, so the vault gains the gross + // while the depositor is issued shares only for the net. + void + testDepositFee() + { + testcase("deposit fee"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const first{"first"}; + Account const second{"second"}; + env.fund(XRP(10'000), owner, first, second); + env.close(); + + Vault vault{env}; + // 10% of what comes in. + auto [tx, keylet] = + vault.create({.owner = owner, .asset = xrpIssue(), .depositFee = 10'000}); + env(tx); + env.close(); + auto const vaultId = keylet.key; + + // The first deposit into an empty vault has no holders to lift, so it + // pays no fee: the vault gains exactly what was sent. + env(vault.deposit({.depositor = first, .id = vaultId, .amount = XRP(1'000)})); + env.close(); + { + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault != nullptr); + if (!sleVault) + return; + BEAST_EXPECT(sleVault->at(sfAssetsTotal) == Number{1'000'000'000}); + } + + // The second deposit pays the fee. The vault still gains the gross, + // which is what lifts the first depositor. + env(vault.deposit({.depositor = second, .id = vaultId, .amount = XRP(1'000)})); + env.close(); + { + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault != nullptr); + if (!sleVault) + return; + BEAST_EXPECT(sleVault->at(sfAssetsTotal) == Number{2'000'000'000}); + } + } + + // The first deal of a window fixes the price for that window, and the next + // window strikes a fresh one. + void + testStruckPrice() + { + testcase("struck price"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const first{"first"}; + Account const second{"second"}; + env.fund(XRP(10'000), owner, first, second); + env.close(); + + Vault vault{env}; + auto const start = static_cast(env.now().time_since_epoch().count()) + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = xrpIssue(), + .vaultKind = std::to_underlying(VaultKind::Rolling), + .subscriptionDate = start, + .dealingInterval = kInterval, + .dealingWindow = kWindow}); + env(tx); + env.close(); + auto const vaultId = keylet.key; + + auto const atTime = [&](std::uint32_t when) { + env.close(NetClock::time_point{NetClock::duration{when}}); + }; + + // Nothing is struck before the first deal. + { + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault && sleVault->at(sfStruckUntil) == 0); + } + + // The first window seeds the vault. An empty vault has no outstanding + // shares, so there is no ratio to strike and the window passes without a strike. + atTime(start + 10); + env(vault.deposit({.depositor = first, .id = vaultId, .amount = XRP(1'000)})); + env.close(); + { + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault && sleVault->at(sfStruckUntil) == 0); + } + + // The first deal of the next window strikes, against that window's end. + auto const secondWindow = start + kInterval; + atTime(secondWindow + 10); + env(vault.deposit({.depositor = second, .id = vaultId, .amount = XRP(500)})); + env.close(); + + Number struckPrice; + { + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault != nullptr); + if (!sleVault) + return; + BEAST_EXPECT(sleVault->at(sfStruckUntil) == secondWindow + kWindow); + struckPrice = sleVault->at(sfStruckPrice); + BEAST_EXPECT(struckPrice > Number{}); + } + + // A later deal in the same window converts at the same price and does + // not restrike it. + env(vault.deposit({.depositor = first, .id = vaultId, .amount = XRP(100)})); + env.close(); + { + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault != nullptr); + if (!sleVault) + return; + BEAST_EXPECT(sleVault->at(sfStruckUntil) == secondWindow + kWindow); + BEAST_EXPECT(sleVault->at(sfStruckPrice) == struckPrice); + } + + // The window after that strikes afresh. + auto const thirdWindow = start + 2 * kInterval; + atTime(thirdWindow + 10); + env(vault.deposit({.depositor = second, .id = vaultId, .amount = XRP(100)})); + env.close(); + { + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault != nullptr); + if (!sleVault) + return; + BEAST_EXPECT(sleVault->at(sfStruckUntil) == thirdWindow + kWindow); + } + } + +public: + void + run() override + { + testVaultCreateRolling(); + testDealingWindow(); + testDepositFee(); + testStruckPrice(); + } +}; + +BEAST_DEFINE_TESTSUITE_PRIO(VaultRolling, app, xrpl, 1); + +} // namespace xrpl diff --git a/src/test/jtx/impl/vault.cpp b/src/test/jtx/impl/vault.cpp index 5bf8ac99814..1122cd118d5 100644 --- a/src/test/jtx/impl/vault.cpp +++ b/src/test/jtx/impl/vault.cpp @@ -41,6 +41,18 @@ Vault::create(CreateArgs const& args) const jv[sfRedemptionDate] = *args.redemptionDate; if (args.leVersion) jv[sfLEVersion] = std::to_underlying(*args.leVersion); + if (args.dealingInterval) + jv[sfDealingInterval] = *args.dealingInterval; + if (args.dealingWindow) + jv[sfDealingWindow] = *args.dealingWindow; + if (args.depositFee) + jv[sfDepositFee] = *args.depositFee; + if (args.redemptionFee) + jv[sfRedemptionFee] = *args.redemptionFee; + if (args.redemptionPeriod) + jv[sfRedemptionPeriod] = *args.redemptionPeriod; + if (args.accountingMethod) + jv[sfAccountingMethod] = *args.accountingMethod; return {jv, keylet}; } diff --git a/src/test/jtx/vault.h b/src/test/jtx/vault.h index 000e8a20ea4..3e0e0744cfc 100644 --- a/src/test/jtx/vault.h +++ b/src/test/jtx/vault.h @@ -36,6 +36,18 @@ struct Vault std::nullopt; // NOLINT(readability-redundant-member-init) std::optional leVersion = std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional dealingInterval = + std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional dealingWindow = + std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional depositFee = + std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional redemptionFee = + std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional redemptionPeriod = + std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional accountingMethod = + std::nullopt; // NOLINT(readability-redundant-member-init) }; /** diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp index 26dde555636..f8b4066db8a 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp @@ -32,8 +32,12 @@ TEST(VaultTests, BuilderSettersRoundTrip) auto const assetsAvailableValue = canonical_NUMBER(); auto const assetsMaximumValue = canonical_NUMBER(); auto const lossUnrealizedValue = canonical_NUMBER(); + auto const unearnedInterestValue = canonical_NUMBER(); + auto const accrualRateValue = canonical_NUMBER(); + auto const lastAccrualTimeValue = canonical_UINT32(); auto const shareMPTIDValue = canonical_UINT192(); auto const withdrawalPolicyValue = canonical_UINT8(); + auto const accountingMethodValue = canonical_UINT8(); auto const scaleValue = canonical_UINT8(); auto const lEVersionValue = canonical_UINT8(); auto const vaultKindValue = canonical_UINT8(); @@ -57,6 +61,10 @@ TEST(VaultTests, BuilderSettersRoundTrip) builder.setAssetsAvailable(assetsAvailableValue); builder.setAssetsMaximum(assetsMaximumValue); builder.setLossUnrealized(lossUnrealizedValue); + builder.setUnearnedInterest(unearnedInterestValue); + builder.setAccrualRate(accrualRateValue); + builder.setLastAccrualTime(lastAccrualTimeValue); + builder.setAccountingMethod(accountingMethodValue); builder.setScale(scaleValue); builder.setLEVersion(lEVersionValue); builder.setVaultKind(vaultKindValue); @@ -166,6 +174,38 @@ TEST(VaultTests, BuilderSettersRoundTrip) EXPECT_TRUE(entry.hasLossUnrealized()); } + { + auto const& expected = unearnedInterestValue; + auto const actualOpt = entry.getUnearnedInterest(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfUnearnedInterest"); + EXPECT_TRUE(entry.hasUnearnedInterest()); + } + + { + auto const& expected = accrualRateValue; + auto const actualOpt = entry.getAccrualRate(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfAccrualRate"); + EXPECT_TRUE(entry.hasAccrualRate()); + } + + { + auto const& expected = lastAccrualTimeValue; + auto const actualOpt = entry.getLastAccrualTime(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfLastAccrualTime"); + EXPECT_TRUE(entry.hasLastAccrualTime()); + } + + { + auto const& expected = accountingMethodValue; + auto const actualOpt = entry.getAccountingMethod(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfAccountingMethod"); + EXPECT_TRUE(entry.hasAccountingMethod()); + } + { auto const& expected = scaleValue; auto const actualOpt = entry.getScale(); @@ -231,8 +271,12 @@ TEST(VaultTests, BuilderFromSleRoundTrip) auto const assetsAvailableValue = canonical_NUMBER(); auto const assetsMaximumValue = canonical_NUMBER(); auto const lossUnrealizedValue = canonical_NUMBER(); + auto const unearnedInterestValue = canonical_NUMBER(); + auto const accrualRateValue = canonical_NUMBER(); + auto const lastAccrualTimeValue = canonical_UINT32(); auto const shareMPTIDValue = canonical_UINT192(); auto const withdrawalPolicyValue = canonical_UINT8(); + auto const accountingMethodValue = canonical_UINT8(); auto const scaleValue = canonical_UINT8(); auto const lEVersionValue = canonical_UINT8(); auto const vaultKindValue = canonical_UINT8(); @@ -253,8 +297,12 @@ TEST(VaultTests, BuilderFromSleRoundTrip) sle->at(sfAssetsAvailable) = assetsAvailableValue; sle->at(sfAssetsMaximum) = assetsMaximumValue; sle->at(sfLossUnrealized) = lossUnrealizedValue; + sle->at(sfUnearnedInterest) = unearnedInterestValue; + sle->at(sfAccrualRate) = accrualRateValue; + sle->at(sfLastAccrualTime) = lastAccrualTimeValue; sle->at(sfShareMPTID) = shareMPTIDValue; sle->at(sfWithdrawalPolicy) = withdrawalPolicyValue; + sle->at(sfAccountingMethod) = accountingMethodValue; sle->at(sfScale) = scaleValue; sle->at(sfLEVersion) = lEVersionValue; sle->at(sfVaultKind) = vaultKindValue; @@ -425,6 +473,58 @@ TEST(VaultTests, BuilderFromSleRoundTrip) expectEqualField(expected, *fromBuilderOpt, "sfLossUnrealized"); } + { + auto const& expected = unearnedInterestValue; + + auto const fromSleOpt = entryFromSle.getUnearnedInterest(); + auto const fromBuilderOpt = entryFromBuilder.getUnearnedInterest(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfUnearnedInterest"); + expectEqualField(expected, *fromBuilderOpt, "sfUnearnedInterest"); + } + + { + auto const& expected = accrualRateValue; + + auto const fromSleOpt = entryFromSle.getAccrualRate(); + auto const fromBuilderOpt = entryFromBuilder.getAccrualRate(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfAccrualRate"); + expectEqualField(expected, *fromBuilderOpt, "sfAccrualRate"); + } + + { + auto const& expected = lastAccrualTimeValue; + + auto const fromSleOpt = entryFromSle.getLastAccrualTime(); + auto const fromBuilderOpt = entryFromBuilder.getLastAccrualTime(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfLastAccrualTime"); + expectEqualField(expected, *fromBuilderOpt, "sfLastAccrualTime"); + } + + { + auto const& expected = accountingMethodValue; + + auto const fromSleOpt = entryFromSle.getAccountingMethod(); + auto const fromBuilderOpt = entryFromBuilder.getAccountingMethod(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfAccountingMethod"); + expectEqualField(expected, *fromBuilderOpt, "sfAccountingMethod"); + } + { auto const& expected = scaleValue; @@ -570,6 +670,14 @@ TEST(VaultTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getAssetsMaximum().has_value()); EXPECT_FALSE(entry.hasLossUnrealized()); EXPECT_FALSE(entry.getLossUnrealized().has_value()); + EXPECT_FALSE(entry.hasUnearnedInterest()); + EXPECT_FALSE(entry.getUnearnedInterest().has_value()); + EXPECT_FALSE(entry.hasAccrualRate()); + EXPECT_FALSE(entry.getAccrualRate().has_value()); + EXPECT_FALSE(entry.hasLastAccrualTime()); + EXPECT_FALSE(entry.getLastAccrualTime().has_value()); + EXPECT_FALSE(entry.hasAccountingMethod()); + EXPECT_FALSE(entry.getAccountingMethod().has_value()); EXPECT_FALSE(entry.hasScale()); EXPECT_FALSE(entry.getScale().has_value()); EXPECT_FALSE(entry.hasLEVersion());