diff --git a/include/xrpl/basics/Number.h b/include/xrpl/basics/Number.h index 41c60d30a1..60ea23a0a5 100644 --- a/include/xrpl/basics/Number.h +++ b/include/xrpl/basics/Number.h @@ -32,6 +32,15 @@ class Number; std::string to_string(Number const& amount); +template +constexpr bool +isPowerOfTen(T value) +{ + while (value >= 10 && value % 10 == 0) + value /= 10; + return value == 1; +} + class Number { using rep = std::int64_t; @@ -41,7 +50,9 @@ class Number public: // The range for the mantissa when normalized constexpr static std::int64_t minMantissa = 1'000'000'000'000'000LL; - constexpr static std::int64_t maxMantissa = 9'999'999'999'999'999LL; + static_assert(isPowerOfTen(minMantissa)); + constexpr static std::int64_t maxMantissa = minMantissa * 10 - 1; + static_assert(maxMantissa == 9'999'999'999'999'999LL); // The range for the exponent when normalized constexpr static int minExponent = -32768; @@ -151,22 +162,7 @@ public: } Number - truncate() const noexcept - { - if (exponent_ >= 0 || mantissa_ == 0) - return *this; - - Number ret = *this; - while (ret.exponent_ < 0 && ret.mantissa_ != 0) - { - ret.exponent_ += 1; - ret.mantissa_ /= rep(10); - } - // We are guaranteed that normalize() will never throw an exception - // because exponent is either negative or zero at this point. - ret.normalize(); - return ret; - } + truncate() const noexcept; friend constexpr bool operator>(Number const& x, Number const& y) noexcept @@ -211,6 +207,8 @@ private: class Guard; }; +constexpr static Number numZero{}; + inline constexpr Number::Number(rep mantissa, int exponent, unchecked) noexcept : mantissa_{mantissa}, exponent_{exponent} { diff --git a/include/xrpl/beast/utility/instrumentation.h b/include/xrpl/beast/utility/instrumentation.h index 3594855eef..cb7e3ffa0e 100644 --- a/include/xrpl/beast/utility/instrumentation.h +++ b/include/xrpl/beast/utility/instrumentation.h @@ -32,7 +32,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. // The duplication is because Visual Studio 2019 cannot compile that header // even with the option -Zc:__cplusplus added. #define ALWAYS(cond, message, ...) assert((message) && (cond)) -#define ALWAYS_OR_UNREACHABLE(cond, message, ...) assert((message) && (cond)) +#define ALWAYS_OR_UNREACHABLE(cond, message) assert((message) && (cond)) #define SOMETIMES(cond, message, ...) #define REACHABLE(message, ...) #define UNREACHABLE(message, ...) assert((message) && false) diff --git a/include/xrpl/json/json_value.h b/include/xrpl/json/json_value.h index 2073bd78a2..0c37269960 100644 --- a/include/xrpl/json/json_value.h +++ b/include/xrpl/json/json_value.h @@ -400,6 +400,9 @@ public: /// Return true if the object has a member named key. bool isMember(std::string const& key) const; + /// Return true if the object has a member named key. + bool + isMember(StaticString const& key) const; /// \brief Return a list of the member names. /// diff --git a/include/xrpl/ledger/ApplyView.h b/include/xrpl/ledger/ApplyView.h index f90033966a..c00d040e09 100644 --- a/include/xrpl/ledger/ApplyView.h +++ b/include/xrpl/ledger/ApplyView.h @@ -387,6 +387,45 @@ public: emptyDirDelete(Keylet const& directory); }; +namespace directory { +/** Helper functions for managing low-level directory operations. + These are not part of the ApplyView interface. + + Don't use them unless you really, really know what you're doing. + Instead use dirAdd, dirInsert, etc. + */ + +std::uint64_t +createRoot( + ApplyView& view, + Keylet const& directory, + uint256 const& key, + std::function const&)> const& describe); + +auto +findPreviousPage(ApplyView& view, Keylet const& directory, SLE::ref start); + +std::uint64_t +insertKey( + ApplyView& view, + SLE::ref node, + std::uint64_t page, + bool preserveOrder, + STVector256& indexes, + uint256 const& key); + +std::optional +insertPage( + ApplyView& view, + std::uint64_t page, + SLE::pointer node, + std::uint64_t nextPage, + SLE::ref next, + uint256 const& key, + Keylet const& directory, + std::function const&)> const& describe); + +} // namespace directory } // namespace ripple #endif diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 9698b4fda3..ece8ef84f9 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -242,6 +243,80 @@ isDeepFrozen( Currency const& currency, AccountID const& issuer); +[[nodiscard]] inline bool +isDeepFrozen( + ReadView const& view, + AccountID const& account, + Issue const& issue, + int = 0 /*ignored*/) +{ + return isDeepFrozen(view, account, issue.currency, issue.account); +} + +[[nodiscard]] inline bool +isDeepFrozen( + ReadView const& view, + AccountID const& account, + MPTIssue const& mptIssue, + int depth = 0) +{ + // Unlike IOUs, frozen / locked MPTs are not allowed to send or receive + // funds, so checking "deep frozen" is the same as checking "frozen". + return isFrozen(view, account, mptIssue, depth); +} + +/** + * isFrozen check is recursive for MPT shares in a vault, descending to + * assets in the vault, up to maxAssetCheckDepth recursion depth. This is + * purely defensive, as we currently do not allow such vaults to be created. + */ +[[nodiscard]] inline bool +isDeepFrozen( + ReadView const& view, + AccountID const& account, + Asset const& asset, + int depth = 0) +{ + return std::visit( + [&](auto const& issue) { + return isDeepFrozen(view, account, issue, depth); + }, + asset.value()); +} + +[[nodiscard]] inline TER +checkDeepFrozen( + ReadView const& view, + AccountID const& account, + Issue const& issue) +{ + return isDeepFrozen(view, account, issue) ? (TER)tecFROZEN + : (TER)tesSUCCESS; +} + +[[nodiscard]] inline TER +checkDeepFrozen( + ReadView const& view, + AccountID const& account, + MPTIssue const& mptIssue) +{ + return isDeepFrozen(view, account, mptIssue) ? (TER)tecLOCKED + : (TER)tesSUCCESS; +} + +[[nodiscard]] inline TER +checkDeepFrozen( + ReadView const& view, + AccountID const& account, + Asset const& asset) +{ + return std::visit( + [&](auto const& issue) { + return checkDeepFrozen(view, account, issue); + }, + asset.value()); +} + [[nodiscard]] bool isLPTokenFrozen( ReadView const& view, @@ -287,6 +362,49 @@ accountHolds( AuthHandling zeroIfUnauthorized, beast::Journal j); +// Returns the amount an account can spend total. +// +// These functions use accountHolds, but unlike accountHolds: +// * The account can go into debt. +// * If the account is the asset issuer the only limit is defined by the asset / +// issuance. +// +// <-- saAmount: amount of currency held by account. May be negative. +[[nodiscard]] STAmount +accountSpendable( + ReadView const& view, + AccountID const& account, + Currency const& currency, + AccountID const& issuer, + FreezeHandling zeroIfFrozen, + beast::Journal j); + +[[nodiscard]] STAmount +accountSpendable( + ReadView const& view, + AccountID const& account, + Issue const& issue, + FreezeHandling zeroIfFrozen, + beast::Journal j); + +[[nodiscard]] STAmount +accountSpendable( + ReadView const& view, + AccountID const& account, + MPTIssue const& mptIssue, + FreezeHandling zeroIfFrozen, + AuthHandling zeroIfUnauthorized, + beast::Journal j); + +[[nodiscard]] STAmount +accountSpendable( + ReadView const& view, + AccountID const& account, + Asset const& asset, + FreezeHandling zeroIfFrozen, + AuthHandling zeroIfUnauthorized, + beast::Journal j); + // Returns the amount an account can spend of the currency type saDefault, or // returns saDefault if this account is the issuer of the currency in // question. Should be used in favor of accountHolds when questioning how much @@ -533,7 +651,11 @@ dirNext( describeOwnerDir(AccountID const& account); [[nodiscard]] TER -dirLink(ApplyView& view, AccountID const& owner, std::shared_ptr& object); +dirLink( + ApplyView& view, + AccountID const& owner, + std::shared_ptr& object, + SF_UINT64 const& node = sfOwnerNode); AccountID pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey); @@ -552,14 +674,17 @@ createPseudoAccount( uint256 const& pseudoOwnerKey, SField const& ownerField); -// Returns true iff sleAcct is a pseudo-account. +// Returns true iff sleAcct is a pseudo-account or specific +// pseudo-accounts in pseudoFieldFilter. // // Returns false if sleAcct is // * NOT a pseudo-account OR // * NOT a ltACCOUNT_ROOT OR // * null pointer [[nodiscard]] bool -isPseudoAccount(std::shared_ptr sleAcct); +isPseudoAccount( + std::shared_ptr sleAcct, + std::set const& pseudoFieldFilter = {}); // Returns the list of fields that define an ACCOUNT_ROOT as a pseudo-account if // set @@ -573,14 +698,91 @@ isPseudoAccount(std::shared_ptr sleAcct); getPseudoAccountFields(); [[nodiscard]] inline bool -isPseudoAccount(ReadView const& view, AccountID accountId) +isPseudoAccount( + ReadView const& view, + AccountID const& accountId, + std::set const& pseudoFieldFilter = {}) { - return isPseudoAccount(view.read(keylet::account(accountId))); + return isPseudoAccount( + view.read(keylet::account(accountId)), pseudoFieldFilter); } [[nodiscard]] TER canAddHolding(ReadView const& view, Asset const& asset); +/** Validates that the destination SLE and tag are valid + + - Checks that the SLE is not null. + - If the SLE requires a destination tag, checks that there is a tag. +*/ +[[nodiscard]] TER +checkDestinationAndTag(SLE::const_ref toSle, bool hasDestinationTag); + +/** Checks that can withdraw funds from an object to itself or a destination. + * + * The receiver may be either the submitting account (sfAccount) or a different + * destination account (sfDestination). + * + * - Checks that the receiver account exists. + * - If the receiver requires a destination tag, check that one exists, even + * if withdrawing to self. + * - If withdrawing to self, succeed. + * - If not, checks if the receiver requires deposit authorization, and if + * the sender has it. + */ +[[nodiscard]] TER +canWithdraw( + AccountID const& from, + ReadView const& view, + AccountID const& to, + SLE::const_ref toSle, + bool hasDestinationTag); + +/** Checks that can withdraw funds from an object to itself or a destination. + * + * The receiver may be either the submitting account (sfAccount) or a different + * destination account (sfDestination). + * + * - Checks that the receiver account exists. + * - If the receiver requires a destination tag, check that one exists, even + * if withdrawing to self. + * - If withdrawing to self, succeed. + * - If not, checks if the receiver requires deposit authorization, and if + * the sender has it. + */ +[[nodiscard]] TER +canWithdraw( + AccountID const& from, + ReadView const& view, + AccountID const& to, + bool hasDestinationTag); + +/** Checks that can withdraw funds from an object to itself or a destination. + * + * The receiver may be either the submitting account (sfAccount) or a different + * destination account (sfDestination). + * + * - Checks that the receiver account exists. + * - If the receiver requires a destination tag, check that one exists, even + * if withdrawing to self. + * - If withdrawing to self, succeed. + * - If not, checks if the receiver requires deposit authorization, and if + * the sender has it. + */ +[[nodiscard]] TER +canWithdraw(ReadView const& view, STTx const& tx); + +[[nodiscard]] TER +doWithdraw( + ApplyView& view, + STTx const& tx, + AccountID const& senderAcct, + AccountID const& dstAcct, + AccountID const& sourceAcct, + XRPAmount priorBalance, + STAmount const& amount, + beast::Journal j); + /// Any transactors that call addEmptyHolding() in doApply must call /// canAddHolding() in preflight with the same View and Asset [[nodiscard]] TER @@ -750,6 +952,22 @@ accountSend( beast::Journal j, WaiveTransferFee waiveFee = WaiveTransferFee::No); +using MultiplePaymentDestinations = std::vector>; +/** Like accountSend, except one account is sending multiple payments (with the + * same asset!) simultaneously + * + * Calls static accountSendMultiIOU if saAmount represents Issue. + * Calls static accountSendMultiMPT if saAmount represents MPTIssue. + */ +[[nodiscard]] TER +accountSendMulti( + ApplyView& view, + AccountID const& senderID, + Asset const& asset, + MultiplePaymentDestinations const& receivers, + beast::Journal j, + WaiveTransferFee waiveFee = WaiveTransferFee::No); + [[nodiscard]] TER issueIOU( ApplyView& view, @@ -821,7 +1039,8 @@ requireAuth( * purely defensive, as we currently do not allow such vaults to be created. * * If StrongAuth then return tecNO_AUTH if MPToken doesn't exist or - * lsfMPTRequireAuth is set and MPToken is not authorized. + * lsfMPTRequireAuth is set and MPToken is not authorized. Vault and LoanBroker + * pseudo-accounts are implicitly authorized. * * If WeakAuth then return tecNO_AUTH if lsfMPTRequireAuth is set and MPToken * doesn't exist or is not authorized (explicitly or via credentials, if @@ -894,6 +1113,26 @@ canTransfer( AccountID const& from, AccountID const& to); +[[nodiscard]] TER +canTransfer( + ReadView const& view, + Issue const& issue, + AccountID const& from, + AccountID const& to); + +[[nodiscard]] TER inline canTransfer( + ReadView const& view, + Asset const& asset, + AccountID const& from, + AccountID const& to) +{ + return std::visit( + [&](TIss const& issue) -> TER { + return canTransfer(view, issue, from, to); + }, + asset.value()); +} + /** Deleter function prototype. Returns the status of the entry deletion * (if should not be skipped) and if the entry should be skipped. The status * is always tesSUCCESS if the entry should be skipped. diff --git a/include/xrpl/protocol/Asset.h b/include/xrpl/protocol/Asset.h index 4438106738..8872989c88 100644 --- a/include/xrpl/protocol/Asset.h +++ b/include/xrpl/protocol/Asset.h @@ -100,7 +100,27 @@ public: bool native() const { - return holds() && get().native(); + return std::visit( + [&](TIss const& issue) { + if constexpr (std::is_same_v) + return issue.native(); + if constexpr (std::is_same_v) + return false; + }, + issue_); + } + + bool + integral() const + { + return std::visit( + [&](TIss const& issue) { + if constexpr (std::is_same_v) + return issue.native(); + if constexpr (std::is_same_v) + return true; + }, + issue_); } friend constexpr bool diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index 79be15d906..f74287f9a5 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -346,6 +346,24 @@ vault(uint256 const& vaultKey) return {ltVAULT, vaultKey}; } +Keylet +loanbroker(AccountID const& owner, std::uint32_t seq) noexcept; + +inline Keylet +loanbroker(uint256 const& key) +{ + return {ltLOAN_BROKER, key}; +} + +Keylet +loan(uint256 const& loanBrokerID, std::uint32_t loanSeq) noexcept; + +inline Keylet +loan(uint256 const& key) +{ + return {ltLOAN, key}; +} + Keylet permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept; diff --git a/include/xrpl/protocol/LedgerFormats.h b/include/xrpl/protocol/LedgerFormats.h index ff4653e5c7..4041023f7c 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -205,6 +205,11 @@ enum LedgerSpecificFlags { // ltVAULT lsfVaultPrivate = 0x00010000, + + // ltLOAN + lsfLoanDefault = 0x00010000, + lsfLoanImpaired = 0x00020000, + lsfLoanOverpayment = 0x00040000, // True, loan allows overpayments }; //------------------------------------------------------------------------------ diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index b3e7086d03..3465618c44 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -22,6 +22,7 @@ #include #include +#include #include @@ -84,6 +85,140 @@ std::size_t constexpr maxDeletableTokenOfferEntries = 500; */ std::uint16_t constexpr maxTransferFee = 50000; +/** There are 10,000 basis points (bips) in 100%. + * + * Basis points represent 0.01%. + * + * Given a value X, to find the amount for B bps, + * use X * B / bipsPerUnity + * + * Example: If a loan broker has 999 XRP of debt, and must maintain 1,000 bps of + * that debt as cover (10%), then the minimum cover amount is 999,000,000 drops + * * 1000 / bipsPerUnity = 99,900,00 drops or 99.9 XRP. + * + * Given a percentage P, to find the number of bps that percentage represents, + * use P * bipsPerUnity. + * + * Example: 50% is 0.50 * bipsPerUnity = 5,000 bps. + */ +Bips32 constexpr bipsPerUnity(100 * 100); +static_assert(bipsPerUnity == Bips32{10'000}); +TenthBips32 constexpr tenthBipsPerUnity(bipsPerUnity.value() * 10); +static_assert(tenthBipsPerUnity == TenthBips32(100'000)); + +constexpr Bips32 +percentageToBips(std::uint32_t percentage) +{ + return Bips32(percentage * bipsPerUnity.value() / 100); +} +constexpr TenthBips32 +percentageToTenthBips(std::uint32_t percentage) +{ + return TenthBips32(percentage * tenthBipsPerUnity.value() / 100); +} +template +constexpr T +bipsOfValue(T value, Bips bips) +{ + return value * bips.value() / bipsPerUnity.value(); +} +template +constexpr T +tenthBipsOfValue(T value, TenthBips bips) +{ + return value * bips.value() / tenthBipsPerUnity.value(); +} + +namespace Lending { +/** The maximum management fee rate allowed by a loan broker in 1/10 bips. + + Valid values are between 0 and 10% inclusive. +*/ +TenthBips16 constexpr maxManagementFeeRate( + unsafe_cast(percentageToTenthBips(10).value())); +static_assert(maxManagementFeeRate == TenthBips16(std::uint16_t(10'000u))); + +/** The maximum coverage rate required of a loan broker in 1/10 bips. + + Valid values are between 0 and 100% inclusive. +*/ +TenthBips32 constexpr maxCoverRate = percentageToTenthBips(100); +static_assert(maxCoverRate == TenthBips32(100'000u)); + +/** The maximum overpayment fee on a loan in 1/10 bips. +* + Valid values are between 0 and 100% inclusive. +*/ +TenthBips32 constexpr maxOverpaymentFee = percentageToTenthBips(100); +static_assert(maxOverpaymentFee == TenthBips32(100'000u)); + +/** Annualized interest rate of the Loan in 1/10 bips. + * + * Valid values are between 0 and 100% inclusive. + */ +TenthBips32 constexpr maxInterestRate = percentageToTenthBips(100); +static_assert(maxInterestRate == TenthBips32(100'000u)); + +/** The maximum premium added to the interest rate for late payments on a loan + * in 1/10 bips. + * + * Valid values are between 0 and 100% inclusive. + */ +TenthBips32 constexpr maxLateInterestRate = percentageToTenthBips(100); +static_assert(maxLateInterestRate == TenthBips32(100'000u)); + +/** The maximum close interest rate charged for repaying a loan early in 1/10 + * bips. + * + * Valid values are between 0 and 100% inclusive. + */ +TenthBips32 constexpr maxCloseInterestRate = percentageToTenthBips(100); +static_assert(maxCloseInterestRate == TenthBips32(100'000u)); + +/** The maximum overpayment interest rate charged on loan overpayments in 1/10 + * bips. + * + * Valid values are between 0 and 100% inclusive. + */ +TenthBips32 constexpr maxOverpaymentInterestRate = percentageToTenthBips(100); +static_assert(maxOverpaymentInterestRate == TenthBips32(100'000u)); + +/** LoanPay transaction cost will be one base fee per X combined payments + * + * The number of payments is estimated based on the Amount paid and the Loan's + * Fixed Payment size. Overpayments (indicated with the tfLoanOverpayment flag) + * count as one more payment. + * + * This number was chosen arbitrarily, but should not be changed once released + * without an amendment + */ +static constexpr int loanPaymentsPerFeeIncrement = 5; + +/** Maximum number of combined payments that a LoanPay transaction will process + * + * This limit is enforced during the loan payment process, and thus is not + * estimated. If the limit is hit, no further payments or overpayments will be + * processed, no matter how much of the transation Amount is left, but the + * transaction will succeed with the payments that have been processed up to + * that point. + * + * This limit is independent of loanPaymentsPerFeeIncrement, so a transaction + * could potentially be charged for many more payments than actually get + * processed. Users should take care not to submit a transaction paying more + * than loanMaximumPaymentsPerTransaction * Loan.PeriodicPayment. Because + * overpayments are charged as a payment, if submitting + * loanMaximumPaymentsPerTransaction * Loan.PeriodicPayment, users should not + * set the tfLoanOverpayment flag. + * + * Even though they're independent, loanMaximumPaymentsPerTransaction should be + * a multiple of loanPaymentsPerFeeIncrement. + * + * This number was chosen arbitrarily, but should not be changed once released + * without an amendment + */ +static constexpr int loanMaximumPaymentsPerTransaction = 100; +} // namespace Lending + /** The maximum length of a URI inside an NFT */ std::size_t constexpr maxTokenURILength = 256; diff --git a/include/xrpl/protocol/SField.h b/include/xrpl/protocol/SField.h index b6ae98b48f..e199939fa4 100644 --- a/include/xrpl/protocol/SField.h +++ b/include/xrpl/protocol/SField.h @@ -139,8 +139,8 @@ field_code(int id, int index) SFields are created at compile time. Each SField, once constructed, lives until program termination, and there - is only one instance per fieldType/fieldValue pair which serves the entire - application. + is only one instance per fieldType/fieldValue pair which serves the + entire application. */ class SField { diff --git a/include/xrpl/protocol/STAmount.h b/include/xrpl/protocol/STAmount.h index f1e34463b6..70efb4cd25 100644 --- a/include/xrpl/protocol/STAmount.h +++ b/include/xrpl/protocol/STAmount.h @@ -66,16 +66,18 @@ public: static int const cMaxOffset = 80; // Maximum native value supported by the code - static std::uint64_t const cMinValue = 1000000000000000ull; - static std::uint64_t const cMaxValue = 9999999999999999ull; - static std::uint64_t const cMaxNative = 9000000000000000000ull; + constexpr static std::uint64_t cMinValue = 1'000'000'000'000'000ull; + static_assert(isPowerOfTen(cMinValue)); + constexpr static std::uint64_t cMaxValue = cMinValue * 10 - 1; + static_assert(cMaxValue == 9'999'999'999'999'999ull); + constexpr static std::uint64_t cMaxNative = 9'000'000'000'000'000'000ull; // Max native value on network. - static std::uint64_t const cMaxNativeN = 100000000000000000ull; - static std::uint64_t const cIssuedCurrency = 0x8000000000000000ull; - static std::uint64_t const cPositive = 0x4000000000000000ull; - static std::uint64_t const cMPToken = 0x2000000000000000ull; - static std::uint64_t const cValueMask = ~(cPositive | cMPToken); + constexpr static std::uint64_t cMaxNativeN = 100'000'000'000'000'000ull; + constexpr static std::uint64_t cIssuedCurrency = 0x8'000'000'000'000'000ull; + constexpr static std::uint64_t cPositive = 0x4'000'000'000'000'000ull; + constexpr static std::uint64_t cMPToken = 0x2'000'000'000'000'000ull; + constexpr static std::uint64_t cValueMask = ~(cPositive | cMPToken); static std::uint64_t const uRateOne; @@ -174,6 +176,9 @@ public: int exponent() const noexcept; + bool + integral() const noexcept; + bool native() const noexcept; @@ -454,6 +459,12 @@ STAmount::exponent() const noexcept return mOffset; } +inline bool +STAmount::integral() const noexcept +{ + return mAsset.integral(); +} + inline bool STAmount::native() const noexcept { @@ -572,7 +583,7 @@ STAmount::clear() { // The -100 is used to allow 0 to sort less than a small positive values // which have a negative exponent. - mOffset = native() ? 0 : -100; + mOffset = integral() ? 0 : -100; mValue = 0; mIsNegative = false; } @@ -695,6 +706,53 @@ divRoundStrict( std::uint64_t getRate(STAmount const& offerOut, STAmount const& offerIn); +/** Round an arbitrary precision Amount to the precision of an STAmount that has + * a given exponent. + * + * This is used to ensure that calculations involving IOU amounts do not collect + * dust beyond the precision of the reference value. + * + * @param value The value to be rounded + * @param scale An exponent value to establish the precision limit of + * `value`. Should be larger than `value.exponent()`. + * @param rounding Optional Number rounding mode + * + */ +STAmount +roundToScale( + STAmount const& value, + std::int32_t scale, + Number::rounding_mode rounding = Number::getround()); + +/** Round an arbitrary precision Number to the precision of a given Asset. + * + * This is used to ensure that calculations do not collect dust beyond the + * precision of the reference value for IOUs, or fractional amounts for the + * integral types XRP and MPT. + * + * @param asset The relevant asset + * @param value The value to be rounded + * @param scale Only relevant to IOU assets. An exponent value to establish the + * precision limit of `value`. Should be larger than `value.exponent()`. + * @param rounding Optional Number rounding mode + */ +template +Number +roundToAsset( + A const& asset, + Number const& value, + std::int32_t scale, + Number::rounding_mode rounding = Number::getround()) +{ + NumberRoundModeGuard mg(rounding); + STAmount const ret{asset, value}; + if (ret.integral()) + return ret; + // Note that the ctor will round integral types (XRP, MPT) via canonicalize, + // so no extra work is needed for those. + return roundToScale(ret, scale); +} + //------------------------------------------------------------------------------ inline bool diff --git a/include/xrpl/protocol/STObject.h b/include/xrpl/protocol/STObject.h index 84706c5833..5c58ce1ba9 100644 --- a/include/xrpl/protocol/STObject.h +++ b/include/xrpl/protocol/STObject.h @@ -501,6 +501,8 @@ public: value_type operator*() const; + /// Do not use operator->() unless the field is required, or you've checked + /// that it's set. T const* operator->() const; @@ -524,7 +526,26 @@ protected: // Constraint += and -= ValueProxy operators // to value types that support arithmetic operations template -concept IsArithmetic = std::is_arithmetic_v || std::is_same_v; +concept IsArithmeticNumber = std::is_arithmetic_v || + std::is_same_v || std::is_same_v; +template < + typename U, + typename Value = typename U::value_type, + typename Unit = typename U::unit_type> +concept IsArithmeticValueUnit = + std::is_same_v> && + IsArithmeticNumber && std::is_class_v; +template +concept IsArithmeticST = !IsArithmeticValueUnit && IsArithmeticNumber; +template +concept IsArithmetic = + IsArithmeticNumber || IsArithmeticST || IsArithmeticValueUnit; + +template +concept Addable = requires(T t, U u) { t = t + u; }; +template +concept IsArithmeticCompatible = + IsArithmetic && Addable; template class STObject::ValueProxy : public Proxy @@ -544,10 +565,12 @@ public: // Convenience operators for value types supporting // arithmetic operations template + requires IsArithmeticCompatible ValueProxy& operator+=(U const& u); template + requires IsArithmeticCompatible ValueProxy& operator-=(U const& u); @@ -737,6 +760,8 @@ STObject::Proxy::operator*() const -> value_type return this->value(); } +/// Do not use operator->() unless the field is required, or you've checked that +/// it's set. template T const* STObject::Proxy::operator->() const @@ -783,6 +808,7 @@ STObject::ValueProxy::operator=(U&& u) template template + requires IsArithmeticCompatible STObject::ValueProxy& STObject::ValueProxy::operator+=(U const& u) { @@ -792,6 +818,7 @@ STObject::ValueProxy::operator+=(U const& u) template template + requires IsArithmeticCompatible STObject::ValueProxy& STObject::ValueProxy::operator-=(U const& u) { diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index dcbc10b927..9aece5eb22 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -285,6 +285,32 @@ constexpr std::uint32_t tfIndependent = 0x00080000; constexpr std::uint32_t const tfBatchMask = ~(tfUniversal | tfAllOrNothing | tfOnlyOne | tfUntilFailure | tfIndependent) | tfInnerBatchTxn; +// LoanSet and LoanPay flags: +// LoanSet: True, indicates the loan supports overpayments +// LoanPay: True, indicates any excess in this payment can be used +// as an overpayment. False, no overpayments will be taken. +constexpr std::uint32_t const tfLoanOverpayment = 0x00010000; +// LoanPay exclusive flags: +// tfLoanFullPayment: True, indicates that the payment is an early +// full payment. It must pay the entire loan including close +// interest and fees, or it will fail. False: Not a full payment. +constexpr std::uint32_t const tfLoanFullPayment = 0x00020000; +// tfLoanLatePayment: True, indicates that the payment is late, +// and includes late iterest and fees. If the loan is not late, +// it will fail. False: not a late payment. If the current payment +// is overdue, the transaction will fail. +constexpr std::uint32_t const tfLoanLatePayment = 0x00040000; +constexpr std::uint32_t const tfLoanSetMask = ~(tfUniversal | + tfLoanOverpayment); +constexpr std::uint32_t const tfLoanPayMask = ~(tfUniversal | + tfLoanOverpayment | tfLoanFullPayment | tfLoanLatePayment); + +// LoanManage flags: +constexpr std::uint32_t const tfLoanDefault = 0x00010000; +constexpr std::uint32_t const tfLoanImpair = 0x00020000; +constexpr std::uint32_t const tfLoanUnimpair = 0x00040000; +constexpr std::uint32_t const tfLoanManageMask = ~(tfUniversal | tfLoanDefault | tfLoanImpair | tfLoanUnimpair); + // clang-format on } // namespace ripple diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index c5d4699199..3928fc5f9e 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -27,9 +27,12 @@ #error "undefined macro: XRPL_RETIRE" #endif +// clang-format off + // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. +XRPL_FEATURE(LendingProtocol, Supported::no, VoteBehavior::DefaultNo) XRPL_FIX (DirectoryLimit, Supported::yes, VoteBehavior::DefaultNo) XRPL_FIX (IncludeKeyletFields, Supported::yes, VoteBehavior::DefaultNo) XRPL_FEATURE(DynamicMPT, Supported::no, VoteBehavior::DefaultNo) @@ -155,3 +158,5 @@ XRPL_RETIRE(fix1512) XRPL_RETIRE(fix1523) XRPL_RETIRE(fix1528) XRPL_RETIRE(FlowCross) + +// clang-format on diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index d3b1b3c651..9ac49ba27b 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -168,6 +168,7 @@ LEDGER_ENTRY(ltACCOUNT_ROOT, 0x0061, AccountRoot, account, ({ {sfFirstNFTokenSequence, soeOPTIONAL}, {sfAMMID, soeOPTIONAL}, // pseudo-account designator {sfVaultID, soeOPTIONAL}, // pseudo-account designator + {sfLoanBrokerID, soeOPTIONAL}, // pseudo-account designator })) /** A ledger object which contains a list of object identifiers. @@ -498,10 +499,10 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ {sfAccount, soeREQUIRED}, {sfData, soeOPTIONAL}, {sfAsset, soeREQUIRED}, - {sfAssetsTotal, soeREQUIRED}, - {sfAssetsAvailable, soeREQUIRED}, + {sfAssetsTotal, soeDEFAULT}, + {sfAssetsAvailable, soeDEFAULT}, {sfAssetsMaximum, soeDEFAULT}, - {sfLossUnrealized, soeREQUIRED}, + {sfLossUnrealized, soeDEFAULT}, {sfShareMPTID, soeREQUIRED}, {sfWithdrawalPolicy, soeREQUIRED}, {sfScale, soeDEFAULT}, @@ -509,5 +510,117 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ // no PermissionedDomainID ever (use MPTIssuance.sfDomainID) })) +/** Reserve 0x0084-0x0087 for future Vault-related objects. */ + +/** A ledger object representing a loan broker + + \sa keylet::loanbroker + */ +LEDGER_ENTRY(ltLOAN_BROKER, 0x0088, LoanBroker, loan_broker, ({ + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfSequence, soeREQUIRED}, + {sfOwnerNode, soeREQUIRED}, + {sfVaultNode, soeREQUIRED}, + {sfVaultID, soeREQUIRED}, + {sfAccount, soeREQUIRED}, + {sfOwner, soeREQUIRED}, + {sfLoanSequence, soeREQUIRED}, + {sfData, soeDEFAULT}, + {sfManagementFeeRate, soeDEFAULT}, + {sfOwnerCount, soeDEFAULT}, + {sfDebtTotal, soeDEFAULT}, + {sfDebtMaximum, soeDEFAULT}, + {sfCoverAvailable, soeDEFAULT}, + {sfCoverRateMinimum, soeDEFAULT}, + {sfCoverRateLiquidation, soeDEFAULT}, +})) + +/** A ledger object representing a loan between a Borrower and a Loan Broker + + \sa keylet::loan + */ +LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({ + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfOwnerNode, soeREQUIRED}, + {sfLoanBrokerNode, soeREQUIRED}, + {sfLoanBrokerID, soeREQUIRED}, + {sfLoanSequence, soeREQUIRED}, + {sfBorrower, soeREQUIRED}, + {sfLoanOriginationFee, soeDEFAULT}, + {sfLoanServiceFee, soeDEFAULT}, + {sfLatePaymentFee, soeDEFAULT}, + {sfClosePaymentFee, soeDEFAULT}, + {sfOverpaymentFee, soeDEFAULT}, + {sfInterestRate, soeDEFAULT}, + {sfLateInterestRate, soeDEFAULT}, + {sfCloseInterestRate, soeDEFAULT}, + {sfOverpaymentInterestRate, soeDEFAULT}, + {sfStartDate, soeREQUIRED}, + {sfPaymentInterval, soeREQUIRED}, + {sfGracePeriod, soeDEFAULT}, + {sfPreviousPaymentDate, soeDEFAULT}, + {sfNextPaymentDueDate, soeDEFAULT}, + // The loan object tracks these values: + // + // - PaymentRemaining: The number of payments left in the loan. When it + // reaches 0, the loan is paid off, and all other relevant values + // must also be 0. + // + // - PeriodicPayment: The fixed, unrounded amount to be paid each + // interval. Stored with as much precision as possible. + // Payment transactions must round this value *UP*. + // + // - TotalValueOutstanding: The rounded total amount owed by the + // borrower to the lender / vault. + // + // - PrincipalOutstanding: The rounded portion of the + // TotalValueOutstanding that is from the principal borrowed. + // + // - ManagementFeeOutstanding: The rounded portion of the + // TotalValueOutstanding that represents management fees + // specifically owed to the broker based on the initial + // loan parameters. + // + // There are additional values that can be computed from these: + // + // - InterestOutstanding = TotalValueOutstanding - PrincipalOutstanding + // The total amount of interest still pending on the loan, + // independent of management fees. + // + // - InterestOwedToVault = InterestOutstanding - ManagementFeeOutstanding + // The amount of the total interest that is owed to the vault, and + // will be sent to it as part of a payment. + // + // - TrueTotalLoanValue = PaymentRemaining * PeriodicPayment + // The unrounded true total value of the loan. + // + // - TrueTotalPrincialOutstanding can be computed using the algorithm + // in the ripple::detail::loanPrincipalFromPeriodicPayment function. + // + // - TrueTotalInterestOutstanding = TrueTotalLoanValue - + // TrueTotalPrincipalOutstanding + // The unrounded true total interest remaining. + // + // - TrueTotalManagementFeeOutstanding = TrueTotalInterestOutstanding * + // LoanBroker.ManagementFeeRate + // The unrounded true total fee still owed to the broker. + // + // Note the the "True" values may differ significantly from the tracked + // rounded values. + {sfPaymentRemaining, soeDEFAULT}, + {sfPeriodicPayment, soeREQUIRED}, + {sfPrincipalOutstanding, soeDEFAULT}, + {sfTotalValueOutstanding, soeDEFAULT}, + {sfManagementFeeOutstanding, soeDEFAULT}, + // Based on the computed total value at creation, used for + // rounding calculated values so they are all on a + // consistent scale - that is, they all have the same + // number of digits after the decimal point (excluding + // trailing zeros). + {sfLoanScale, soeDEFAULT}, +})) + #undef EXPAND #undef LEDGER_ENTRY_DUPLICATE diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index f932ae2328..086cda0e08 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -24,6 +24,8 @@ #error "undefined macro: TYPED_SFIELD" #endif +// clang-format off + // untyped UNTYPED_SFIELD(sfLedgerEntry, LEDGERENTRY, 257) UNTYPED_SFIELD(sfTransaction, TRANSACTION, 257) @@ -59,6 +61,7 @@ TYPED_SFIELD(sfHookEmitCount, UINT16, 18) TYPED_SFIELD(sfHookExecutionIndex, UINT16, 19) TYPED_SFIELD(sfHookApiVersion, UINT16, 20) TYPED_SFIELD(sfLedgerFixType, UINT16, 21) +TYPED_SFIELD(sfManagementFeeRate, UINT16, 22) // 1/10 basis points (bips) // 32-bit integers (common) TYPED_SFIELD(sfNetworkID, UINT32, 1) @@ -115,6 +118,21 @@ TYPED_SFIELD(sfFirstNFTokenSequence, UINT32, 50) TYPED_SFIELD(sfOracleDocumentID, UINT32, 51) TYPED_SFIELD(sfPermissionValue, UINT32, 52) TYPED_SFIELD(sfMutableFlags, UINT32, 53) +TYPED_SFIELD(sfStartDate, UINT32, 54) +TYPED_SFIELD(sfPaymentInterval, UINT32, 55) +TYPED_SFIELD(sfGracePeriod, UINT32, 56) +TYPED_SFIELD(sfPreviousPaymentDate, UINT32, 57) +TYPED_SFIELD(sfNextPaymentDueDate, UINT32, 58) +TYPED_SFIELD(sfPaymentRemaining, UINT32, 59) +TYPED_SFIELD(sfPaymentTotal, UINT32, 60) +TYPED_SFIELD(sfLoanSequence, UINT32, 61) +TYPED_SFIELD(sfCoverRateMinimum, UINT32, 62) // 1/10 basis points (bips) +TYPED_SFIELD(sfCoverRateLiquidation, UINT32, 63) // 1/10 basis points (bips) +TYPED_SFIELD(sfOverpaymentFee, UINT32, 64) // 1/10 basis points (bips) +TYPED_SFIELD(sfInterestRate, UINT32, 65) // 1/10 basis points (bips) +TYPED_SFIELD(sfLateInterestRate, UINT32, 66) // 1/10 basis points (bips) +TYPED_SFIELD(sfCloseInterestRate, UINT32, 67) // 1/10 basis points (bips) +TYPED_SFIELD(sfOverpaymentInterestRate, UINT32, 68) // 1/10 basis points (bips) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) @@ -146,6 +164,8 @@ TYPED_SFIELD(sfMPTAmount, UINT64, 26, SField::sMD_BaseTen|SFie TYPED_SFIELD(sfIssuerNode, UINT64, 27) TYPED_SFIELD(sfSubjectNode, UINT64, 28) TYPED_SFIELD(sfLockedAmount, UINT64, 29, SField::sMD_BaseTen|SField::sMD_Default) +TYPED_SFIELD(sfVaultNode, UINT64, 30) +TYPED_SFIELD(sfLoanBrokerNode, UINT64, 31) // 128-bit TYPED_SFIELD(sfEmailHash, UINT128, 1) @@ -200,6 +220,9 @@ TYPED_SFIELD(sfDomainID, UINT256, 34) TYPED_SFIELD(sfVaultID, UINT256, 35, SField::sMD_PseudoAccount | SField::sMD_Default) TYPED_SFIELD(sfParentBatchID, UINT256, 36) +TYPED_SFIELD(sfLoanBrokerID, UINT256, 37, + SField::sMD_PseudoAccount | SField::sMD_Default) +TYPED_SFIELD(sfLoanID, UINT256, 38) // number (common) TYPED_SFIELD(sfNumber, NUMBER, 1) @@ -207,12 +230,21 @@ TYPED_SFIELD(sfAssetsAvailable, NUMBER, 2) TYPED_SFIELD(sfAssetsMaximum, NUMBER, 3) TYPED_SFIELD(sfAssetsTotal, NUMBER, 4) TYPED_SFIELD(sfLossUnrealized, NUMBER, 5) +TYPED_SFIELD(sfDebtTotal, NUMBER, 6) +TYPED_SFIELD(sfDebtMaximum, NUMBER, 7) +TYPED_SFIELD(sfCoverAvailable, NUMBER, 8) +TYPED_SFIELD(sfLoanOriginationFee, NUMBER, 9) +TYPED_SFIELD(sfLoanServiceFee, NUMBER, 10) +TYPED_SFIELD(sfLatePaymentFee, NUMBER, 11) +TYPED_SFIELD(sfClosePaymentFee, NUMBER, 12) +TYPED_SFIELD(sfPrincipalOutstanding, NUMBER, 13) +TYPED_SFIELD(sfPrincipalRequested, NUMBER, 14) +TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15) +TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16) +TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17) // int32 -// NOTE: Do not use `sfDummyInt32`. It's so far the only use of INT32 -// in this file and has been defined here for test only. -// TODO: Replace `sfDummyInt32` with actually useful field. -TYPED_SFIELD(sfDummyInt32, INT32, 1) // for tests only +TYPED_SFIELD(sfLoanScale, INT32, 1) // currency amount (common) TYPED_SFIELD(sfAmount, AMOUNT, 1) @@ -308,6 +340,8 @@ TYPED_SFIELD(sfAttestationRewardAccount, ACCOUNT, 21) TYPED_SFIELD(sfLockingChainDoor, ACCOUNT, 22) TYPED_SFIELD(sfIssuingChainDoor, ACCOUNT, 23) TYPED_SFIELD(sfSubject, ACCOUNT, 24) +TYPED_SFIELD(sfBorrower, ACCOUNT, 25) +TYPED_SFIELD(sfCounterparty, ACCOUNT, 26) // vector of 256-bit TYPED_SFIELD(sfIndexes, VECTOR256, 1, SField::sMD_Never) @@ -371,6 +405,7 @@ UNTYPED_SFIELD(sfCredential, OBJECT, 33) UNTYPED_SFIELD(sfRawTransaction, OBJECT, 34) UNTYPED_SFIELD(sfBatchSigner, OBJECT, 35) UNTYPED_SFIELD(sfBook, OBJECT, 36) +UNTYPED_SFIELD(sfCounterpartySignature, OBJECT, 37, SField::sMD_Default, SField::notSigning) // array of objects (common) // ARRAY/1 is reserved for end of array @@ -405,3 +440,5 @@ UNTYPED_SFIELD(sfAcceptedCredentials, ARRAY, 28) UNTYPED_SFIELD(sfPermissions, ARRAY, 29) UNTYPED_SFIELD(sfRawTransactions, ARRAY, 30) UNTYPED_SFIELD(sfBatchSigners, ARRAY, 31, SField::sMD_Default, SField::notSigning) + +// clang-format on diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 119c3f8b7b..36c837c02d 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -944,6 +944,139 @@ TRANSACTION(ttBATCH, 71, Batch, {sfBatchSigners, soeOPTIONAL}, })) +/** Reserve 72-73 for future Vault-related transactions */ + +/** This transaction creates and updates a Loan Broker */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, + Delegation::delegatable, + featureLendingProtocol, + createPseudoAcct | mayAuthorizeMPT, ({ + {sfVaultID, soeREQUIRED}, + {sfLoanBrokerID, soeOPTIONAL}, + {sfData, soeOPTIONAL}, + {sfManagementFeeRate, soeOPTIONAL}, + {sfDebtMaximum, soeOPTIONAL}, + {sfCoverRateMinimum, soeOPTIONAL}, + {sfCoverRateLiquidation, soeOPTIONAL}, +})) + +/** This transaction deletes a Loan Broker */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_BROKER_DELETE, 75, LoanBrokerDelete, + Delegation::delegatable, + featureLendingProtocol, + mustDeleteAcct | mayAuthorizeMPT, ({ + {sfLoanBrokerID, soeREQUIRED}, +})) + +/** This transaction deposits First Loss Capital into a Loan Broker */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_BROKER_COVER_DEPOSIT, 76, LoanBrokerCoverDeposit, + Delegation::delegatable, + featureLendingProtocol, + noPriv, ({ + {sfLoanBrokerID, soeREQUIRED}, + {sfAmount, soeREQUIRED, soeMPTSupported}, +})) + +/** This transaction withdraws First Loss Capital from a Loan Broker */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, + Delegation::delegatable, + featureLendingProtocol, + mayAuthorizeMPT, ({ + {sfLoanBrokerID, soeREQUIRED}, + {sfAmount, soeREQUIRED, soeMPTSupported}, + {sfDestination, soeOPTIONAL}, + {sfDestinationTag, soeOPTIONAL}, +})) + +/** This transaction claws back First Loss Capital from a Loan Broker to + the issuer of the capital */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_BROKER_COVER_CLAWBACK, 78, LoanBrokerCoverClawback, + Delegation::delegatable, + featureLendingProtocol, + noPriv, ({ + {sfLoanBrokerID, soeOPTIONAL}, + {sfAmount, soeOPTIONAL, soeMPTSupported}, +})) + +/** This transaction creates a Loan */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_SET, 80, LoanSet, + Delegation::delegatable, + featureLendingProtocol, + mayAuthorizeMPT | mustModifyVault, ({ + {sfLoanBrokerID, soeREQUIRED}, + {sfData, soeOPTIONAL}, + {sfCounterparty, soeOPTIONAL}, + {sfCounterpartySignature, soeOPTIONAL}, + {sfLoanOriginationFee, soeOPTIONAL}, + {sfLoanServiceFee, soeOPTIONAL}, + {sfLatePaymentFee, soeOPTIONAL}, + {sfClosePaymentFee, soeOPTIONAL}, + {sfOverpaymentFee, soeOPTIONAL}, + {sfInterestRate, soeOPTIONAL}, + {sfLateInterestRate, soeOPTIONAL}, + {sfCloseInterestRate, soeOPTIONAL}, + {sfOverpaymentInterestRate, soeOPTIONAL}, + {sfPrincipalRequested, soeREQUIRED}, + {sfPaymentTotal, soeOPTIONAL}, + {sfPaymentInterval, soeOPTIONAL}, + {sfGracePeriod, soeOPTIONAL}, +})) + +/** This transaction deletes an existing Loan */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, + Delegation::delegatable, + featureLendingProtocol, + noPriv, ({ + {sfLoanID, soeREQUIRED}, +})) + +/** This transaction is used to change the delinquency status of an existing Loan */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, + Delegation::delegatable, + featureLendingProtocol, + // All of the LoanManage options will modify the vault, but the + // transaction can succeed without options, essentially making it + // a noop. + mayModifyVault, ({ + {sfLoanID, soeREQUIRED}, +})) + +/** The Borrower uses this transaction to make a Payment on the Loan. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_PAY, 84, LoanPay, + Delegation::delegatable, + featureLendingProtocol, + mayAuthorizeMPT | mustModifyVault, ({ + {sfLoanID, soeREQUIRED}, + {sfAmount, soeREQUIRED, soeMPTSupported}, +})) + /** This system-generated transaction type is used to update the status of the various amendments. For details, see: https://xrpl.org/amendments.html diff --git a/include/xrpl/protocol/jss.h b/include/xrpl/protocol/jss.h index bb5af1fc4d..733985d331 100644 --- a/include/xrpl/protocol/jss.h +++ b/include/xrpl/protocol/jss.h @@ -59,6 +59,8 @@ JSS(BaseAsset); // in: Oracle JSS(BidMax); // in: AMM Bid JSS(BidMin); // in: AMM Bid JSS(ClearFlag); // field. +JSS(Counterparty); // field. +JSS(CounterpartySignature);// field. JSS(DeliverMax); // out: alias to Amount JSS(DeliverMin); // in: TransactionSign JSS(Destination); // in: TransactionSign; field. @@ -392,6 +394,8 @@ JSS(load_factor_local); // out: NetworkOPs JSS(load_factor_net); // out: NetworkOPs JSS(load_factor_server); // out: NetworkOPs JSS(load_fee); // out: LoadFeeTrackImp, NetworkOPs +JSS(loan_broker_id); // in: LedgerEntry +JSS(loan_seq); // in: LedgerEntry JSS(local); // out: resource/Logic.h JSS(local_txs); // out: GetCounts JSS(local_static_keys); // out: ValidatorList @@ -504,6 +508,7 @@ JSS(propose_seq); // out: LedgerPropose JSS(proposers); // out: NetworkOPs, LedgerConsensus JSS(protocol); // out: NetworkOPs, PeerImp JSS(proxied); // out: RPC ping +JSS(pseudo_account); // out: AccountInfo JSS(pubkey_node); // out: NetworkOPs JSS(pubkey_publisher); // out: ValidatorList JSS(pubkey_validator); // out: NetworkOPs, ValidatorList diff --git a/src/libxrpl/basics/Number.cpp b/src/libxrpl/basics/Number.cpp index f43288b57b..228def4720 100644 --- a/src/libxrpl/basics/Number.cpp +++ b/src/libxrpl/basics/Number.cpp @@ -93,6 +93,18 @@ public: // tie, round towards even. int round() noexcept; + + // Modify the result to the correctly rounded value + void + doRoundUp(rep& mantissa, int& exponent, std::string location); + + // Modify the result to the correctly rounded value + void + doRoundDown(rep& mantissa, int& exponent); + + // Modify the result to the correctly rounded value + void + doRound(rep& drops); }; inline void @@ -170,6 +182,61 @@ Number::Guard::round() noexcept return 0; } +void +Number::Guard::doRoundUp(rep& mantissa, int& exponent, std::string location) +{ + auto r = round(); + if (r == 1 || (r == 0 && (mantissa & 1) == 1)) + { + ++mantissa; + if (mantissa > maxMantissa) + { + mantissa /= 10; + ++exponent; + } + } + if (exponent < minExponent) + { + mantissa = 0; + exponent = Number{}.exponent_; + } + if (exponent > maxExponent) + throw std::overflow_error(location); +} + +void +Number::Guard::doRoundDown(rep& mantissa, int& exponent) +{ + auto r = round(); + if (r == 1 || (r == 0 && (mantissa & 1) == 1)) + { + --mantissa; + if (mantissa < minMantissa) + { + mantissa *= 10; + --exponent; + } + } + if (exponent < minExponent) + { + mantissa = 0; + exponent = Number{}.exponent_; + } +} + +// Modify the result to the correctly rounded value +void +Number::Guard::doRound(rep& drops) +{ + auto r = round(); + if (r == 1 || (r == 0 && (drops & 1) == 1)) + { + ++drops; + } + if (is_negative()) + drops = -drops; +} + // Number constexpr Number one{1000000000000000, -15, Number::unchecked{}}; @@ -209,18 +276,7 @@ Number::normalize() return; } - auto r = g.round(); - if (r == 1 || (r == 0 && (mantissa_ & 1) == 1)) - { - ++mantissa_; - if (mantissa_ > maxMantissa) - { - mantissa_ /= 10; - ++exponent_; - } - } - if (exponent_ > maxExponent) - throw std::overflow_error("Number::normalize 2"); + g.doRoundUp(mantissa_, exponent_, "Number::normalize 2"); if (negative) mantissa_ = -mantissa_; @@ -292,18 +348,7 @@ Number::operator+=(Number const& y) xm /= 10; ++xe; } - auto r = g.round(); - if (r == 1 || (r == 0 && (xm & 1) == 1)) - { - ++xm; - if (xm > maxMantissa) - { - xm /= 10; - ++xe; - } - } - if (xe > maxExponent) - throw std::overflow_error("Number::addition overflow"); + g.doRoundUp(xm, xe, "Number::addition overflow"); } else { @@ -323,21 +368,7 @@ Number::operator+=(Number const& y) xm -= g.pop(); --xe; } - auto r = g.round(); - if (r == 1 || (r == 0 && (xm & 1) == 1)) - { - --xm; - if (xm < minMantissa) - { - xm *= 10; - --xe; - } - } - if (xe < minExponent) - { - xm = 0; - xe = Number{}.exponent_; - } + g.doRoundDown(xm, xe); } mantissa_ = xm * xn; exponent_ = xe; @@ -417,25 +448,10 @@ Number::operator*=(Number const& y) } xm = static_cast(zm); xe = ze; - auto r = g.round(); - if (r == 1 || (r == 0 && (xm & 1) == 1)) - { - ++xm; - if (xm > maxMantissa) - { - xm /= 10; - ++xe; - } - } - if (xe < minExponent) - { - xm = 0; - xe = Number{}.exponent_; - } - if (xe > maxExponent) - throw std::overflow_error( - "Number::multiplication overflow : exponent is " + - std::to_string(xe)); + g.doRoundUp( + xm, + xe, + "Number::multiplication overflow : exponent is " + std::to_string(xe)); mantissa_ = xm * zn; exponent_ = xe; XRPL_ASSERT( @@ -500,17 +516,29 @@ Number::operator rep() const throw std::overflow_error("Number::operator rep() overflow"); drops *= 10; } - auto r = g.round(); - if (r == 1 || (r == 0 && (drops & 1) == 1)) - { - ++drops; - } - if (g.is_negative()) - drops = -drops; + g.doRound(drops); } return drops; } +Number +Number::truncate() const noexcept +{ + if (exponent_ >= 0 || mantissa_ == 0) + return *this; + + Number ret = *this; + while (ret.exponent_ < 0 && ret.mantissa_ != 0) + { + ret.exponent_ += 1; + ret.mantissa_ /= rep(10); + } + // We are guaranteed that normalize() will never throw an exception + // because exponent is either negative or zero at this point. + ret.normalize(); + return ret; +} + std::string to_string(Number const& amount) { diff --git a/src/libxrpl/json/json_value.cpp b/src/libxrpl/json/json_value.cpp index 527aa6ba44..88cc8e8483 100644 --- a/src/libxrpl/json/json_value.cpp +++ b/src/libxrpl/json/json_value.cpp @@ -1061,6 +1061,12 @@ Value::isMember(std::string const& key) const return isMember(key.c_str()); } +bool +Value::isMember(StaticString const& key) const +{ + return isMember(key.c_str()); +} + Value::Members Value::getMemberNames() const { diff --git a/src/libxrpl/ledger/ApplyView.cpp b/src/libxrpl/ledger/ApplyView.cpp index bbc8f317ce..c5cb990a81 100644 --- a/src/libxrpl/ledger/ApplyView.cpp +++ b/src/libxrpl/ledger/ApplyView.cpp @@ -27,73 +27,95 @@ namespace ripple { -std::optional -ApplyView::dirAdd( - bool preserveOrder, +namespace directory { + +std::uint64_t +createRoot( + ApplyView& view, Keylet const& directory, uint256 const& key, std::function const&)> const& describe) { - auto root = peek(directory); + auto newRoot = std::make_shared(directory); + newRoot->setFieldH256(sfRootIndex, directory.key); + describe(newRoot); - if (!root) - { - // No root, make it. - root = std::make_shared(directory); - root->setFieldH256(sfRootIndex, directory.key); - describe(root); + STVector256 v; + v.push_back(key); + newRoot->setFieldV256(sfIndexes, v); - STVector256 v; - v.push_back(key); - root->setFieldV256(sfIndexes, v); + view.insert(newRoot); + return std::uint64_t{0}; +} - insert(root); - return std::uint64_t{0}; - } +auto +findPreviousPage(ApplyView& view, Keylet const& directory, SLE::ref start) +{ + std::uint64_t page = start->getFieldU64(sfIndexPrevious); - std::uint64_t page = root->getFieldU64(sfIndexPrevious); - - auto node = root; + auto node = start; if (page) { - node = peek(keylet::page(directory, page)); + node = view.peek(keylet::page(directory, page)); if (!node) + { // LCOV_EXCL_START LogicError("Directory chain: root back-pointer broken."); + // LCOV_EXCL_STOP + } } auto indexes = node->getFieldV256(sfIndexes); + return std::make_tuple(page, node, indexes); +} - // If there's space, we use it: - if (indexes.size() < dirNodeMaxEntries) +std::uint64_t +insertKey( + ApplyView& view, + SLE::ref node, + std::uint64_t page, + bool preserveOrder, + STVector256& indexes, + uint256 const& key) +{ + if (preserveOrder) { - if (preserveOrder) - { - if (std::find(indexes.begin(), indexes.end(), key) != indexes.end()) - LogicError("dirInsert: double insertion"); + if (std::find(indexes.begin(), indexes.end(), key) != indexes.end()) + LogicError("dirInsert: double insertion"); // LCOV_EXCL_LINE - indexes.push_back(key); - } - else - { - // We can't be sure if this page is already sorted because - // it may be a legacy page we haven't yet touched. Take - // the time to sort it. - std::sort(indexes.begin(), indexes.end()); + indexes.push_back(key); + } + else + { + // We can't be sure if this page is already sorted because + // it may be a legacy page we haven't yet touched. Take + // the time to sort it. + std::sort(indexes.begin(), indexes.end()); - auto pos = std::lower_bound(indexes.begin(), indexes.end(), key); + auto pos = std::lower_bound(indexes.begin(), indexes.end(), key); - if (pos != indexes.end() && key == *pos) - LogicError("dirInsert: double insertion"); + if (pos != indexes.end() && key == *pos) + LogicError("dirInsert: double insertion"); // LCOV_EXCL_LINE - indexes.insert(pos, key); - } - - node->setFieldV256(sfIndexes, indexes); - update(node); - return page; + indexes.insert(pos, key); } + node->setFieldV256(sfIndexes, indexes); + view.update(node); + return page; +} + +std::optional +insertPage( + ApplyView& view, + std::uint64_t page, + SLE::pointer node, + std::uint64_t nextPage, + SLE::ref next, + uint256 const& key, + Keylet const& directory, + std::function const&)> const& describe) +{ // We rely on modulo arithmetic of unsigned integers (guaranteed in // [basic.fundamental] paragraph 2) to detect page representation overflow. // For signed integers this would be UB, hence static_assert here. @@ -107,20 +129,20 @@ ApplyView::dirAdd( // Check whether we're out of pages. if (page == 0) return std::nullopt; - if (!rules().enabled(fixDirectoryLimit) && + if (!view.rules().enabled(fixDirectoryLimit) && page >= dirNodeMaxPages) // Old pages limit return std::nullopt; // We are about to create a new node; we'll link it to // the chain first: node->setFieldU64(sfIndexNext, page); - update(node); + view.update(node); - root->setFieldU64(sfIndexPrevious, page); - update(root); + next->setFieldU64(sfIndexPrevious, page); + view.update(next); // Insert the new key: - indexes.clear(); + STVector256 indexes; indexes.push_back(key); node = std::make_shared(keylet::page(directory, page)); @@ -131,12 +153,52 @@ ApplyView::dirAdd( // it's the default. if (page != 1) node->setFieldU64(sfIndexPrevious, page - 1); + XRPL_ASSERT_PARTS( + !nextPage, + "ripple::directory::insertPage", + "nextPage has default value"); + /* Reserved for future use when directory pages may be inserted in + * between two other pages instead of only at the end of the chain. + if (nextPage) + node->setFieldU64(sfIndexNext, nextPage); + */ describe(node); - insert(node); + view.insert(node); return page; } +} // namespace directory + +std::optional +ApplyView::dirAdd( + bool preserveOrder, + Keylet const& directory, + uint256 const& key, + std::function const&)> const& describe) +{ + auto root = peek(directory); + + if (!root) + { + // No root, make it. + return directory::createRoot(*this, directory, key, describe); + } + + auto [page, node, indexes] = + directory::findPreviousPage(*this, directory, root); + + // If there's space, we use it: + if (indexes.size() < dirNodeMaxEntries) + { + return directory::insertKey( + *this, node, page, preserveOrder, indexes, key); + } + + return directory::insertPage( + *this, page, node, 0, root, key, directory, describe); +} + bool ApplyView::emptyDirDelete(Keylet const& directory) { @@ -164,10 +226,10 @@ ApplyView::emptyDirDelete(Keylet const& directory) auto nextPage = node->getFieldU64(sfIndexNext); if (nextPage == rootPage && prevPage != rootPage) - LogicError("Directory chain: fwd link broken"); + LogicError("Directory chain: fwd link broken"); // LCOV_EXCL_LINE if (prevPage == rootPage && nextPage != rootPage) - LogicError("Directory chain: rev link broken"); + LogicError("Directory chain: rev link broken"); // LCOV_EXCL_LINE // Older versions of the code would, in some cases, allow the last // page to be empty. Remove such pages: @@ -176,7 +238,10 @@ ApplyView::emptyDirDelete(Keylet const& directory) auto last = peek(keylet::page(directory, nextPage)); if (!last) + { // LCOV_EXCL_START LogicError("Directory chain: fwd link broken."); + // LCOV_EXCL_STOP + } if (!last->getFieldV256(sfIndexes).empty()) return false; @@ -248,10 +313,16 @@ ApplyView::dirRemove( if (page == rootPage) { if (nextPage == page && prevPage != page) + { // LCOV_EXCL_START LogicError("Directory chain: fwd link broken"); + // LCOV_EXCL_STOP + } if (prevPage == page && nextPage != page) + { // LCOV_EXCL_START LogicError("Directory chain: rev link broken"); + // LCOV_EXCL_STOP + } // Older versions of the code would, in some cases, // allow the last page to be empty. Remove such @@ -260,7 +331,10 @@ ApplyView::dirRemove( { auto last = peek(keylet::page(directory, nextPage)); if (!last) + { // LCOV_EXCL_START LogicError("Directory chain: fwd link broken."); + // LCOV_EXCL_STOP + } if (last->getFieldV256(sfIndexes).empty()) { @@ -292,10 +366,10 @@ ApplyView::dirRemove( // This can never happen for nodes other than the root: if (nextPage == page) - LogicError("Directory chain: fwd link broken"); + LogicError("Directory chain: fwd link broken"); // LCOV_EXCL_LINE if (prevPage == page) - LogicError("Directory chain: rev link broken"); + LogicError("Directory chain: rev link broken"); // LCOV_EXCL_LINE // This node isn't the root, so it can either be in the // middle of the list, or at the end. Unlink it first @@ -303,14 +377,14 @@ ApplyView::dirRemove( // root: auto prev = peek(keylet::page(directory, prevPage)); if (!prev) - LogicError("Directory chain: fwd link broken."); + LogicError("Directory chain: fwd link broken."); // LCOV_EXCL_LINE // Fix previous to point to its new next. prev->setFieldU64(sfIndexNext, nextPage); update(prev); auto next = peek(keylet::page(directory, nextPage)); if (!next) - LogicError("Directory chain: rev link broken."); + LogicError("Directory chain: rev link broken."); // LCOV_EXCL_LINE // Fix next to point to its new previous. next->setFieldU64(sfIndexPrevious, prevPage); update(next); @@ -334,7 +408,10 @@ ApplyView::dirRemove( // And the root points to the last page: auto root = peek(keylet::page(directory, rootPage)); if (!root) + { // LCOV_EXCL_START LogicError("Directory chain: root link broken."); + // LCOV_EXCL_STOP + } root->setFieldU64(sfIndexPrevious, prevPage); update(root); diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index 1b093b7017..3db6e296ac 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -383,6 +383,99 @@ isLPTokenFrozen( isFrozen(view, account, asset2.currency, asset2.account); } +static SLE::const_pointer +getLineIfUsable( + ReadView const& view, + AccountID const& account, + Currency const& currency, + AccountID const& issuer, + FreezeHandling zeroIfFrozen, + beast::Journal j) +{ + auto const sle = view.read(keylet::line(account, issuer, currency)); + + if (!sle) + { + return nullptr; + } + + if (zeroIfFrozen == fhZERO_IF_FROZEN) + { + if (isFrozen(view, account, currency, issuer) || + isDeepFrozen(view, account, currency, issuer)) + { + return nullptr; + } + + // when fixFrozenLPTokenTransfer is enabled, if currency is lptoken, + // we need to check if the associated assets have been frozen + if (view.rules().enabled(fixFrozenLPTokenTransfer)) + { + auto const sleIssuer = view.read(keylet::account(issuer)); + if (!sleIssuer) + { + return nullptr; // LCOV_EXCL_LINE + } + else if (sleIssuer->isFieldPresent(sfAMMID)) + { + auto const sleAmm = + view.read(keylet::amm((*sleIssuer)[sfAMMID])); + + if (!sleAmm || + isLPTokenFrozen( + view, + account, + (*sleAmm)[sfAsset].get(), + (*sleAmm)[sfAsset2].get())) + { + return nullptr; + } + } + } + } + + return sle; +} + +static STAmount +getTrustLineBalance( + ReadView const& view, + SLE::const_ref sle, + AccountID const& account, + Currency const& currency, + AccountID const& issuer, + bool includeOppositeLimit, + beast::Journal j) +{ + STAmount amount; + if (sle) + { + amount = sle->getFieldAmount(sfBalance); + bool const accountHigh = account > issuer; + auto const& oppositeField = accountHigh ? sfLowLimit : sfHighLimit; + if (accountHigh) + { + // Put balance in account terms. + amount.negate(); + } + if (includeOppositeLimit) + { + amount += sle->getFieldAmount(oppositeField); + } + amount.setIssuer(issuer); + } + else + { + amount.clear(Issue{currency, issuer}); + } + + JLOG(j.trace()) << "getTrustLineBalance:" + << " account=" << to_string(account) + << " amount=" << amount.getFullText(); + + return view.balanceHook(account, issuer, amount); +} + STAmount accountHolds( ReadView const& view, @@ -399,71 +492,10 @@ accountHolds( } // IOU: Return balance on trust line modulo freeze - auto const sle = view.read(keylet::line(account, issuer, currency)); - auto const allowBalance = [&]() { - if (!sle) - { - return false; - } + SLE::const_pointer const sle = + getLineIfUsable(view, account, currency, issuer, zeroIfFrozen, j); - if (zeroIfFrozen == fhZERO_IF_FROZEN) - { - if (isFrozen(view, account, currency, issuer) || - isDeepFrozen(view, account, currency, issuer)) - { - return false; - } - - // when fixFrozenLPTokenTransfer is enabled, if currency is lptoken, - // we need to check if the associated assets have been frozen - if (view.rules().enabled(fixFrozenLPTokenTransfer)) - { - auto const sleIssuer = view.read(keylet::account(issuer)); - if (!sleIssuer) - { - return false; // LCOV_EXCL_LINE - } - else if (sleIssuer->isFieldPresent(sfAMMID)) - { - auto const sleAmm = - view.read(keylet::amm((*sleIssuer)[sfAMMID])); - - if (!sleAmm || - isLPTokenFrozen( - view, - account, - (*sleAmm)[sfAsset].get(), - (*sleAmm)[sfAsset2].get())) - { - return false; - } - } - } - } - - return true; - }(); - - if (allowBalance) - { - amount = sle->getFieldAmount(sfBalance); - if (account > issuer) - { - // Put balance in account terms. - amount.negate(); - } - amount.setIssuer(issuer); - } - else - { - amount.clear(Issue{currency, issuer}); - } - - JLOG(j.trace()) << "accountHolds:" - << " account=" << to_string(account) - << " amount=" << amount.getFullText(); - - return view.balanceHook(account, issuer, amount); + return getTrustLineBalance(view, sle, account, currency, issuer, false, j); } STAmount @@ -550,6 +582,96 @@ accountHolds( asset.value()); } +STAmount +accountSpendable( + ReadView const& view, + AccountID const& account, + Currency const& currency, + AccountID const& issuer, + FreezeHandling zeroIfFrozen, + beast::Journal j) +{ + if (isXRP(currency)) + return accountHolds(view, account, currency, issuer, zeroIfFrozen, j); + + if (account == issuer) + // If the account is the issuer, then their limit is effectively + // infinite + return STAmount{ + Issue{currency, issuer}, STAmount::cMaxValue, STAmount::cMaxOffset}; + + // IOU: Return balance on trust line modulo freeze + SLE::const_pointer const sle = + getLineIfUsable(view, account, currency, issuer, zeroIfFrozen, j); + + return getTrustLineBalance(view, sle, account, currency, issuer, true, j); +} + +STAmount +accountSpendable( + ReadView const& view, + AccountID const& account, + Issue const& issue, + FreezeHandling zeroIfFrozen, + beast::Journal j) +{ + return accountSpendable( + view, account, issue.currency, issue.account, zeroIfFrozen, j); +} + +STAmount +accountSpendable( + ReadView const& view, + AccountID const& account, + MPTIssue const& mptIssue, + FreezeHandling zeroIfFrozen, + AuthHandling zeroIfUnauthorized, + beast::Journal j) +{ + if (account == mptIssue.getIssuer()) + { + // if the account is the issuer, and the issuance exists, their limit is + // the issuance limit minus the outstanding value + auto const issuance = + view.read(keylet::mptIssuance(mptIssue.getMptID())); + + if (!issuance) + { + return STAmount{mptIssue}; + } + return STAmount{ + mptIssue, + issuance->at(~sfMaximumAmount).value_or(maxMPTokenAmount) - + issuance->at(sfOutstandingAmount)}; + } + + return accountHolds( + view, account, mptIssue, zeroIfFrozen, zeroIfUnauthorized, j); +} + +[[nodiscard]] STAmount +accountSpendable( + ReadView const& view, + AccountID const& account, + Asset const& asset, + FreezeHandling zeroIfFrozen, + AuthHandling zeroIfUnauthorized, + beast::Journal j) +{ + return std::visit( + [&](auto const& value) { + if constexpr (std::is_same_v< + std::remove_cvref_t, + Issue>) + { + return accountSpendable(view, account, value, zeroIfFrozen, j); + } + return accountSpendable( + view, account, value, zeroIfFrozen, zeroIfUnauthorized, j); + }, + asset.value()); +} + STAmount accountFunds( ReadView const& view, @@ -1055,13 +1177,17 @@ describeOwnerDir(AccountID const& account) } TER -dirLink(ApplyView& view, AccountID const& owner, std::shared_ptr& object) +dirLink( + ApplyView& view, + AccountID const& owner, + std::shared_ptr& object, + SF_UINT64 const& node) { auto const page = view.dirInsert( keylet::ownerDir(owner), object->key(), describeOwnerDir(owner)); if (!page) return tecDIR_FULL; // LCOV_EXCL_LINE - object->setFieldU64(sfOwnerNode, *page); + object->setFieldU64(node, *page); return tesSUCCESS; } @@ -1097,7 +1223,8 @@ getPseudoAccountFields() { // LCOV_EXCL_START LogicError( - "ripple::isPseudoAccount : unable to find account root ledger " + "ripple::getPseudoAccountFields : unable to find account root " + "ledger " "format"); // LCOV_EXCL_STOP } @@ -1115,7 +1242,9 @@ getPseudoAccountFields() } [[nodiscard]] bool -isPseudoAccount(std::shared_ptr sleAcct) +isPseudoAccount( + std::shared_ptr sleAcct, + std::set const& pseudoFieldFilter) { auto const& fields = getPseudoAccountFields(); @@ -1123,8 +1252,12 @@ isPseudoAccount(std::shared_ptr sleAcct) // semantics of true return value clean. return sleAcct && sleAcct->getType() == ltACCOUNT_ROOT && std::count_if( - fields.begin(), fields.end(), [&sleAcct](SField const* sf) -> bool { - return sleAcct->isFieldPresent(*sf); + fields.begin(), + fields.end(), + [&sleAcct, &pseudoFieldFilter](SField const* sf) -> bool { + return sleAcct->isFieldPresent(*sf) && + (pseudoFieldFilter.empty() || + pseudoFieldFilter.contains(sf)); }) > 0; } @@ -1134,7 +1267,8 @@ createPseudoAccount( uint256 const& pseudoOwnerKey, SField const& ownerField) { - [[maybe_unused]] auto const& fields = getPseudoAccountFields(); + [[maybe_unused]] + auto const& fields = getPseudoAccountFields(); XRPL_ASSERT( std::count_if( fields.begin(), @@ -1156,9 +1290,10 @@ createPseudoAccount( // Pseudo-accounts can't submit transactions, so set the sequence number // to 0 to make them easier to spot and verify, and add an extra level // of protection. - std::uint32_t const seqno = // - view.rules().enabled(featureSingleAssetVault) // - ? 0 // + std::uint32_t const seqno = // + view.rules().enabled(featureSingleAssetVault) || // + view.rules().enabled(featureLendingProtocol) // + ? 0 // : view.seq(); account->setFieldU32(sfSequence, seqno); // Ignore reserves requirement, disable the master key, allow default @@ -1212,6 +1347,113 @@ canAddHolding(ReadView const& view, Asset const& asset) asset.value()); } +[[nodiscard]] TER +checkDestinationAndTag(SLE::const_ref toSle, bool hasDestinationTag) +{ + if (toSle == nullptr) + return tecNO_DST; + + // The tag is basically account-specific information we don't + // understand, but we can require someone to fill it in. + if (toSle->isFlag(lsfRequireDestTag) && !hasDestinationTag) + return tecDST_TAG_NEEDED; // Cannot send without a tag + + return tesSUCCESS; +} + +[[nodiscard]] TER +canWithdraw( + AccountID const& from, + ReadView const& view, + AccountID const& to, + SLE::const_ref toSle, + bool hasDestinationTag) +{ + if (auto const ret = checkDestinationAndTag(toSle, hasDestinationTag)) + return ret; + + if (from == to) + return tesSUCCESS; + + if (toSle->isFlag(lsfDepositAuth)) + { + if (!view.exists(keylet::depositPreauth(to, from))) + return tecNO_PERMISSION; + } + + return tesSUCCESS; +} + +[[nodiscard]] TER +canWithdraw( + AccountID const& from, + ReadView const& view, + AccountID const& to, + bool hasDestinationTag) +{ + auto const toSle = view.read(keylet::account(to)); + + return canWithdraw(from, view, to, toSle, hasDestinationTag); +} + +[[nodiscard]] TER +canWithdraw(ReadView const& view, STTx const& tx) +{ + auto const from = tx[sfAccount]; + auto const to = tx[~sfDestination].value_or(from); + + return canWithdraw(from, view, to, tx.isFieldPresent(sfDestinationTag)); +} + +TER +doWithdraw( + ApplyView& view, + STTx const& tx, + AccountID const& senderAcct, + AccountID const& dstAcct, + AccountID const& sourceAcct, + XRPAmount priorBalance, + STAmount const& amount, + beast::Journal j) +{ + // Create trust line or MPToken for the receiving account + if (dstAcct == senderAcct) + { + if (auto const ter = addEmptyHolding( + view, senderAcct, priorBalance, amount.asset(), j); + !isTesSuccess(ter) && ter != tecDUPLICATE) + return ter; + } + else + { + auto dstSle = view.peek(keylet::account(dstAcct)); + if (auto err = + verifyDepositPreauth(tx, view, senderAcct, dstAcct, dstSle, j)) + return err; + } + + // Sanity check + if (accountHolds( + view, + sourceAcct, + amount.asset(), + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j) < amount) + { + // LCOV_EXCL_START + JLOG(j.error()) << "LoanBrokerCoverWithdraw: negative balance of " + "broker cover assets."; + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + // Move the funds directly from the broker's pseudo-account to the + // dstAcct + return accountSend( + view, sourceAcct, dstAcct, amount, j, WaiveTransferFee::Yes); +} + [[nodiscard]] TER addEmptyHolding( ApplyView& view, @@ -1220,8 +1462,8 @@ addEmptyHolding( Issue const& issue, beast::Journal journal) { - // Every account can hold XRP. - if (issue.native()) + // Every account can hold XRP. An issuer can issue directly. + if (issue.native() || accountID == issue.getIssuer()) return tesSUCCESS; auto const& issuerId = issue.getIssuer(); @@ -1282,6 +1524,8 @@ addEmptyHolding( return tefINTERNAL; // LCOV_EXCL_LINE if (view.peek(keylet::mptoken(mptID, accountID))) return tecDUPLICATE; + if (accountID == mptIssue.getIssuer()) + return tesSUCCESS; return authorizeMPToken(view, priorBalance, mptID, accountID, journal); } @@ -1345,6 +1589,18 @@ authorizeMPToken( if (priorBalance < reserveCreate) return tecINSUFFICIENT_RESERVE; + // Defensive check before we attempt to create MPToken for the issuer + auto const mpt = view.read(keylet::mptIssuance(mptIssuanceID)); + if (!mpt || mpt->getAccountID(sfIssuer) == account) + { + // LCOV_EXCL_START + UNREACHABLE( + "ripple::authorizeMPToken : invalid issuance or issuers token"); + if (view.rules().enabled(featureLendingProtocol)) + return tecINTERNAL; + // LCOV_EXCL_STOP + } + auto const mptokenKey = keylet::mptoken(mptIssuanceID, account); auto mptoken = std::make_shared(mptokenKey); if (auto ter = dirLink(view, account, mptoken)) @@ -1420,6 +1676,14 @@ trustCreate( auto const& uLowAccountID = !bSrcHigh ? uSrcAccountID : uDstAccountID; auto const& uHighAccountID = bSrcHigh ? uSrcAccountID : uDstAccountID; + if (uLowAccountID == uHighAccountID) + { + // LCOV_EXCL_START + UNREACHABLE("ripple::trustCreate : trust line to self"); + if (view.rules().enabled(featureLendingProtocol)) + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const sleRippleState = std::make_shared(ltRIPPLE_STATE, uIndex); view.insert(sleRippleState); @@ -1534,10 +1798,13 @@ removeEmptyHolding( } // `asset` is an IOU. + // If the account is the issuer, then no line should exist. Check anyway. If + // a line does exist, it will get deleted. If not, return success. + bool const accountIsIssuer = accountID == issue.account; auto const line = view.peek(keylet::line(accountID, issue)); if (!line) - return tecOBJECT_NOT_FOUND; - if (line->at(sfBalance)->iou() != beast::zero) + return accountIsIssuer ? (TER)tesSUCCESS : (TER)tecOBJECT_NOT_FOUND; + if (!accountIsIssuer && line->at(sfBalance)->iou() != beast::zero) return tecHAS_OBLIGATIONS; // Adjust the owner count(s) @@ -1586,10 +1853,18 @@ removeEmptyHolding( MPTIssue const& mptIssue, beast::Journal journal) { + // If the account is the issuer, then no token should exist. MPTs do not + // have the legacy ability to create such a situation, but check anyway. If + // a token does exist, it will get deleted. If not, return success. + bool const accountIsIssuer = accountID == mptIssue.getIssuer(); auto const& mptID = mptIssue.getMptID(); auto const mptoken = view.peek(keylet::mptoken(mptID, accountID)); if (!mptoken) - return tecOBJECT_NOT_FOUND; + return accountIsIssuer ? (TER)tesSUCCESS : (TER)tecOBJECT_NOT_FOUND; + // Unlike a trust line, if the account is the issuer, and the token has a + // balance, it can not just be deleted, because that will throw the issuance + // accounting out of balance, so fail. Since this should be impossible + // anyway, I'm not going to put any effort into it. if (mptoken->at(sfMPTAmount) != 0) return tecHAS_OBLIGATIONS; @@ -1868,7 +2143,7 @@ rippleSendIOU( beast::Journal j, WaiveTransferFee waiveFee) { - auto const issuer = saAmount.getIssuer(); + auto const& issuer = saAmount.getIssuer(); XRPL_ASSERT( !isXRP(uSenderID) && !isXRP(uReceiverID), @@ -1910,6 +2185,87 @@ rippleSendIOU( return terResult; } +// Send regardless of limits. +// --> receivers: Amount/currency/issuer to deliver to receivers. +// <-- saActual: Amount actually cost to sender. Sender pays fees. +static TER +rippleSendMultiIOU( + ApplyView& view, + AccountID const& senderID, + Issue const& issue, + MultiplePaymentDestinations const& receivers, + STAmount& actual, + beast::Journal j, + WaiveTransferFee waiveFee) +{ + auto const& issuer = issue.getIssuer(); + + XRPL_ASSERT( + !isXRP(senderID), "ripple::rippleSendMultiIOU : sender is not XRP"); + + // These may diverge + STAmount takeFromSender{issue}; + actual = takeFromSender; + + // Failures return immediately. + for (auto const& r : receivers) + { + auto const& receiverID = r.first; + STAmount amount{issue, r.second}; + + /* If we aren't sending anything or if the sender is the same as the + * receiver then we don't need to do anything. + */ + if (!amount || (senderID == receiverID)) + continue; + + XRPL_ASSERT( + !isXRP(receiverID), + "ripple::rippleSendMultiIOU : receiver is not XRP"); + + if (senderID == issuer || receiverID == issuer || issuer == noAccount()) + { + // Direct send: redeeming IOUs and/or sending own IOUs. + if (auto const ter = rippleCreditIOU( + view, senderID, receiverID, amount, false, j)) + return ter; + actual += amount; + // Do not add amount to takeFromSender, because rippleCreditIOU took + // it. + + continue; + } + + // Sending 3rd party IOUs: transit. + + // Calculate the amount to transfer accounting + // for any transfer fees if the fee is not waived: + STAmount actualSend = (waiveFee == WaiveTransferFee::Yes) + ? amount + : multiply(amount, transferRate(view, issuer)); + actual += actualSend; + takeFromSender += actualSend; + + JLOG(j.debug()) << "rippleSendMultiIOU> " << to_string(senderID) + << " - > " << to_string(receiverID) + << " : deliver=" << amount.getFullText() + << " cost=" << actual.getFullText(); + + if (TER const terResult = + rippleCreditIOU(view, issuer, receiverID, amount, true, j)) + return terResult; + } + + if (senderID != issuer && takeFromSender) + { + if (TER const terResult = rippleCreditIOU( + view, senderID, issuer, takeFromSender, true, j)) + return terResult; + } + + return tesSUCCESS; +} + static TER accountSendIOU( ApplyView& view, @@ -2034,6 +2390,144 @@ accountSendIOU( return terResult; } +static TER +accountSendMultiIOU( + ApplyView& view, + AccountID const& senderID, + Issue const& issue, + MultiplePaymentDestinations const& receivers, + beast::Journal j, + WaiveTransferFee waiveFee) +{ + XRPL_ASSERT_PARTS( + receivers.size() > 1, + "ripple::accountSendMultiIOU", + "multiple recipients provided"); + + if (!issue.native()) + { + STAmount actual; + JLOG(j.trace()) << "accountSendMultiIOU: " << to_string(senderID) + << " sending " << receivers.size() << " IOUs"; + + return rippleSendMultiIOU( + view, senderID, issue, receivers, actual, j, waiveFee); + } + + /* XRP send which does not check reserve and can do pure adjustment. + * Note that sender or receiver may be null and this not a mistake; this + * setup could be used during pathfinding and it is carefully controlled to + * ensure that transfers are balanced. + */ + + SLE::pointer sender = senderID != beast::zero + ? view.peek(keylet::account(senderID)) + : SLE::pointer(); + + if (auto stream = j.trace()) + { + std::string sender_bal("-"); + + if (sender) + sender_bal = sender->getFieldAmount(sfBalance).getFullText(); + + stream << "accountSendMultiIOU> " << to_string(senderID) << " (" + << sender_bal << ") -> " << receivers.size() << " receivers."; + } + + // Failures return immediately. + STAmount takeFromSender{issue}; + for (auto const& r : receivers) + { + auto const& receiverID = r.first; + STAmount amount{issue, r.second}; + + if (amount < beast::zero) + { + return tecINTERNAL; // LCOV_EXCL_LINE + } + + /* If we aren't sending anything or if the sender is the same as the + * receiver then we don't need to do anything. + */ + if (!amount || (senderID == receiverID)) + continue; + + SLE::pointer receiver = receiverID != beast::zero + ? view.peek(keylet::account(receiverID)) + : SLE::pointer(); + + if (auto stream = j.trace()) + { + std::string receiver_bal("-"); + + if (receiver) + receiver_bal = + receiver->getFieldAmount(sfBalance).getFullText(); + + stream << "accountSendMultiIOU> " << to_string(senderID) << " -> " + << to_string(receiverID) << " (" << receiver_bal + << ") : " << amount.getFullText(); + } + + if (receiver) + { + // Increment XRP balance. + auto const rcvBal = receiver->getFieldAmount(sfBalance); + receiver->setFieldAmount(sfBalance, rcvBal + amount); + view.creditHook(xrpAccount(), receiverID, amount, -rcvBal); + + view.update(receiver); + + // Take what is actually sent + takeFromSender += amount; + } + + if (auto stream = j.trace()) + { + std::string receiver_bal("-"); + + if (receiver) + receiver_bal = + receiver->getFieldAmount(sfBalance).getFullText(); + + stream << "accountSendMultiIOU< " << to_string(senderID) << " -> " + << to_string(receiverID) << " (" << receiver_bal + << ") : " << amount.getFullText(); + } + } + + if (sender) + { + if (sender->getFieldAmount(sfBalance) < takeFromSender) + { + return TER{tecFAILED_PROCESSING}; + } + else + { + auto const sndBal = sender->getFieldAmount(sfBalance); + view.creditHook(senderID, xrpAccount(), takeFromSender, sndBal); + + // Decrement XRP balance. + sender->setFieldAmount(sfBalance, sndBal - takeFromSender); + view.update(sender); + } + } + + if (auto stream = j.trace()) + { + std::string sender_bal("-"); + std::string receiver_bal("-"); + + if (sender) + sender_bal = sender->getFieldAmount(sfBalance).getFullText(); + + stream << "accountSendMultiIOU< " << to_string(senderID) << " (" + << sender_bal << ") -> " << receivers.size() << " receivers."; + } + return tesSUCCESS; +} + static TER rippleCreditMPT( ApplyView& view, @@ -2044,7 +2538,7 @@ rippleCreditMPT( { // Do not check MPT authorization here - it must have been checked earlier auto const mptID = keylet::mptIssuance(saAmount.get().getMptID()); - auto const issuer = saAmount.getIssuer(); + auto const& issuer = saAmount.getIssuer(); auto sleIssuance = view.peek(mptID); if (!sleIssuance) return tecOBJECT_NOT_FOUND; @@ -2111,7 +2605,7 @@ rippleSendMPT( "ripple::rippleSendMPT : sender is not receiver"); // Safe to get MPT since rippleSendMPT is only called by accountSendMPT - auto const issuer = saAmount.getIssuer(); + auto const& issuer = saAmount.getIssuer(); auto const sle = view.read(keylet::mptIssuance(saAmount.get().getMptID())); @@ -2162,6 +2656,102 @@ rippleSendMPT( return rippleCreditMPT(view, uSenderID, issuer, saActual, j); } +static TER +rippleSendMultiMPT( + ApplyView& view, + AccountID const& senderID, + MPTIssue const& mptIssue, + MultiplePaymentDestinations const& receivers, + STAmount& actual, + beast::Journal j, + WaiveTransferFee waiveFee) +{ + // Safe to get MPT since rippleSendMultiMPT is only called by + // accountSendMultiMPT + auto const& issuer = mptIssue.getIssuer(); + + auto const sle = view.read(keylet::mptIssuance(mptIssue.getMptID())); + if (!sle) + return tecOBJECT_NOT_FOUND; + + // These may diverge + STAmount takeFromSender{mptIssue}; + actual = takeFromSender; + + for (auto const& r : receivers) + { + auto const& receiverID = r.first; + STAmount amount{mptIssue, r.second}; + + if (amount < beast::zero) + { + return tecINTERNAL; // LCOV_EXCL_LINE + } + + /* If we aren't sending anything or if the sender is the same as the + * receiver then we don't need to do anything. + */ + if (!amount || (senderID == receiverID)) + continue; + + if (senderID == issuer || receiverID == issuer) + { + // if sender is issuer, check that the new OutstandingAmount will + // not exceed MaximumAmount + if (senderID == issuer) + { + XRPL_ASSERT_PARTS( + takeFromSender == beast::zero, + "rippler::rippleSendMultiMPT", + "sender == issuer, takeFromSender == zero"); + auto const sendAmount = amount.mpt().value(); + auto const maximumAmount = + sle->at(~sfMaximumAmount).value_or(maxMPTokenAmount); + if (sendAmount > maximumAmount || + sle->getFieldU64(sfOutstandingAmount) > + maximumAmount - sendAmount) + return tecPATH_DRY; + } + + // Direct send: redeeming MPTs and/or sending own MPTs. + if (auto const ter = + rippleCreditMPT(view, senderID, receiverID, amount, j)) + return ter; + actual += amount; + // Do not add amount to takeFromSender, because rippleCreditMPT took + // it + + continue; + } + + // Sending 3rd party MPTs: transit. + STAmount actualSend = (waiveFee == WaiveTransferFee::Yes) + ? amount + : multiply( + amount, + transferRate(view, amount.get().getMptID())); + actual += actualSend; + takeFromSender += actualSend; + + JLOG(j.debug()) << "rippleSendMultiMPT> " << to_string(senderID) + << " - > " << to_string(receiverID) + << " : deliver=" << amount.getFullText() + << " cost=" << actualSend.getFullText(); + + if (auto const terResult = + rippleCreditMPT(view, issuer, receiverID, amount, j)) + return terResult; + } + if (senderID != issuer && takeFromSender) + { + if (TER const terResult = + rippleCreditMPT(view, senderID, issuer, takeFromSender, j)) + return terResult; + } + + return tesSUCCESS; +} + static TER accountSendMPT( ApplyView& view, @@ -2187,6 +2777,21 @@ accountSendMPT( view, uSenderID, uReceiverID, saAmount, saActual, j, waiveFee); } +static TER +accountSendMultiMPT( + ApplyView& view, + AccountID const& senderID, + MPTIssue const& mptIssue, + MultiplePaymentDestinations const& receivers, + beast::Journal j, + WaiveTransferFee waiveFee) +{ + STAmount actual; + + return rippleSendMultiMPT( + view, senderID, mptIssue, receivers, actual, j, waiveFee); +} + TER accountSend( ApplyView& view, @@ -2208,6 +2813,31 @@ accountSend( saAmount.asset().value()); } +TER +accountSendMulti( + ApplyView& view, + AccountID const& senderID, + Asset const& asset, + MultiplePaymentDestinations const& receivers, + beast::Journal j, + WaiveTransferFee waiveFee) +{ + XRPL_ASSERT_PARTS( + receivers.size() > 1, + "ripple::accountSendMulti", + "multiple recipients provided"); + return std::visit( + [&](TIss const& issue) { + if constexpr (std::is_same_v) + return accountSendMultiIOU( + view, senderID, issue, receivers, j, waiveFee); + else + return accountSendMultiMPT( + view, senderID, issue, receivers, j, waiveFee); + }, + asset.value()); +} + static bool updateTrustLine( ApplyView& view, @@ -2532,7 +3162,10 @@ requireAuth( if (mptIssuer == account) // Issuer won't have MPToken return tesSUCCESS; - if (view.rules().enabled(featureSingleAssetVault)) + bool const featureSAVEnabled = + view.rules().enabled(featureSingleAssetVault); + + if (featureSAVEnabled) { if (depth >= maxAssetCheckDepth) return tecINTERNAL; // LCOV_EXCL_LINE @@ -2591,6 +3224,13 @@ requireAuth( // belong to someone who is explicitly authorized e.g. a vault owner. } + if (featureSAVEnabled) + { + // Implicitly authorize Vault and LoanBroker pseudo-accounts + if (isPseudoAccount(view, account, {&sfVaultID, &sfLoanBrokerID})) + return tesSUCCESS; + } + // mptoken must be authorized if issuance enabled requireAuth if (sleIssuance->isFlag(lsfMPTRequireAuth) && (!sleToken || !sleToken->isFlag(lsfMPTAuthorized))) @@ -2720,6 +3360,42 @@ canTransfer( return tesSUCCESS; } +[[nodiscard]] TER +canTransfer( + ReadView const& view, + Issue const& issue, + AccountID const& from, + AccountID const& to) +{ + if (issue.native()) + return tesSUCCESS; + + auto const& issuerId = issue.getIssuer(); + if (issuerId == from || issuerId == to) + return tesSUCCESS; + auto const sleIssuer = view.read(keylet::account(issuerId)); + if (sleIssuer == nullptr) + return tefINTERNAL; // LCOV_EXCL_LINE + + auto const isRippleDisabled = [&](AccountID account) -> bool { + // Line might not exist, but some transfers can create it. If this + // is the case, just check the default ripple on the issuer account. + auto const line = view.read(keylet::line(account, issue)); + if (line) + { + bool const issuerHigh = issuerId > account; + return line->isFlag(issuerHigh ? lsfHighNoRipple : lsfLowNoRipple); + } + return sleIssuer->isFlag(lsfDefaultRipple) == false; + }; + + // Fail if rippling disabled on both trust lines + if (isRippleDisabled(from) && isRippleDisabled(to)) + return terNO_RIPPLE; + + return tesSUCCESS; +} + TER cleanupOnAccountDelete( ApplyView& view, diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index 486945992a..d87241b719 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -96,6 +96,8 @@ enum class LedgerNameSpace : std::uint16_t { PERMISSIONED_DOMAIN = 'm', DELEGATE = 'E', VAULT = 'V', + LOAN_BROKER = 'l', // lower-case L + LOAN = 'L', // No longer used or supported. Left here to reserve the space // to avoid accidental reuse. @@ -566,6 +568,18 @@ vault(AccountID const& owner, std::uint32_t seq) noexcept return vault(indexHash(LedgerNameSpace::VAULT, owner, seq)); } +Keylet +loanbroker(AccountID const& owner, std::uint32_t seq) noexcept +{ + return loanbroker(indexHash(LedgerNameSpace::LOAN_BROKER, owner, seq)); +} + +Keylet +loan(uint256 const& loanBrokerID, std::uint32_t loanSeq) noexcept +{ + return loan(indexHash(LedgerNameSpace::LOAN, loanBrokerID, loanSeq)); +} + Keylet permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept { diff --git a/src/libxrpl/protocol/InnerObjectFormats.cpp b/src/libxrpl/protocol/InnerObjectFormats.cpp index 2de5e6624e..ebb6646f65 100644 --- a/src/libxrpl/protocol/InnerObjectFormats.cpp +++ b/src/libxrpl/protocol/InnerObjectFormats.cpp @@ -172,6 +172,14 @@ InnerObjectFormats::InnerObjectFormats() {sfBookDirectory, soeREQUIRED}, {sfBookNode, soeREQUIRED}, }); + + add(sfCounterpartySignature.jsonName, + sfCounterpartySignature.getCode(), + { + {sfSigningPubKey, soeOPTIONAL}, + {sfTxnSignature, soeOPTIONAL}, + {sfSigners, soeOPTIONAL}, + }); } InnerObjectFormats const& diff --git a/src/libxrpl/protocol/STAmount.cpp b/src/libxrpl/protocol/STAmount.cpp index a091ca4d70..57d33bebfa 100644 --- a/src/libxrpl/protocol/STAmount.cpp +++ b/src/libxrpl/protocol/STAmount.cpp @@ -321,7 +321,7 @@ STAmount::xrp() const IOUAmount STAmount::iou() const { - if (native() || !holds()) + if (integral()) Throw("Cannot return non-IOU STAmount as IOUAmount"); auto mantissa = static_cast(mValue); @@ -872,7 +872,7 @@ STAmount::isDefault() const void STAmount::canonicalize() { - if (native() || mAsset.holds()) + if (integral()) { // native and MPT currency amounts should always have an offset of zero // log(2^64,10) ~ 19.2 @@ -905,8 +905,10 @@ STAmount::canonicalize() }; if (native()) set(XRPAmount{num}); - else + else if (mAsset.holds()) set(MPTAmount{num}); + else + Throw("Unknown integral asset type"); mOffset = 0; } else @@ -1509,6 +1511,33 @@ canonicalizeRoundStrict( } } +STAmount +roundToScale( + STAmount const& value, + std::int32_t scale, + Number::rounding_mode rounding) +{ + // Nothing to do for integral types. + if (value.integral()) + return value; + + // If the value's exponent is greater than or equal to the scale, then + // rounding will do nothing, and might even lose precision, so just return + // the value. + if (value.exponent() >= scale) + return value; + + STAmount const referenceValue{ + value.asset(), STAmount::cMinValue, scale, value.negative()}; + + NumberRoundModeGuard mg(rounding); + // With an IOU, the the result of addition will be truncated to the + // precision of the larger value, which in this case is referenceValue. Then + // remove the reference value via subtraction, and we're left with the + // rounded value. + return (value + referenceValue) - referenceValue; +} + namespace { // We need a class that has an interface similar to NumberRoundModeGuard diff --git a/src/libxrpl/protocol/STTx.cpp b/src/libxrpl/protocol/STTx.cpp index 7326f48424..b156ea0901 100644 --- a/src/libxrpl/protocol/STTx.cpp +++ b/src/libxrpl/protocol/STTx.cpp @@ -286,7 +286,6 @@ STTx::checkSign( if (auto const ret = checkSign(requireCanonicalSig, rules, *this); !ret) return ret; - /* Placeholder for field that will be added by Lending Protocol if (isFieldPresent(sfCounterpartySignature)) { auto const counterSig = getFieldObject(sfCounterpartySignature); @@ -294,7 +293,6 @@ STTx::checkSign( !ret) return Unexpected("Counterparty: " + ret.error()); } - */ return {}; } diff --git a/src/test/app/Batch_test.cpp b/src/test/app/Batch_test.cpp index 92f286ca6a..1dac56d2d9 100644 --- a/src/test/app/Batch_test.cpp +++ b/src/test/app/Batch_test.cpp @@ -2553,6 +2553,207 @@ class Batch_test : public beast::unit_test::suite } } + void + testLoan(FeatureBitset features) + { + testcase("loan"); + + bool const lendingBatchEnabled = !std::any_of( + Batch::disabledTxTypes.begin(), + Batch::disabledTxTypes.end(), + [](auto const& disabled) { return disabled == ttLOAN_BROKER_SET; }); + + using namespace test::jtx; + + test::jtx::Env env{ + *this, + envconfig(), + features | featureSingleAssetVault | featureLendingProtocol | + featureMPTokensV1}; + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + // Borrower only wants to borrow + Account const borrower{"borrower"}; + + // Fund the accounts and trust lines with the same amount so that tests + // can use the same values regardless of the asset. + env.fund(XRP(100'000), issuer, noripple(lender, borrower)); + env.close(); + + // Just use an XRP asset + PrettyAsset const asset{xrpIssue(), 1'000'000}; + + Vault vault{env}; + + auto const deposit = asset(50'000); + auto const debtMaximumValue = asset(25'000).value(); + auto const coverDepositValue = asset(1000).value(); + + auto [tx, vaultKeylet] = + vault.create({.owner = lender, .asset = asset}); + env(tx); + env.close(); + BEAST_EXPECT(env.le(vaultKeylet)); + + env(vault.deposit( + {.depositor = lender, .id = vaultKeylet.key, .amount = deposit})); + env.close(); + + auto const brokerKeylet = + keylet::loanbroker(lender.id(), env.seq(lender)); + + { + using namespace loanBroker; + env(set(lender, vaultKeylet.key), + managementFeeRate(TenthBips16(100)), + debtMaximum(debtMaximumValue), + coverRateMinimum(TenthBips32(percentageToTenthBips(10))), + coverRateLiquidation(TenthBips32(percentageToTenthBips(25)))); + + env(coverDeposit(lender, brokerKeylet.key, coverDepositValue)); + + env.close(); + } + + { + using namespace loan; + using namespace std::chrono_literals; + + auto const lenderSeq = env.seq(lender); + auto const batchFee = batch::calcBatchFee(env, 0, 2); + + auto const loanKeylet = keylet::loan(brokerKeylet.key, 1); + { + auto const [txIDs, batchID] = submitBatch( + env, + lendingBatchEnabled ? temBAD_SIGNATURE + : temINVALID_INNER_BATCH, + batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing), + batch::inner( + env.json( + set(lender, brokerKeylet.key, asset(1000).value()), + // Not allowed to include the counterparty signature + sig(sfCounterpartySignature, borrower), + sig(none), + fee(none), + seq(none)), + lenderSeq + 1), + batch::inner( + pay(lender, + loanKeylet.key, + STAmount{asset, asset(500).value()}), + lenderSeq + 2)); + } + { + auto const [txIDs, batchID] = submitBatch( + env, + temINVALID_INNER_BATCH, + batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing), + batch::inner( + env.json( + set(lender, brokerKeylet.key, asset(1000).value()), + // Counterparty must be set + sig(none), + fee(none), + seq(none)), + lenderSeq + 1), + batch::inner( + pay(lender, + loanKeylet.key, + STAmount{asset, asset(500).value()}), + lenderSeq + 2)); + } + { + auto const [txIDs, batchID] = submitBatch( + env, + lendingBatchEnabled ? temBAD_SIGNER + : temINVALID_INNER_BATCH, + batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing), + batch::inner( + env.json( + set(lender, brokerKeylet.key, asset(1000).value()), + // Counterparty must sign the outer transaction + counterparty(borrower.id()), + sig(none), + fee(none), + seq(none)), + lenderSeq + 1), + batch::inner( + pay(lender, + loanKeylet.key, + STAmount{asset, asset(500).value()}), + lenderSeq + 2)); + } + { + // LoanSet normally charges at least 2x base fee, but since the + // signature check is done by the batch, it only charges the + // base fee. + auto const batchFee = batch::calcBatchFee(env, 1, 2); + auto const [txIDs, batchID] = submitBatch( + env, + lendingBatchEnabled ? TER(tesSUCCESS) + : TER(temINVALID_INNER_BATCH), + batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing), + batch::inner( + env.json( + set(lender, brokerKeylet.key, asset(1000).value()), + counterparty(borrower.id()), + sig(none), + fee(none), + seq(none)), + lenderSeq + 1), + batch::inner( + pay( + // However, this inner transaction will fail, + // because the lender is not allowed to draw the + // transaction + lender, + loanKeylet.key, + STAmount{asset, asset(500).value()}), + lenderSeq + 2), + batch::sig(borrower)); + } + env.close(); + BEAST_EXPECT(env.le(brokerKeylet)); + BEAST_EXPECT(!env.le(loanKeylet)); + { + // LoanSet normally charges at least 2x base fee, but since the + // signature check is done by the batch, it only charges the + // base fee. + auto const lenderSeq = env.seq(lender); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + auto const [txIDs, batchID] = submitBatch( + env, + lendingBatchEnabled ? TER(tesSUCCESS) + : TER(temINVALID_INNER_BATCH), + batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing), + batch::inner( + env.json( + set(lender, brokerKeylet.key, asset(1000).value()), + counterparty(borrower.id()), + sig(none), + fee(none), + seq(none)), + lenderSeq + 1), + batch::inner( + manage(lender, loanKeylet.key, tfLoanImpair), + lenderSeq + 2), + batch::sig(borrower)); + } + env.close(); + BEAST_EXPECT(env.le(brokerKeylet)); + if (auto const sleLoan = env.le(loanKeylet); lendingBatchEnabled + ? BEAST_EXPECT(sleLoan) + : !BEAST_EXPECT(!sleLoan)) + { + BEAST_EXPECT(sleLoan->isFlag(lsfLoanImpaired)); + } + } + } + void testObjectCreateSequence(FeatureBitset features) { @@ -4147,6 +4348,7 @@ class Batch_test : public beast::unit_test::suite testAccountActivation(features); testAccountSet(features); testAccountDelete(features); + testLoan(features); testObjectCreateSequence(features); testObjectCreateTicket(features); testObjectCreate3rdParty(features); diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 925776a1b4..3237e61e35 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -174,9 +174,13 @@ class Invariants_test : public beast::unit_test::suite {{"an account root was deleted"}}, [](Account const& A1, Account const&, ApplyContext& ac) { // remove an account from the view - auto const sle = ac.view().peek(keylet::account(A1.id())); + auto sle = ac.view().peek(keylet::account(A1.id())); if (!sle) return false; + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sle->at(sfBalance) = beast::zero; ac.view().erase(sle); return true; }); @@ -200,10 +204,15 @@ class Invariants_test : public beast::unit_test::suite {{"account deletion succeeded but deleted multiple accounts"}}, [](Account const& A1, Account const& A2, ApplyContext& ac) { // remove two accounts from the view - auto const sleA1 = ac.view().peek(keylet::account(A1.id())); - auto const sleA2 = ac.view().peek(keylet::account(A2.id())); + auto sleA1 = ac.view().peek(keylet::account(A1.id())); + auto sleA2 = ac.view().peek(keylet::account(A2.id())); if (!sleA1 || !sleA2) return false; + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sleA1->at(sfBalance) = beast::zero; + sleA2->at(sfBalance) = beast::zero; ac.view().erase(sleA1); ac.view().erase(sleA2); return true; @@ -218,6 +227,46 @@ class Invariants_test : public beast::unit_test::suite using namespace test::jtx; testcase << "account root deletion left artifact"; + doInvariantCheck( + {{"account deletion left behind a non-zero balance"}}, + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + // A1 has a balance. Delete A1 + auto const a1 = A1.id(); + auto const sleA1 = ac.view().peek(keylet::account(a1)); + if (!sleA1) + return false; + if (!BEAST_EXPECT(*sleA1->at(sfBalance) != beast::zero)) + return false; + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + + doInvariantCheck( + {{"account deletion left behind a non-zero owner count"}}, + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + // Increment A1's owner count, then delete A1 + auto const a1 = A1.id(); + auto const sleA1 = ac.view().peek(keylet::account(a1)); + if (!sleA1) + return false; + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sleA1->at(sfBalance) = beast::zero; + BEAST_EXPECT(sleA1->at(sfOwnerCount) == 0); + adjustOwnerCount(ac.view(), sleA1, 1, ac.journal); + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + for (auto const& keyletInfo : directAccountKeylets) { // TODO: Use structured binding once LLVM 16 is the minimum @@ -238,29 +287,38 @@ class Invariants_test : public beast::unit_test::suite // Add an object to the ledger for account A1, then delete // A1 auto const a1 = A1.id(); - auto const sleA1 = ac.view().peek(keylet::account(a1)); + auto sleA1 = ac.view().peek(keylet::account(a1)); if (!sleA1) return false; auto const key = std::invoke(keyletfunc, a1); auto const newSLE = std::make_shared(key); ac.view().insert(newSLE); + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the + // desired check. + sleA1->at(sfBalance) = beast::zero; ac.view().erase(sleA1); return true; }, XRPAmount{}, STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - }; + } // NFT special case doInvariantCheck( {{"account deletion left behind a NFTokenPage object"}}, [&](Account const& A1, Account const&, ApplyContext& ac) { // remove an account from the view - auto const sle = ac.view().peek(keylet::account(A1.id())); + auto sle = ac.view().peek(keylet::account(A1.id())); if (!sle) return false; + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sle->at(sfBalance) = beast::zero; + sle->at(sfOwnerCount) = 0; ac.view().erase(sle); return true; }, @@ -284,13 +342,18 @@ class Invariants_test : public beast::unit_test::suite [&](Account const& A1, Account const& A2, ApplyContext& ac) { // Delete the AMM account without cleaning up the directory or // deleting the AMM object - auto const sle = ac.view().peek(keylet::account(ammAcctID)); + auto sle = ac.view().peek(keylet::account(ammAcctID)); if (!sle) return false; BEAST_EXPECT(sle->at(~sfAMMID)); BEAST_EXPECT(sle->at(~sfAMMID) == ammKey); + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sle->at(sfBalance) = beast::zero; + sle->at(sfOwnerCount) = 0; ac.view().erase(sle); return true; @@ -313,7 +376,7 @@ class Invariants_test : public beast::unit_test::suite // Delete all the AMM's trust lines, remove the AMM from the AMM // account's directory (this deletes the directory), and delete // the AMM account. Do not delete the AMM object. - auto const sle = ac.view().peek(keylet::account(ammAcctID)); + auto sle = ac.view().peek(keylet::account(ammAcctID)); if (!sle) return false; @@ -353,6 +416,11 @@ class Invariants_test : public beast::unit_test::suite !ac.view().exists(ownerDirKeylet) || ac.view().emptyDirDelete(ownerDirKeylet)); + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sle->at(sfBalance) = beast::zero; + sle->at(sfOwnerCount) = 0; ac.view().erase(sle); return true; @@ -1741,6 +1809,359 @@ class Invariants_test : public beast::unit_test::suite {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); } + Keylet + createLoanBroker( + jtx::Account const& a, + jtx::Env& env, + jtx::PrettyAsset const& asset) + { + using namespace jtx; + + // Create vault + uint256 vaultID; + Vault vault{env}; + auto [tx, vKeylet] = vault.create({.owner = a, .asset = asset}); + env(tx); + BEAST_EXPECT(env.le(vKeylet)); + + vaultID = vKeylet.key; + + // Create Loan Broker + using namespace loanBroker; + + auto const loanBrokerKeylet = keylet::loanbroker(a.id(), env.seq(a)); + // Create a Loan Broker with all default values. + env(set(a, vaultID), fee(increment)); + + return loanBrokerKeylet; + }; + + void + testNoModifiedUnmodifiableFields() + { + testcase("no modified unmodifiable fields"); + using namespace jtx; + + // Initialize with a placeholder value because there's no default ctor + Keylet loanBrokerKeylet = keylet::amendments(); + Preclose createLoanBroker = + [&, this](Account const& a, Account const& b, Env& env) { + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + loanBrokerKeylet = this->createLoanBroker(a, env, xrpAsset); + return BEAST_EXPECT(env.le(loanBrokerKeylet)); + }; + + { + auto const mods = + std::to_array>({ + [](SLE::pointer& sle) { sle->at(sfSequence) += 1; }, + [](SLE::pointer& sle) { sle->at(sfOwnerNode) += 1; }, + [](SLE::pointer& sle) { sle->at(sfVaultNode) += 1; }, + [](SLE::pointer& sle) { sle->at(sfVaultID) = uint256(1u); }, + [](SLE::pointer& sle) { + sle->at(sfAccount) = sle->at(sfOwner); + }, + [](SLE::pointer& sle) { + sle->at(sfOwner) = sle->at(sfAccount); + }, + [](SLE::pointer& sle) { + sle->at(sfManagementFeeRate) += 1; + }, + [](SLE::pointer& sle) { sle->at(sfCoverRateMinimum) += 1; }, + [](SLE::pointer& sle) { + sle->at(sfCoverRateLiquidation) += 1; + }, + [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; }, + [](SLE::pointer& sle) { + sle->at(sfLedgerIndex) = sle->at(sfVaultID).value(); + }, + }); + + for (auto const& mod : mods) + { + doInvariantCheck( + {{"changed an unchangable field"}}, + [&](Account const& A1, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(loanBrokerKeylet); + if (!sle) + return false; + mod(sle); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + } + } + + // TODO: Loan Object + + { + auto const mods = + std::to_array>({ + [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; }, + [](SLE::pointer& sle) { + sle->at(sfLedgerIndex) = uint256(1u); + }, + }); + + for (auto const& mod : mods) + { + doInvariantCheck( + {{"changed an unchangable field"}}, + [&](Account const& A1, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(keylet::account(A1.id())); + if (!sle) + return false; + mod(sle); + ac.view().update(sle); + return true; + }); + } + } + } + + void + testValidLoanBroker() + { + testcase << "valid loan broker"; + + using namespace jtx; + + enum class Asset { XRP, IOU, MPT }; + auto const assetTypes = + std::to_array({Asset::XRP, Asset::IOU, Asset::MPT}); + + for (auto const assetType : assetTypes) + { + // Initialize with a placeholder value because there's no default + // ctor + Keylet loanBrokerKeylet = keylet::amendments(); + Preclose createLoanBroker = [&, this]( + Account const& alice, + Account const& issuer, + Env& env) { + PrettyAsset const asset = [&]() { + switch (assetType) + { + case Asset::IOU: { + PrettyAsset const iouAsset = issuer["IOU"]; + env(trust(alice, iouAsset(1000))); + env(pay(issuer, alice, iouAsset(1000))); + env.close(); + return iouAsset; + } + + case Asset::MPT: { + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | + tfMPTCanLock}); + PrettyAsset const mptAsset = mptt.issuanceID(); + mptt.authorize({.account = alice}); + env(pay(issuer, alice, mptAsset(1000))); + env.close(); + return mptAsset; + } + + case Asset::XRP: + default: + return PrettyAsset{xrpIssue(), 1'000'000}; + } + }(); + loanBrokerKeylet = this->createLoanBroker(alice, env, asset); + return BEAST_EXPECT(env.le(loanBrokerKeylet)); + }; + + // Ensure the test scenarios are set up completely. The test cases + // will need to recompute any of these values it needs for itself + // rather than trying to return a bunch of items + auto setupTest = + [&, this](Account const& A1, Account const&, ApplyContext& ac) + -> std::optional> { + if (loanBrokerKeylet.type != ltLOAN_BROKER) + return {}; + auto sleBroker = ac.view().peek(loanBrokerKeylet); + if (!sleBroker) + return {}; + if (!BEAST_EXPECT(sleBroker->at(sfOwnerCount) == 0)) + return {}; + // Need to touch sleBroker so that it is included in the + // modified entries for the invariant to find + ac.view().update(sleBroker); + + // The pseudo-account holds the directory, so get it + auto const pseudoAccountID = sleBroker->at(sfAccount); + auto const pseudoAccountKeylet = + keylet::account(pseudoAccountID); + // Strictly speaking, we don't need to load the + // ACCOUNT_ROOT, but check anyway + auto slePseudo = ac.view().peek(pseudoAccountKeylet); + if (!BEAST_EXPECT(slePseudo)) + return {}; + // Make sure the directory doesn't already exist + auto const dirKeylet = keylet::ownerDir(pseudoAccountID); + auto sleDir = ac.view().peek(dirKeylet); + auto const describe = describeOwnerDir(pseudoAccountID); + if (!sleDir) + { + // Create the directory + BEAST_EXPECT( + ::ripple::directory::createRoot( + ac.view(), + dirKeylet, + loanBrokerKeylet.key, + describe) == 0); + + sleDir = ac.view().peek(dirKeylet); + } + + return std::make_pair(slePseudo, sleDir); + }; + + doInvariantCheck( + {{"Loan Broker with zero OwnerCount has multiple directory " + "pages"}}, + [&setupTest, this]( + Account const& A1, Account const& A2, ApplyContext& ac) { + auto test = setupTest(A1, A2, ac); + if (!test || !test->first || !test->second) + return false; + + auto slePseudo = test->first; + auto sleDir = test->second; + auto const describe = + describeOwnerDir(slePseudo->at(sfAccount)); + + BEAST_EXPECT( + ::ripple::directory::insertPage( + ac.view(), + 0, + sleDir, + 0, + sleDir, + slePseudo->key(), + keylet::page(sleDir->key(), 0), + describe) == 1); + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + doInvariantCheck( + {{"Loan Broker with zero OwnerCount has multiple indexes in " + "the Directory root"}}, + [&setupTest]( + Account const& A1, Account const& A2, ApplyContext& ac) { + auto test = setupTest(A1, A2, ac); + if (!test || !test->first || !test->second) + return false; + + auto slePseudo = test->first; + auto sleDir = test->second; + auto indexes = sleDir->getFieldV256(sfIndexes); + + // Put some extra garbage into the directory + for (auto const& key : {slePseudo->key(), sleDir->key()}) + { + ::ripple::directory::insertKey( + ac.view(), sleDir, 0, false, indexes, key); + } + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + doInvariantCheck( + {{"Loan Broker directory corrupt"}}, + [&setupTest]( + Account const& A1, Account const& A2, ApplyContext& ac) { + auto test = setupTest(A1, A2, ac); + if (!test || !test->first || !test->second) + return false; + + auto slePseudo = test->first; + auto sleDir = test->second; + auto const describe = + describeOwnerDir(slePseudo->at(sfAccount)); + // Empty vector will overwrite the existing entry for the + // holding, if any, avoiding the "has multiple indexes" + // failure. + STVector256 indexes; + + // Put one meaningless key into the directory + auto const key = + keylet::account(Account("random").id()).key; + ::ripple::directory::insertKey( + ac.view(), sleDir, 0, false, indexes, key); + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + doInvariantCheck( + {{"Loan Broker with zero OwnerCount has an unexpected entry in " + "the directory"}}, + [&setupTest]( + Account const& A1, Account const& A2, ApplyContext& ac) { + auto test = setupTest(A1, A2, ac); + if (!test || !test->first || !test->second) + return false; + + auto slePseudo = test->first; + auto sleDir = test->second; + // Empty vector will overwrite the existing entry for the + // holding, if any, avoiding the "has multiple indexes" + // failure. + STVector256 indexes; + + ::ripple::directory::insertKey( + ac.view(), sleDir, 0, false, indexes, slePseudo->key()); + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + doInvariantCheck( + {{"Loan Broker sequence number decreased"}}, + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + if (loanBrokerKeylet.type != ltLOAN_BROKER) + return false; + auto sleBroker = ac.view().peek(loanBrokerKeylet); + if (!sleBroker) + return false; + if (!BEAST_EXPECT(sleBroker->at(sfLoanSequence) > 0)) + return false; + // Need to touch sleBroker so that it is included in the + // modified entries for the invariant to find + ac.view().update(sleBroker); + + sleBroker->at(sfLoanSequence) -= 1; + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + } + } + void testVault() { @@ -3504,8 +3925,10 @@ public: testValidNewAccountRoot(); testNFTokenPageInvariants(); testPermissionedDomainInvariants(); - testValidPseudoAccounts(); testPermissionedDEX(); + testNoModifiedUnmodifiableFields(); + testValidPseudoAccounts(); + testValidLoanBroker(); testVault(); } }; diff --git a/src/test/app/LoanBroker_test.cpp b/src/test/app/LoanBroker_test.cpp new file mode 100644 index 0000000000..597e2ea75b --- /dev/null +++ b/src/test/app/LoanBroker_test.cpp @@ -0,0 +1,1462 @@ +#include + +#include + +#include + +namespace ripple { +namespace test { + +class LoanBroker_test : public beast::unit_test::suite +{ + // Ensure that all the features needed for Lending Protocol are included, + // even if they are set to unsupported. + FeatureBitset const all{ + jtx::testable_amendments() | featureMPTokensV1 | + featureSingleAssetVault | featureLendingProtocol}; + + void + testDisabled() + { + testcase("Disabled"); + // Lending Protocol depends on Single Asset Vault (SAV). Test + // combinations of the two amendments. + // Single Asset Vault depends on MPTokensV1, but don't test every combo + // of that. + using namespace jtx; + auto failAll = [this](FeatureBitset features, bool goodVault = false) { + Env env(*this, features); + + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + + // Try to create a vault + PrettyAsset const asset{xrpIssue(), 1'000'000}; + Vault vault{env}; + auto const [tx, keylet] = + vault.create({.owner = alice, .asset = asset}); + env(tx, ter(goodVault ? ter(tesSUCCESS) : ter(temDISABLED))); + env.close(); + BEAST_EXPECT(static_cast(env.le(keylet)) == goodVault); + + using namespace loanBroker; + // Can't create a loan broker regardless of whether the vault exists + env(set(alice, keylet.key), ter(temDISABLED)); + auto const brokerKeylet = + keylet::loanbroker(alice.id(), env.seq(alice)); + // Other LoanBroker transactions are disabled, too. + // 1. LoanBrokerCoverDeposit + env(coverDeposit(alice, brokerKeylet.key, asset(1000)), + ter(temDISABLED)); + // 2. LoanBrokerCoverWithdraw + env(coverWithdraw(alice, brokerKeylet.key, asset(1000)), + ter(temDISABLED)); + // 3. LoanBrokerCoverClawback + env(coverClawback(alice), ter(temDISABLED)); + env(coverClawback(alice), + loanBrokerID(brokerKeylet.key), + ter(temDISABLED)); + env(coverClawback(alice), amount(asset(0)), ter(temDISABLED)); + env(coverClawback(alice), + loanBrokerID(brokerKeylet.key), + amount(asset(1000)), + ter(temDISABLED)); + // 4. LoanBrokerDelete + env(del(alice, brokerKeylet.key), ter(temDISABLED)); + }; + failAll(all - featureMPTokensV1); + failAll(all - featureSingleAssetVault - featureLendingProtocol); + failAll(all - featureSingleAssetVault); + failAll(all - featureLendingProtocol, true); + } + + struct VaultInfo + { + jtx::PrettyAsset asset; + uint256 vaultID; + jtx::Account pseudoAccount; + VaultInfo( + jtx::PrettyAsset const& asset_, + uint256 const& vaultID_, + AccountID const& pseudo) + : asset(asset_), vaultID(vaultID_), pseudoAccount("vault", pseudo) + { + } + }; + + void + lifecycle( + char const* label, + jtx::Env& env, + jtx::Account const& issuer, + jtx::Account const& alice, + jtx::Account const& evan, + jtx::Account const& bystander, + VaultInfo const& vault, + VaultInfo const& badVault, + std::function modifyJTx, + std::function checkBroker, + std::function changeBroker, + std::function checkChangedBroker) + { + { + auto const& asset = vault.asset.raw(); + testcase << "Lifecycle: " + << (asset.native() ? "XRP " + : asset.holds() ? "IOU " + : asset.holds() ? "MPT " + : "Unknown ") + << label; + } + + using namespace jtx; + using namespace loanBroker; + + // Bogus assets to use in test cases + static PrettyAsset const badMptAsset = [&]() { + MPTTester badMptt{env, evan, mptInitNoFund}; + badMptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + env.close(); + return badMptt["BAD"]; + }(); + static PrettyAsset const badIouAsset = evan["BAD"]; + static Account const nonExistent{"NonExistent"}; + static PrettyAsset const ghostIouAsset = nonExistent["GST"]; + PrettyAsset const vaultPseudoIouAsset = vault.pseudoAccount["PSD"]; + + auto const badKeylet = keylet::loanbroker(alice.id(), env.seq(alice)); + env(set(alice, badVault.vaultID)); + env.close(); + auto const badBrokerPseudo = [&]() { + if (auto const le = env.le(badKeylet); BEAST_EXPECT(le)) + { + return Account{"Bad Broker pseudo-account", le->at(sfAccount)}; + } + // Just to make the build work + return vault.pseudoAccount; + }(); + PrettyAsset const badBrokerPseudoIouAsset = badBrokerPseudo["WAT"]; + + auto const keylet = keylet::loanbroker(alice.id(), env.seq(alice)); + { + // Start with default values + auto jtx = env.jt(set(alice, vault.vaultID)); + // Modify as desired + if (modifyJTx) + jtx = modifyJTx(jtx); + // Successfully create a Loan Broker + env(jtx); + } + + env.close(); + if (auto broker = env.le(keylet); BEAST_EXPECT(broker)) + { + // log << "Broker after create: " << to_string(broker->getJson()) + // << std::endl; + BEAST_EXPECT(broker->at(sfVaultID) == vault.vaultID); + BEAST_EXPECT(broker->at(sfAccount) != alice.id()); + BEAST_EXPECT(broker->at(sfOwner) == alice.id()); + BEAST_EXPECT(broker->at(sfFlags) == 0); + BEAST_EXPECT(broker->at(sfSequence) == env.seq(alice) - 1); + BEAST_EXPECT(broker->at(sfOwnerCount) == 0); + BEAST_EXPECT(broker->at(sfLoanSequence) == 1); + BEAST_EXPECT(broker->at(sfDebtTotal) == 0); + BEAST_EXPECT(broker->at(sfCoverAvailable) == 0); + if (checkBroker) + checkBroker(broker); + + // if (auto const vaultSLE = env.le(keylet::vault(vault.vaultID))) + //{ + // log << "Vault: " << to_string(vaultSLE->getJson()) << + // std::endl; + // } + // Load the pseudo-account + Account const pseudoAccount{ + "Broker pseudo-account", broker->at(sfAccount)}; + + auto const pseudoKeylet = keylet::account(pseudoAccount); + if (auto const pseudo = env.le(pseudoKeylet); BEAST_EXPECT(pseudo)) + { + // log << "Pseudo-account after create: " + // << to_string(pseudo->getJson()) << std::endl + // << std::endl; + BEAST_EXPECT( + pseudo->at(sfFlags) == + (lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth)); + BEAST_EXPECT(pseudo->at(sfSequence) == 0); + BEAST_EXPECT(pseudo->at(sfBalance) == beast::zero); + BEAST_EXPECT( + pseudo->at(sfOwnerCount) == + (vault.asset.raw().native() ? 0 : 1)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfAccountTxnID)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfRegularKey)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfEmailHash)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfWalletLocator)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfWalletSize)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfMessageKey)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfTransferRate)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfDomain)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfTickSize)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfTicketCount)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfNFTokenMinter)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfMintedNFTokens)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfBurnedNFTokens)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfFirstNFTokenSequence)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfAMMID)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfVaultID)); + BEAST_EXPECT(pseudo->at(sfLoanBrokerID) == keylet.key); + } + + { + // Get the AccountInfo RPC result for the broker pseudo-account + std::string const pseudoStr = to_string(pseudoAccount.id()); + auto const accountInfo = env.rpc("account_info", pseudoStr); + if (BEAST_EXPECT(accountInfo.isObject())) + { + auto const& accountData = + accountInfo[jss::result][jss::account_data]; + if (BEAST_EXPECT(accountData.isObject())) + { + BEAST_EXPECT(accountData[jss::Account] == pseudoStr); + BEAST_EXPECT( + accountData[sfLoanBrokerID] == + to_string(keylet.key)); + } + auto const& pseudoInfo = + accountInfo[jss::result][jss::pseudo_account]; + if (BEAST_EXPECT(pseudoInfo.isObject())) + { + BEAST_EXPECT(pseudoInfo[jss::type] == "LoanBroker"); + } + } + } + + auto verifyCoverAmount = + [&env, &vault, &pseudoAccount, &broker, &keylet, this](auto n) { + using namespace jtx; + + if (BEAST_EXPECT(broker = env.le(keylet))) + { + auto const amount = vault.asset(n); + BEAST_EXPECT( + broker->at(sfCoverAvailable) == amount.number()); + env.require(balance(pseudoAccount, amount)); + } + }; + + // Test Cover funding before allowing alterations + env(coverDeposit(alice, uint256(0), vault.asset(10)), + ter(temINVALID)); + env(coverDeposit(evan, keylet.key, vault.asset(10)), + ter(tecNO_PERMISSION)); + env(coverDeposit(evan, keylet.key, vault.asset(0)), + ter(temBAD_AMOUNT)); + env(coverDeposit(evan, keylet.key, vault.asset(-10)), + ter(temBAD_AMOUNT)); + env(coverDeposit(alice, vault.vaultID, vault.asset(10)), + ter(tecNO_ENTRY)); + + verifyCoverAmount(0); + + // Test cover clawback failure cases BEFORE depositing any cover + // Need one of brokerID or amount + env(coverClawback(alice), ter(temINVALID)); + env(coverClawback(alice), + loanBrokerID(uint256(0)), + ter(temINVALID)); + env(coverClawback(alice), amount(XRP(1000)), ter(temBAD_AMOUNT)); + env(coverClawback(alice), + amount(vault.asset(-10)), + ter(temBAD_AMOUNT)); + // Clawbacks with an MPT need to specify the broker ID + env(coverClawback(alice), amount(badMptAsset(1)), ter(temINVALID)); + env(coverClawback(evan), + loanBrokerID(vault.vaultID), + ter(tecNO_ENTRY)); + // Only the issuer can clawback + env(coverClawback(alice), + loanBrokerID(keylet.key), + ter(tecNO_PERMISSION)); + if (vault.asset.raw().native()) + { + // Can not clawback XRP under any circumstances + env(coverClawback(issuer), + loanBrokerID(keylet.key), + ter(tecNO_PERMISSION)); + } + else + { + if (vault.asset.raw().holds()) + { + // Clawbacks without a loanBrokerID need to specify an IOU + // with the broker's pseudo-account as the issuer + env(coverClawback(alice), + amount(ghostIouAsset(1)), + ter(tecNO_ENTRY)); + env(coverClawback(alice), + amount(badIouAsset(1)), + ter(tecOBJECT_NOT_FOUND)); + // Pseudo-account is not for a broker + env(coverClawback(alice), + amount(vaultPseudoIouAsset(1)), + ter(tecOBJECT_NOT_FOUND)); + // If we specify a pseudo-account as the IOU amount, it + // needs to match the loan broker + env(coverClawback(issuer), + loanBrokerID(keylet.key), + amount(badBrokerPseudoIouAsset(10)), + ter(tecWRONG_ASSET)); + PrettyAsset const brokerWrongCurrencyAsset = + pseudoAccount["WAT"]; + env(coverClawback(issuer), + loanBrokerID(keylet.key), + amount(brokerWrongCurrencyAsset(10)), + ter(tecWRONG_ASSET)); + } + else + { + // Clawbacks with an MPT need to specify the broker ID, even + // if the asset is valid + BEAST_EXPECT(vault.asset.raw().holds()); + env(coverClawback(alice), + amount(vault.asset(10)), + ter(temINVALID)); + } + // Since no cover has been deposited, there's nothing to claw + // back + env(coverClawback(issuer), + loanBrokerID(keylet.key), + amount(vault.asset(10)), + ter(tecINSUFFICIENT_FUNDS)); + } + env.close(); + + // Fund the cover deposit + env(coverDeposit(alice, keylet.key, vault.asset(10))); + env.close(); + verifyCoverAmount(10); + + // Test withdrawal failure cases + env(coverWithdraw(alice, uint256(0), vault.asset(10)), + ter(temINVALID)); + env(coverWithdraw(evan, keylet.key, vault.asset(10)), + ter(tecNO_PERMISSION)); + env(coverWithdraw(evan, keylet.key, vault.asset(0)), + ter(temBAD_AMOUNT)); + env(coverWithdraw(evan, keylet.key, vault.asset(-10)), + ter(temBAD_AMOUNT)); + env(coverWithdraw(alice, vault.vaultID, vault.asset(10)), + ter(tecNO_ENTRY)); + env(coverWithdraw(alice, keylet.key, vault.asset(900)), + ter(tecINSUFFICIENT_FUNDS)); + + // Skip this test for XRP, because that can always be sent + if (!vault.asset.raw().native()) + { + TER const expected = vault.asset.raw().holds() + ? tecNO_AUTH + : tecNO_LINE; + env(coverWithdraw(alice, keylet.key, vault.asset(1)), + destination(bystander), + ter(expected)); + } + + // Can not withdraw to the zero address + env(coverWithdraw(alice, keylet.key, vault.asset(1)), + destination(AccountID{}), + ter(temMALFORMED)); + + // Withdraw some of the cover amount + env(coverWithdraw(alice, keylet.key, vault.asset(7))); + env.close(); + verifyCoverAmount(3); + + // Add some more cover + env(coverDeposit(alice, keylet.key, vault.asset(5))); + env.close(); + verifyCoverAmount(8); + + // Withdraw some more. Send it to Evan. Very generous, considering + // how much trouble he's been. + env(coverWithdraw(alice, keylet.key, vault.asset(1)), + destination(evan)); + env.close(); + verifyCoverAmount(7); + + // Withdraw some more. Send it to Evan. Very generous, considering + // how much trouble he's been. + env(coverWithdraw(alice, keylet.key, vault.asset(1)), + destination(evan), + dtag(3)); + env.close(); + verifyCoverAmount(6); + + if (!vault.asset.raw().native()) + { + // Issuer claws back some of the cover + env(coverClawback(issuer), + loanBrokerID(keylet.key), + amount(vault.asset(2))); + env.close(); + verifyCoverAmount(4); + + // Deposit some back + env(coverDeposit(alice, keylet.key, vault.asset(5))); + env.close(); + verifyCoverAmount(9); + + // Issuer claws it all back in various different ways + for (auto const& tx : { + // defer autofills until submission time + env.json( + coverClawback(issuer), + loanBrokerID(keylet.key), + fee(none), + seq(none), + sig(none)), + env.json( + coverClawback(issuer), + loanBrokerID(keylet.key), + amount(vault.asset(0)), + fee(none), + seq(none), + sig(none)), + env.json( + coverClawback(issuer), + loanBrokerID(keylet.key), + amount(vault.asset(6)), + fee(none), + seq(none), + sig(none)), + // amount will be truncated to what's available + env.json( + coverClawback(issuer), + loanBrokerID(keylet.key), + amount(vault.asset(100)), + fee(none), + seq(none), + sig(none)), + }) + { + // Issuer claws it all back + env(tx); + env.close(); + verifyCoverAmount(0); + + // Deposit some back + env(coverDeposit(alice, keylet.key, vault.asset(6))); + env.close(); + verifyCoverAmount(6); + } + } + + // no-op + env(set(alice, vault.vaultID), loanBrokerID(keylet.key)); + env.close(); + + // Make modifications to the broker + if (changeBroker) + changeBroker(broker); + + env.close(); + + // Check the results of modifications + if (BEAST_EXPECT(broker = env.le(keylet)) && checkChangedBroker) + checkChangedBroker(broker); + + // Verify that fields get removed when set to default values + // Debt maximum: explicit 0 + // Data: explicit empty + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + debtMaximum(Number(0)), + data("")); + env.close(); + + // Check the updated fields + if (BEAST_EXPECT(broker = env.le(keylet))) + { + BEAST_EXPECT(!broker->isFieldPresent(sfDebtMaximum)); + BEAST_EXPECT(!broker->isFieldPresent(sfData)); + } + + ///////////////////////////////////// + // try to delete the wrong broker object + env(del(alice, vault.vaultID), ter(tecNO_ENTRY)); + // evan tries to delete the broker + env(del(evan, keylet.key), ter(tecNO_PERMISSION)); + + // Get the "bad" broker out of the way + env(del(alice, badKeylet.key)); + env.close(); + + // Note alice's balance of the asset and the broker account's cover + // funds + auto const aliceBalance = env.balance(alice, vault.asset); + auto const coverFunds = env.balance(pseudoAccount, vault.asset); + BEAST_EXPECT(coverFunds.number() == broker->at(sfCoverAvailable)); + BEAST_EXPECT(coverFunds != beast::zero); + verifyCoverAmount(6); + + // delete the broker + // log << "Broker before delete: " << to_string(broker->getJson()) + // << std::endl; + // if (auto const pseudo = env.le(pseudoKeylet); + // BEAST_EXPECT(pseudo)) + //{ + // log << "Pseudo-account before delete: " + // << to_string(pseudo->getJson()) << std::endl + // << std::endl; + //} + + env(del(alice, keylet.key)); + env.close(); + { + broker = env.le(keylet); + BEAST_EXPECT(!broker); + auto pseudo = env.le(pseudoKeylet); + BEAST_EXPECT(!pseudo); + } + auto const expectedBalance = aliceBalance + coverFunds - + (aliceBalance.value().native() + ? STAmount(env.current()->fees().base.value()) + : vault.asset(0)); + env.require(balance(alice, expectedBalance)); + env.require(balance(pseudoAccount, vault.asset(none))); + } + } + + void + testLifecycle() + { + testcase("Lifecycle"); + using namespace jtx; + + // Create 3 loan brokers: one for XRP, one for an IOU, and one for an + // MPT. That'll require three corresponding SAVs. + Env env(*this, all); + + Account issuer{"issuer"}; + // For simplicity, alice will be the sole actor for the vault & brokers. + Account alice{"alice"}; + // Evan will attempt to be naughty + Account evan{"evan"}; + // Bystander doesn't have anything to do with the SAV or Broker, or any + // of the relevant tokens + Account bystander{"bystander"}; + Vault vault{env}; + + // Fund the accounts and trust lines with the same amount so that tests + // can use the same values regardless of the asset. + env.fund(XRP(100'000), issuer, noripple(alice, evan, bystander)); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + // Create assets + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + PrettyAsset const iouAsset = issuer["IOU"]; + env(trust(alice, iouAsset(1'000'000))); + env(trust(evan, iouAsset(1'000'000))); + env.close(); + env(pay(issuer, evan, iouAsset(100'000))); + env(pay(issuer, alice, iouAsset(100'000))); + env.close(); + + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + env.close(); + PrettyAsset const mptAsset = mptt["MPT"]; + mptt.authorize({.account = alice}); + mptt.authorize({.account = evan}); + env.close(); + env(pay(issuer, alice, mptAsset(100'000))); + env(pay(issuer, evan, mptAsset(100'000))); + env.close(); + + std::array const assets{xrpAsset, iouAsset, mptAsset}; + + // Create vaults + std::vector vaults; + for (auto const& asset : assets) + { + auto [tx, keylet] = vault.create({.owner = alice, .asset = asset}); + env(tx); + env.close(); + if (auto const le = env.le(keylet); BEAST_EXPECT(env.le(keylet))) + { + vaults.emplace_back(asset, keylet.key, le->at(sfAccount)); + } + + env(vault.deposit( + {.depositor = alice, .id = keylet.key, .amount = asset(50)})); + env.close(); + } + VaultInfo const badVault = [&]() -> VaultInfo { + auto [tx, keylet] = + vault.create({.owner = alice, .asset = iouAsset}); + env(tx); + env.close(); + if (auto const le = env.le(keylet); BEAST_EXPECT(env.le(keylet))) + { + return {iouAsset, keylet.key, le->at(sfAccount)}; + } + // This should never happen + return {iouAsset, keylet.key, evan.id()}; + }(); + + auto const aliceOriginalCount = env.ownerCount(alice); + + // Create and update Loan Brokers + for (auto const& vault : vaults) + { + { + // Get the AccountInfo RPC result for the vault pseudo-account + std::string const pseudoStr = + to_string(vault.pseudoAccount.id()); + auto const accountInfo = env.rpc("account_info", pseudoStr); + if (BEAST_EXPECT(accountInfo.isObject())) + { + auto const& accountData = + accountInfo[jss::result][jss::account_data]; + if (BEAST_EXPECT(accountData.isObject())) + { + BEAST_EXPECT(accountData[jss::Account] == pseudoStr); + BEAST_EXPECT( + accountData[sfVaultID] == to_string(vault.vaultID)); + } + auto const& pseudoInfo = + accountInfo[jss::result][jss::pseudo_account]; + if (BEAST_EXPECT(pseudoInfo.isObject())) + { + BEAST_EXPECT(pseudoInfo[jss::type] == "Vault"); + } + } + } + + using namespace loanBroker; + using namespace ripple::Lending; + + TenthBips32 const tenthBipsZero{0}; + + auto badKeylet = keylet::vault(alice.id(), env.seq(alice)); + // Try some failure cases + // not the vault owner + env(set(evan, vault.vaultID), ter(tecNO_PERMISSION)); + // not a vault + env(set(alice, badKeylet.key), ter(tecNO_ENTRY)); + // flags are checked first + env(set(evan, vault.vaultID, ~tfUniversal), ter(temINVALID_FLAG)); + // field length validation + // sfData: good length, bad account + env(set(evan, vault.vaultID), + data(std::string(maxDataPayloadLength, 'X')), + ter(tecNO_PERMISSION)); + // sfData: too long + env(set(evan, vault.vaultID), + data(std::string(maxDataPayloadLength + 1, 'Y')), + ter(temINVALID)); + // sfManagementFeeRate: good value, bad account + env(set(evan, vault.vaultID), + managementFeeRate(maxManagementFeeRate), + ter(tecNO_PERMISSION)); + // sfManagementFeeRate: too big + env(set(evan, vault.vaultID), + managementFeeRate(maxManagementFeeRate + TenthBips16(10)), + ter(temINVALID)); + // sfCoverRateMinimum and sfCoverRateLiquidation are linked + // Cover: good value, bad account + env(set(evan, vault.vaultID), + coverRateMinimum(maxCoverRate), + coverRateLiquidation(maxCoverRate), + ter(tecNO_PERMISSION)); + // CoverMinimum: too big + env(set(evan, vault.vaultID), + coverRateMinimum(maxCoverRate + 1), + coverRateLiquidation(maxCoverRate + 1), + ter(temINVALID)); + // CoverLiquidation: too big + env(set(evan, vault.vaultID), + coverRateMinimum(maxCoverRate / 2), + coverRateLiquidation(maxCoverRate + 1), + ter(temINVALID)); + // Cover: zero min, non-zero liquidation - implicit and + // explicit zero values. + env(set(evan, vault.vaultID), + coverRateLiquidation(maxCoverRate), + ter(temINVALID)); + env(set(evan, vault.vaultID), + coverRateMinimum(tenthBipsZero), + coverRateLiquidation(maxCoverRate), + ter(temINVALID)); + // Cover: non-zero min, zero liquidation - implicit and + // explicit zero values. + env(set(evan, vault.vaultID), + coverRateMinimum(maxCoverRate), + ter(temINVALID)); + env(set(evan, vault.vaultID), + coverRateMinimum(maxCoverRate), + coverRateLiquidation(tenthBipsZero), + ter(temINVALID)); + // sfDebtMaximum: good value, bad account + env(set(evan, vault.vaultID), + debtMaximum(Number(0)), + ter(tecNO_PERMISSION)); + // sfDebtMaximum: overflow + env(set(evan, vault.vaultID), + debtMaximum(Number(1, 100)), + ter(temINVALID)); + // sfDebtMaximum: negative + env(set(evan, vault.vaultID), + debtMaximum(Number(-1)), + ter(temINVALID)); + + std::string testData; + lifecycle( + "default fields", + env, + issuer, + alice, + evan, + bystander, + vault, + badVault, + // No modifications + {}, + [&](SLE::const_ref broker) { + // Extra checks + BEAST_EXPECT(!broker->isFieldPresent(sfManagementFeeRate)); + BEAST_EXPECT(!broker->isFieldPresent(sfCoverRateMinimum)); + BEAST_EXPECT( + !broker->isFieldPresent(sfCoverRateLiquidation)); + BEAST_EXPECT(!broker->isFieldPresent(sfData)); + BEAST_EXPECT(!broker->isFieldPresent(sfDebtMaximum)); + BEAST_EXPECT(broker->at(sfDebtMaximum) == 0); + BEAST_EXPECT(broker->at(sfCoverRateMinimum) == 0); + BEAST_EXPECT(broker->at(sfCoverRateLiquidation) == 0); + + BEAST_EXPECT( + env.ownerCount(alice) == aliceOriginalCount + 4); + }, + [&](SLE::const_ref broker) { + // Modifications + + // Update the fields + auto const nextKeylet = + keylet::loanbroker(alice.id(), env.seq(alice)); + + // fields that can't be changed + // LoanBrokerID + env(set(alice, vault.vaultID), + loanBrokerID(nextKeylet.key), + ter(tecNO_ENTRY)); + // VaultID + env(set(alice, nextKeylet.key), + loanBrokerID(broker->key()), + ter(tecNO_PERMISSION)); + // Owner + env(set(evan, vault.vaultID), + loanBrokerID(broker->key()), + ter(tecNO_PERMISSION)); + // ManagementFeeRate + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + managementFeeRate(maxManagementFeeRate), + ter(temINVALID)); + // CoverRateMinimum + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + coverRateMinimum(maxManagementFeeRate), + ter(temINVALID)); + // CoverRateLiquidation + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + coverRateLiquidation(maxManagementFeeRate), + ter(temINVALID)); + + // fields that can be changed + testData = "Test Data 1234"; + // Bad data: too long + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + data(std::string(maxDataPayloadLength + 1, 'W')), + ter(temINVALID)); + + // Bad debt maximum + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + debtMaximum(Number(-175, -1)), + ter(temINVALID)); + // Data & Debt maximum + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + data(testData), + debtMaximum(Number(175, -1))); + }, + [&](SLE::const_ref broker) { + // Check the updated fields + BEAST_EXPECT(checkVL(broker->at(sfData), testData)); + BEAST_EXPECT(broker->at(sfDebtMaximum) == Number(175, -1)); + }); + + lifecycle( + "non-default fields", + env, + issuer, + alice, + evan, + bystander, + vault, + badVault, + [&](jtx::JTx const& jv) { + testData = "spam spam spam spam"; + // Finally, create another Loan Broker with none of the + // values at default + return env.jt( + jv, + data(testData), + managementFeeRate(TenthBips16(123)), + debtMaximum(Number(9)), + coverRateMinimum(TenthBips32(100)), + coverRateLiquidation(TenthBips32(200))); + }, + [&](SLE::const_ref broker) { + // Extra checks + BEAST_EXPECT(broker->at(sfManagementFeeRate) == 123); + BEAST_EXPECT(broker->at(sfCoverRateMinimum) == 100); + BEAST_EXPECT(broker->at(sfCoverRateLiquidation) == 200); + BEAST_EXPECT(broker->at(sfDebtMaximum) == Number(9)); + BEAST_EXPECT(checkVL(broker->at(sfData), testData)); + }, + [&](SLE::const_ref broker) { + // Reset Data & Debt maximum to default values + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + data(""), + debtMaximum(Number(0))); + }, + [&](SLE::const_ref broker) { + // Check the updated fields + BEAST_EXPECT(!broker->isFieldPresent(sfData)); + BEAST_EXPECT(!broker->isFieldPresent(sfDebtMaximum)); + }); + } + + BEAST_EXPECT(env.ownerCount(alice) == aliceOriginalCount); + } + + enum LoanBrokerTest { + CoverClawback, + CoverDeposit, + CoverWithdraw, + Delete, + Set + }; + + void + testLoanBroker( + std::function getAsset, + LoanBrokerTest brokerTest) + { + using namespace jtx; + using namespace loanBroker; + Account const issuer{"issuer"}; + Account const alice{"alice"}; + Env env(*this); + Vault vault{env}; + + env.fund(XRP(100'000), issuer, alice); + env.close(); + + PrettyAsset const asset = [&]() { + if (getAsset) + return getAsset(env, issuer, alice); + env(trust(alice, issuer["IOU"](1'000'000)), THISLINE); + env.close(); + return PrettyAsset(issuer["IOU"]); + }(); + + env(pay(issuer, alice, asset(100'000)), THISLINE); + env.close(); + + auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = asset}); + env(tx, THISLINE); + env.close(); + auto const le = env.le(vaultKeylet); + VaultInfo vaultInfo = [&]() { + if (BEAST_EXPECT(le)) + return VaultInfo{asset, vaultKeylet.key, le->at(sfAccount)}; + return VaultInfo{asset, {}, {}}; + }(); + if (vaultInfo.vaultID == uint256{}) + return; + + env(vault.deposit( + {.depositor = alice, + .id = vaultKeylet.key, + .amount = asset(50)}), + THISLINE); + env.close(); + + auto const brokerKeylet = + keylet::loanbroker(alice.id(), env.seq(alice)); + env(set(alice, vaultInfo.vaultID), THISLINE); + env.close(); + + auto broker = env.le(brokerKeylet); + if (!BEAST_EXPECT(broker)) + return; + + auto testZeroBrokerID = [&](auto&& getTxJv) { + auto jv = getTxJv(); + // empty broker ID + jv[sfLoanBrokerID] = ""; + env(jv, ter(temINVALID), THISLINE); + // zero broker ID + jv[sfLoanBrokerID] = to_string(uint256{}); + // needs a flag to distinguish the parsed STTx from the prior + // test + env(jv, txflags(tfFullyCanonicalSig), ter(temINVALID), THISLINE); + }; + auto testZeroVaultID = [&](auto&& getTxJv) { + auto jv = getTxJv(); + // empty broker ID + jv[sfVaultID] = ""; + env(jv, ter(temINVALID), THISLINE); + // zero broker ID + jv[sfVaultID] = to_string(uint256{}); + // needs a flag to distinguish the parsed STTx from the prior + // test + env(jv, txflags(tfFullyCanonicalSig), ter(temINVALID), THISLINE); + }; + + if (brokerTest == CoverDeposit) + { + // preflight: temINVALID (empty/zero broker id) + testZeroBrokerID([&]() { + return coverDeposit(alice, brokerKeylet.key, asset(10)); + }); + + // preclaim: tecWRONG_ASSET + env(coverDeposit(alice, brokerKeylet.key, issuer["BAD"](10)), + ter(tecWRONG_ASSET), + THISLINE); + + // preclaim: tecINSUFFICIENT_FUNDS + env(pay(alice, issuer, asset(100'000 - 50)), THISLINE); + env.close(); + env(coverDeposit(alice, brokerKeylet.key, vaultInfo.asset(10)), + ter(tecINSUFFICIENT_FUNDS)); + + // preclaim: tecFROZEN + env(fset(issuer, asfGlobalFreeze), THISLINE); + env.close(); + env(coverDeposit(alice, brokerKeylet.key, vaultInfo.asset(10)), + ter(tecFROZEN), + THISLINE); + } + else + // Fund the cover deposit + env(coverDeposit(alice, brokerKeylet.key, vaultInfo.asset(10)), + THISLINE); + env.close(); + + if (brokerTest == CoverWithdraw) + { + // preflight: temINVALID (empty/zero broker id) + testZeroBrokerID([&]() { + return coverWithdraw(alice, brokerKeylet.key, asset(10)); + }); + + // preclaim: tecWRONG_ASSSET + env(coverWithdraw(alice, brokerKeylet.key, issuer["BAD"](10)), + ter(tecWRONG_ASSET), + THISLINE); + + // preclaim: tecNO_DST + Account const bogus{"bogus"}; + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(bogus), + ter(tecNO_DST), + THISLINE); + + // preclaim: tecDST_TAG_NEEDED + Account const dest{"dest"}; + env.fund(XRP(1'000), dest); + env(fset(dest, asfRequireDest), THISLINE); + env.close(); + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(dest), + ter(tecDST_TAG_NEEDED), + THISLINE); + + // preclaim: tecNO_PERMISSION + env(fclear(dest, asfRequireDest), THISLINE); + env(fset(dest, asfDepositAuth), THISLINE); + env.close(); + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(dest), + ter(tecNO_PERMISSION), + THISLINE); + + // preclaim: tecFROZEN + env(trust(dest, asset(1'000)), THISLINE); + env(fclear(dest, asfDepositAuth), THISLINE); + env(fset(issuer, asfGlobalFreeze), THISLINE); + env.close(); + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(dest), + ter(tecFROZEN), + THISLINE); + + // preclaim:: tecFROZEN (deep frozen) + env(fclear(issuer, asfGlobalFreeze), THISLINE); + env(trust( + issuer, asset(1'000), dest, tfSetFreeze | tfSetDeepFreeze), + THISLINE); + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(dest), + ter(tecFROZEN), + THISLINE); + } + + if (brokerTest == CoverClawback) + { + // preflight: temINVALID (empty/zero broker id) + testZeroBrokerID([&]() { + return env.json( + coverClawback(alice), + loanBrokerID(brokerKeylet.key), + amount(vaultInfo.asset(2))); + }); + + if (asset.holds()) + { + // preclaim: AllowTrustLineClaback is not set + env(coverClawback(issuer), + loanBrokerID(brokerKeylet.key), + amount(vaultInfo.asset(2)), + ter(tecNO_PERMISSION), + THISLINE); + + // preclaim: NoFreeze is set + env(fset(issuer, asfAllowTrustLineClawback | asfNoFreeze), + THISLINE); + env.close(); + env(coverClawback(issuer), + loanBrokerID(brokerKeylet.key), + amount(vaultInfo.asset(2)), + ter(tecNO_PERMISSION), + THISLINE); + } + else + { + // preclaim: MPTCanClawback is not set or MPTCanLock is not set + env(coverClawback(issuer), + loanBrokerID(brokerKeylet.key), + amount(vaultInfo.asset(2)), + ter(tecNO_PERMISSION), + THISLINE); + } + env.close(); + } + + if (brokerTest == Delete) + { + Account const borrower{"borrower"}; + env.fund(XRP(1'000), borrower); + env(loan::set(borrower, brokerKeylet.key, asset(50).value()), + sig(sfCounterpartySignature, alice), + fee(env.current()->fees().base * 2), + THISLINE); + + // preflight: temINVALID (empty/zero broker id) + testZeroBrokerID([&]() { return del(alice, brokerKeylet.key); }); + + // preclaim: tecHAS_OBLIGATIONS + env(del(alice, brokerKeylet.key), + ter(tecHAS_OBLIGATIONS), + THISLINE); + + // Repay and delete the loan + auto const loanKeylet = keylet::loan(brokerKeylet.key, 1); + env(loan::pay(borrower, loanKeylet.key, asset(50).value()), + THISLINE); + env(loan::del(alice, loanKeylet.key), THISLINE); + + env(trust(issuer, asset(0), alice, tfSetFreeze | tfSetDeepFreeze), + THISLINE); + // preclaim: tecFROZEN (deep frozen) + env(del(alice, brokerKeylet.key), ter(tecFROZEN), THISLINE); + env(trust( + issuer, asset(0), alice, tfClearFreeze | tfClearDeepFreeze), + THISLINE); + + // successful delete the loan broker object + env(del(alice, brokerKeylet.key), ter(tesSUCCESS), THISLINE); + } + else + env(del(alice, brokerKeylet.key), THISLINE); + + if (brokerTest == Set) + { + // preflight: temINVALID (empty/zero broker id) + testZeroBrokerID([&]() { + return env.json( + set(alice, vaultInfo.vaultID), + loanBrokerID(brokerKeylet.key)); + }); + // preflight: temINVALID (empty/zero vault id) + testZeroVaultID([&]() { + return env.json( + set(alice, vaultInfo.vaultID), + loanBrokerID(brokerKeylet.key)); + }); + + if (asset.holds()) + { + env(fclear(issuer, asfDefaultRipple), THISLINE); + env.close(); + // preclaim: DefaultRipple is not set + env(set(alice, vaultInfo.vaultID), ter(terNO_RIPPLE), THISLINE); + + env(fset(issuer, asfDefaultRipple), THISLINE); + env.close(); + } + + auto const amt = env.balance(alice) - + env.current()->fees().accountReserve(env.ownerCount(alice)); + env(pay(alice, issuer, amt), THISLINE); + + // preclaim:: tecINSUFFICIENT_RESERVE + env(set(alice, vaultInfo.vaultID), + ter(tecINSUFFICIENT_RESERVE), + THISLINE); + } + } + + void + testInvalidLoanBrokerCoverClawback() + { + testcase("Invalid LoanBrokerCoverClawback"); + using namespace jtx; + using namespace loanBroker; + + // preflight + { + Account const alice{"alice"}; + Account const issuer{"issuer"}; + auto const USD = alice["USD"]; + Env env(*this); + env.fund(XRP(100'000), alice); + env.close(); + + auto jtx = env.jt(coverClawback(alice), amount(USD(100))); + + // holder == account + env(jtx, ter(temINVALID), THISLINE); + + // holder == beast::zero + STAmount bad(Issue{USD.currency, beast::zero}, 100); + jtx.jv[sfAmount] = bad.getJson(); + jtx.stx = env.ust(jtx); + Serializer s; + jtx.stx->add(s); + auto const jrr = env.rpc("submit", strHex(s.slice()))[jss::result]; + // fails in doSubmit() on STTx construction + BEAST_EXPECT(jrr[jss::error] == "invalidTransaction"); + BEAST_EXPECT(jrr[jss::error_exception] == "invalid native account"); + } + + // preclaim + + // Issue: + // AllowTrustLineClawback is not set or NoFreeze is set + testLoanBroker({}, CoverClawback); + + // MPTIssue: + // MPTCanClawback is not set + testLoanBroker( + [&](Env& env, Account const& issuer, Account const& alice) -> MPT { + MPTTester mpt( + {.env = env, .issuer = issuer, .holders = {alice}}); + return mpt; + }, + CoverClawback); + } + + void + testInvalidLoanBrokerCoverDeposit() + { + testcase("Invalid LoanBrokerCoverDeposit"); + using namespace jtx; + + // preclaim: + // tecWRONG_ASSET, tecINSUFFICIENT_FUNDS, frozen asset + testLoanBroker({}, CoverDeposit); + } + + void + testInvalidLoanBrokerCoverWithdraw() + { + testcase("Invalid LoanBrokerCoverWithdraw"); + using namespace jtx; + + /* + preflight: illegal net + isLegalNet() check is probably redundant. STAmount parsing + should throw an exception on deserialize + + preclaim: tecWRONG_ASSET, tecNO_DST, tecDST_TAG_NEEDED, + tecNO_PERMISSION, checkFrozen failure, checkDeepFrozenFailure, + second+third tecINSUFFICIENT_FUNDS (can this happen)? + doApply: tecPATH_DRY (can it happen, funds already checked?) + */ + testLoanBroker({}, CoverWithdraw); + } + + void + testInvalidLoanBrokerDelete() + { + using namespace jtx; + testcase("Invalid LoanBrokerDelete"); + /* + preclaim: tecHAS_OBLIGATIONS + doApply: + accountSend failure, removeEmptyHolding failure, + all tecHAS_OBLIGATIONS (can any of these happen?) + */ + testLoanBroker({}, Delete); + } + + void + testInvalidLoanBrokerSet() + { + using namespace jtx; + testcase("Invalid LoanBrokerSet"); + + /*preclaim: canAddHolding failure (can it happen with MPT? + can't create Vault if CanTransfer is not enabled.) + doApply: + first+second dirLink failure, createPseudoAccount failure, + addEmptyHolding failure + can any of these happen? + */ + testLoanBroker({}, Set); + } + + void + testLoanBrokerCoverDepositNullVault() + { + // This test is lifted directly from + // https://bugs.immunefi.com/dashboard/submission/57808 + using namespace jtx; + Env env(*this); + + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + env.close(); + + // Create a Vault owned by alice with an XRP asset + PrettyAsset const asset{xrpIssue(), 1}; + Vault vault{env}; + auto const [createTx, vaultKeylet] = + vault.create({.owner = alice, .asset = asset}); + env(createTx); + env.close(); + + // Predict LoanBroker key using alice's current sequence BEFORE submit + auto const brokerKeylet = + keylet::loanbroker(alice.id(), env.seq(alice)); + + // Create LoanBroker pointing to the vault + env(loanBroker::set(alice, vaultKeylet.key)); + env.close(); + + // Build the CoverDeposit STTx directly + STTx tx{ttLOAN_BROKER_COVER_DEPOSIT, [](STObject&) {}}; + tx.setAccountID(sfAccount, alice.id()); + tx.setFieldH256(sfLoanBrokerID, brokerKeylet.key); + tx.setFieldAmount(sfAmount, asset(1)); + + // Create a writable view cloned from the current ledger and remove the + // vault SLE + OpenView ov{*env.current()}; + test::StreamSink sink{beast::severities::kWarning}; + beast::Journal jlog{sink}; + ApplyContext ac{ + env.app(), + ov, + tx, + tesSUCCESS, + env.current()->fees().base, + tapNONE, + jlog}; + + if (auto sleBroker = + ac.view().peek(keylet::loanbroker(brokerKeylet.key))) + { + auto const vaultID = (*sleBroker)[sfVaultID]; + if (auto sleVault = ac.view().peek(keylet::vault(vaultID))) + { + ac.view().erase(sleVault); + } + } + + // Invoke preclaim against the mutated (ApplyView) view; triggers + // nullptr deref + PreclaimContext pctx{ + env.app(), ac.view(), tesSUCCESS, tx, tapNONE, jlog}; + (void)LoanBrokerCoverDeposit::preclaim(pctx); + } + + void + testRequireAuth() + { + testcase("Require Auth - Implicit Pseudo-account authorization"); + using namespace jtx; + using namespace loanBroker; + + Account const issuer{"issuer"}; + Account const alice{"alice"}; + Env env(*this); + Vault vault{env}; + + env.fund(XRP(100'000), issuer, alice); + env.close(); + + auto asset = MPTTester({ + .env = env, + .issuer = issuer, + .holders = {alice}, + .flags = MPTDEXFlags | tfMPTRequireAuth | tfMPTCanClawback | + tfMPTCanLock, + .authHolder = true, + }); + + env(pay(issuer, alice, asset(100'000))); + env.close(); + + // Alice is not authorized, can still create the vault + asset.authorize( + {.account = issuer, .holder = alice, .flags = tfMPTUnauthorize}); + auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = asset}); + env(tx); + env.close(); + + auto const le = env.le(vaultKeylet); + VaultInfo vaultInfo = [&]() { + if (BEAST_EXPECT(le)) + return VaultInfo{asset, vaultKeylet.key, le->at(sfAccount)}; + return VaultInfo{asset, {}, {}}; + }(); + if (vaultInfo.vaultID == uint256{}) + return; + + // Can't unauthorize Vault pseudo-account + asset.authorize( + {.account = issuer, + .holder = vaultInfo.pseudoAccount, + .flags = tfMPTUnauthorize, + .err = tecNO_PERMISSION}); + + auto forUnauthAuth = [&](auto&& doTx) { + for (auto const flag : {tfMPTUnauthorize, 0u}) + { + asset.authorize( + {.account = issuer, .holder = alice, .flags = flag}); + env.close(); + doTx(flag == 0); + env.close(); + } + }; + + // Can't deposit into Vault if the vault owner is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? ter(tecNO_AUTH) : ter(tesSUCCESS); + env(vault.deposit( + {.depositor = alice, + .id = vaultKeylet.key, + .amount = asset(51)}), + err); + }); + + // Can't withdraw from Vault if the vault owner is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? ter(tecNO_AUTH) : ter(tesSUCCESS); + env(vault.withdraw( + {.depositor = alice, + .id = vaultKeylet.key, + .amount = asset(1)}), + err); + }); + + auto const brokerKeylet = + keylet::loanbroker(alice.id(), env.seq(alice)); + // Can create LoanBroker if the vault owner is not authorized + forUnauthAuth([&](auto) { env(set(alice, vaultInfo.vaultID)); }); + + auto const broker = env.le(brokerKeylet); + if (!BEAST_EXPECT(broker)) + return; + Account brokerPseudo("pseudo", broker->at(sfAccount)); + + // Can't unauthorize LoanBroker pseudo-account + asset.authorize( + {.account = issuer, + .holder = brokerPseudo, + .flags = tfMPTUnauthorize, + .err = tecNO_PERMISSION}); + + // Can't cover deposit into Vault if the vault owner is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? ter(tecNO_AUTH) : ter(tesSUCCESS); + env(coverDeposit(alice, brokerKeylet.key, vaultInfo.asset(10)), + err); + }); + + // Can't cover withdraw from Vault if the vault owner is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? ter(tecNO_AUTH) : ter(tesSUCCESS); + env(coverWithdraw(alice, brokerKeylet.key, vaultInfo.asset(5)), + err); + }); + + // Issuer can always cover clawback. The holder authorization is n/a. + forUnauthAuth([&](bool) { + env(coverClawback(issuer), + loanBrokerID(brokerKeylet.key), + amount(vaultInfo.asset(1))); + }); + } + +public: + void + run() override + { + testLoanBrokerCoverDepositNullVault(); + + testDisabled(); + testLifecycle(); + testInvalidLoanBrokerCoverClawback(); + testInvalidLoanBrokerCoverDeposit(); + testInvalidLoanBrokerCoverWithdraw(); + testInvalidLoanBrokerDelete(); + testInvalidLoanBrokerSet(); + testRequireAuth(); + + // TODO: Write clawback failure tests with an issuer / MPT that doesn't + // have the right flags set. + } +}; + +BEAST_DEFINE_TESTSUITE(LoanBroker, tx, ripple); + +} // namespace test +} // namespace ripple diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp new file mode 100644 index 0000000000..2f5105a27a --- /dev/null +++ b/src/test/app/Loan_test.cpp @@ -0,0 +1,7217 @@ +#include +// +#include +#include + +#include +#include +#include +#include + +#include +#include + +namespace ripple { +namespace test { + +class Loan_test : public beast::unit_test::suite +{ +protected: + // Ensure that all the features needed for Lending Protocol are included, + // even if they are set to unsupported. + FeatureBitset const all{ + jtx::testable_amendments() | featureMPTokensV1 | + featureSingleAssetVault | featureLendingProtocol}; + + std::string const iouCurrency{"IOU"}; + + void + testDisabled() + { + testcase("Disabled"); + // Lending Protocol depends on Single Asset Vault (SAV). Test + // combinations of the two amendments. + // Single Asset Vault depends on MPTokensV1, but don't test every combo + // of that. + using namespace jtx; + auto failAll = [this](FeatureBitset features) { + Env env(*this, features); + + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, bob); + + auto const keylet = keylet::loanbroker(alice, env.seq(alice)); + + using namespace std::chrono_literals; + using namespace loan; + + // counter party signature is optional on LoanSet. Confirm that by + // sending transaction without one. + auto setTx = + env.jt(set(alice, keylet.key, Number(10000)), ter(temDISABLED)); + env(setTx); + + // All loan transactions are disabled. + // 1. LoanSet + setTx = env.jt( + setTx, sig(sfCounterpartySignature, bob), ter(temDISABLED)); + env(setTx); + // Actual sequence will be based off the loan broker, but we + // obviously don't have one of those if the amendment is disabled + auto const loanKeylet = keylet::loan(keylet.key, env.seq(alice)); + // Other Loan transactions are disabled, too. + // 2. LoanDelete + env(del(alice, loanKeylet.key), ter(temDISABLED)); + // 3. LoanManage + env(manage(alice, loanKeylet.key, tfLoanImpair), ter(temDISABLED)); + // 4. LoanPay + env(pay(alice, loanKeylet.key, XRP(500)), ter(temDISABLED)); + }; + failAll(all - featureMPTokensV1); + failAll(all - featureSingleAssetVault - featureLendingProtocol); + failAll(all - featureSingleAssetVault); + failAll(all - featureLendingProtocol); + } + + struct BrokerParameters + { + Number vaultDeposit = 1'000'000; + Number debtMax = 25'000; + TenthBips32 coverRateMin = percentageToTenthBips(10); + int coverDeposit = 1000; + TenthBips16 managementFeeRate{100}; + TenthBips32 coverRateLiquidation = percentageToTenthBips(25); + std::string data{}; + std::uint32_t flags = 0; + + Number + maxCoveredLoanValue(Number const& currentDebt) const + { + NumberRoundModeGuard mg(Number::downward); + auto debtLimit = + coverDeposit * tenthBipsPerUnity.value() / coverRateMin.value(); + + return debtLimit - currentDebt; + } + + static BrokerParameters const& + defaults() + { + static BrokerParameters const result{}; + return result; + } + + // TODO: create an operator() which returns a transaction similar to + // LoanParameters + }; + + struct BrokerInfo + { + jtx::PrettyAsset asset; + uint256 brokerID; + uint256 vaultID; + BrokerParameters params; + BrokerInfo( + jtx::PrettyAsset const& asset_, + Keylet const& brokerKeylet_, + Keylet const& vaultKeylet_, + BrokerParameters const& p) + : asset(asset_) + , brokerID(brokerKeylet_.key) + , vaultID(vaultKeylet_.key) + , params(p) + { + } + + Keylet + brokerKeylet() const + { + return keylet::loanbroker(brokerID); + } + Keylet + vaultKeylet() const + { + return keylet::vault(vaultID); + } + + int + vaultScale(jtx::Env const& env) const + { + using namespace jtx; + + auto const vaultSle = env.le(keylet::vault(vaultID)); + return getVaultScale(vaultSle); + } + }; + + struct LoanParameters + { + // The account submitting the transaction. May be borrower or broker. + jtx::Account account; + // The counterparty. Should be the other of borrower or broker. + jtx::Account counter; + // Whether the counterparty is specified in the `counterparty` field, or + // only signs. + bool counterpartyExplicit = true; + Number principalRequest; + std::optional setFee{}; + std::optional originationFee{}; + std::optional serviceFee{}; + std::optional lateFee{}; + std::optional closeFee{}; + std::optional overFee{}; + std::optional interest{}; + std::optional lateInterest{}; + std::optional closeInterest{}; + std::optional overpaymentInterest{}; + std::optional payTotal{}; + std::optional payInterval{}; + std::optional gracePd{}; + std::optional flags{}; + + template + jtx::JTx + operator()(jtx::Env& env, BrokerInfo const& broker, FN const&... fN) + const + { + using namespace jtx; + using namespace jtx::loan; + + JTx jt{loan::set( + account, + broker.brokerID, + broker.asset(principalRequest).number(), + flags.value_or(0))}; + + sig(sfCounterpartySignature, counter)(env, jt); + + fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt); + + if (counterpartyExplicit) + counterparty(counter)(env, jt); + if (originationFee) + loanOriginationFee(broker.asset(*originationFee).number())( + env, jt); + if (serviceFee) + loanServiceFee(broker.asset(*serviceFee).number())(env, jt); + if (lateFee) + latePaymentFee(broker.asset(*lateFee).number())(env, jt); + if (closeFee) + closePaymentFee(broker.asset(*closeFee).number())(env, jt); + if (overFee) + overpaymentFee (*overFee)(env, jt); + if (interest) + interestRate (*interest)(env, jt); + if (lateInterest) + lateInterestRate (*lateInterest)(env, jt); + if (closeInterest) + closeInterestRate (*closeInterest)(env, jt); + if (overpaymentInterest) + overpaymentInterestRate (*overpaymentInterest)(env, jt); + if (payTotal) + paymentTotal (*payTotal)(env, jt); + if (payInterval) + paymentInterval (*payInterval)(env, jt); + if (gracePd) + gracePeriod (*gracePd)(env, jt); + + return env.jt(jt, fN...); + } + }; + + struct PaymentParameters + { + Number overpaymentFactor = Number{1}; + std::optional overpaymentExtra = std::nullopt; + std::uint32_t flags = 0; + bool showStepBalances = false; + bool validateBalances = true; + + static PaymentParameters const& + defaults() + { + static PaymentParameters const result{}; + return result; + } + }; + + struct LoanState + { + std::uint32_t previousPaymentDate = 0; + NetClock::time_point startDate = {}; + std::uint32_t nextPaymentDate = 0; + std::uint32_t paymentRemaining = 0; + std::int32_t const loanScale = 0; + Number totalValue = 0; + Number principalOutstanding = 0; + Number managementFeeOutstanding = 0; + Number periodicPayment = 0; + std::uint32_t flags = 0; + std::uint32_t const paymentInterval = 0; + TenthBips32 const interestRate{}; + }; + + /** Helper class to compare the expected state of a loan and loan broker + * against the data in the ledger. + */ + struct VerifyLoanStatus + { + public: + jtx::Env const& env; + BrokerInfo const& broker; + jtx::Account const& pseudoAccount; + Keylet const& loanKeylet; + + VerifyLoanStatus( + jtx::Env const& env_, + BrokerInfo const& broker_, + jtx::Account const& pseudo_, + Keylet const& keylet_) + : env(env_) + , broker(broker_) + , pseudoAccount(pseudo_) + , loanKeylet(keylet_) + { + } + + /** Checks the expected broker state against the ledger + */ + void + checkBroker( + Number const& principalOutstanding, + Number const& interestOwed, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining, + std::uint32_t ownerCount) const + { + using namespace jtx; + if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + env.test.BEAST_EXPECT(brokerSle)) + { + TenthBips16 const managementFeeRate{ + brokerSle->at(sfManagementFeeRate)}; + auto const brokerDebt = brokerSle->at(sfDebtTotal); + auto const expectedDebt = principalOutstanding + interestOwed; + env.test.BEAST_EXPECT(brokerDebt == expectedDebt); + env.test.BEAST_EXPECT( + env.balance(pseudoAccount, broker.asset).number() == + brokerSle->at(sfCoverAvailable)); + env.test.BEAST_EXPECT( + brokerSle->at(sfOwnerCount) == ownerCount); + + if (auto vaultSle = + env.le(keylet::vault(brokerSle->at(sfVaultID))); + env.test.BEAST_EXPECT(vaultSle)) + { + Account const vaultPseudo{ + "vaultPseudoAccount", vaultSle->at(sfAccount)}; + env.test.BEAST_EXPECT( + vaultSle->at(sfAssetsAvailable) == + env.balance(vaultPseudo, broker.asset).number()); + if (ownerCount == 0) + { + // Allow some slop for rounding IOUs + + // TODO: This needs to be an exact match once all the + // other rounding issues are worked out. + auto const total = vaultSle->at(sfAssetsTotal); + auto const available = vaultSle->at(sfAssetsAvailable); + env.test.BEAST_EXPECT( + total == available || + (!broker.asset.integral() && available != 0 && + ((total - available) / available < + Number(1, -6)))); + env.test.BEAST_EXPECT( + vaultSle->at(sfLossUnrealized) == 0); + } + } + } + } + + void + checkPayment( + std::int32_t loanScale, + jtx::Account const& account, + jtx::PrettyAmount const& balanceBefore, + STAmount const& expectedPayment, + jtx::PrettyAmount const& adjustment) const + { + auto const borrowerScale = + std::max(loanScale, balanceBefore.number().exponent()); + + STAmount const balanceChangeAmount{ + broker.asset, + roundToAsset( + broker.asset, expectedPayment + adjustment, borrowerScale)}; + { + auto const difference = roundToScale( + env.balance(account, broker.asset) - + (balanceBefore - balanceChangeAmount), + borrowerScale); + env.test.BEAST_EXPECT( + roundToScale(difference, loanScale) >= beast::zero); + } + } + + /** Checks both the loan and broker expect states against the ledger */ + void + operator()( + std::uint32_t previousPaymentDate, + std::uint32_t nextPaymentDate, + std::uint32_t paymentRemaining, + Number const& loanScale, + Number const& totalValue, + Number const& principalOutstanding, + Number const& managementFeeOutstanding, + Number const& periodicPayment, + std::uint32_t flags) const + { + using namespace jtx; + if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) + { + env.test.BEAST_EXPECT( + loan->at(sfPreviousPaymentDate) == previousPaymentDate); + env.test.BEAST_EXPECT( + loan->at(sfPaymentRemaining) == paymentRemaining); + env.test.BEAST_EXPECT( + loan->at(sfNextPaymentDueDate) == nextPaymentDate); + env.test.BEAST_EXPECT(loan->at(sfLoanScale) == loanScale); + env.test.BEAST_EXPECT( + loan->at(sfTotalValueOutstanding) == totalValue); + env.test.BEAST_EXPECT( + loan->at(sfPrincipalOutstanding) == principalOutstanding); + env.test.BEAST_EXPECT( + loan->at(sfManagementFeeOutstanding) == + managementFeeOutstanding); + env.test.BEAST_EXPECT( + loan->at(sfPeriodicPayment) == periodicPayment); + env.test.BEAST_EXPECT(loan->at(sfFlags) == flags); + + auto const ls = constructRoundedLoanState(loan); + + auto const interestRate = TenthBips32{loan->at(sfInterestRate)}; + auto const paymentInterval = loan->at(sfPaymentInterval); + checkBroker( + principalOutstanding, + ls.interestDue, + interestRate, + paymentInterval, + paymentRemaining, + 1); + + if (auto brokerSle = + env.le(keylet::loanbroker(broker.brokerID)); + env.test.BEAST_EXPECT(brokerSle)) + { + if (auto vaultSle = + env.le(keylet::vault(brokerSle->at(sfVaultID))); + env.test.BEAST_EXPECT(vaultSle)) + { + if ((flags & lsfLoanImpaired) && + !(flags & lsfLoanDefault)) + { + env.test.BEAST_EXPECT( + vaultSle->at(sfLossUnrealized) == + totalValue - managementFeeOutstanding); + } + else + { + env.test.BEAST_EXPECT( + vaultSle->at(sfLossUnrealized) == 0); + } + } + } + } + } + + /** Checks both the loan and broker expect states against the ledger */ + void + operator()(LoanState const& state) const + { + operator()( + state.previousPaymentDate, + state.nextPaymentDate, + state.paymentRemaining, + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + state.flags); + }; + }; + + BrokerInfo + createVaultAndBroker( + jtx::Env& env, + jtx::PrettyAsset const& asset, + jtx::Account const& lender, + BrokerParameters const& params = BrokerParameters::defaults()) + { + using namespace jtx; + + Vault vault{env}; + + auto const deposit = asset(params.vaultDeposit); + auto const debtMaximumValue = asset(params.debtMax).value(); + auto const coverDepositValue = asset(params.coverDeposit).value(); + + auto const coverRateMinValue = params.coverRateMin; + + auto [tx, vaultKeylet] = + vault.create({.owner = lender, .asset = asset}); + env(tx); + env.close(); + BEAST_EXPECT(env.le(vaultKeylet)); + + env(vault.deposit( + {.depositor = lender, .id = vaultKeylet.key, .amount = deposit})); + env.close(); + if (auto const vault = env.le(keylet::vault(vaultKeylet.key)); + BEAST_EXPECT(vault)) + { + BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value()); + } + + auto const keylet = keylet::loanbroker(lender.id(), env.seq(lender)); + + using namespace loanBroker; + env(set(lender, vaultKeylet.key, params.flags), + data(params.data), + managementFeeRate(params.managementFeeRate), + debtMaximum(debtMaximumValue), + coverRateMinimum(coverRateMinValue), + coverRateLiquidation(TenthBips32(params.coverRateLiquidation))); + + if (coverDepositValue != beast::zero) + env(coverDeposit(lender, keylet.key, coverDepositValue)); + + env.close(); + + return {asset, keylet, vaultKeylet, params}; + } + + /// Get the state without checking anything + LoanState + getCurrentState( + jtx::Env const& env, + BrokerInfo const& broker, + Keylet const& loanKeylet) + { + using d = NetClock::duration; + using tp = NetClock::time_point; + + // Lookup the current loan state + if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + return LoanState{ + .previousPaymentDate = loan->at(sfPreviousPaymentDate), + .startDate = tp{d{loan->at(sfStartDate)}}, + .nextPaymentDate = loan->at(sfNextPaymentDueDate), + .paymentRemaining = loan->at(sfPaymentRemaining), + .loanScale = loan->at(sfLoanScale), + .totalValue = loan->at(sfTotalValueOutstanding), + .principalOutstanding = loan->at(sfPrincipalOutstanding), + .managementFeeOutstanding = + loan->at(sfManagementFeeOutstanding), + .periodicPayment = loan->at(sfPeriodicPayment), + .flags = loan->at(sfFlags), + .paymentInterval = loan->at(sfPaymentInterval), + .interestRate = TenthBips32{loan->at(sfInterestRate)}, + }; + } + return LoanState{}; + } + + /// Get the state and check the values against the parameters used in + /// `lifecycle` + LoanState + getCurrentState( + jtx::Env const& env, + BrokerInfo const& broker, + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) + { + using namespace std::chrono_literals; + using d = NetClock::duration; + using tp = NetClock::time_point; + + auto const state = getCurrentState(env, broker, loanKeylet); + BEAST_EXPECT(state.previousPaymentDate == 0); + BEAST_EXPECT(tp{d{state.nextPaymentDate}} == state.startDate + 600s); + BEAST_EXPECT(state.paymentRemaining == 12); + BEAST_EXPECT(state.principalOutstanding == broker.asset(1000).value()); + BEAST_EXPECT( + state.loanScale >= + (broker.asset.integral() + ? 0 + : std::max( + broker.vaultScale(env), + state.principalOutstanding.exponent()))); + BEAST_EXPECT(state.paymentInterval == 600); + BEAST_EXPECT( + state.totalValue == + roundToAsset( + broker.asset, + state.periodicPayment * state.paymentRemaining, + state.loanScale)); + BEAST_EXPECT( + state.managementFeeOutstanding == + computeManagementFee( + broker.asset, + state.totalValue - state.principalOutstanding, + broker.params.managementFeeRate, + state.loanScale)); + + verifyLoanStatus(state); + + return state; + } + + bool + canImpairLoan( + jtx::Env const& env, + BrokerInfo const& broker, + LoanState const& state) + { + if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + if (auto const vaultSle = + env.le(keylet::vault(brokerSle->at(sfVaultID))); + BEAST_EXPECT(vaultSle)) + { + // log << vaultSle->getJson() << std::endl; + auto const assetsUnavailable = vaultSle->at(sfAssetsTotal) - + vaultSle->at(sfAssetsAvailable); + auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + + state.totalValue - state.managementFeeOutstanding; + + if (unrealizedLoss > assetsUnavailable) + { + return false; + } + } + } + return true; + } + + enum class AssetType { XRP = 0, IOU = 1, MPT = 2 }; + + // Specify the accounts as params to allow other accounts to be used + jtx::PrettyAsset + createAsset( + jtx::Env& env, + AssetType assetType, + BrokerParameters const& brokerParams, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + switch (assetType) + { + case AssetType::XRP: + // TODO: remove the factor, and set up loans in drops + return PrettyAsset{xrpIssue(), 1'000'000}; + + case AssetType::IOU: { + PrettyAsset const asset{issuer[iouCurrency]}; + + auto const limit = asset( + 100 * + (brokerParams.vaultDeposit + brokerParams.coverDeposit)); + if (lender != issuer) + env(trust(lender, limit)); + if (borrower != issuer) + env(trust(borrower, limit)); + + return asset; + } + + case AssetType::MPT: { + // Enough to cover initial fees + if (!env.le(keylet::account(issuer))) + env.fund( + env.current()->fees().accountReserve(10) * 10, issuer); + if (!env.le(keylet::account(lender))) + env.fund( + env.current()->fees().accountReserve(10) * 10, + noripple(lender)); + if (!env.le(keylet::account(borrower))) + env.fund( + env.current()->fees().accountReserve(10) * 10, + noripple(borrower)); + + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = + tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + // Scale the MPT asset so interest is interesting + PrettyAsset const asset{mptt.issuanceID(), 10'000}; + // Need to do the authorization here because mptt isn't + // accessible outside + if (lender != issuer) + mptt.authorize({.account = lender}); + if (borrower != issuer) + mptt.authorize({.account = borrower}); + + env.close(); + + return asset; + } + + default: + throw std::runtime_error("Unknown asset type"); + } + } + + void + describeLoan( + jtx::Env& env, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams, + AssetType assetType, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + auto const asset = + createAsset(env, assetType, brokerParams, issuer, lender, borrower); + auto const principal = asset(loanParams.principalRequest).number(); + auto const interest = loanParams.interest.value_or(TenthBips32{}); + auto const interval = + loanParams.payInterval.value_or(LoanSet::defaultPaymentInterval); + auto const total = + loanParams.payTotal.value_or(LoanSet::defaultPaymentTotal); + auto const feeRate = brokerParams.managementFeeRate; + auto const props = computeLoanProperties( + asset, + principal, + interest, + interval, + total, + feeRate, + asset(brokerParams.vaultDeposit).number().exponent()); + log << "Loan properties:\n" + << "\tPrincipal: " << principal << std::endl + << "\tInterest rate: " << interest << std::endl + << "\tPayment interval: " << interval << std::endl + << "\tManagement Fee Rate: " << feeRate << std::endl + << "\tTotal Payments: " << total << std::endl + << "\tPeriodic Payment: " << props.periodicPayment << std::endl + << "\tTotal Value: " << props.totalValueOutstanding << std::endl + << "\tManagement Fee: " << props.managementFeeOwedToBroker + << std::endl + << "\tLoan Scale: " << props.loanScale << std::endl + << "\tFirst payment principal: " << props.firstPaymentPrincipal + << std::endl; + + // checkGuards returns a TER, so success is 0 + BEAST_EXPECT(!checkLoanGuards( + asset, + asset(loanParams.principalRequest).number(), + loanParams.interest.value_or(TenthBips32{}) != beast::zero, + loanParams.payTotal.value_or(LoanSet::defaultPaymentTotal), + props, + env.journal)); + } + + std::optional> + createLoan( + jtx::Env& env, + AssetType assetType, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + // Enough to cover initial fees + env.fund(env.current()->fees().accountReserve(10) * 10, issuer); + if (lender != issuer) + env.fund( + env.current()->fees().accountReserve(10) * 10, + noripple(lender)); + if (borrower != issuer && borrower != lender) + env.fund( + env.current()->fees().accountReserve(10) * 10, + noripple(borrower)); + + describeLoan( + env, brokerParams, loanParams, assetType, issuer, lender, borrower); + + // Make the asset + auto const asset = + createAsset(env, assetType, brokerParams, issuer, lender, borrower); + + env.close(); + if (asset.native() || lender != issuer) + env(pay( + (asset.native() ? env.master : issuer), + lender, + asset(brokerParams.vaultDeposit + brokerParams.coverDeposit))); + // Fund the borrower later once we know the total loan + // size + + BrokerInfo const broker = + createVaultAndBroker(env, asset, lender, brokerParams); + + auto const pseudoAcctOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + auto const brokerPseudo = brokerSle->at(sfAccount); + return Account("Broker pseudo-account", brokerPseudo); + }(); + if (!pseudoAcctOpt) + return std::nullopt; + Account const& pseudoAcct = *pseudoAcctOpt; + + auto const loanKeyletOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the + // _LOAN_BROKER_ object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return keylet::loan(broker.brokerID, loanSequence); + }(); + if (!loanKeyletOpt) + return std::nullopt; + Keylet const& loanKeylet = *loanKeyletOpt; + + env(loanParams(env, broker)); + + env.close(); + + return std::make_tuple(broker, loanKeylet, pseudoAcct); + } + + void + topUpBorrower( + jtx::Env& env, + BrokerInfo const& broker, + jtx::Account const& issuer, + jtx::Account const& borrower, + LoanState const& state, + std::optional const& servFee) + { + using namespace jtx; + + STAmount const serviceFee = broker.asset(servFee.value_or(0)); + + // Ensure the borrower has enough funds to make the payments + // (including tx fees, if necessary) + auto const borrowerBalance = env.balance(borrower, broker.asset); + + auto const baseFee = env.current()->fees().base; + + // Add extra for transaction fees and reserves, if appropriate, or a + // tiny amount for the extra paid in each transaction + auto const totalNeeded = state.totalValue + + (serviceFee * state.paymentRemaining) + + (broker.asset.native() ? Number( + baseFee * state.paymentRemaining + + env.current()->fees().accountReserve( + env.ownerCount(borrower))) + : broker.asset(15).number()); + + auto const shortage = totalNeeded - borrowerBalance.number(); + + if (shortage > beast::zero && + (broker.asset.native() || issuer != borrower)) + env( + pay((broker.asset.native() ? env.master : issuer), + borrower, + STAmount{broker.asset, shortage})); + } + + void + makeLoanPayments( + jtx::Env& env, + BrokerInfo const& broker, + LoanParameters const& loanParams, + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower, + PaymentParameters const& paymentParams = PaymentParameters::defaults()) + { + // Make all the individual payments + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + using d = NetClock::duration; + + // Account const evan{"evan"}; + // Account const alice{"alice"}; + + bool const showStepBalances = paymentParams.showStepBalances; + + auto const currencyLabel = getCurrencyLabel(broker.asset); + + auto const baseFee = env.current()->fees().base; + + env.close(); + auto state = getCurrentState(env, broker, loanKeylet); + + verifyLoanStatus(state); + + STAmount const serviceFee = + broker.asset(loanParams.serviceFee.value_or(0)); + + topUpBorrower( + env, broker, issuer, borrower, state, loanParams.serviceFee); + + // Periodic payment amount will consist of + // 1. principal outstanding (1000) + // 2. interest interest rate (at 12%) + // 3. payment interval (600s) + // 4. loan service fee (2) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + auto const periodicRate = + loanPeriodicRate(state.interestRate, state.paymentInterval); + STAmount const roundedPeriodicPayment{ + broker.asset, + roundPeriodicPayment( + broker.asset, state.periodicPayment, state.loanScale)}; + + if (!showStepBalances) + log << currencyLabel << " Payment components: " + << "Payments remaining, " + << "rawInterest, rawPrincipal, " + "rawMFee, " + << "trackedValueDelta, trackedPrincipalDelta, " + "trackedInterestDelta, trackedMgmtFeeDelta, special" + << std::endl; + + // Include the service fee + STAmount const totalDue = roundToScale( + roundedPeriodicPayment + serviceFee, + state.loanScale, + Number::upward); + + auto currentRoundedState = constructLoanState( + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding); + { + auto const raw = computeRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + if (showStepBalances) + { + log << currencyLabel << " Starting loan balances: " + << "\n\tTotal value: " + << currentRoundedState.valueOutstanding << "\n\tPrincipal: " + << currentRoundedState.principalOutstanding + << "\n\tInterest: " << currentRoundedState.interestDue + << "\n\tMgmt fee: " << currentRoundedState.managementFeeDue + << "\n\tPayments remaining " << state.paymentRemaining + << std::endl; + } + else + { + log << currencyLabel + << " Loan starting state: " << state.paymentRemaining + << ", " << raw.interestDue << ", " + << raw.principalOutstanding << ", " << raw.managementFeeDue + << ", " << currentRoundedState.valueOutstanding << ", " + << currentRoundedState.principalOutstanding << ", " + << currentRoundedState.interestDue << ", " + << currentRoundedState.managementFeeDue << std::endl; + } + } + + // Try to pay a little extra to show that it's _not_ + // taken + auto const extraAmount = paymentParams.overpaymentExtra + ? broker.asset(*paymentParams.overpaymentExtra).value() + : std::min( + broker.asset(10).value(), + STAmount{broker.asset, totalDue / 20}); + + STAmount const transactionAmount = + STAmount{broker.asset, totalDue * paymentParams.overpaymentFactor} + + extraAmount; + + auto const borrowerInitialBalance = + env.balance(borrower, broker.asset).number(); + auto const initialState = state; + detail::PaymentComponents totalPaid{ + .trackedValueDelta = 0, + .trackedPrincipalDelta = 0, + .trackedManagementFeeDelta = 0}; + Number totalInterestPaid = 0; + Number totalFeesPaid = 0; + std::size_t totalPaymentsMade = 0; + + ripple::LoanState currentTrueState = computeRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + auto validateBorrowerBalance = [&]() { + if (borrower == issuer || !paymentParams.validateBalances) + return; + auto const totalSpent = + (totalPaid.trackedValueDelta + totalFeesPaid + + (broker.asset.native() ? Number(baseFee) * totalPaymentsMade + : numZero)); + BEAST_EXPECT( + env.balance(borrower, broker.asset).number() == + borrowerInitialBalance - totalSpent); + }; + + auto const defaultRound = broker.asset.integral() ? 3 : 0; + auto truncate = [defaultRound]( + Number const& n, + std::optional places = std::nullopt) { + auto const p = places.value_or(defaultRound); + if (p == 0) + return n; + auto const factor = Number{1, p}; + return (n * factor).truncate() / factor; + }; + while (state.paymentRemaining > 0) + { + validateBorrowerBalance(); + // Compute the expected principal amount + auto const paymentComponents = detail::computePaymentComponents( + broker.asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + BEAST_EXPECT( + paymentComponents.trackedValueDelta <= roundedPeriodicPayment || + (paymentComponents.specialCase == + detail::PaymentSpecialCase::final && + paymentComponents.trackedValueDelta >= + roundedPeriodicPayment)); + BEAST_EXPECT( + paymentComponents.trackedValueDelta == + paymentComponents.trackedPrincipalDelta + + paymentComponents.trackedInterestPart() + + paymentComponents.trackedManagementFeeDelta); + + ripple::LoanState const nextTrueState = computeRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining - 1, + broker.params.managementFeeRate); + detail::LoanStateDeltas const deltas = + currentTrueState - nextTrueState; + BEAST_EXPECT( + deltas.total() == + deltas.principal + deltas.interest + deltas.managementFee); + BEAST_EXPECT( + paymentComponents.specialCase == + detail::PaymentSpecialCase::final || + deltas.total() == state.periodicPayment || + (state.loanScale - + (deltas.total() - state.periodicPayment).exponent()) > 14); + + if (!showStepBalances) + log << currencyLabel + << " Payment components: " << state.paymentRemaining << ", " + + << deltas.interest << ", " << deltas.principal << ", " + << deltas.managementFee << ", " + << paymentComponents.trackedValueDelta << ", " + << paymentComponents.trackedPrincipalDelta << ", " + << paymentComponents.trackedInterestPart() << ", " + << paymentComponents.trackedManagementFeeDelta << ", " + << (paymentComponents.specialCase == + detail::PaymentSpecialCase::final + ? "final" + : paymentComponents.specialCase == + detail::PaymentSpecialCase::extra + ? "extra" + : "none") + << std::endl; + + auto const totalDueAmount = STAmount{ + broker.asset, paymentComponents.trackedValueDelta + serviceFee}; + + if (paymentParams.validateBalances) + { + // Due to the rounding algorithms to keep the interest and + // principal in sync with "true" values, the computed amount + // may be a little less than the rounded fixed payment + // amount. For integral types, the difference should be < 3 + // (1 unit for each of the interest and management fee). For + // IOUs, the difference should be dust. + Number const diff = totalDue - totalDueAmount; + BEAST_EXPECT( + paymentComponents.specialCase == + detail::PaymentSpecialCase::final || + diff == beast::zero || + (diff > beast::zero && + ((broker.asset.integral() && + (static_cast(diff) < 3)) || + (state.loanScale - diff.exponent() > 13)))); + + BEAST_EXPECT( + paymentComponents.trackedPrincipalDelta >= beast::zero && + paymentComponents.trackedPrincipalDelta <= + state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase != + detail::PaymentSpecialCase::final || + paymentComponents.trackedPrincipalDelta == + state.principalOutstanding); + } + + auto const borrowerBalanceBeforePayment = + env.balance(borrower, broker.asset); + + // Make the payment + env( + pay(borrower, + loanKeylet.key, + transactionAmount, + paymentParams.flags)); + + env.close(d{state.paymentInterval / 2}); + + if (paymentParams.validateBalances) + { + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + + // Check the result + verifyLoanStatus.checkPayment( + state.loanScale, + borrower, + borrowerBalanceBeforePayment, + totalDueAmount, + adjustment); + } + + if (showStepBalances) + { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + // No reason for this not to exist + return; + auto const current = constructRoundedLoanState(loanSle); + auto const errors = nextTrueState - current; + log << currencyLabel << " Loan balances: " + << "\n\tAmount taken: " + << paymentComponents.trackedValueDelta + << "\n\tTotal value: " << current.valueOutstanding + << " (true: " << truncate(nextTrueState.valueOutstanding) + << ", error: " << truncate(errors.total()) + << ")\n\tPrincipal: " << current.principalOutstanding + << " (true: " + << truncate(nextTrueState.principalOutstanding) + << ", error: " << truncate(errors.principal) + << ")\n\tInterest: " << current.interestDue + << " (true: " << truncate(nextTrueState.interestDue) + << ", error: " << truncate(errors.interest) + << ")\n\tMgmt fee: " << current.managementFeeDue + << " (true: " << truncate(nextTrueState.managementFeeDue) + << ", error: " << truncate(errors.managementFee) + << ")\n\tPayments remaining " + << loanSle->at(sfPaymentRemaining) << std::endl; + + currentRoundedState = current; + } + + --state.paymentRemaining; + state.previousPaymentDate = state.nextPaymentDate; + if (paymentComponents.specialCase == + detail::PaymentSpecialCase::final) + { + state.paymentRemaining = 0; + state.nextPaymentDate = 0; + } + else + { + state.nextPaymentDate += state.paymentInterval; + } + state.principalOutstanding -= + paymentComponents.trackedPrincipalDelta; + state.managementFeeOutstanding -= + paymentComponents.trackedManagementFeeDelta; + state.totalValue -= paymentComponents.trackedValueDelta; + + if (paymentParams.validateBalances) + verifyLoanStatus(state); + + totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; + totalPaid.trackedPrincipalDelta += + paymentComponents.trackedPrincipalDelta; + totalPaid.trackedManagementFeeDelta += + paymentComponents.trackedManagementFeeDelta; + totalInterestPaid += paymentComponents.trackedInterestPart(); + totalFeesPaid += serviceFee; + ++totalPaymentsMade; + + currentTrueState = nextTrueState; + } + validateBorrowerBalance(); + + // Loan is paid off + BEAST_EXPECT(state.paymentRemaining == 0); + BEAST_EXPECT(state.principalOutstanding == 0); + + auto const initialInterestDue = initialState.totalValue - + (initialState.principalOutstanding + + initialState.managementFeeOutstanding); + if (paymentParams.validateBalances) + { + // Make sure all the payments add up + BEAST_EXPECT( + totalPaid.trackedValueDelta == initialState.totalValue); + BEAST_EXPECT( + totalPaid.trackedPrincipalDelta == + initialState.principalOutstanding); + BEAST_EXPECT( + totalPaid.trackedManagementFeeDelta == + initialState.managementFeeOutstanding); + // This is almost a tautology given the previous checks, but + // check it anyway for completeness. + BEAST_EXPECT(totalInterestPaid == initialInterestDue); + BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); + } + + if (showStepBalances) + { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + // No reason for this not to exist + return; + log << currencyLabel << " Total amounts paid: " + << "\n\tTotal value: " << totalPaid.trackedValueDelta + << " (initial: " << truncate(initialState.totalValue) + << ", error: " + << truncate( + initialState.totalValue - totalPaid.trackedValueDelta) + << ")\n\tPrincipal: " << totalPaid.trackedPrincipalDelta + << " (initial: " << truncate(initialState.principalOutstanding) + << ", error: " + << truncate( + initialState.principalOutstanding - + totalPaid.trackedPrincipalDelta) + << ")\n\tInterest: " << totalInterestPaid + << " (initial: " << truncate(initialInterestDue) << ", error: " + << truncate(initialInterestDue - totalInterestPaid) + << ")\n\tMgmt fee: " << totalPaid.trackedManagementFeeDelta + << " (initial: " + << truncate(initialState.managementFeeOutstanding) + << ", error: " + << truncate( + initialState.managementFeeOutstanding - + totalPaid.trackedManagementFeeDelta) + << ")\n\tTotal payments made: " << totalPaymentsMade + << std::endl; + } + } + + void + runLoan( + AssetType assetType, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams) + { + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + Env env(*this, all); + + auto loanResult = createLoan( + env, assetType, brokerParams, loanParams, issuer, lender, borrower); + if (!BEAST_EXPECT(loanResult)) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower); + } + + /** Runs through the complete lifecycle of a loan + * + * 1. Create a loan. + * 2. Test a bunch of transaction failure conditions. + * 3. Use the `toEndOfLife` callback to take the loan to 0. How that is done + * depends on the callback. e.g. Default, Early payoff, make all the + * normal payments, etc. + * 4. Delete the loan. The loan will alternate between being deleted by the + * lender and the borrower. + */ + void + lifecycle( + std::string const& caseLabel, + char const* label, + jtx::Env& env, + Number const& loanAmount, + int interestExponent, + jtx::Account const& lender, + jtx::Account const& borrower, + jtx::Account const& evan, + BrokerInfo const& broker, + jtx::Account const& pseudoAcct, + std::uint32_t flags, + // The end of life callback is expected to take the loan to 0 payments + // remaining, one way or another + std::function toEndOfLife) + { + auto const [keylet, loanSequence] = [&]() { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + // will be invalid + return std::make_pair( + keylet::loan(broker.brokerID), std::uint32_t(0)); + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the _LOAN_BROKER_ + // object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return std::make_pair( + keylet::loan(broker.brokerID, loanSequence), loanSequence); + }(); + + VerifyLoanStatus const verifyLoanStatus( + env, broker, pseudoAcct, keylet); + + // No loans yet + verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 1, 0, 0); + + if (!BEAST_EXPECT(loanSequence != 0)) + return; + + testcase << caseLabel << " " << label; + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + auto applyExponent = [interestExponent, + this](TenthBips32 value) mutable { + BEAST_EXPECT(value > TenthBips32(0)); + while (interestExponent > 0) + { + auto const oldValue = value; + value *= 10; + --interestExponent; + BEAST_EXPECT(value / 10 == oldValue); + } + while (interestExponent < 0) + { + auto const oldValue = value; + value /= 10; + ++interestExponent; + BEAST_EXPECT(value * 10 == oldValue); + } + return value; + }; + + auto const borrowerOwnerCount = env.ownerCount(borrower); + + auto const loanSetFee = env.current()->fees().base * 2; + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .counterpartyExplicit = false, + .principalRequest = loanAmount, + .setFee = loanSetFee, + .originationFee = 1, + .serviceFee = 2, + .lateFee = 3, + .closeFee = 4, + .overFee = applyExponent(percentageToTenthBips(5) / 10), + .interest = applyExponent(percentageToTenthBips(12)), + // 2.4% + .lateInterest = applyExponent(percentageToTenthBips(24) / 10), + .closeInterest = applyExponent(percentageToTenthBips(36) / 10), + .overpaymentInterest = + applyExponent(percentageToTenthBips(48) / 10), + .payTotal = 12, + .payInterval = 600, + .gracePd = 60, + .flags = flags, + }; + Number const principalRequestAmount = + broker.asset(loanParams.principalRequest).value(); + auto const originationFeeAmount = + broker.asset(*loanParams.originationFee).value(); + auto const serviceFeeAmount = + broker.asset(*loanParams.serviceFee).value(); + auto const lateFeeAmount = broker.asset(*loanParams.lateFee).value(); + auto const closeFeeAmount = broker.asset(*loanParams.closeFee).value(); + + auto const borrowerStartbalance = env.balance(borrower, broker.asset); + + auto createJtx = loanParams(env, broker); + // Successfully create a Loan + env(createJtx); + + env.close(); + + auto const startDate = + env.current()->info().parentCloseTime.time_since_epoch().count(); + + if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 1); + } + + { + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = 2 * env.current()->fees().base; + } + + BEAST_EXPECT( + env.balance(borrower, broker.asset).value() == + borrowerStartbalance.value() + principalRequestAmount - + originationFeeAmount - adjustment.value()); + } + + auto const loanFlags = createJtx.stx->isFlag(tfLoanOverpayment) + ? lsfLoanOverpayment + : LedgerSpecificFlags(0); + + if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) + { + // log << "loan after create: " << to_string(loan->getJson()) + // << std::endl; + BEAST_EXPECT( + loan->isFlag(lsfLoanOverpayment) == + createJtx.stx->isFlag(tfLoanOverpayment)); + BEAST_EXPECT(loan->at(sfLoanSequence) == loanSequence); + BEAST_EXPECT(loan->at(sfBorrower) == borrower.id()); + BEAST_EXPECT(loan->at(sfLoanBrokerID) == broker.brokerID); + BEAST_EXPECT( + loan->at(sfLoanOriginationFee) == originationFeeAmount); + BEAST_EXPECT(loan->at(sfLoanServiceFee) == serviceFeeAmount); + BEAST_EXPECT(loan->at(sfLatePaymentFee) == lateFeeAmount); + BEAST_EXPECT(loan->at(sfClosePaymentFee) == closeFeeAmount); + BEAST_EXPECT(loan->at(sfOverpaymentFee) == *loanParams.overFee); + BEAST_EXPECT(loan->at(sfInterestRate) == *loanParams.interest); + BEAST_EXPECT( + loan->at(sfLateInterestRate) == *loanParams.lateInterest); + BEAST_EXPECT( + loan->at(sfCloseInterestRate) == *loanParams.closeInterest); + BEAST_EXPECT( + loan->at(sfOverpaymentInterestRate) == + *loanParams.overpaymentInterest); + BEAST_EXPECT(loan->at(sfStartDate) == startDate); + BEAST_EXPECT( + loan->at(sfPaymentInterval) == *loanParams.payInterval); + BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd); + BEAST_EXPECT(loan->at(sfPreviousPaymentDate) == 0); + BEAST_EXPECT( + loan->at(sfNextPaymentDueDate) == + startDate + *loanParams.payInterval); + BEAST_EXPECT(loan->at(sfPaymentRemaining) == *loanParams.payTotal); + BEAST_EXPECT( + loan->at(sfLoanScale) >= + (broker.asset.integral() + ? 0 + : std::max( + broker.vaultScale(env), + principalRequestAmount.exponent()))); + BEAST_EXPECT( + loan->at(sfPrincipalOutstanding) == principalRequestAmount); + } + + auto state = getCurrentState(env, broker, keylet, verifyLoanStatus); + + auto const loanProperties = computeLoanProperties( + broker.asset.raw(), + state.principalOutstanding, + state.interestRate, + state.paymentInterval, + state.paymentRemaining, + broker.params.managementFeeRate, + state.loanScale); + + verifyLoanStatus( + 0, + startDate + *loanParams.payInterval, + *loanParams.payTotal, + state.loanScale, + loanProperties.totalValueOutstanding, + principalRequestAmount, + loanProperties.managementFeeOwedToBroker, + loanProperties.periodicPayment, + loanFlags | 0); + + // Manage the loan + // no-op + env(manage(lender, keylet.key, 0)); + { + // no flags + auto jt = manage(lender, keylet.key, 0); + jt.removeMember(sfFlags.getName()); + env(jt); + } + // Only the lender can manage + env(manage(evan, keylet.key, 0), ter(tecNO_PERMISSION)); + // unknown flags + env(manage(lender, keylet.key, tfLoanManageMask), ter(temINVALID_FLAG)); + // combinations of flags are not allowed + env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair), + ter(temINVALID_FLAG)); + env(manage(lender, keylet.key, tfLoanImpair | tfLoanDefault), + ter(temINVALID_FLAG)); + env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanDefault), + ter(temINVALID_FLAG)); + env(manage( + lender, + keylet.key, + tfLoanUnimpair | tfLoanImpair | tfLoanDefault), + ter(temINVALID_FLAG)); + // invalid loan ID + env(manage(lender, broker.brokerID, tfLoanImpair), ter(tecNO_ENTRY)); + // Loan is unimpaired, can't unimpair it again + env(manage(lender, keylet.key, tfLoanUnimpair), ter(tecNO_PERMISSION)); + // Loan is unimpaired, it can go into default, but only after it's past + // due + env(manage(lender, keylet.key, tfLoanDefault), ter(tecTOO_SOON)); + + // Check the vault + bool const canImpair = canImpairLoan(env, broker, state); + // Impair the loan, if possible + env(manage(lender, keylet.key, tfLoanImpair), + canImpair ? ter(tesSUCCESS) : ter(tecLIMIT_EXCEEDED)); + // Unimpair the loan + env(manage(lender, keylet.key, tfLoanUnimpair), + canImpair ? ter(tesSUCCESS) : ter(tecNO_PERMISSION)); + + auto const nextDueDate = startDate + *loanParams.payInterval; + + env.close(); + + verifyLoanStatus( + 0, + nextDueDate, + *loanParams.payTotal, + loanProperties.loanScale, + loanProperties.totalValueOutstanding, + principalRequestAmount, + loanProperties.managementFeeOwedToBroker, + loanProperties.periodicPayment, + loanFlags | 0); + + // Can't delete the loan yet. It has payments remaining. + env(del(lender, keylet.key), ter(tecHAS_OBLIGATIONS)); + + if (BEAST_EXPECT(toEndOfLife)) + toEndOfLife(keylet, verifyLoanStatus); + env.close(); + + // Verify the loan is at EOL + if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0); + BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == 0); + } + auto const borrowerStartingBalance = + env.balance(borrower, broker.asset); + + // Try to delete the loan broker with an active loan + env(loanBroker::del(lender, broker.brokerID), ter(tecHAS_OBLIGATIONS)); + // Ensure the above tx doesn't get ordered after the LoanDelete and + // delete our broker! + env.close(); + + // Test failure cases + env(del(lender, keylet.key, tfLoanOverpayment), ter(temINVALID_FLAG)); + env(del(evan, keylet.key), ter(tecNO_PERMISSION)); + env(del(lender, broker.brokerID), ter(tecNO_ENTRY)); + + // Delete the loan + // Either the borrower or the lender can delete the loan. Alternate + // between who does it across tests. + static unsigned deleteCounter = 0; + auto const deleter = ++deleteCounter % 2 ? lender : borrower; + env(del(deleter, keylet.key)); + env.close(); + + PrettyAmount adjustment = broker.asset(0); + if (deleter == borrower) + { + // Need to account for fees if the loan is in XRP + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + } + + // No loans left + verifyLoanStatus.checkBroker(0, 0, *loanParams.interest, 1, 0, 0); + + BEAST_EXPECT( + env.balance(borrower, broker.asset).value() == + borrowerStartingBalance.value() - adjustment); + BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount); + + if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + } + } + + std::string + getCurrencyLabel(Asset const& asset) + { + return ( + asset.native() ? "XRP" + : asset.holds() ? "IOU" + : asset.holds() ? "MPT" + : "Unknown"); + } + + /** Wrapper to run a series of lifecycle tests for a given asset and loan + * amount + * + * Will be used in the future to vary the loan parameters. For now, it is + * only called once. + * + * Tests a bunch of LoanSet failure conditions before lifecycle. + */ + template + void + testCaseWrapper( + jtx::Env& env, + jtx::MPTTester& mptt, + std::array const& assets, + BrokerInfo const& broker, + Number const& loanAmount, + int interestExponent) + { + using namespace jtx; + using namespace Lending; + + auto const& asset = broker.asset.raw(); + auto const currencyLabel = getCurrencyLabel(asset); + auto const caseLabel = [&]() { + std::stringstream ss; + ss << "Lifecycle: " << loanAmount << " " << currencyLabel + << " Scale interest to: " << interestExponent << " "; + return ss.str(); + }(); + testcase << caseLabel; + + using namespace loan; + using namespace std::chrono_literals; + using d = NetClock::duration; + using tp = NetClock::time_point; + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + // Borrower only wants to borrow + Account const borrower{"borrower"}; + // Evan will attempt to be naughty + Account const evan{"evan"}; + // Do not fund alice + Account const alice{"alice"}; + + Number const principalRequest = broker.asset(loanAmount).value(); + Number const maxCoveredLoanValue = broker.params.maxCoveredLoanValue(0); + BEAST_EXPECT(maxCoveredLoanValue == 1000 * 100 / 10); + Number const maxCoveredLoanRequest = + broker.asset(maxCoveredLoanValue).value(); + Number const totalVaultRequest = + broker.asset(broker.params.vaultDeposit).value(); + Number const debtMaximumRequest = + broker.asset(broker.params.debtMax).value(); + + auto const loanSetFee = fee(env.current()->fees().base * 2); + + auto const pseudoAcct = [&]() { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return lender; + auto const brokerPseudo = brokerSle->at(sfAccount); + return Account("Broker pseudo-account", brokerPseudo); + }(); + + auto const baseFee = env.current()->fees().base; + + auto badKeylet = keylet::vault(lender.id(), env.seq(lender)); + // Try some failure cases + // flags are checked first + env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(temINVALID_FLAG)); + + // field length validation + // sfData: good length, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + data(std::string(maxDataPayloadLength, 'X')), + loanSetFee, + ter(tefBAD_AUTH)); + // sfData: too long + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + data(std::string(maxDataPayloadLength + 1, 'Y')), + loanSetFee, + ter(temINVALID)); + + // field range validation + // sfOverpaymentFee: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + overpaymentFee(maxOverpaymentFee), + loanSetFee, + ter(tefBAD_AUTH)); + // sfOverpaymentFee: too big + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + overpaymentFee(maxOverpaymentFee + 1), + loanSetFee, + ter(temINVALID)); + + // sfInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + interestRate(maxInterestRate), + loanSetFee, + ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + interestRate(TenthBips32(0)), + loanSetFee, + ter(tefBAD_AUTH)); + // sfInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + interestRate(maxInterestRate + 1), + loanSetFee, + ter(temINVALID)); + // sfInterestRate: too small + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + interestRate(TenthBips32(-1)), + loanSetFee, + ter(temINVALID)); + + // sfLateInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + lateInterestRate(maxLateInterestRate), + loanSetFee, + ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + lateInterestRate(TenthBips32(0)), + loanSetFee, + ter(tefBAD_AUTH)); + // sfLateInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + lateInterestRate(maxLateInterestRate + 1), + loanSetFee, + ter(temINVALID)); + // sfLateInterestRate: too small + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + lateInterestRate(TenthBips32(-1)), + loanSetFee, + ter(temINVALID)); + + // sfCloseInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + closeInterestRate(maxCloseInterestRate), + loanSetFee, + ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + closeInterestRate(TenthBips32(0)), + loanSetFee, + ter(tefBAD_AUTH)); + // sfCloseInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + closeInterestRate(maxCloseInterestRate + 1), + loanSetFee, + ter(temINVALID)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + closeInterestRate(TenthBips32(-1)), + loanSetFee, + ter(temINVALID)); + + // sfOverpaymentInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + overpaymentInterestRate(maxOverpaymentInterestRate), + loanSetFee, + ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + overpaymentInterestRate(TenthBips32(0)), + loanSetFee, + ter(tefBAD_AUTH)); + // sfOverpaymentInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + overpaymentInterestRate(maxOverpaymentInterestRate + 1), + loanSetFee, + ter(temINVALID)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + overpaymentInterestRate(TenthBips32(-1)), + loanSetFee, + ter(temINVALID)); + + // sfPaymentTotal: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + paymentTotal(LoanSet::minPaymentTotal), + loanSetFee, + ter(tefBAD_AUTH)); + // sfPaymentTotal: too small (there is no max) + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + paymentTotal(LoanSet::minPaymentTotal - 1), + loanSetFee, + ter(temINVALID)); + + // sfPaymentInterval: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + paymentInterval(LoanSet::minPaymentInterval), + loanSetFee, + ter(tefBAD_AUTH)); + // sfPaymentInterval: too small (there is no max) + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + paymentInterval(LoanSet::minPaymentInterval - 1), + loanSetFee, + ter(temINVALID)); + + // sfGracePeriod: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + paymentInterval(LoanSet::minPaymentInterval * 2), + gracePeriod(LoanSet::minPaymentInterval * 2), + loanSetFee, + ter(tefBAD_AUTH)); + // sfGracePeriod: larger than paymentInterval + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + paymentInterval(LoanSet::minPaymentInterval * 2), + gracePeriod(LoanSet::minPaymentInterval * 3), + loanSetFee, + ter(temINVALID)); + + // insufficient fee - single sign + env(set(borrower, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + ter(telINSUF_FEE_P)); + // insufficient fee - multisign + env(signers(lender, 2, {{evan, 1}, {borrower, 1}})); + env(signers(borrower, 2, {{evan, 1}, {lender, 1}})); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + msig(evan, lender), + msig(sfCounterpartySignature, evan, borrower), + fee(env.current()->fees().base * 5 - 1), + ter(telINSUF_FEE_P)); + // Bad multisign signatures for borrower (Account) + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + msig(alice, issuer), + msig(sfCounterpartySignature, evan, borrower), + fee(env.current()->fees().base * 5), + ter(tefBAD_SIGNATURE), + THISLINE); + // Bad multisign signatures for issuer (Counterparty) + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + msig(evan, lender), + msig(sfCounterpartySignature, alice, issuer), + fee(env.current()->fees().base * 5), + ter(tefBAD_SIGNATURE), + THISLINE); + env(signers(lender, none)); + env(signers(borrower, none)); + // multisign sufficient fee, but no signers set up + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + msig(evan, lender), + msig(sfCounterpartySignature, evan, borrower), + fee(env.current()->fees().base * 5), + ter(tefNOT_MULTI_SIGNING)); + // not the broker owner, no counterparty, not signed by broker + // owner + env(set(borrower, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, evan), + loanSetFee, + ter(tefBAD_AUTH)); + // not the broker owner, counterparty is borrower + env(set(evan, broker.brokerID, principalRequest), + counterparty(borrower), + sig(sfCounterpartySignature, borrower), + loanSetFee, + ter(tecNO_PERMISSION)); + // not a LoanBroker object, no counterparty + env(set(lender, badKeylet.key, principalRequest), + sig(sfCounterpartySignature, evan), + loanSetFee, + ter(temBAD_SIGNER)); + // not a LoanBroker object, counterparty is valid + env(set(lender, badKeylet.key, principalRequest), + counterparty(borrower), + sig(sfCounterpartySignature, borrower), + loanSetFee, + ter(tecNO_ENTRY)); + // borrower doesn't exist + env(set(lender, broker.brokerID, principalRequest), + counterparty(alice), + sig(sfCounterpartySignature, alice), + loanSetFee, + ter(terNO_ACCOUNT)); + + // Request more funds than the vault has available + env(set(evan, broker.brokerID, totalVaultRequest + 1), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(tecINSUFFICIENT_FUNDS)); + + // Request more funds than the broker's first-loss capital can + // cover. + env(set(evan, broker.brokerID, maxCoveredLoanRequest + 1), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(tecINSUFFICIENT_FUNDS)); + + // Frozen trust line / locked MPT issuance + // XRP can not be frozen, but run through the loop anyway to test + // the tecLIMIT_EXCEEDED case + { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return; + + auto const vaultPseudo = [&]() { + auto const vaultSle = + env.le(keylet::vault(brokerSle->at(sfVaultID))); + if (!BEAST_EXPECT(vaultSle)) + // This will be wrong, but the test has failed anyway. + return lender; + auto const vaultPseudo = + Account("Vault pseudo-account", vaultSle->at(sfAccount)); + return vaultPseudo; + }(); + + auto const [freeze, deepfreeze, unfreeze, expectedResult] = + [&]() -> std::tuple< + std::function, + std::function, + std::function, + TER> { + // Freeze / lock the asset + std::function empty; + if (broker.asset.native()) + { + // XRP can't be frozen + return std::make_tuple(empty, empty, empty, tesSUCCESS); + } + else if (broker.asset.holds()) + { + auto freeze = [&](Account const& holder) { + env(trust(issuer, holder[iouCurrency](0), tfSetFreeze)); + }; + auto deepfreeze = [&](Account const& holder) { + env(trust( + issuer, + holder[iouCurrency](0), + tfSetFreeze | tfSetDeepFreeze)); + }; + auto unfreeze = [&](Account const& holder) { + env(trust( + issuer, + holder[iouCurrency](0), + tfClearFreeze | tfClearDeepFreeze)); + }; + return std::make_tuple( + freeze, deepfreeze, unfreeze, tecFROZEN); + } + else + { + auto freeze = [&](Account const& holder) { + mptt.set( + {.account = issuer, + .holder = holder, + .flags = tfMPTLock}); + }; + auto unfreeze = [&](Account const& holder) { + mptt.set( + {.account = issuer, + .holder = holder, + .flags = tfMPTUnlock}); + }; + return std::make_tuple(freeze, empty, unfreeze, tecLOCKED); + } + }(); + + // Try freezing the accounts that can't be frozen + if (freeze) + { + for (auto const& account : {vaultPseudo, evan}) + { + // Freeze the account + freeze(account); + + // Try to create a loan with a frozen line + env(set(evan, broker.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(expectedResult)); + + // Unfreeze the account + BEAST_EXPECT(unfreeze); + unfreeze(account); + + // Ensure the line is unfrozen with a request that is fine + // except too it requests more principal than the broker can + // carry + env(set(evan, broker.brokerID, debtMaximumRequest + 1), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(tecLIMIT_EXCEEDED)); + } + } + + // Deep freeze the borrower, which prevents them from receiving + // funds + if (deepfreeze) + { + // Make sure evan has a trust line that so the issuer can + // freeze it. (Don't need to do this for the borrower, + // because LoanSet will create a line to the borrower + // automatically.) + env(trust(evan, issuer[iouCurrency](100'000))); + + for (auto const& account : + {// these accounts can't be frozen, which deep freeze + // implies + vaultPseudo, + evan, + // these accounts can't be deep frozen + lender}) + { + // Freeze evan + deepfreeze(account); + + // Try to create a loan with a deep frozen line + env(set(evan, broker.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(expectedResult)); + + // Unfreeze evan + BEAST_EXPECT(unfreeze); + unfreeze(account); + + // Ensure the line is unfrozen with a request that is fine + // except too it requests more principal than the broker can + // carry + env(set(evan, broker.brokerID, debtMaximumRequest + 1), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(tecLIMIT_EXCEEDED)); + } + } + } + + // Finally! Create a loan + std::string testData; + + auto coverAvailable = + [&env, this](uint256 const& brokerID, Number const& expected) { + if (auto const brokerSle = env.le(keylet::loanbroker(brokerID)); + BEAST_EXPECT(brokerSle)) + { + auto const available = brokerSle->at(sfCoverAvailable); + BEAST_EXPECT(available == expected); + return available; + } + return Number{}; + }; + auto getDefaultInfo = [&env, this]( + LoanState const& state, + BrokerInfo const& broker) { + if (auto const brokerSle = + env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT( + state.loanScale >= + (broker.asset.integral() + ? 0 + : std::max( + broker.vaultScale(env), + state.principalOutstanding.exponent()))); + NumberRoundModeGuard mg(Number::upward); + auto const defaultAmount = roundToAsset( + broker.asset, + std::min( + tenthBipsOfValue( + tenthBipsOfValue( + brokerSle->at(sfDebtTotal), + broker.params.coverRateMin), + broker.params.coverRateLiquidation), + state.totalValue - state.managementFeeOutstanding), + state.loanScale); + return std::make_pair(defaultAmount, brokerSle->at(sfOwner)); + } + return std::make_pair(Number{}, AccountID{}); + }; + auto replenishCover = [&env, &coverAvailable]( + BrokerInfo const& broker, + AccountID const& brokerAcct, + Number const& startingCoverAvailable, + Number const& amountToBeCovered) { + coverAvailable( + broker.brokerID, startingCoverAvailable - amountToBeCovered); + env(loanBroker::coverDeposit( + brokerAcct, + broker.brokerID, + STAmount{broker.asset, amountToBeCovered})); + coverAvailable(broker.brokerID, startingCoverAvailable); + env.close(); + }; + + auto defaultImmediately = [&](std::uint32_t baseFlag, + bool impair = true) { + return [&, impair, baseFlag]( + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + // Default the loan + + // Initialize values with the current state + auto state = + getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + BEAST_EXPECT(state.flags == baseFlag); + + auto const& broker = verifyLoanStatus.broker; + auto const startingCoverAvailable = coverAvailable( + broker.brokerID, + broker.asset(broker.params.coverDeposit).number()); + + if (impair) + { + // Check the vault + bool const canImpair = canImpairLoan(env, broker, state); + // Impair the loan, if possible + env(manage(lender, loanKeylet.key, tfLoanImpair), + canImpair ? ter(tesSUCCESS) : ter(tecLIMIT_EXCEEDED)); + + if (canImpair) + { + state.flags |= tfLoanImpair; + state.nextPaymentDate = + env.now().time_since_epoch().count(); + + // Once the loan is impaired, it can't be impaired again + env(manage(lender, loanKeylet.key, tfLoanImpair), + ter(tecNO_PERMISSION)); + } + verifyLoanStatus(state); + } + + auto const nextDueDate = tp{d{state.nextPaymentDate}}; + + // Can't default the loan yet. The grace period hasn't + // expired + env(manage(lender, loanKeylet.key, tfLoanDefault), + ter(tecTOO_SOON)); + + // Let some time pass so that the loan can be + // defaulted + env.close(nextDueDate + 60s); + + auto const [amountToBeCovered, brokerAcct] = + getDefaultInfo(state, broker); + + // Default the loan + env(manage(lender, loanKeylet.key, tfLoanDefault)); + env.close(); + + // The LoanBroker just lost some of it's first-loss capital. + // Replenish it. + replenishCover( + broker, + brokerAcct, + startingCoverAvailable, + amountToBeCovered); + + state.flags |= tfLoanDefault; + state.paymentRemaining = 0; + state.totalValue = 0; + state.principalOutstanding = 0; + state.managementFeeOutstanding = 0; + state.nextPaymentDate = 0; + verifyLoanStatus(state); + + // Once a loan is defaulted, it can't be managed + env(manage(lender, loanKeylet.key, tfLoanUnimpair), + ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanImpair), + ter(tecNO_PERMISSION)); + // Can't make a payment on it either + env(pay(borrower, loanKeylet.key, broker.asset(300)), + ter(tecKILLED)); + }; + }; + + auto singlePayment = [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus, + LoanState& state, + STAmount const& payoffAmount, + std::uint32_t numPayments, + std::uint32_t baseFlag, + std::uint32_t txFlags) { + // toEndOfLife + // + verifyLoanStatus(state); + + // Send some bogus pay transactions + env(pay(borrower, + keylet::loan(uint256(0)).key, + broker.asset(10), + txFlags), + ter(temINVALID)); + // broker.asset(80) is less than a single payment, but all these + // checks fail before that matters + env(pay(borrower, loanKeylet.key, broker.asset(-80), txFlags), + ter(temBAD_AMOUNT)); + env(pay(borrower, broker.brokerID, broker.asset(80), txFlags), + ter(tecNO_ENTRY)); + env(pay(evan, loanKeylet.key, broker.asset(80), txFlags), + ter(tecNO_PERMISSION)); + + // TODO: Write a general "isFlag" function? See STObject::isFlag. + // Maybe add a static overloaded member? + if (!(state.flags & lsfLoanOverpayment)) + { + // If the loan does not allow overpayments, send a payment that + // tries to make an overpayment. Do not include `txFlags`, so we + // don't end up duplicating the next test transaction. + env(pay(borrower, + loanKeylet.key, + STAmount{ + broker.asset, + state.periodicPayment * Number{15, -1}}, + tfLoanOverpayment), + fee(XRPAmount{ + baseFee * + (Number{15, -1} / loanPaymentsPerFeeIncrement + 1)}), + ter(temINVALID_FLAG)); + } + // Try to send a payment marked as multiple mutually exclusive + // payment types. Do not include `txFlags`, so we don't duplicate + // the prior test transaction. + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanFullPayment), + ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanOverpayment), + ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanOverpayment | tfLoanFullPayment), + ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanOverpayment | tfLoanFullPayment), + ter(temINVALID_FLAG)); + + { + auto const otherAsset = broker.asset.raw() == assets[0].raw() + ? assets[1] + : assets[0]; + env(pay(borrower, loanKeylet.key, otherAsset(100), txFlags), + ter(tecWRONG_ASSET)); + } + + // Amount doesn't cover a single payment + env(pay(borrower, + loanKeylet.key, + STAmount{broker.asset, 1}, + txFlags), + ter(tecINSUFFICIENT_PAYMENT)); + + // Get the balance after these failed transactions take + // fees + auto const borrowerBalanceBeforePayment = + env.balance(borrower, broker.asset); + + BEAST_EXPECT(payoffAmount > state.principalOutstanding); + // Try to pay a little extra to show that it's _not_ + // taken + auto const transactionAmount = payoffAmount + broker.asset(10); + + // Send a transaction that tries to pay more than the borrowers's + // balance + XRPAmount const badFee{ + baseFee * + (borrowerBalanceBeforePayment.number() * 2 / + state.periodicPayment / loanPaymentsPerFeeIncrement + + 1)}; + env(pay(borrower, + loanKeylet.key, + STAmount{ + broker.asset, + borrowerBalanceBeforePayment.number() * 2}, + txFlags), + fee(badFee), + ter(tecINSUFFICIENT_FUNDS)); + + XRPAmount const goodFee{ + baseFee * (numPayments / loanPaymentsPerFeeIncrement + 1)}; + env(pay(borrower, loanKeylet.key, transactionAmount, txFlags), + fee(goodFee)); + + env.close(); + + // log << env.meta()->getJson() << std::endl; + + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = badFee + goodFee; + } + + state.paymentRemaining = 0; + state.principalOutstanding = 0; + state.totalValue = 0; + state.managementFeeOutstanding = 0; + state.previousPaymentDate = state.nextPaymentDate + + state.paymentInterval * (numPayments - 1); + state.nextPaymentDate = 0; + verifyLoanStatus(state); + + verifyLoanStatus.checkPayment( + state.loanScale, + borrower, + borrowerBalanceBeforePayment, + payoffAmount, + adjustment); + + // Can't impair or default a paid off loan + env(manage(lender, loanKeylet.key, tfLoanImpair), + ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), + ter(tecNO_PERMISSION)); + }; + + auto fullPayment = [&](std::uint32_t baseFlag) { + return [&, baseFlag]( + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + auto state = + getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + env.close(state.startDate + 20s); + auto const loanAge = (env.now() - state.startDate).count(); + BEAST_EXPECT(loanAge == 30); + + // Full payoff amount will consist of + // 1. principal outstanding (1000) + // 2. accrued interest (at 12%) + // 3. prepayment penalty (closeInterest at 3.6%) + // 4. close payment fee (4) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + Number const interval = state.paymentInterval; + auto const periodicRate = + interval * Number(12, -2) / secondsInYear; + BEAST_EXPECT( + periodicRate == + Number(2283105022831050, -21, Number::unchecked{})); + STAmount const principalOutstanding{ + broker.asset, state.principalOutstanding}; + STAmount const accruedInterest{ + broker.asset, + state.principalOutstanding * periodicRate * loanAge / + interval}; + BEAST_EXPECT( + accruedInterest == + broker.asset(Number(1141552511415525, -19))); + STAmount const prepaymentPenalty{ + broker.asset, state.principalOutstanding * Number(36, -3)}; + BEAST_EXPECT(prepaymentPenalty == broker.asset(36)); + STAmount const closePaymentFee = broker.asset(4); + auto const payoffAmount = roundToScale( + principalOutstanding + accruedInterest + prepaymentPenalty + + closePaymentFee, + state.loanScale); + BEAST_EXPECT( + payoffAmount == + roundToAsset( + broker.asset, + broker.asset(Number(1040000114155251, -12)).number(), + state.loanScale)); + + // The terms of this loan actually make the early payoff + // more expensive than just making payments + BEAST_EXPECT( + payoffAmount > state.paymentRemaining * + (state.periodicPayment + broker.asset(2).value())); + + singlePayment( + loanKeylet, + verifyLoanStatus, + state, + payoffAmount, + 1, + baseFlag, + tfLoanFullPayment); + }; + }; + + auto combineAllPayments = [&](std::uint32_t baseFlag) { + return [&, baseFlag]( + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + + auto state = + getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + env.close(); + + // Make all the payments in one transaction + // service fee is 2 + auto const startingPayments = state.paymentRemaining; + auto const rawPayoff = startingPayments * + (state.periodicPayment + broker.asset(2).value()); + STAmount const payoffAmount{broker.asset, rawPayoff}; + BEAST_EXPECT( + payoffAmount == + broker.asset(Number(1024014840139457, -12))); + BEAST_EXPECT(payoffAmount > state.principalOutstanding); + + singlePayment( + loanKeylet, + verifyLoanStatus, + state, + payoffAmount, + state.paymentRemaining, + baseFlag, + 0); + }; + }; + + // There are a lot of fields that can be set on a loan, but most + // of them only affect the "math" when a payment is made. The + // only one that really affects behavior is the + // `tfLoanOverpayment` flag. + lifecycle( + caseLabel, + "Loan overpayment allowed - Impair and Default", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + defaultImmediately(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Impair and Default", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + defaultImmediately(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Default without Impair", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + defaultImmediately(lsfLoanOverpayment, false)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Default without Impair", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + defaultImmediately(0, false)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Pay off immediately", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + fullPayment(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Pay off immediately", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + fullPayment(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Combine all payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + combineAllPayments(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Combine all payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + combineAllPayments(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Make payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + // Draw and make multiple payments + auto state = + getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + BEAST_EXPECT(state.flags == 0); + env.close(); + + verifyLoanStatus(state); + + env.close(state.startDate + 20s); + auto const loanAge = (env.now() - state.startDate).count(); + BEAST_EXPECT(loanAge == 30); + + // Periodic payment amount will consist of + // 1. principal outstanding (1000) + // 2. interest interest rate (at 12%) + // 3. payment interval (600s) + // 4. loan service fee (2) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + Number const interval = state.paymentInterval; + auto const periodicRate = + interval * Number(12, -2) / secondsInYear; + BEAST_EXPECT( + periodicRate == + Number(2283105022831050, -21, Number::unchecked{})); + STAmount const roundedPeriodicPayment{ + broker.asset, + roundPeriodicPayment( + broker.asset, state.periodicPayment, state.loanScale)}; + + testcase + << currencyLabel << " Payment components: " + << "Payments remaining, rawInterest, rawPrincipal, " + "rawMFee, trackedValueDelta, trackedPrincipalDelta, " + "trackedInterestDelta, trackedMgmtFeeDelta, special"; + + auto const serviceFee = broker.asset(2); + + BEAST_EXPECT( + roundedPeriodicPayment == + roundToScale( + broker.asset( + Number(8333457001162141, -14), Number::upward), + state.loanScale, + Number::upward)); + // 83334570.01162141 + // Include the service fee + STAmount const totalDue = roundToScale( + roundedPeriodicPayment + serviceFee, + state.loanScale, + Number::upward); + // Only check the first payment since the rounding + // may drift as payments are made + BEAST_EXPECT( + totalDue == + roundToScale( + broker.asset( + Number(8533457001162141, -14), Number::upward), + state.loanScale, + Number::upward)); + + { + auto const raw = computeRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + auto const rounded = constructLoanState( + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding); + testcase + << currencyLabel + << " Loan starting state: " << state.paymentRemaining + << ", " << raw.interestDue << ", " + << raw.principalOutstanding << ", " + << raw.managementFeeDue << ", " + << rounded.valueOutstanding << ", " + << rounded.principalOutstanding << ", " + << rounded.interestDue << ", " + << rounded.managementFeeDue; + } + + // Try to pay a little extra to show that it's _not_ + // taken + STAmount const transactionAmount = + STAmount{broker.asset, totalDue} + broker.asset(10); + // Only check the first payment since the rounding + // may drift as payments are made + BEAST_EXPECT( + transactionAmount == + roundToScale( + broker.asset( + Number(9533457001162141, -14), Number::upward), + state.loanScale, + Number::upward)); + + auto const initialState = state; + detail::PaymentComponents totalPaid{ + .trackedValueDelta = 0, + .trackedPrincipalDelta = 0, + .trackedManagementFeeDelta = 0}; + Number totalInterestPaid = 0; + std::size_t totalPaymentsMade = 0; + + ripple::LoanState currentTrueState = computeRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + while (state.paymentRemaining > 0) + { + // Compute the expected principal amount + auto const paymentComponents = + detail::computePaymentComponents( + broker.asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + BEAST_EXPECT( + paymentComponents.trackedValueDelta <= + roundedPeriodicPayment); + + ripple::LoanState const nextTrueState = computeRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining - 1, + broker.params.managementFeeRate); + detail::LoanStateDeltas const deltas = + currentTrueState - nextTrueState; + + testcase + << currencyLabel + << " Payment components: " << state.paymentRemaining + << ", " << deltas.interest << ", " << deltas.principal + << ", " << deltas.managementFee << ", " + << paymentComponents.trackedValueDelta << ", " + << paymentComponents.trackedPrincipalDelta << ", " + << paymentComponents.trackedInterestPart() << ", " + << paymentComponents.trackedManagementFeeDelta << ", " + << (paymentComponents.specialCase == + detail::PaymentSpecialCase::final + ? "final" + : paymentComponents.specialCase == + detail::PaymentSpecialCase::extra + ? "extra" + : "none"); + + auto const totalDueAmount = STAmount{ + broker.asset, + paymentComponents.trackedValueDelta + + serviceFee.number()}; + + // Due to the rounding algorithms to keep the interest and + // principal in sync with "true" values, the computed amount + // may be a little less than the rounded fixed payment + // amount. For integral types, the difference should be < 3 + // (1 unit for each of the interest and management fee). For + // IOUs, the difference should be after the 8th digit. + Number const diff = totalDue - totalDueAmount; + BEAST_EXPECT( + paymentComponents.specialCase == + detail::PaymentSpecialCase::final || + diff == beast::zero || + (diff > beast::zero && + ((broker.asset.integral() && + (static_cast(diff) < 3)) || + (state.loanScale - diff.exponent() > 13)))); + + BEAST_EXPECT( + paymentComponents.trackedValueDelta == + paymentComponents.trackedPrincipalDelta + + paymentComponents.trackedInterestPart() + + paymentComponents.trackedManagementFeeDelta); + BEAST_EXPECT( + paymentComponents.trackedValueDelta <= + roundedPeriodicPayment); + + BEAST_EXPECT( + state.paymentRemaining < 12 || + roundToAsset( + broker.asset, + deltas.principal, + state.loanScale, + Number::upward) == + roundToScale( + broker.asset( + Number(8333228695260180, -14), + Number::upward), + state.loanScale, + Number::upward)); + BEAST_EXPECT( + paymentComponents.trackedPrincipalDelta >= + beast::zero && + paymentComponents.trackedPrincipalDelta <= + state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase != + detail::PaymentSpecialCase::final || + paymentComponents.trackedPrincipalDelta == + state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase == + detail::PaymentSpecialCase::final || + (state.periodicPayment.exponent() - + (deltas.principal + deltas.interest + + deltas.managementFee - state.periodicPayment) + .exponent()) > 14); + + auto const borrowerBalanceBeforePayment = + env.balance(borrower, broker.asset); + + if (canImpairLoan(env, broker, state)) + // Making a payment will unimpair the loan + env(manage(lender, loanKeylet.key, tfLoanImpair)); + + env.close(); + + // Make the payment + env(pay(borrower, loanKeylet.key, transactionAmount)); + + env.close(); + + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + + // Check the result + verifyLoanStatus.checkPayment( + state.loanScale, + borrower, + borrowerBalanceBeforePayment, + totalDueAmount, + adjustment); + + --state.paymentRemaining; + state.previousPaymentDate = state.nextPaymentDate; + if (paymentComponents.specialCase == + detail::PaymentSpecialCase::final) + { + state.paymentRemaining = 0; + state.nextPaymentDate = 0; + } + else + { + state.nextPaymentDate += state.paymentInterval; + } + state.principalOutstanding -= + paymentComponents.trackedPrincipalDelta; + state.managementFeeOutstanding -= + paymentComponents.trackedManagementFeeDelta; + state.totalValue -= paymentComponents.trackedValueDelta; + + verifyLoanStatus(state); + + totalPaid.trackedValueDelta += + paymentComponents.trackedValueDelta; + totalPaid.trackedPrincipalDelta += + paymentComponents.trackedPrincipalDelta; + totalPaid.trackedManagementFeeDelta += + paymentComponents.trackedManagementFeeDelta; + totalInterestPaid += + paymentComponents.trackedInterestPart(); + ++totalPaymentsMade; + + currentTrueState = nextTrueState; + } + + // Loan is paid off + BEAST_EXPECT(state.paymentRemaining == 0); + BEAST_EXPECT(state.principalOutstanding == 0); + + // Make sure all the payments add up + BEAST_EXPECT( + totalPaid.trackedValueDelta == initialState.totalValue); + BEAST_EXPECT( + totalPaid.trackedPrincipalDelta == + initialState.principalOutstanding); + BEAST_EXPECT( + totalPaid.trackedManagementFeeDelta == + initialState.managementFeeOutstanding); + // This is almost a tautology given the previous checks, but + // check it anyway for completeness. + BEAST_EXPECT( + totalInterestPaid == + initialState.totalValue - + (initialState.principalOutstanding + + initialState.managementFeeOutstanding)); + BEAST_EXPECT( + totalPaymentsMade == initialState.paymentRemaining); + + // Can't impair or default a paid off loan + env(manage(lender, loanKeylet.key, tfLoanImpair), + ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), + ter(tecNO_PERMISSION)); + }); + +#if LOANTODO + // TODO + + /* + LoanPay fails with tecINVARIANT_FAILED error when loan_broker(also + borrower) tries to do the payment. Here's the sceanrio: Create a XRP + loan with loan broker as borrower, loan origination fee and loan service + fee. Loan broker makes the first payment with periodic payment and loan + service fee. + */ + + auto time = [&](std::string label, std::function timed) { + if (!BEAST_EXPECT(timed)) + return; + + using clock_type = std::chrono::steady_clock; + using duration_type = std::chrono::milliseconds; + + auto const start = clock_type::now(); + timed(); + auto const duration = std::chrono::duration_cast( + clock_type::now() - start); + + log << label << " took " << duration.count() << "ms" << std::endl; + + return duration; + }; + + lifecycle( + caseLabel, + "timing", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { + // Estimate optimal values for loanPaymentsPerFeeIncrement and + // loanMaximumPaymentsPerTransaction. + using namespace loan; + + auto const state = + getCurrentState(env, broker, verifyLoanStatus.keylet); + auto const serviceFee = broker.asset(2).value(); + + STAmount const totalDue{ + broker.asset, + roundPeriodicPayment( + broker.asset, + state.periodicPayment + serviceFee, + state.loanScale)}; + + // Make a single payment + time("single payment", [&]() { + env(pay(borrower, loanKeylet.key, totalDue)); + }); + env.close(); + + // Make all but the final payment + auto const numPayments = (state.paymentRemaining - 2); + STAmount const bigPayment{broker.asset, totalDue * numPayments}; + XRPAmount const bigFee{ + baseFee * (numPayments / loanPaymentsPerFeeIncrement + 1)}; + time("ten payments", [&]() { + env(pay(borrower, loanKeylet.key, bigPayment), fee(bigFee)); + }); + env.close(); + + time("final payment", [&]() { + // Make the final payment + env( + pay(borrower, + loanKeylet.key, + totalDue + STAmount{broker.asset, 1})); + }); + env.close(); + }); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Explicit overpayment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Late payment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Late payment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Late payment and overpayment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + +#endif + } + + void + testLoanSet() + { + using namespace jtx; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + struct CaseArgs + { + bool requireAuth = false; + bool authorizeBorrower = false; + int initialXRP = 1'000'000; + }; + + auto const testCase = + [&, this]( + std::function + mptTest, + std::function iouTest, + CaseArgs args = {}) { + Env env(*this, all); + env.fund(XRP(args.initialXRP), issuer, lender, borrower); + env.close(); + if (args.requireAuth) + { + env(fset(issuer, asfRequireAuth)); + env.close(); + } + + // We need two different asset types, MPT and IOU. Prepare MPT + // first + MPTTester mptt{env, issuer, mptInitNoFund}; + + auto const none = LedgerSpecificFlags(0); + mptt.create( + {.flags = tfMPTCanTransfer | tfMPTCanLock | + (args.requireAuth ? tfMPTRequireAuth : none)}); + env.close(); + PrettyAsset mptAsset = mptt.issuanceID(); + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + env.close(); + if (args.requireAuth) + { + mptt.authorize({.account = issuer, .holder = lender}); + if (args.authorizeBorrower) + mptt.authorize({.account = issuer, .holder = borrower}); + env.close(); + } + + env(pay(issuer, lender, mptAsset(10'000'000))); + env.close(); + + // Prepare IOU + PrettyAsset const iouAsset = issuer[iouCurrency]; + env(trust(lender, iouAsset(10'000'000))); + env(trust(borrower, iouAsset(10'000'000))); + env.close(); + if (args.requireAuth) + { + env(trust(issuer, iouAsset(0), lender, tfSetfAuth)); + env(pay(issuer, lender, iouAsset(10'000'000))); + if (args.authorizeBorrower) + { + env(trust(issuer, iouAsset(0), borrower, tfSetfAuth)); + env(pay(issuer, borrower, iouAsset(10'000))); + } + } + else + { + env(pay(issuer, lender, iouAsset(10'000'000))); + env(pay(issuer, borrower, iouAsset(10'000))); + } + env.close(); + + // Create vaults and loan brokers + std::array const assets{mptAsset, iouAsset}; + std::vector brokers; + for (auto const& asset : assets) + { + brokers.emplace_back( + createVaultAndBroker(env, asset, lender)); + } + + if (mptTest) + (mptTest)(env, brokers[0], mptt); + if (iouTest) + (iouTest)(env, brokers[1]); + }; + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT issuer is borrower, issuer submits"); + env(set(issuer, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + + testcase("MPT issuer is borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + counterparty(issuer), + sig(sfCounterpartySignature, issuer), + fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU issuer is borrower, issuer submits"); + env(set(issuer, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + + testcase("IOU issuer is borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + counterparty(issuer), + sig(sfCounterpartySignature, issuer), + fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT unauthorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + ter{tecNO_AUTH}); + + testcase("MPT unauthorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + counterparty(borrower), + sig(sfCounterpartySignature, borrower), + fee(env.current()->fees().base * 5), + ter{tecNO_AUTH}); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU unauthorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + ter{tecNO_AUTH}); + + testcase("IOU unauthorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + counterparty(borrower), + sig(sfCounterpartySignature, borrower), + fee(env.current()->fees().base * 5), + ter{tecNO_AUTH}); + }, + CaseArgs{.requireAuth = true}); + + auto const [acctReserve, incReserve] = [this]() -> std::pair { + Env env{*this, testable_amendments()}; + return { + env.current()->fees().accountReserve(0).drops() / + DROPS_PER_XRP.drops(), + env.current()->fees().increment.drops() / + DROPS_PER_XRP.drops()}; + }(); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "MPT authorized borrower, borrower submits, borrower has " + "no reserve"); + mptt.authorize( + {.account = borrower, .flags = tfMPTUnauthorize}); + env.close(); + + auto const mptoken = + keylet::mptoken(mptt.issuanceID(), borrower); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 == nullptr); + + // Burn some XRP + env(noop(borrower), fee(XRP(acctReserve * 2 + incReserve * 2))); + env.close(); + + // Cannot create loan, not enough reserve to create MPToken + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + ter{tecINSUFFICIENT_RESERVE}); + env.close(); + + // Can create loan now, will implicitly create MPToken + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + env.close(); + + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 != nullptr); + }, + {}, + CaseArgs{.initialXRP = acctReserve * 2 + incReserve * 8 + 1}); + + testCase( + {}, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "IOU authorized borrower, borrower submits, borrower has " + "no reserve"); + // Remove trust line from borrower to issuer + env.trust(broker.asset(0), borrower); + env.close(); + + env(pay(borrower, issuer, broker.asset(10'000))); + env.close(); + auto const trustline = + keylet::line(borrower, broker.asset.raw().get()); + auto const sleLine1 = env.le(trustline); + BEAST_EXPECT(sleLine1 == nullptr); + + // Burn some XRP + env(noop(borrower), fee(XRP(acctReserve * 2 + incReserve * 2))); + env.close(); + + // Cannot create loan, not enough reserve to create trust line + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + ter{tecNO_LINE_INSUF_RESERVE}); + env.close(); + + // Can create loan now, will implicitly create trust line + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + env.close(); + + auto const sleLine2 = env.le(trustline); + BEAST_EXPECT(sleLine2 != nullptr); + }, + CaseArgs{.initialXRP = acctReserve * 2 + incReserve * 8 + 1}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "MPT authorized borrower, borrower submits, lender has " + "no reserve"); + auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 != nullptr); + + env(pay( + lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); + env.close(); + + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); + + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 == nullptr); + + // Burn some XRP + env(noop(lender), fee(XRP(incReserve))); + env.close(); + + // Cannot create loan, not enough reserve to create MPToken + env(set(borrower, broker.brokerID, principalRequest), + loanOriginationFee(broker.asset(1).value()), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + ter{tecINSUFFICIENT_RESERVE}); + env.close(); + + // Can create loan now, will implicitly create MPToken + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + loanOriginationFee(broker.asset(1).value()), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + env.close(); + + auto const sleMPT3 = env.le(mptoken); + BEAST_EXPECT(sleMPT3 != nullptr); + }, + {}, + CaseArgs{.initialXRP = acctReserve * 2 + incReserve * 8 + 1}); + + testCase( + {}, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "IOU authorized borrower, borrower submits, lender has no " + "reserve"); + // Remove trust line from lender to issuer + env.trust(broker.asset(0), lender); + env.close(); + + auto const trustline = + keylet::line(lender, broker.asset.raw().get()); + auto const sleLine1 = env.le(trustline); + BEAST_EXPECT(sleLine1 != nullptr); + + env( + pay(lender, + issuer, + broker.asset(abs(sleLine1->at(sfBalance).value())))); + env.close(); + auto const sleLine2 = env.le(trustline); + BEAST_EXPECT(sleLine2 == nullptr); + + // Burn some XRP + env(noop(lender), fee(XRP(incReserve))); + env.close(); + + // Cannot create loan, not enough reserve to create trust line + env(set(borrower, broker.brokerID, principalRequest), + loanOriginationFee(broker.asset(1).value()), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + ter{tecNO_LINE_INSUF_RESERVE}); + env.close(); + + // Can create loan now, will implicitly create trust line + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + loanOriginationFee(broker.asset(1).value()), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + env.close(); + + auto const sleLine3 = env.le(trustline); + BEAST_EXPECT(sleLine3 != nullptr); + }, + CaseArgs{.initialXRP = acctReserve * 2 + incReserve * 8 + 1}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT authorized borrower, unauthorized lender"); + auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 != nullptr); + + env(pay( + lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); + env.close(); + + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); + + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 == nullptr); + + // Cannot create loan, lender not authorized to receive fee + env(set(borrower, broker.brokerID, principalRequest), + loanOriginationFee(broker.asset(1).value()), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + ter{tecNO_AUTH}); + env.close(); + + // Can create loan without origination fee + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + env.close(); + + // No MPToken for lender - no authorization and no payment + auto const sleMPT3 = env.le(mptoken); + BEAST_EXPECT(sleMPT3 == nullptr); + }, + {}, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT authorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU authorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT authorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + counterparty(borrower), + sig(sfCounterpartySignature, borrower), + fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU authorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + counterparty(borrower), + sig(sfCounterpartySignature, borrower), + fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + jtx::Account const alice{"alice"}; + jtx::Account const bella{"bella"}; + auto const msigSetup = [&](Env& env, Account const& account) { + Json::Value tx1 = signers(account, 2, {{alice, 1}, {bella, 1}}); + env(tx1); + env.close(); + }; + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + msigSetup(env, lender); + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "MPT authorized borrower, borrower submits, lender " + "multisign"); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + msig(sfCounterpartySignature, alice, bella), + fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + msigSetup(env, lender); + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "IOU authorized borrower, borrower submits, lender " + "multisign"); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + msig(sfCounterpartySignature, alice, bella), + fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + msigSetup(env, borrower); + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "MPT authorized borrower, lender submits, borrower " + "multisign"); + env(set(lender, broker.brokerID, principalRequest), + counterparty(borrower), + msig(sfCounterpartySignature, alice, bella), + fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + msigSetup(env, borrower); + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "IOU authorized borrower, lender submits, borrower " + "multisign"); + env(set(lender, broker.brokerID, principalRequest), + counterparty(borrower), + msig(sfCounterpartySignature, alice, bella), + fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + } + + void + testLifecycle() + { + testcase("Lifecycle"); + using namespace jtx; + + // Create 3 loan brokers: one for XRP, one for an IOU, and one for + // an MPT. That'll require three corresponding SAVs. + Env env(*this, all); + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + // Borrower only wants to borrow + Account const borrower{"borrower"}; + // Evan will attempt to be naughty + Account const evan{"evan"}; + // Do not fund alice + Account const alice{"alice"}; + + // Fund the accounts and trust lines with the same amount so that + // tests can use the same values regardless of the asset. + env.fund(XRP(100'000'000), issuer, noripple(lender, borrower, evan)); + env.close(); + + // Create assets + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + PrettyAsset const iouAsset = issuer[iouCurrency]; + env(trust(lender, iouAsset(10'000'000))); + env(trust(borrower, iouAsset(10'000'000))); + env(trust(evan, iouAsset(10'000'000))); + env(pay(issuer, evan, iouAsset(1'000'000))); + env(pay(issuer, lender, iouAsset(10'000'000))); + // Fund the borrower with enough to cover interest and fees + env(pay(issuer, borrower, iouAsset(10'000))); + env.close(); + + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + // Scale the MPT asset a little bit so we can get some interest + PrettyAsset const mptAsset{mptt.issuanceID(), 100}; + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + mptt.authorize({.account = evan}); + env(pay(issuer, lender, mptAsset(10'000'000))); + env(pay(issuer, evan, mptAsset(1'000'000))); + // Fund the borrower with enough to cover interest and fees + env(pay(issuer, borrower, mptAsset(10'000))); + env.close(); + + std::array const assets{xrpAsset, mptAsset, iouAsset}; + + // Create vaults and loan brokers + std::vector brokers; + for (auto const& asset : assets) + { + brokers.emplace_back(createVaultAndBroker( + env, + asset, + lender, + BrokerParameters{.data = "spam spam spam spam"})); + } + + // Create and update Loans + for (auto const& broker : brokers) + { + for (int amountExponent = 3; amountExponent >= 3; --amountExponent) + { + Number const loanAmount{1, amountExponent}; + for (int interestExponent = 0; interestExponent >= 0; + --interestExponent) + { + testCaseWrapper( + env, + mptt, + assets, + broker, + loanAmount, + interestExponent); + } + } + + if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0); + + auto const coverAvailable = brokerSle->at(sfCoverAvailable); + env(loanBroker::coverWithdraw( + lender, + broker.brokerID, + STAmount(broker.asset, coverAvailable))); + env.close(); + + brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0); + } + // Verify we can delete the loan broker + env(loanBroker::del(lender, broker.brokerID)); + env.close(); + } + } + + void + testSelfLoan() + { + testcase << "Self Loan"; + + using namespace jtx; + using namespace std::chrono_literals; + // Create 3 loan brokers: one for XRP, one for an IOU, and one for + // an MPT. That'll require three corresponding SAVs. + Env env(*this, all); + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + + // Fund the accounts and trust lines with the same amount so that + // tests can use the same values regardless of the asset. + env.fund(XRP(100'000'000), issuer, noripple(lender)); + env.close(); + + // Use an XRP asset for simplicity + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + // Create vaults and loan brokers + BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + // The LoanSet json can be created without a counterparty signature, + // but it will not pass preflight + auto createJson = env.json( + set(lender, + broker.brokerID, + broker.asset(principalRequest).value()), + fee(loanSetFee)); + env(createJson, ter(temBAD_SIGNER)); + + // Adding an empty counterparty signature object also fails, but + // at the RPC level. + createJson = env.json( + createJson, json(sfCounterpartySignature, Json::objectValue)); + env(createJson, ter(telENV_RPC_FAILED)); + + if (auto const jt = env.jt(createJson); BEAST_EXPECT(jt.stx)) + { + Serializer s; + jt.stx->add(s); + auto const jr = env.rpc("submit", strHex(s.slice())); + + BEAST_EXPECT(jr.isMember(jss::result)); + auto const jResult = jr[jss::result]; + BEAST_EXPECT(jResult[jss::error] == "invalidTransaction"); + BEAST_EXPECT( + jResult[jss::error_exception] == + "fails local checks: Transaction has bad signature."); + } + + // Copy the transaction signature into the counterparty signature. + Json::Value counterpartyJson{Json::objectValue}; + counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; + counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; + if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) + counterpartyJson[sfSigners] = createJson[sfSigners]; + + // The duplicated signature works + createJson = env.json( + createJson, json(sfCounterpartySignature, counterpartyJson)); + env(createJson); + + env.close(); + + auto const startDate = env.current()->info().parentCloseTime; + + // Loan is successfully created + { + auto const res = env.rpc("account_objects", lender.human()); + auto const objects = res[jss::result][jss::account_objects]; + + std::map types; + BEAST_EXPECT(objects.size() == 4); + for (auto const& object : objects) + { + ++types[object[sfLedgerEntryType].asString()]; + } + BEAST_EXPECT(types.size() == 4); + for (std::string const type : + {"MPToken", "Vault", "LoanBroker", "Loan"}) + { + BEAST_EXPECT(types[type] == 1); + } + } + auto const loanID = [&]() { + Json::Value params(Json::objectValue); + params[jss::account] = lender.human(); + params[jss::type] = "Loan"; + auto const res = + env.rpc("json", "account_objects", to_string(params)); + auto const objects = res[jss::result][jss::account_objects]; + + BEAST_EXPECT(objects.size() == 1); + + auto const loan = objects[0u]; + BEAST_EXPECT(loan[sfBorrower] == lender.human()); + // soeDEFAULT fields are not returned if they're in the default + // state + BEAST_EXPECT(!loan.isMember(sfCloseInterestRate)); + BEAST_EXPECT(!loan.isMember(sfClosePaymentFee)); + BEAST_EXPECT(loan[sfFlags] == 0); + BEAST_EXPECT(loan[sfGracePeriod] == 60); + BEAST_EXPECT(!loan.isMember(sfInterestRate)); + BEAST_EXPECT(!loan.isMember(sfLateInterestRate)); + BEAST_EXPECT(!loan.isMember(sfLatePaymentFee)); + BEAST_EXPECT(loan[sfLoanBrokerID] == to_string(broker.brokerID)); + BEAST_EXPECT(!loan.isMember(sfLoanOriginationFee)); + BEAST_EXPECT(loan[sfLoanSequence] == 1); + BEAST_EXPECT(!loan.isMember(sfLoanServiceFee)); + BEAST_EXPECT( + loan[sfNextPaymentDueDate] == loan[sfStartDate].asUInt() + 60); + BEAST_EXPECT(!loan.isMember(sfOverpaymentFee)); + BEAST_EXPECT(!loan.isMember(sfOverpaymentInterestRate)); + BEAST_EXPECT(loan[sfPaymentInterval] == 60); + BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000"); + BEAST_EXPECT(loan[sfPaymentRemaining] == 1); + BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDate)); + BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000"); + BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000"); + BEAST_EXPECT(!loan.isMember(sfLoanScale)); + BEAST_EXPECT( + loan[sfStartDate].asUInt() == + startDate.time_since_epoch().count()); + + return loan["index"].asString(); + }(); + auto const loanKeylet{keylet::loan(uint256{std::string_view(loanID)})}; + + env.close(startDate); + + // Make a payment + env(pay(lender, loanKeylet.key, broker.asset(1000))); + } + + void + testBatchBypassCounterparty() + { + // From FIND-001 + testcase << "Batch Bypass Counterparty"; + + bool const lendingBatchEnabled = !std::any_of( + Batch::disabledTxTypes.begin(), + Batch::disabledTxTypes.end(), + [](auto const& disabled) { return disabled == ttLOAN_BROKER_SET; }); + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, all); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + BrokerParameters brokerParams; + env.fund(XRP(brokerParams.vaultDeposit * 100), lender, borrower); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + BrokerInfo broker{ + createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto forgedLoanSet = + set(borrower, broker.brokerID, principalRequest, 0); + + Json::Value randomData{Json::objectValue}; + randomData[jss::SigningPubKey] = Json::StaticString{"2600"}; + Json::Value sigObject{Json::objectValue}; + sigObject[jss::SigningPubKey] = strHex(lender.pk().slice()); + Serializer ss; + ss.add32(HashPrefix::txSign); + parse(randomData).addWithoutSigningFields(ss); + auto const sig = ripple::sign(borrower.pk(), borrower.sk(), ss.slice()); + sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()}); + + forgedLoanSet[Json::StaticString{"CounterpartySignature"}] = sigObject; + + // ? Fails because the lender hasn't signed the tx + env(env.json(forgedLoanSet, fee(loanSetFee)), ter(telENV_RPC_FAILED)); + + auto const seq = env.seq(borrower); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + // ! Should fail because the lender hasn't signed the tx + env(batch::outer(borrower, seq, batchFee, tfAllOrNothing), + batch::inner(forgedLoanSet, seq + 1), + batch::inner(pay(borrower, lender, XRP(1)), seq + 2), + ter(lendingBatchEnabled ? temBAD_SIGNATURE + : temINVALID_INNER_BATCH)); + env.close(); + + // ? Check that the loan was NOT created + { + Json::Value params(Json::objectValue); + params[jss::account] = borrower.human(); + params[jss::type] = "Loan"; + auto const res = + env.rpc("json", "account_objects", to_string(params)); + auto const objects = res[jss::result][jss::account_objects]; + BEAST_EXPECT(objects.size() == 0); + } + } + + void + testWrongMaxDebtBehavior() + { + // From FIND-003 + testcase << "Wrong Max Debt Behavior"; + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + + BrokerParameters brokerParams{.debtMax = 0}; + env.fund( + XRP(brokerParams.vaultDeposit * 100), issuer, noripple(lender)); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + BrokerInfo broker{ + createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfDebtMaximum) == 0); + } + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(lender, broker.brokerID, principalRequest), fee(loanSetFee)); + + Json::Value counterpartyJson{Json::objectValue}; + counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; + counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; + if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) + counterpartyJson[sfSigners] = createJson[sfSigners]; + + createJson = env.json( + createJson, json(sfCounterpartySignature, counterpartyJson)); + env(createJson); + + env.close(); + } + + void + testLoanPayComputePeriodicPaymentValidRateInvariant() + { + // From FIND-012 + testcase << "LoanPay ripple::detail::computePeriodicPayment : " + "valid rate"; + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + BrokerParameters brokerParams; + env.fund( + XRP(brokerParams.vaultDeposit * 100), issuer, lender, borrower); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerInfo broker{ + createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{640562, -5}; + + Number const serviceFee{2462611968}; + std::uint32_t const numPayments{4294967295 / 800}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + fee(loanSetFee), + loanServiceFee(serviceFee), + paymentTotal(numPayments), + json(sfCounterpartySignature, Json::objectValue)); + + createJson["CloseInterestRate"] = 55374; + createJson["ClosePaymentFee"] = "3825205248"; + createJson["GracePeriod"] = 0; + createJson["LatePaymentFee"] = "237"; + createJson["LoanOriginationFee"] = "0"; + createJson["OverpaymentFee"] = 35167; + createJson["OverpaymentInterestRate"] = 1360; + createJson["PaymentInterval"] = 727; + + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); + // Fails in preclaim because principal requested can't be + // represented as XRP + env(createJson, ter(tecPRECISION_LOSS)); + env.close(); + + BEAST_EXPECT(!env.le(keylet)); + + Number const actualPrincipal{6}; + + createJson[sfPrincipalRequested] = actualPrincipal; + createJson.removeMember(sfSequence.jsonName); + createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); + // Fails in doApply because the payment is too small to be + // represented as XRP. + env(createJson, ter(tecPRECISION_LOSS)); + env.close(); + } + + void + testRPC() + { + // This will expand as more test cases are added. Some functionality + // is tested in other test functions. + testcase("RPC"); + + using namespace jtx; + + Env env(*this, all); + + auto lowerFee = [&]() { + // Run the local fee back down. + while (env.app().getFeeTrack().lowerLocalFee()) + ; + }; + + auto const baseFee = env.current()->fees().base; + + Account const alice{"alice"}; + std::string const borrowerPass = "borrower"; + std::string const borrowerSeed = "ssBRAsLpH4778sLNYC4ik1JBJsBVf"; + Account borrower{borrowerPass, KeyType::ed25519}; + auto const lenderPass = "lender"; + std::string const lenderSeed = "shPTCZGwTEhJrYT8NbcNkeaa8pzPM"; + Account lender{lenderPass, KeyType::ed25519}; + + env.fund(XRP(1'000'000), alice, lender, borrower); + env.close(); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env.close(); + + { + testcase("RPC AccountSet"); + Json::Value txJson{Json::objectValue}; + txJson[sfTransactionType] = "AccountSet"; + txJson[sfAccount] = borrower.human(); + + auto const signParams = [&]() { + Json::Value signParams{Json::objectValue}; + signParams[jss::passphrase] = borrowerPass; + signParams[jss::key_type] = "ed25519"; + signParams[jss::tx_json] = txJson; + return signParams; + }(); + auto const jSign = env.rpc("json", "sign", to_string(signParams)); + BEAST_EXPECT( + jSign.isMember(jss::result) && + jSign[jss::result].isMember(jss::tx_json)); + auto txSignResult = jSign[jss::result][jss::tx_json]; + auto txSignBlob = jSign[jss::result][jss::tx_blob].asString(); + txSignResult.removeMember(jss::hash); + + auto const jtx = env.jt(txJson, sig(borrower)); + BEAST_EXPECT(txSignResult == jtx.jv); + + lowerFee(); + auto const jSubmit = env.rpc("submit", txSignBlob); + BEAST_EXPECT( + jSubmit.isMember(jss::result) && + jSubmit[jss::result].isMember(jss::engine_result) && + jSubmit[jss::result][jss::engine_result].asString() == + "tesSUCCESS"); + + lowerFee(); + env(jtx.jv, sig(none), seq(none), fee(none), ter(tefPAST_SEQ)); + } + + { + testcase("RPC LoanSet - illegal signature_target"); + + Json::Value txJson{Json::objectValue}; + txJson[sfTransactionType] = "AccountSet"; + txJson[sfAccount] = borrower.human(); + + auto const borrowerSignParams = [&]() { + Json::Value params{Json::objectValue}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "Destination"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignBorrower = + env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECT( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::error) && + jSignBorrower[jss::result][jss::error] == "invalidParams" && + jSignBorrower[jss::result].isMember(jss::error_message) && + jSignBorrower[jss::result][jss::error_message] == + "Destination"); + } + { + testcase("RPC LoanSet - sign and submit borrower initiated"); + // 1. Borrower creates the transaction + Json::Value txJson{Json::objectValue}; + txJson[sfTransactionType] = "LoanSet"; + txJson[sfAccount] = borrower.human(); + txJson[sfCounterparty] = lender.human(); + txJson[sfLoanBrokerID] = + "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" + "F83F" + "5C"; + txJson[sfPrincipalRequested] = "100000000"; + txJson[sfPaymentTotal] = 10000; + txJson[sfPaymentInterval] = 3600; + txJson[sfGracePeriod] = 300; + txJson[sfFlags] = 65536; // tfLoanOverpayment + txJson[sfFee] = to_string(24 * baseFee / 10); + + // 2. Borrower signs the transaction + auto const borrowerSignParams = [&]() { + Json::Value params{Json::objectValue}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignBorrower = + env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECTS( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::tx_json), + to_string(jSignBorrower)); + auto const txBorrowerSignResult = + jSignBorrower[jss::result][jss::tx_json]; + auto const txBorrowerSignBlob = + jSignBorrower[jss::result][jss::tx_blob].asString(); + + // 2a. Borrower attempts to submit the transaction. It doesn't + // work + { + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + // Transaction fails because the CounterpartySignature is + // missing + BEAST_EXPECT( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == + "temBAD_SIGNER"); + } + + // 3. Borrower sends the signed transaction to the lender + // 4. Lender signs the transaction + auto const lenderSignParams = [&]() { + Json::Value params{Json::objectValue}; + params[jss::passphrase] = lenderPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "CounterpartySignature"; + params[jss::tx_json] = txBorrowerSignResult; + return params; + }(); + auto const jSignLender = + env.rpc("json", "sign", to_string(lenderSignParams)); + BEAST_EXPECT( + jSignLender.isMember(jss::result) && + jSignLender[jss::result].isMember(jss::tx_json)); + auto const txLenderSignResult = + jSignLender[jss::result][jss::tx_json]; + auto const txLenderSignBlob = + jSignLender[jss::result][jss::tx_blob].asString(); + + // 5. Lender submits the signed transaction blob + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; + // To get far enough to return tecNO_ENTRY means that the + // signatures all validated. Of course the transaction won't + // succeed because no Vault or Broker were created. + BEAST_EXPECTS( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == + "tecNO_ENTRY", + to_string(jSubmitBlobResult)); + + BEAST_EXPECT( + !jSubmitBlob.isMember(jss::error) && + !jSubmitBlobResult.isMember(jss::error)); + + // 4-alt. Lender submits the transaction json originally + // received from the Borrower. It gets signed, but is now a + // duplicate, so fails. Borrower could done this instead of + // steps 4 and 5. + lowerFee(); + auto const jSubmitJson = + env.rpc("json", "submit", to_string(lenderSignParams)); + BEAST_EXPECT(jSubmitJson.isMember(jss::result)); + auto const jSubmitJsonResult = jSubmitJson[jss::result]; + BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); + auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; + // Since the previous tx claimed a fee, this duplicate is not + // going anywhere + BEAST_EXPECTS( + jSubmitJsonResult.isMember(jss::engine_result) && + jSubmitJsonResult[jss::engine_result].asString() == + "tefPAST_SEQ", + to_string(jSubmitJsonResult)); + + BEAST_EXPECT( + !jSubmitJson.isMember(jss::error) && + !jSubmitJsonResult.isMember(jss::error)); + + BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); + } + + { + testcase("RPC LoanSet - sign and submit lender initiated"); + // 1. Lender creates the transaction + Json::Value txJson{Json::objectValue}; + txJson[sfTransactionType] = "LoanSet"; + txJson[sfAccount] = lender.human(); + txJson[sfCounterparty] = borrower.human(); + txJson[sfLoanBrokerID] = + "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" + "F83F" + "5C"; + txJson[sfPrincipalRequested] = "100000000"; + txJson[sfPaymentTotal] = 10000; + txJson[sfPaymentInterval] = 3600; + txJson[sfGracePeriod] = 300; + txJson[sfFlags] = 65536; // tfLoanOverpayment + txJson[sfFee] = to_string(24 * baseFee / 10); + + // 2. Lender signs the transaction + auto const lenderSignParams = [&]() { + Json::Value params{Json::objectValue}; + params[jss::passphrase] = lenderPass; + params[jss::key_type] = "ed25519"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignLender = + env.rpc("json", "sign", to_string(lenderSignParams)); + BEAST_EXPECT( + jSignLender.isMember(jss::result) && + jSignLender[jss::result].isMember(jss::tx_json)); + auto const txLenderSignResult = + jSignLender[jss::result][jss::tx_json]; + auto const txLenderSignBlob = + jSignLender[jss::result][jss::tx_blob].asString(); + + // 2a. Lender attempts to submit the transaction. It doesn't + // work + { + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + // Transaction fails because the CounterpartySignature is + // missing + BEAST_EXPECT( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == + "temBAD_SIGNER"); + } + + // 3. Lender sends the signed transaction to the Borrower + // 4. Borrower signs the transaction + auto const borrowerSignParams = [&]() { + Json::Value params{Json::objectValue}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "CounterpartySignature"; + params[jss::tx_json] = txLenderSignResult; + return params; + }(); + auto const jSignBorrower = + env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECT( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::tx_json)); + auto const txBorrowerSignResult = + jSignBorrower[jss::result][jss::tx_json]; + auto const txBorrowerSignBlob = + jSignBorrower[jss::result][jss::tx_blob].asString(); + + // 5. Borrower submits the signed transaction blob + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; + // To get far enough to return tecNO_ENTRY means that the + // signatures all validated. Of course the transaction won't + // succeed because no Vault or Broker were created. + BEAST_EXPECTS( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == + "tecNO_ENTRY", + to_string(jSubmitBlobResult)); + + BEAST_EXPECT( + !jSubmitBlob.isMember(jss::error) && + !jSubmitBlobResult.isMember(jss::error)); + + // 4-alt. Borrower submits the transaction json originally + // received from the Lender. It gets signed, but is now a + // duplicate, so fails. Lender could done this instead of steps + // 4 and 5. + lowerFee(); + auto const jSubmitJson = + env.rpc("json", "submit", to_string(borrowerSignParams)); + BEAST_EXPECT(jSubmitJson.isMember(jss::result)); + auto const jSubmitJsonResult = jSubmitJson[jss::result]; + BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); + auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; + // Since the previous tx claimed a fee, this duplicate is not + // going anywhere + BEAST_EXPECTS( + jSubmitJsonResult.isMember(jss::engine_result) && + jSubmitJsonResult[jss::engine_result].asString() == + "tefPAST_SEQ", + to_string(jSubmitJsonResult)); + + BEAST_EXPECT( + !jSubmitJson.isMember(jss::error) && + !jSubmitJsonResult.isMember(jss::error)); + + BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); + } + } + + void + testServiceFeeOnBrokerDeepFreeze() + { + testcase << "Service Fee On Broker Deep Freeze"; + using namespace jtx; + using namespace loan; + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + auto const IOU = issuer["IOU"]; + + for (bool const deepFreeze : {true, false}) + { + Env env(*this); + + auto getCoverBalance = [&](BrokerInfo const& brokerInfo, + auto const& accountField) { + if (auto const le = + env.le(keylet::loanbroker(brokerInfo.brokerID)); + BEAST_EXPECT(le)) + { + auto const account = le->at(accountField); + if (auto const sleLine = env.le(keylet::line(account, IOU)); + BEAST_EXPECT(sleLine)) + { + STAmount balance = sleLine->at(sfBalance); + if (account > issuer.id()) + balance.negate(); + return balance; + } + } + return STAmount{IOU}; + }; + + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + env(trust(broker, IOU(20'000'000))); + env(pay(issuer, broker, IOU(10'000'000))); + env.close(); + + auto const brokerInfo = createVaultAndBroker(env, IOU, broker); + + BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == IOU(1'000)); + + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + + env(set(borrower, brokerInfo.brokerID, 10'000), + sig(sfCounterpartySignature, broker), + loanServiceFee(IOU(100).value()), + paymentInterval(100), + fee(XRP(100))); + env.close(); + + env(trust(borrower, IOU(20'000'000))); + // The borrower increases their limit and acquires some IOU so + // they can pay interest + env(pay(issuer, borrower, IOU(500))); + env.close(); + + if (auto const le = env.le(keylet::loan(keylet.key)); + BEAST_EXPECT(le)) + { + if (deepFreeze) + { + env(trust( + issuer, + broker["IOU"](0), + tfSetFreeze | tfSetDeepFreeze)); + env.close(); + } + + env(pay(borrower, keylet.key, IOU(10'100)), fee(XRP(100))); + env.close(); + + if (deepFreeze) + { + // The fee goes to the broker pseudo-account + BEAST_EXPECT( + getCoverBalance(brokerInfo, sfAccount) == IOU(1'100)); + BEAST_EXPECT( + getCoverBalance(brokerInfo, sfOwner) == IOU(8'999'000)); + } + else + { + // The fee goes to the broker account + BEAST_EXPECT( + getCoverBalance(brokerInfo, sfOwner) == IOU(8'999'100)); + BEAST_EXPECT( + getCoverBalance(brokerInfo, sfAccount) == IOU(1'000)); + } + } + }; + } + + void + testBasicMath() + { + // Test the functions defined in LendingHelpers.h + testcase("Basic Math"); + + pass(); + } + + void + testIssuerLoan() + { + testcase << "Issuer Loan"; + + using namespace jtx; + using namespace loan; + Account const issuer("issuer"); + Account const borrower = issuer; + Account const lender("lender"); + Env env(*this); + + env.fund(XRP(1'000), issuer, lender); + + std::int64_t constexpr issuerBalance = 10'000'000; + MPTTester asset( + {.env = env, + .issuer = issuer, + .holders = {lender}, + .pay = issuerBalance}); + + BrokerParameters const brokerParams{ + .debtMax = 200, + }; + auto const broker = + createVaultAndBroker(env, asset, lender, brokerParams); + auto const loanSetFee = fee(env.current()->fees().base * 2); + // Create Loan + env(set(borrower, broker.brokerID, 200), + sig(sfCounterpartySignature, lender), + loanSetFee); + env.close(); + // Issuer should not create MPToken + BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer))); + // Issuer "borrowed" 200, OutstandingAmount decreased by 200 + BEAST_EXPECT(env.balance(issuer, asset) == asset(-issuerBalance + 200)); + // Pay Loan + auto const loanKeylet = keylet::loan(broker.brokerID, 1); + env(pay(borrower, loanKeylet.key, asset(200))); + env.close(); + // Issuer "re-payed" 200, OutstandingAmount increased by 200 + BEAST_EXPECT(env.balance(issuer, asset) == asset(-issuerBalance)); + } + + void + testInvalidLoanDelete() + { + testcase("Invalid LoanDelete"); + using namespace jtx; + using namespace loan; + + // preflight: temINVALID, LoanID == zero + { + Account const alice{"alice"}; + Env env(*this); + env.fund(XRP(1'000), alice); + env.close(); + env(del(alice, beast::zero), ter(temINVALID)); + } + } + + void + testInvalidLoanManage() + { + testcase("Invalid LoanManage"); + using namespace jtx; + using namespace loan; + + // preflight: temINVALID, LoanID == zero + { + Account const alice{"alice"}; + Env env(*this); + env.fund(XRP(1'000), alice); + env.close(); + env(manage(alice, beast::zero, tfLoanDefault), ter(temINVALID)); + } + } + + void + testInvalidLoanPay() + { + testcase("Invalid LoanPay"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + auto const IOU = issuer["IOU"]; + + // preclaim + Env env(*this); + env.fund(XRP(1'000), lender, issuer, borrower); + env(trust(lender, IOU(10'000'000)), THISLINE); + env(pay(issuer, lender, IOU(5'000'000)), THISLINE); + BrokerInfo brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); + + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + THISLINE); + + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); + + env(fset(issuer, asfGlobalFreeze), THISLINE); + env.close(); + + // preclaim: tecFROZEN + env(pay(borrower, loanKeylet.key, debtMaximumRequest), + ter(tecFROZEN), + THISLINE); + env.close(); + + env(fclear(issuer, asfGlobalFreeze), THISLINE); + env.close(); + + auto const pseudoBroker = [&]() -> std::optional { + if (auto brokerSle = + env.le(keylet::loanbroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + return Account{"pseudo", brokerSle->at(sfAccount)}; + } + else + { + return std::nullopt; + } + }(); + if (!pseudoBroker) + return; + + // Lender and pseudoaccount must both be frozen + env(trust( + issuer, + lender["IOU"](1'000), + lender, + tfSetFreeze | tfSetDeepFreeze), + THISLINE); + env(trust( + issuer, + (*pseudoBroker)["IOU"](1'000), + *pseudoBroker, + tfSetFreeze | tfSetDeepFreeze), + THISLINE); + env.close(); + + // preclaim: tecFROZEN due to deep frozen + env(pay(borrower, loanKeylet.key, debtMaximumRequest), + ter(tecFROZEN), + THISLINE); + env.close(); + + // Only one needs to be unfrozen + env(trust( + issuer, + lender["IOU"](1'000), + tfClearFreeze | tfClearDeepFreeze), + THISLINE); + env.close(); + + // The payment is late by this point + env(pay(borrower, loanKeylet.key, debtMaximumRequest), + ter(tecEXPIRED), + THISLINE); + env.close(); + env(pay(borrower, + loanKeylet.key, + debtMaximumRequest, + tfLoanLatePayment), + THISLINE); + env.close(); + + // preclaim: tecKILLED + // note that tecKILLED in loanMakePayment() + // doesn't happen because of the preclaim check. + env(pay(borrower, loanKeylet.key, debtMaximumRequest), + ter(tecKILLED), + THISLINE); + } + + void + testInvalidLoanSet() + { + testcase("Invalid LoanSet"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + auto const IOU = issuer["IOU"]; + + auto testWrapper = [&](auto&& test) { + Env env(*this); + env.fund(XRP(1'000), lender, issuer, borrower); + env(trust(lender, IOU(10'000'000))); + env(pay(issuer, lender, IOU(5'000'000))); + BrokerInfo brokerInfo{ + createVaultAndBroker(env, issuer["IOU"], lender)}; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const debtMaximumRequest = brokerInfo.asset(1'000).value(); + test(env, brokerInfo, loanSetFee, debtMaximumRequest); + }; + + // preflight: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::fee const& loanSetFee, + Number const& debtMaximumRequest) { + // first temBAD_SIGNER: TODO + + // empty/zero broker ID + { + auto jv = set(borrower, uint256{}, debtMaximumRequest); + + auto testZeroBrokerID = [&](std::string const& id, + std::uint32_t flags = 0) { + // empty broker ID + jv[sfLoanBrokerID] = id; + env(jv, + sig(sfCounterpartySignature, lender), + loanSetFee, + txflags(flags), + ter(temINVALID)); + }; + // empty broker ID + testZeroBrokerID(std::string("")); + // zero broker ID + // needs a flag to distinguish the parsed STTx from the prior + // test + testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig); + } + + // preflightCheckSigningKey() failure: + // can it happen? the signature is checked before transactor + // executes + + JTx tx = env.jt( + set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee); + STTx local = *(tx.stx); + auto counterpartySig = + local.getFieldObject(sfCounterpartySignature); + auto badPubKey = counterpartySig.getFieldVL(sfSigningPubKey); + badPubKey[20] ^= 0xAA; + counterpartySig.setFieldVL(sfSigningPubKey, badPubKey); + local.setFieldObject(sfCounterpartySignature, counterpartySig); + Json::Value jvResult; + jvResult[jss::tx_blob] = strHex(local.getSerializer().slice()); + auto res = env.rpc("json", "submit", to_string(jvResult))["result"]; + BEAST_EXPECT( + res[jss::error] == "invalidTransaction" && + res[jss::error_exception] == + "fails local checks: Counterparty: Invalid signature."); + }); + + // preclaim: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::fee const& loanSetFee, + Number const& debtMaximumRequest) { + // canAddHoldingFailure (IOU only, if MPT doesn't have + // MPTCanTransfer set, then can't create Vault/LoanBroker, + // and LoanSet will fail with different error + env(fclear(issuer, asfDefaultRipple)); + env.close(); + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(terNO_RIPPLE)); + }); + + // doApply: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::fee const& loanSetFee, + Number const& debtMaximumRequest) { + auto const amt = env.balance(borrower) - + env.current()->fees().accountReserve(env.ownerCount(borrower)); + env(pay(borrower, issuer, amt)); + + // tecINSUFFICIENT_RESERVE + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(tecINSUFFICIENT_RESERVE)); + + // addEmptyHolding failure + env(pay(issuer, borrower, amt)); + env(fset(issuer, asfGlobalFreeze)); + env.close(); + + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(tecFROZEN)); + }); + } + + void + testAccountSendMptMinAmountInvariant() + { + // (From FIND-006) + testcase << "LoanSet trigger ripple::accountSendMPT : minimum amount " + "and MPT"; + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset const mptAsset = mptt.issuanceID(); + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + env(pay(issuer, lender, mptAsset(2'000'000))); + env(pay(issuer, borrower, mptAsset(1'000))); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, mptAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + fee(loanSetFee), + json(sfCounterpartySignature, Json::objectValue)); + + createJson["CloseInterestRate"] = 76671; + createJson["ClosePaymentFee"] = "2061925410"; + createJson["GracePeriod"] = 434; + createJson["InterestRate"] = 50302; + createJson["LateInterestRate"] = 30322; + createJson["LatePaymentFee"] = "294427911"; + createJson["LoanOriginationFee"] = "3250635102"; + createJson["LoanServiceFee"] = "9557386"; + createJson["OverpaymentFee"] = 51249; + createJson["OverpaymentInterestRate"] = 14304; + createJson["PaymentInterval"] = 434; + createJson["PaymentTotal"] = "2891743748"; + createJson["PrincipalRequested"] = "8516.98"; + + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + + createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); + env(createJson, ter(temINVALID)); + env.close(); + } + + void + testLoanPayDebtDecreaseInvariant() + { + // From FIND-007 + testcase << "LoanPay ripple::LoanPay::doApply : debtDecrease " + "rounding good"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const iouAsset = issuer[iouCurrency]; + auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); + env(trustLenderTx); + auto trustBorrowerTx = + env.json(trust(borrower, iouAsset(1'000'000'000))); + env(trustBorrowerTx); + auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); + env(payLenderTx); + auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); + env(payIssuerTx); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const baseFee = env.current()->fees().base; + auto const loanSetFee = fee(baseFee * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + fee(loanSetFee), + json(sfCounterpartySignature, Json::objectValue)); + + createJson["ClosePaymentFee"] = "0"; + createJson["GracePeriod"] = 60; + createJson["InterestRate"] = 24346; + createJson["LateInterestRate"] = 65535; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); + env(createJson, ter(tesSUCCESS)); + env.close(); + + auto const pseudoAcct = [&]() { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return lender; + auto const brokerPseudo = brokerSle->at(sfAccount); + return Account("Broker pseudo-account", brokerPseudo); + }(); + + VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, keylet); + auto const originalState = getCurrentState(env, broker, keylet); + verifyLoanStatus(originalState); + + Number const payment{3'269'349'176'470'588, -12}; + XRPAmount const payFee{ + baseFee * + ((payment / originalState.periodicPayment) / + loanPaymentsPerFeeIncrement + + 1)}; + auto loanPayTx = env.json( + pay(borrower, keylet.key, STAmount{broker.asset, payment}), + fee(payFee)); + BEAST_EXPECT(to_string(payment) == "3269.349176470588"); + env(loanPayTx, ter(tesSUCCESS)); + env.close(); + + auto const newState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(isRounded( + broker.asset, + newState.managementFeeOutstanding, + originalState.loanScale)); + BEAST_EXPECT( + newState.managementFeeOutstanding < + originalState.managementFeeOutstanding); + BEAST_EXPECT(isRounded( + broker.asset, newState.totalValue, originalState.loanScale)); + BEAST_EXPECT(isRounded( + broker.asset, + newState.principalOutstanding, + originalState.loanScale)); + } + + void + testLoanPayComputePeriodicPaymentValidTotalInterestInvariant() + { + // From FIND-010 + testcase << "ripple::loanComputePaymentParts : valid total interest"; + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const iouAsset = issuer[iouCurrency]; + auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); + env(trustLenderTx); + auto trustBorrowerTx = + env.json(trust(borrower, iouAsset(1'000'000'000))); + env(trustBorrowerTx); + auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); + env(payLenderTx); + auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); + env(payIssuerTx); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + auto const startDate = env.now() + 60s; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + fee(loanSetFee), + json(sfCounterpartySignature, Json::objectValue)); + + createJson["CloseInterestRate"] = 47299; + createJson["ClosePaymentFee"] = "3985819770"; + createJson["GracePeriod"] = 0; + createJson["InterestRate"] = 92; + createJson["LatePaymentFee"] = "3866894865"; + createJson["LoanOriginationFee"] = "0"; + createJson["LoanServiceFee"] = "2348810240"; + createJson["OverpaymentFee"] = 58545; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 1; + createJson["PrincipalRequested"] = "0.000763058"; + + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); + env(createJson, ter(tecPRECISION_LOSS)); + env.close(startDate); + + auto loanPayTx = env.json( + pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + loanPayTx["Amount"]["value"] = "0.000281284125490196"; + env(loanPayTx, ter(tecNO_ENTRY)); + env.close(); + } + + void + testDosLoanPay() + { + // From FIND-005 + testcase << "DoS LoanPay"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const iouAsset = issuer[iouCurrency]; + env(trust(lender, iouAsset(100'000'000))); + env(trust(borrower, iouAsset(100'000'000))); + env(pay(issuer, lender, iouAsset(10'000'000))); + env(pay(issuer, borrower, iouAsset(1'000))); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + auto const baseFee = env.current()->fees().base; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + fee(loanSetFee), + json(sfCounterpartySignature, Json::objectValue)); + + createJson["ClosePaymentFee"] = "0"; + createJson["GracePeriod"] = 60; + createJson["InterestRate"] = 20930; + createJson["LateInterestRate"] = 77049; + createJson["LatePaymentFee"] = "0"; + createJson["LoanServiceFee"] = "0"; + createJson["OverpaymentFee"] = 7; + createJson["OverpaymentInterestRate"] = 66653; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 3239184; + createJson["PrincipalRequested"] = "3959.37"; + + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); + env(createJson, ter(tesSUCCESS)); + env.close(); + + auto const stateBefore = getCurrentState(env, broker, keylet); + BEAST_EXPECT(stateBefore.paymentRemaining == 3239184); + BEAST_EXPECT( + stateBefore.paymentRemaining > loanMaximumPaymentsPerTransaction); + + auto loanPayTx = env.json( + pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{395937, -2}; + loanPayTx["Amount"]["value"] = to_string(amount); + XRPAmount const payFee{ + baseFee * + std::int64_t( + amount / stateBefore.periodicPayment / + loanPaymentsPerFeeIncrement + + 1)}; + env(loanPayTx, ter(tesSUCCESS), fee(payFee)); + env.close(); + + auto const stateAfter = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + stateAfter.paymentRemaining == + stateBefore.paymentRemaining - loanMaximumPaymentsPerTransaction); + } + + void + testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant() + { + // From FIND-009 + testcase << "ripple::loanComputePaymentParts : totalPrincipalPaid " + "rounded"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const iouAsset = issuer[iouCurrency]; + auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); + env(trustLenderTx); + auto trustBorrowerTx = + env.json(trust(borrower, iouAsset(1'000'000'000))); + env(trustBorrowerTx); + auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); + env(payLenderTx); + auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); + env(payIssuerTx); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + fee(loanSetFee), + json(sfCounterpartySignature, Json::objectValue)); + + createJson["ClosePaymentFee"] = "0"; + createJson["GracePeriod"] = 0; + createJson["InterestRate"] = 24346; + createJson["LateInterestRate"] = 65535; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); + env(createJson, ter(tesSUCCESS)); + env.close(); + + auto const baseFee = env.current()->fees().base; + + auto const stateBefore = getCurrentState(env, broker, keylet); + + { + auto loanPayTx = env.json( + pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{3074'745'058'823'529, -12}; + BEAST_EXPECT(to_string(amount) == "3074.745058823529"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / + loanPaymentsPerFeeIncrement + + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, fee(payFee), ter(tesSUCCESS)); + env.close(); + } + + { + auto loanPayTx = env.json( + pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{6732'118'170'944'051, -12}; + BEAST_EXPECT(to_string(amount) == "6732.118170944051"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / + loanPaymentsPerFeeIncrement + + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, fee(payFee), ter(tesSUCCESS)); + env.close(); + } + + auto const stateAfter = getCurrentState(env, broker, keylet); + // Total interest outstanding is non-negative + BEAST_EXPECT(stateAfter.totalValue >= stateAfter.principalOutstanding); + // Principal paid is non-negative + BEAST_EXPECT( + stateBefore.principalOutstanding >= + stateAfter.principalOutstanding); + // Total value change is non-negative + BEAST_EXPECT(stateBefore.totalValue >= stateAfter.totalValue); + // Value delta is larger or same as principal delta (meaning + // non-negative interest paid) + BEAST_EXPECT( + (stateBefore.totalValue - stateAfter.totalValue) >= + (stateBefore.principalOutstanding - + stateAfter.principalOutstanding)); + } + + void + testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant() + { + // From FIND-008 + testcase << "ripple::loanComputePaymentParts : loanValueChange rounded"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const iouAsset = issuer[iouCurrency]; + auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); + env(trustLenderTx); + auto trustBorrowerTx = + env.json(trust(borrower, iouAsset(1'000'000'000))); + env(trustBorrowerTx); + auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); + env(payLenderTx); + auto payIssuerTx = pay(issuer, borrower, iouAsset(10'000'000)); + env(payIssuerTx); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; + { + auto const coverDepositValue = + broker.asset(broker.params.coverDeposit * 10).value(); + env(loanBroker::coverDeposit( + lender, broker.brokerID, coverDepositValue)); + env.close(); + } + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + fee(loanSetFee), + json(sfCounterpartySignature, Json::objectValue)); + + createJson["ClosePaymentFee"] = "0"; + createJson["GracePeriod"] = 0; + createJson["InterestRate"] = 12833; + createJson["LateInterestRate"] = 77048; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 752; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); + env(createJson, ter(tesSUCCESS)); + env.close(); + + auto const baseFee = env.current()->fees().base; + + auto const stateBefore = getCurrentState(env, broker, keylet); + BEAST_EXPECT(stateBefore.paymentRemaining == 5678); + BEAST_EXPECT( + stateBefore.paymentRemaining > loanMaximumPaymentsPerTransaction); + + auto loanPayTx = env.json( + pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{9924'81, -2}; + BEAST_EXPECT(to_string(amount) == "9924.81"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / + loanPaymentsPerFeeIncrement + + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, fee(payFee), ter(tesSUCCESS)); + env.close(); + + auto const stateAfter = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + stateAfter.paymentRemaining == + stateBefore.paymentRemaining - loanMaximumPaymentsPerTransaction); + } + + void + testLoanNextPaymentDueDateOverflow() + { + // For FIND-013 + testcase << "Prevent nextPaymentDueDate overflow"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const iouAsset = issuer[iouCurrency]; + auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); + env(trustLenderTx); + auto trustBorrowerTx = + env.json(trust(borrower, iouAsset(1'000'000'000))); + env(trustBorrowerTx); + auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); + env(payLenderTx); + auto payIssuerTx = pay(issuer, borrower, iouAsset(10'000'000)); + env(payIssuerTx); + env.close(); + + BrokerParameters const brokerParams{ + .debtMax = Number{0}, .coverRateMin = TenthBips32{1}}; + BrokerInfo broker{ + createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + + using timeType = decltype(sfNextPaymentDueDate)::type::value_type; + static_assert(std::is_same_v); + timeType constexpr maxTime = std::numeric_limits::max(); + static_assert(maxTime == 4'294'967'295); + + auto const baseJson = [&]() { + auto createJson = env.json( + set(borrower, broker.brokerID, Number{55524'81, -2}), + fee(loanSetFee), + closePaymentFee(0), + gracePeriod(0), + interestRate(TenthBips32(12833)), + lateInterestRate(TenthBips32(77048)), + latePaymentFee(0), + loanOriginationFee(218), + json(sfCounterpartySignature, Json::objectValue)); + + createJson.removeMember(sfSequence.getJsonName()); + + return createJson; + }(); + + auto const baseFee = env.current()->fees().base; + + auto parentCloseTime = [&]() { + return env.current()->parentCloseTime().time_since_epoch().count(); + }; + auto maxLoanTime = [&]() { + auto const startDate = parentCloseTime(); + + BEAST_EXPECT(startDate >= 50); + + return maxTime - startDate; + }; + + { + // straight-up overflow: interval + auto const interval = maxLoanTime() + 1; + auto const total = 1; + auto createJson = env.json( + baseJson, paymentInterval(interval), paymentTotal(total)); + + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tecKILLED)); + env.close(); + } + { + // straight-up overflow: total + // min interval is 60 + auto const interval = 60; + auto const total = maxLoanTime() + 1; + auto createJson = env.json( + baseJson, paymentInterval(interval), paymentTotal(total)); + + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tecKILLED)); + env.close(); + } + { + // straight-up overflow: grace period + // min interval is 60 + auto const interval = maxLoanTime() + 1; + auto const total = 1; + auto const grace = interval; + auto createJson = env.json( + baseJson, + paymentInterval(interval), + paymentTotal(total), + gracePeriod(grace)); + + // The grace period can't be larger than the interval. + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tecKILLED)); + env.close(); + } + { + // Overflow with multiplication of a few large intervals + auto const interval = 1'000'000'000; + auto const total = 10; + auto createJson = env.json( + baseJson, paymentInterval(interval), paymentTotal(total)); + + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tecKILLED)); + env.close(); + } + { + // Overflow with multiplication of many small payments + // min interval is 60 + auto const interval = 60; + auto const total = 1'000'000'000; + auto createJson = env.json( + baseJson, paymentInterval(interval), paymentTotal(total)); + + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tecKILLED)); + env.close(); + } + { + // Overflow with an absurdly large grace period + // min interval is 60 + auto const total = 60; + auto const interval = (maxLoanTime() - total) / total; + auto const grace = interval; + auto createJson = env.json( + baseJson, + paymentInterval(interval), + paymentTotal(total), + gracePeriod(grace)); + + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tecKILLED)); + env.close(); + } + { + // Start date when the ledger is closed will be larger + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + auto const grace = 100; + auto const interval = maxLoanTime() - grace; + auto const total = 1; + auto createJson = env.json( + baseJson, + paymentInterval(interval), + paymentTotal(total), + gracePeriod(grace)); + + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tesSUCCESS)); + env.close(); + + // The transaction is killed in the closed ledger + auto const meta = env.meta(); + if (BEAST_EXPECT(meta)) + { + BEAST_EXPECT(meta->at(sfTransactionResult) == tecKILLED); + } + + // If the transaction had succeeded, the loan would exist + auto const loanSle = env.le(keylet); + // but it doesn't + BEAST_EXPECT(!loanSle); + } + { + // Start date when the ledger is closed will be larger + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + auto const closeStartDate = (parentCloseTime() / 10 + 1) * 10; + auto const grace = 5'000; + auto const interval = maxTime - closeStartDate - grace; + auto const total = 1; + auto createJson = env.json( + baseJson, + paymentInterval(interval), + paymentTotal(total), + gracePeriod(grace)); + + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tesSUCCESS)); + env.close(); + + // The transaction succeeds in the closed ledger + auto const meta = env.meta(); + if (BEAST_EXPECT(meta)) + { + BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); + } + + // This loan exists + auto const afterState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(afterState.nextPaymentDate == maxTime - grace); + BEAST_EXPECT(afterState.previousPaymentDate == 0); + BEAST_EXPECT(afterState.paymentRemaining == 1); + } + + { + // Ensure the borrower has funds to pay back the loan + env(pay(issuer, borrower, iouAsset(Number{1'055'524'81, -2}))); + + // Start date when the ledger is closed will be larger + auto const closeStartDate = (parentCloseTime() / 10 + 1) * 10; + auto const grace = 5'000; + auto const maxLoanTime = maxTime - closeStartDate - grace; + auto const total = [&]() { + if (maxLoanTime % 5 == 0) + return 5; + if (maxLoanTime % 3 == 0) + return 3; + if (maxLoanTime % 2 == 0) + return 2; + return 0; + }(); + if (!BEAST_EXPECT(total != 0)) + return; + + auto const brokerState = + env.le(keylet::loanbroker(broker.brokerID)); + // Intentionally shadow the outer values + auto const loanSequence = brokerState->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + auto const interval = maxLoanTime / total; + auto createJson = env.json( + baseJson, + paymentInterval(interval), + paymentTotal(total), + gracePeriod(grace)); + + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tesSUCCESS)); + env.close(); + + // This loan exists + auto const beforeState = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + beforeState.nextPaymentDate == closeStartDate + interval); + BEAST_EXPECT(beforeState.previousPaymentDate == 0); + BEAST_EXPECT(beforeState.paymentRemaining == total); + BEAST_EXPECT(beforeState.periodicPayment > 0); + + // pay all but the last payment + Number const payment = beforeState.periodicPayment * (total - 1); + XRPAmount const payFee{ + baseFee * ((total - 1) / loanPaymentsPerFeeIncrement + 1)}; + auto loanPayTx = env.json( + pay(borrower, keylet.key, STAmount{broker.asset, payment}), + fee(payFee)); + env(loanPayTx, ter(tesSUCCESS)); + env.close(); + + // The loan is on the last payment + auto const afterState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(afterState.nextPaymentDate == maxTime - grace); + BEAST_EXPECT( + afterState.previousPaymentDate == maxTime - grace - interval); + BEAST_EXPECT(afterState.paymentRemaining == 1); + } + } + + void + testRequireAuth() + { + testcase("Require Auth - Implicit Pseudo-account authorization"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Env env(*this); + + env.fund(XRP(100'000), issuer, lender, borrower); + env.close(); + + auto asset = MPTTester({ + .env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = MPTDEXFlags | tfMPTRequireAuth | tfMPTCanClawback | + tfMPTCanLock, + .authHolder = true, + }); + + env(pay(issuer, lender, asset(5'000'000))); + BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)}; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); + + auto forUnauthAuth = [&](auto&& doTx) { + for (auto const flag : {tfMPTUnauthorize, 0u}) + { + asset.authorize( + {.account = issuer, .holder = borrower, .flags = flag}); + env.close(); + doTx(flag == 0); + env.close(); + } + }; + + // Can't create a loan if the borrower is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? ter(tecNO_AUTH) : ter(tesSUCCESS); + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + err); + }); + + std::uint32_t constexpr loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); + + // Can't loan pay if the borrower is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? ter(tecNO_AUTH) : ter(tesSUCCESS); + env(pay(borrower, loanKeylet.key, debtMaximumRequest), err); + }); + } + + void + testCoverDepositWithdrawNonTransferableMPT() + { + testcase( + "CoverDeposit and CoverWithdraw reject MPT without CanTransfer"); + using namespace jtx; + using namespace loanBroker; + + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const alice{"alice"}; + + env.fund(XRP(100'000), issuer, alice); + env.close(); + + MPTTester mpt{env, issuer, mptInitNoFund}; + + mpt.create( + {.flags = tfMPTCanTransfer, + .mutableFlags = tmfMPTCanMutateCanTransfer}); + + env.close(); + + PrettyAsset const asset = mpt["MPT"]; + mpt.authorize({.account = alice}); + env.close(); + + // Issuer can fund the holder even if CanTransfer is not set. + env(pay(issuer, alice, asset(100))); + env.close(); + + Vault vault{env}; + auto const [createTx, vaultKeylet] = + vault.create({.owner = alice, .asset = asset}); + env(createTx); + env.close(); + + auto const brokerKeylet = + keylet::loanbroker(alice.id(), env.seq(alice)); + env(set(alice, vaultKeylet.key)); + env.close(); + + auto const brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return; + + Account const pseudoAccount{ + "Loan Broker pseudo-account", brokerSle->at(sfAccount)}; + + // Remove CanTransfer after the broker is set up. + mpt.set({.mutableFlags = tmfMPTClearCanTransfer}); + env.close(); + + // Standard Payment path should forbid third-party transfers. + env(pay(alice, pseudoAccount, asset(1)), ter(tecNO_AUTH)); + env.close(); + + // Cover cannot be transferred to broker account + auto const depositAmount = asset(1); + env(coverDeposit(alice, brokerKeylet.key, depositAmount), + ter{tecNO_AUTH}); + env.close(); + + if (auto const refreshed = env.le(brokerKeylet); + BEAST_EXPECT(refreshed)) + { + BEAST_EXPECT(refreshed->at(sfCoverAvailable) == 0); + env.require(balance(pseudoAccount, asset(0))); + } + + // Set CanTransfer again and transfer some deposit + mpt.set({.mutableFlags = tmfMPTSetCanTransfer}); + env.close(); + + env(coverDeposit(alice, brokerKeylet.key, depositAmount)); + env.close(); + + if (auto const refreshed = env.le(brokerKeylet); + BEAST_EXPECT(refreshed)) + { + BEAST_EXPECT(refreshed->at(sfCoverAvailable) == 1); + env.require(balance(pseudoAccount, depositAmount)); + } + + // Remove CanTransfer after the deposit + mpt.set({.mutableFlags = tmfMPTClearCanTransfer}); + env.close(); + + // Cover cannot be transferred from broker account + env(coverWithdraw(alice, brokerKeylet.key, depositAmount), + ter{tecNO_AUTH}); + env.close(); + + // Set CanTransfer again and withdraw + mpt.set({.mutableFlags = tmfMPTSetCanTransfer}); + env.close(); + + env(coverWithdraw(alice, brokerKeylet.key, depositAmount)); + env.close(); + + if (auto const refreshed = env.le(brokerKeylet); + BEAST_EXPECT(refreshed)) + { + BEAST_EXPECT(refreshed->at(sfCoverAvailable) == 0); + env.require(balance(pseudoAccount, asset(0))); + } + } + +#if LOANTODO + void + testLoanPayLateFullPaymentBypassesPenalties() + { + testcase("LoanPay full payment skips late penalties"); + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const asset = issuer[iouCurrency]; + env(trust(lender, asset(100'000'000))); + env(trust(borrower, asset(100'000'000))); + env(pay(issuer, lender, asset(50'000'000))); + env(pay(issuer, borrower, asset(5'000'000))); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, asset, lender)}; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + + auto const brokerPreLoan = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerPreLoan)) + return; + + auto const loanSequence = brokerPreLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + Number const principal = asset(1'000).value(); + Number const serviceFee = asset(2).value(); + Number const lateFee = asset(5).value(); + Number const closeFee = asset(4).value(); + + env(set(borrower, broker.brokerID, principal), + sig(sfCounterpartySignature, lender), + loanServiceFee(serviceFee), + latePaymentFee(lateFee), + closePaymentFee(closeFee), + interestRate(percentageToTenthBips(12)), + lateInterestRate(percentageToTenthBips(24) / 10), + closeInterestRate(percentageToTenthBips(5)), + paymentTotal(12), + paymentInterval(600), + gracePeriod(0), + fee(loanSetFee)); + env.close(); + + auto state1 = getCurrentState(env, broker, loanKeylet); + if (!BEAST_EXPECT(state1.paymentRemaining > 1)) + return; + + using d = NetClock::duration; + using tp = NetClock::time_point; + auto const overdueClose = + tp{d{state1.nextPaymentDate + state1.paymentInterval}}; + env.close(overdueClose); + + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(brokerSle && loanSle)) + return; + + auto state = getCurrentState(env, broker, loanKeylet); + + TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; + TenthBips32 const interestRateValue{loanSle->at(sfInterestRate)}; + TenthBips32 const lateInterestRateValue{ + loanSle->at(sfLateInterestRate)}; + TenthBips32 const closeInterestRateValue{ + loanSle->at(sfCloseInterestRate)}; + + Number const closePaymentFeeRounded = roundToAsset( + broker.asset, loanSle->at(sfClosePaymentFee), state.loanScale); + Number const latePaymentFeeRounded = roundToAsset( + broker.asset, loanSle->at(sfLatePaymentFee), state.loanScale); + + auto const roundedLoanState = constructLoanState( + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding); + Number const totalInterestOutstanding = roundedLoanState.interestDue; + + auto const periodicRate = + loanPeriodicRate(interestRateValue, state.paymentInterval); + auto const rawLoanState = computeRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining, + managementFeeRate); + + auto const parentCloseTime = env.current()->parentCloseTime(); + auto const startDateSeconds = static_cast( + state.startDate.time_since_epoch().count()); + + Number const fullPaymentInterest = computeFullPaymentInterest( + rawLoanState.principalOutstanding, + periodicRate, + parentCloseTime, + state.paymentInterval, + state.previousPaymentDate, + startDateSeconds, + closeInterestRateValue); + + Number const roundedFullInterestAmount = + roundToAsset(broker.asset, fullPaymentInterest, state.loanScale); + Number const roundedFullManagementFee = computeManagementFee( + broker.asset, + roundedFullInterestAmount, + managementFeeRate, + state.loanScale); + Number const roundedFullInterest = + roundedFullInterestAmount - roundedFullManagementFee; + + Number const trackedValueDelta = state.principalOutstanding + + totalInterestOutstanding + state.managementFeeOutstanding; + Number const untrackedManagementFee = closePaymentFeeRounded + + roundedFullManagementFee - state.managementFeeOutstanding; + Number const untrackedInterest = + roundedFullInterest - totalInterestOutstanding; + + Number const baseFullDue = + trackedValueDelta + untrackedInterest + untrackedManagementFee; + BEAST_EXPECT( + baseFullDue == + roundToAsset(broker.asset, baseFullDue, state.loanScale)); + + auto const overdueSeconds = + parentCloseTime.time_since_epoch().count() - state.nextPaymentDate; + if (!BEAST_EXPECT(overdueSeconds > 0)) + return; + + Number const overdueRate = + loanPeriodicRate(lateInterestRateValue, overdueSeconds); + Number const lateInterestRaw = state.principalOutstanding * overdueRate; + Number const lateInterestRounded = + roundToAsset(broker.asset, lateInterestRaw, state.loanScale); + Number const lateManagementFeeRounded = computeManagementFee( + broker.asset, + lateInterestRounded, + managementFeeRate, + state.loanScale); + Number const penaltyDue = lateInterestRounded + + lateManagementFeeRounded + latePaymentFeeRounded; + BEAST_EXPECT(penaltyDue > Number{}); + + auto const balanceBefore = env.balance(borrower, broker.asset).number(); + + STAmount const paymentAmount{broker.asset.raw(), baseFullDue}; + env(pay(borrower, loanKeylet.key, paymentAmount, tfLoanFullPayment)); + env.close(); + + if (auto const meta = env.meta(); BEAST_EXPECT(meta)) + BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); + + auto const balanceAfter = env.balance(borrower, broker.asset).number(); + Number const actualPaid = balanceBefore - balanceAfter; + BEAST_EXPECT(actualPaid == baseFullDue); + + Number const expectedWithPenalty = baseFullDue + penaltyDue; + BEAST_EXPECT(expectedWithPenalty > actualPaid); + BEAST_EXPECT(expectedWithPenalty - actualPaid == penaltyDue); + } + + void + testLoanCoverMinimumRoundingExploit() + { + auto testLoanCoverMinimumRoundingExploit = + [&, this](Number const& principalRequest) { + testcase << "LoanBrokerCoverClawback drains cover via rounding" + << " principalRequested=" + << to_string(principalRequest); + + using namespace jtx; + using namespace loan; + using namespace loanBroker; + + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000'000), issuer, lender, borrower); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const asset = issuer[iouCurrency]; + env(trust(lender, asset(2'000'0000))); + env(trust(borrower, asset(2'000'0000))); + env.close(); + + env(pay(issuer, lender, asset(2'000'0000))); + env.close(); + + BrokerParameters brokerParams{ + .debtMax = 0, .coverRateMin = TenthBips32{10'000}}; + BrokerInfo broker{ + createVaultAndBroker(env, asset, lender, brokerParams)}; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + auto createTx = env.jt( + set(borrower, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + paymentInterval(600), + paymentTotal(1), + gracePeriod(60)); + env(createTx); + env.close(); + + auto const brokerBefore = + env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerBefore); + if (!brokerBefore) + return; + + Number const debtOutstanding = brokerBefore->at(sfDebtTotal); + Number const coverAvailableBefore = + brokerBefore->at(sfCoverAvailable); + + BEAST_EXPECT(debtOutstanding > Number{}); + BEAST_EXPECT(coverAvailableBefore > Number{}); + + log << "debt=" << to_string(debtOutstanding) + << " cover_available=" << to_string(coverAvailableBefore); + + env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID)); + env.close(); + + auto const brokerAfter = + env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerAfter); + if (!brokerAfter) + return; + + Number const debtAfter = brokerAfter->at(sfDebtTotal); + // the debt has not changed + BEAST_EXPECT(debtAfter == debtOutstanding); + + Number const coverAvailableAfter = + brokerAfter->at(sfCoverAvailable); + + // since the cover rate min != 0, the cover available should not + // be zero + BEAST_EXPECT(coverAvailableAfter != Number{}); + }; + + // Call the lambda with different principal values + testLoanCoverMinimumRoundingExploit(Number{1, -30}); // 1e-30 units + testLoanCoverMinimumRoundingExploit(Number{1, -20}); // 1e-20 units + testLoanCoverMinimumRoundingExploit(Number{1, -10}); // 1e-10 units + testLoanCoverMinimumRoundingExploit(Number{1, 1}); // 1e-10 units + } +#endif + + void + testPoC_UnsignedUnderflowOnFullPayAfterEarlyPeriodic() + { + // --- PoC Summary ---------------------------------------------------- + // Scenario: Borrower makes one periodic payment early (before next due) + // so doPayment sets sfPreviousPaymentDate to the (future) + // sfNextPaymentDueDate and advances sfNextPaymentDueDate by one + // interval. Borrower then immediately performs a full-payment + // (tfLoanFullPayment). Why it matters: Full-payment interest accrual + // uses + // delta = now - max(prevPaymentDate, startDate) + // with an unsigned clock representation (uint32). If prevPaymentDate is + // in the future, the subtraction underflows to a very large positive + // number. This inflates roundedFullInterest and total full-close due, + // and LoanPay applies the inflated valueChange to the vault + // (sfAssetsTotal), increasing NAV. + // -------------------------------------------------------------------- + testcase( + "PoC: Unsigned-underflow full-pay accrual after early periodic"); + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + Env env(*this, all); + + Account const lender{"poc_lender4"}; + Account const borrower{"poc_borrower4"}; + env.fund(XRP(3'000'000), lender, borrower); + env.close(); + + PrettyAsset const asset{xrpIssue(), 1'000'000}; + BrokerParameters brokerParams{}; + auto const broker = + createVaultAndBroker(env, asset, lender, brokerParams); + + // Create a 3-payment loan so full-payment path is enabled after 1 + // periodic payment. + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest = asset(1000).value(); + auto const originationFee = asset(0).value(); + auto const serviceFee = asset(1).value(); + auto const serviceFeePA = asset(1); + auto const lateFee = asset(0).value(); + auto const closeFee = asset(0).value(); + auto const interest = percentageToTenthBips(12); + auto const lateInterest = percentageToTenthBips(12) / 10; + auto const closeInterest = percentageToTenthBips(12) / 10; + auto const overpaymentInterest = percentageToTenthBips(12) / 10; + auto const total = 3u; + auto const interval = 600u; + auto const grace = 60u; + + auto createJtx = env.jt( + set(borrower, broker.brokerID, principalRequest, 0), + sig(sfCounterpartySignature, lender), + loanOriginationFee(originationFee), + loanServiceFee(serviceFee), + latePaymentFee(lateFee), + closePaymentFee(closeFee), + overpaymentFee(percentageToTenthBips(5) / 10), + interestRate(interest), + lateInterestRate(lateInterest), + closeInterestRate(closeInterest), + overpaymentInterestRate(overpaymentInterest), + paymentTotal(total), + paymentInterval(interval), + gracePeriod(grace), + fee(loanSetFee)); + + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle); + auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(createJtx); + env.close(); + + // Compute a regular periodic due and pay it early (before next due). + auto state = getCurrentState(env, broker, loanKeylet); + Number const periodicRate = + loanPeriodicRate(state.interestRate, state.paymentInterval); + auto const components = detail::computePaymentComponents( + asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + brokerParams.managementFeeRate); + STAmount const regularDue{ + asset, components.trackedValueDelta + serviceFeePA.number()}; + // now < nextDue immediately after creation, so this is an early pay. + env(pay(borrower, loanKeylet.key, regularDue)); + env.close(); + + // Immediately attempt a full payoff. Compute the exact full-payment + // due to ensure the tx applies. + auto after = getCurrentState(env, broker, loanKeylet); + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + auto const brokerSle2 = env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle2); + + auto const closePaymentFee = + loanSle ? loanSle->at(sfClosePaymentFee) : Number{}; + auto const closeInterestRate = loanSle + ? TenthBips32{loanSle->at(sfCloseInterestRate)} + : TenthBips32{}; + auto const managementFeeRate = brokerSle2 + ? TenthBips16{brokerSle2->at(sfManagementFeeRate)} + : TenthBips16{}; + + Number const periodicRate2 = + loanPeriodicRate(after.interestRate, after.paymentInterval); + // Accrued + prepayment-penalty interest based on current periodic + // schedule + auto const fullPaymentInterest = computeFullPaymentInterest( + after.periodicPayment, + periodicRate2, + after.paymentRemaining, + env.current()->parentCloseTime(), + after.paymentInterval, + after.previousPaymentDate, + static_cast( + after.startDate.time_since_epoch().count()), + closeInterestRate); + // Round to asset scale and split interest/fee parts + auto const roundedInterest = + roundToAsset(asset.raw(), fullPaymentInterest, after.loanScale); + Number const roundedFullMgmtFee = computeManagementFee( + asset.raw(), roundedInterest, managementFeeRate, after.loanScale); + Number const roundedFullInterest = roundedInterest - roundedFullMgmtFee; + + // Show both signed and unsigned deltas to highlight the underflow. + auto const nowSecs = static_cast( + env.current()->parentCloseTime().time_since_epoch().count()); + auto const startSecs = static_cast( + after.startDate.time_since_epoch().count()); + auto const lastPaymentDate = + std::max(after.previousPaymentDate, startSecs); + auto const signedDelta = static_cast(nowSecs) - + static_cast(lastPaymentDate); + auto const unsignedDelta = + static_cast(nowSecs - lastPaymentDate); + log << "PoC window: prev=" << after.previousPaymentDate + << " start=" << startSecs << " now=" << nowSecs + << " signedDelta=" << signedDelta + << " unsignedDelta=" << unsignedDelta << std::endl; + + // Reference (clamped) computation: emulate a non-negative accrual + // window by clamping prevPaymentDate to 'now' for the full-pay path. + auto const prevClamped = std::min(after.previousPaymentDate, nowSecs); + auto const fullPaymentInterestClamped = computeFullPaymentInterest( + after.periodicPayment, + periodicRate2, + after.paymentRemaining, + env.current()->parentCloseTime(), + after.paymentInterval, + prevClamped, + startSecs, + closeInterestRate); + auto const roundedInterestClamped = roundToAsset( + asset.raw(), fullPaymentInterestClamped, after.loanScale); + Number const roundedFullMgmtFeeClamped = computeManagementFee( + asset.raw(), + roundedInterestClamped, + managementFeeRate, + after.loanScale); + Number const roundedFullInterestClamped = + roundedInterestClamped - roundedFullMgmtFeeClamped; + STAmount const fullDueClamped{ + asset, + after.principalOutstanding + roundedFullInterestClamped + + roundedFullMgmtFeeClamped + closePaymentFee}; + + // Collect vault NAV before closing payment + auto const vaultId2 = + brokerSle2 ? brokerSle2->at(sfVaultID) : uint256{}; + auto const vaultKey2 = keylet::vault(vaultId2); + auto const vaultBefore = env.le(vaultKey2); + BEAST_EXPECT(vaultBefore); + Number const assetsTotalBefore = + vaultBefore ? vaultBefore->at(sfAssetsTotal) : Number{}; + + STAmount const fullDue{ + asset, + after.principalOutstanding + roundedFullInterest + + roundedFullMgmtFee + closePaymentFee}; + + log << "PoC payoff: principalOutstanding=" << after.principalOutstanding + << " roundedFullInterest=" << roundedFullInterest + << " roundedFullMgmtFee=" << roundedFullMgmtFee + << " closeFee=" << closePaymentFee + << " fullDue=" << to_string(fullDue.getJson()) << std::endl; + log << "PoC reference (clamped): roundedFullInterestClamped=" + << roundedFullInterestClamped + << " roundedFullMgmtFeeClamped=" << roundedFullMgmtFeeClamped + << " fullDueClamped=" << to_string(fullDueClamped.getJson()) + << std::endl; + + env(pay(borrower, loanKeylet.key, fullDue), txflags(tfLoanFullPayment)); + env.close(); + + // Sanity: underflow present (unsigned delta very large relative to + // interval) + BEAST_EXPECT(unsignedDelta > after.paymentInterval); + + // Compare vault NAV before/after the full close + auto const vaultAfter = env.le(vaultKey2); + BEAST_EXPECT(vaultAfter); + if (vaultAfter) + { + auto const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); + log << "PoC NAV: assetsTotalBefore=" << assetsTotalBefore + << " assetsTotalAfter=" << assetsTotalAfter + << " delta=" << (assetsTotalAfter - assetsTotalBefore) + << std::endl; + + // Value-based proof: underflowed window yields a payoff larger than + // the clamped (non-underflow) reference. + BEAST_EXPECT(fullDue == fullDueClamped); + if (fullDue > fullDueClamped) + log << "PoC delta: overcharge (fullDue > clamped)" << std::endl; + } + + // Loan should be paid off + auto const finalLoan = env.le(loanKeylet); + BEAST_EXPECT(finalLoan); + if (finalLoan) + { + BEAST_EXPECT(finalLoan->at(sfPaymentRemaining) == 0); + BEAST_EXPECT(finalLoan->at(sfPrincipalOutstanding) == 0); + } + } + + void + testDustManipulation() + { + testcase("Dust manipulation"); + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, all); + + // Setup: Create accounts + Account issuer{"issuer"}; + Account lender{"lender"}; + Account borrower{"borrower"}; + Account victim{"victim"}; + + env.fund(XRP(1'000'000'00), issuer, lender, borrower, victim); + env.close(); + + // Step 1: Create vault with IOU asset + auto asset = issuer["USD"]; + env(trust(lender, asset(100000))); + env(trust(borrower, asset(100000))); + env(trust(victim, asset(100000))); + env(pay(issuer, lender, asset(50000))); + env(pay(issuer, borrower, asset(50000))); + env(pay(issuer, victim, asset(50000))); + env.close(); + + BrokerParameters brokerParams{ + .vaultDeposit = 10000, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{1000}, + .coverRateLiquidation = TenthBips32{2500}}; + + auto broker = createVaultAndBroker(env, asset, lender, brokerParams); + + auto const loanKeyletOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the + // _LOAN_BROKER_ object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return keylet::loan(broker.brokerID, loanSequence); + }(); + if (!loanKeyletOpt) + return; + + auto const& vaultKeylet = broker.vaultKeylet(); + + { + auto const vaultSle = env.le(vaultKeylet); + Number assetsTotal = vaultSle->at(sfAssetsTotal); + Number assetsAvail = vaultSle->at(sfAssetsAvailable); + + log << "Before loan creation:" << std::endl; + log << " AssetsTotal: " << assetsTotal << std::endl; + log << " AssetsAvailable: " << assetsAvail << std::endl; + log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; + + // before the loan the assets total and available should be equal + BEAST_EXPECT(assetsAvail == assetsTotal); + BEAST_EXPECT( + assetsAvail == + broker.asset(brokerParams.vaultDeposit).number()); + } + + Keylet const& loanKeylet = *loanKeyletOpt; + + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{100}, + .interest = TenthBips32{1922}, + .payTotal = 5816, + .payInterval = 86400 * 6, + .gracePd = 86400 * 5, + }; + + env(loanParams(env, broker)); + env.close(); + + // Wait for loan to be late enough to default + env.close(std::chrono::seconds(86400 * 40)); // 40 days + + { + auto const vaultSle = env.le(vaultKeylet); + Number assetsTotal = vaultSle->at(sfAssetsTotal); + Number assetsAvail = vaultSle->at(sfAssetsAvailable); + + log << "After loan creation:" << std::endl; + log << " AssetsTotal: " << assetsTotal << std::endl; + log << " AssetsAvailable: " << assetsAvail << std::endl; + log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; + + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + return; + auto const state = constructRoundedLoanState(loanSle); + + log << "Loan state:" << std::endl; + log << " ValueOutstanding: " << state.valueOutstanding + << std::endl; + log << " PrincipalOutstanding: " << state.principalOutstanding + << std::endl; + log << " InterestOutstanding: " << state.interestOutstanding() + << std::endl; + log << " InterestDue: " << state.interestDue << std::endl; + log << " FeeDue: " << state.managementFeeDue << std::endl; + + // after loan creation the assets total and available should + // reflect the value of the loan + BEAST_EXPECT(assetsAvail < assetsTotal); + BEAST_EXPECT( + assetsAvail == + broker + .asset( + brokerParams.vaultDeposit - loanParams.principalRequest) + .number()); + BEAST_EXPECT( + assetsTotal == + broker.asset(brokerParams.vaultDeposit + state.interestDue) + .number()); + } + + // Step 7: Trigger default (dust adjustment will occur) + env(jtx::loan::manage(lender, loanKeylet.key, tfLoanDefault)); + env.close(); + + // Step 8: Verify phantom assets created + { + auto const vaultSle2 = env.le(vaultKeylet); + Number assetsTotal2 = vaultSle2->at(sfAssetsTotal); + Number assetsAvail2 = vaultSle2->at(sfAssetsAvailable); + + log << "After default:" << std::endl; + log << " AssetsTotal: " << assetsTotal2 << std::endl; + log << " AssetsAvailable: " << assetsAvail2 << std::endl; + log << " Difference: " << (assetsTotal2 - assetsAvail2) + << std::endl; + + // after a default the assets total and available should be equal + BEAST_EXPECT(assetsAvail2 == assetsTotal2); + } + } + + void + testRIPD3831() + { + using namespace jtx; + + testcase("RIPD-3831"); + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 100000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + // .managementFeeRate = TenthBips16{5919}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{200'000, -6}, + .lateFee = Number{200, -6}, + .interest = TenthBips32{50'000}, + .payTotal = 10, + .payInterval = 150, + .gracePd = 0}; + + auto const assetType = AssetType::XRP; + + Env env(*this, all); + + auto loanResult = createLoan( + env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (!BEAST_EXPECT(loanResult)) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + + using tp = NetClock::time_point; + using d = NetClock::duration; + + auto state = getCurrentState(env, broker, loanKeylet); + if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + // log << "loan after create: " << to_string(loan->getJson()) + // << std::endl; + + env.close(tp{d{ + loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}}); + } + + topUpBorrower( + env, broker, issuer, borrower, state, loanParams.serviceFee); + + using namespace jtx::loan; + + auto jv = + pay(borrower, loanKeylet.key, drops(XRPAmount(state.totalValue))); + + { + auto const submitParam = to_string(jv); + // log << "about to submit: " << submitParam << std::endl; + auto const jr = env.rpc("submit", borrower.name(), submitParam); + + // log << jr << std::endl; + BEAST_EXPECT(jr.isMember(jss::result)); + auto const jResult = jr[jss::result]; + // BEAST_EXPECT(jResult[jss::error] == "invalidTransaction"); + // BEAST_EXPECT( + // jResult[jss::error_exception] == + // "fails local checks: Transaction has bad signature."); + } + + env.close(); + + // Make sure the system keeps responding + env(noop(borrower)); + env.close(); + env(noop(issuer)); + env.close(); + env(noop(lender)); + env.close(); + } + + void + testRIPD3459() + { + testcase("RIPD-3459 - LoanBroker incorrect debt total"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 200'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{500}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{100'000, -4}, + .interest = TenthBips32{100'000}, + .payTotal = 10, + .gracePd = 0}; + + auto const assetType = AssetType::MPT; + + Env env(*this, all); + + auto loanResult = createLoan( + env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (!BEAST_EXPECT(loanResult)) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + if (auto const brokerSle = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerSle)) + { + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + BEAST_EXPECT( + brokerSle->at(sfDebtTotal) == + loanSle->at(sfTotalValueOutstanding)); + } + } + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); + + if (auto const brokerSle = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerSle)) + { + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + BEAST_EXPECT( + brokerSle->at(sfDebtTotal) == + loanSle->at(sfTotalValueOutstanding)); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::zero); + } + } + } + + void + testRIPD3901() + { + testcase("Crash with tfLoanOverpayment"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Account const depositor{"depositor"}; + auto const txfee = fee(XRP(100)); + + Env env(*this); + Vault vault(env); + + env.fund(XRP(10'000), lender, issuer, borrower, depositor); + env.close(); + + auto [tx, vaultKeyLet] = + vault.create({.owner = lender, .asset = xrpIssue()}); + env(tx, txfee); + env.close(); + + env(vault.deposit( + {.depositor = depositor, + .id = vaultKeyLet.key, + .amount = XRP(1'000)}), + txfee); + env.close(); + + auto const brokerKeyLet = + keylet::loanbroker(lender.id(), env.seq(lender)); + + env(loanBroker::set(lender, vaultKeyLet.key), txfee); + env.close(); + + // BrokerInfo brokerInfo{xrpIssue(), keylet, vaultKeyLet, {}}; + + STAmount const debtMaximumRequest = XRPAmount(200'000); + + env(set(borrower, brokerKeyLet.key, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + interestRate(TenthBips32(50'000)), + paymentTotal(2), + paymentInterval(150), + txflags(tfLoanOverpayment), + txfee); + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerKeyLet.key, loanSequence); + + if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) + { + env(loan::pay(borrower, loanKeylet.key, XRPAmount(150'001)), + txflags(tfLoanOverpayment), + txfee); + env.close(); + } + } + + void + testRoundingAllowsUndercoverage() + { + testcase("Minimum cover rounding allows undercoverage (XRP)"); + + using namespace jtx; + using namespace loanBroker; + + Env env(*this, all); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(200'000), lender, borrower); + env.close(); + + // Vault with XRP asset + Vault vault{env}; + auto [vaultCreate, vaultKeylet] = + vault.create({.owner = lender, .asset = xrpIssue()}); + env(vaultCreate); + env.close(); + BEAST_EXPECT(env.le(vaultKeylet)); + + // Seed the vault with XRP so it can fund the loan principal + PrettyAsset const xrpAsset{xrpIssue(), 1}; + + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{10'000}, + .coverDeposit = 82, + }; + + auto const brokerInfo = + createVaultAndBroker(env, xrpAsset, lender, brokerParams); + // Create a loan with principal 804 XRP and 0% interest (so + // DebtTotal increases by exactly 804) + env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()), + loan::interestRate(TenthBips32(0)), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 2)); + BEAST_EXPECT(env.ter() == tesSUCCESS); + env.close(); + + // Verify DebtTotal is exactly 804 + if (auto const brokerSle = + env.le(keylet::loanbroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + log << *brokerSle << std::endl; + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); + } + + // Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable. + // The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails. + env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()), + ter(tecINSUFFICIENT_FUNDS)); + BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS); + env.close(); + + // Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable. + // because that leaves sufficient cover, this succeeds + env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value())); + BEAST_EXPECT(env.ter() == tesSUCCESS); + env.close(); + + // Validate CoverAvailable == 80 XRP and DebtTotal remains 804 + if (auto const brokerSle = + env.le(keylet::loanbroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + log << *brokerSle << std::endl; + BEAST_EXPECT( + brokerSle->at(sfCoverAvailable) == xrpAsset(81).value()); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); + + // Also demonstrate that the true minimum (804 * 10%) exceeds 80 + auto const theoreticalMin = + tenthBipsOfValue(Number(804), TenthBips32(10'000)); + log << "Theoretical min cover: " << theoreticalMin << std::endl; + BEAST_EXPECT(Number(804, -1) == theoreticalMin); + } + } + + void + testRIPD3902() + { + testcase("RIPD-3902 - 1 IOU loan payments"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 10, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{1, 0}, + .interest = TenthBips32{100'000}, + .payTotal = 5, + .payInterval = 150, + .gracePd = 60}; + + auto const assetType = AssetType::IOU; + + Env env(*this, all); + + auto loanResult = createLoan( + env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (!BEAST_EXPECT(loanResult)) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); + } + + void + testBorrowerIsBroker() + { + testcase("Test Borrower is Broker"); + using namespace jtx; + using namespace loan; + Account const broker{"broker"}; + Account const issuer{"issuer"}; + Account const borrower_{"borrower"}; + Account const depositor{"depositor"}; + + auto testLoanAsset = [&](auto&& getMaxDebt, auto const& borrower) { + Env env(*this); + Vault vault(env); + + if (borrower == broker) + env.fund(XRP(10'000), broker, issuer, depositor); + else + env.fund(XRP(10'000), broker, borrower, issuer, depositor); + env.close(); + + auto const xrpFee = XRP(100); + auto const txFee = fee(xrpFee); + + STAmount const debtMaximumRequest = getMaxDebt(env); + + auto const& asset = debtMaximumRequest.asset(); + auto const initialVault = asset(debtMaximumRequest * 100); + + auto [tx, vaultKeylet] = + vault.create({.owner = broker, .asset = asset}); + env(tx, txFee); + env.close(); + + env(vault.deposit( + {.depositor = depositor, + .id = vaultKeylet.key, + .amount = initialVault}), + txFee); + env.close(); + + auto const brokerKeylet = + keylet::loanbroker(broker.id(), env.seq(broker)); + + env(loanBroker::set(broker, vaultKeylet.key), txFee); + env.close(); + + auto const serviceFee = 101; + + env(set(broker, brokerKeylet.key, debtMaximumRequest), + counterparty(borrower), + sig(sfCounterpartySignature, borrower), + loanServiceFee(serviceFee), + paymentTotal(10), + txFee); + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = + keylet::loan(brokerKeylet.key, loanSequence); + + auto const brokerBalanceBefore = env.balance(broker, asset); + + if (auto const loanSle = env.le(loanKeylet); + env.test.BEAST_EXPECT(loanSle)) + { + auto const payment = loanSle->at(sfPeriodicPayment); + auto const totalPayment = payment + serviceFee; + env(loan::pay(borrower, loanKeylet.key, asset(totalPayment)), + txFee); + env.close(); + if (auto const vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle)) + { + auto const expected = [&]() { + // The service fee is transferred to the broker if + // a borrower is not the broker + if (borrower != broker) + return brokerBalanceBefore.number() + serviceFee; + // Since a borrower is the broker, the payment is + // transferred to the Vault from the broker but not + // the service fee. + // If the asset is XRP then the broker pays the txfee. + if (asset.native()) + return brokerBalanceBefore.number() - payment - + xrpFee.number(); + return brokerBalanceBefore.number() - payment; + }(); + BEAST_EXPECT( + env.balance(broker, asset).value() == + asset(expected).value()); + } + } + }; + // Test when a borrower is the broker and is not to verify correct + // service fee transfer in both cases. + for (auto const& borrowerAcct : {broker, borrower_}) + { + testLoanAsset( + [&](Env&) -> STAmount { return STAmount{XRPAmount{200'000}}; }, + borrowerAcct); + testLoanAsset( + [&](Env& env) -> STAmount { + auto const IOU = issuer["USD"]; + env(trust(broker, IOU(1'000'000'000))); + env(trust(depositor, IOU(1'000'000'000))); + env(pay(issuer, broker, IOU(100'000'000))); + env(pay(issuer, depositor, IOU(100'000'000))); + env.close(); + return IOU(200'000); + }, + borrowerAcct); + testLoanAsset( + [&](Env& env) -> STAmount { + MPTTester mpt( + {.env = env, + .issuer = issuer, + .holders = {broker, depositor}, + .pay = 100'000'000}); + return mpt(200'000); + }, + borrowerAcct); + } + } + + void + testIssuerIsBorrower() + { + testcase("RIPD-4096 - Issuer as borrower"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = issuer, + .principalRequest = Number{10000}}; + + auto const assetType = AssetType::IOU; + + Env env(*this, all); + + auto loanResult = createLoan( + env, assetType, brokerParams, loanParams, issuer, lender, issuer); + + if (!BEAST_EXPECT(loanResult)) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + issuer, + PaymentParameters{.showStepBalances = true}); + } + + void + testLimitExceeded() + { + testcase("RIPD-4125 - overpayment"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{200000, -6}, + .interest = TenthBips32{50000}, + .payTotal = 3, + .payInterval = 200, + .gracePd = 60, + .flags = tfLoanOverpayment, + }; + + auto const assetType = AssetType::XRP; + + Env env( + *this, + makeConfig(), + all, + nullptr, + beast::severities::Severity::kWarning); + + auto loanResult = createLoan( + env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (!BEAST_EXPECT(loanResult)) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + auto const state = getCurrentState(env, broker, loanKeylet); + + env(loan::pay( + borrower, + loanKeylet.key, + STAmount{broker.asset, state.periodicPayment * 3 / 2 + 1}, + tfLoanOverpayment)); + env.close(); + + PaymentParameters paymentParams{ + //.overpaymentFactor = Number{15, -1}, + //.overpaymentExtra = Number{1, -6}, + //.flags = tfLoanOverpayment, + .showStepBalances = true, + //.validateBalances = false, + }; + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + paymentParams); + } + +public: + void + run() override + { +#if LOANTODO + testLoanPayLateFullPaymentBypassesPenalties(); + testLoanCoverMinimumRoundingExploit(); +#endif + testCoverDepositWithdrawNonTransferableMPT(); + testPoC_UnsignedUnderflowOnFullPayAfterEarlyPeriodic(); + + testDisabled(); + testSelfLoan(); + testIssuerLoan(); + testLoanSet(); + testLifecycle(); + testServiceFeeOnBrokerDeepFreeze(); + + testRPC(); + testBasicMath(); + + testInvalidLoanDelete(); + testInvalidLoanManage(); + testInvalidLoanPay(); + testInvalidLoanSet(); + + testBatchBypassCounterparty(); + testLoanPayComputePeriodicPaymentValidRateInvariant(); + testAccountSendMptMinAmountInvariant(); + testLoanPayDebtDecreaseInvariant(); + testWrongMaxDebtBehavior(); + testLoanPayComputePeriodicPaymentValidTotalInterestInvariant(); + testDosLoanPay(); + testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant(); + testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant(); + testLoanNextPaymentDueDateOverflow(); + + testRequireAuth(); + testDustManipulation(); + + testRIPD3831(); + testRIPD3459(); + testRIPD3901(); + testRIPD3902(); + testRoundingAllowsUndercoverage(); + testBorrowerIsBroker(); + testIssuerIsBorrower(); + testLimitExceeded(); + } +}; + +class LoanBatch_test : public Loan_test +{ +protected: + beast::xor_shift_engine engine_; + + std::uniform_int_distribution<> assetDist{0, 2}; + std::uniform_int_distribution principalDist{ + 100'000, + 1'000'000'000}; + std::uniform_int_distribution interestRateDist{0, 10000}; + std::uniform_int_distribution<> paymentTotalDist{12, 10000}; + std::uniform_int_distribution<> paymentIntervalDist{60, 3600 * 24 * 30}; + std::uniform_int_distribution managementFeeRateDist{ + 0, + 10'000}; + std::uniform_int_distribution<> serviceFeeDist{0, 20}; + /* + # Generate parameters that are more likely to be valid + principal = Decimal(str(rand.randint(100000, + 100'000'000))).quantize(ROUND_TARGET) + + interest_rate = Decimal(rand.randint(1, 10000)) / + Decimal(100000) + + payment_total = rand.randint(12, 10000) + + payment_interval = Decimal(str(rand.randint(60, 2629746))) + + interest_fee = Decimal(rand.randint(0, 100000)) / + Decimal(100000) +*/ + + void + testRandomLoan() + { + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + // Determine all the random parameters at once + AssetType assetType = static_cast(assetDist(engine_)); + auto const principalRequest = principalDist(engine_); + TenthBips16 managementFeeRate{managementFeeRateDist(engine_)}; + auto const serviceFee = serviceFeeDist(engine_); + TenthBips32 interest{interestRateDist(engine_)}; + auto const payTotal = paymentTotalDist(engine_); + auto const payInterval = paymentIntervalDist(engine_); + + BrokerParameters brokerParams{ + .vaultDeposit = principalRequest * 10, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = managementFeeRate}; + LoanParameters loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = principalRequest, + .serviceFee = serviceFee, + .interest = interest, + .payTotal = payTotal, + .payInterval = payInterval, + }; + + runLoan(assetType, brokerParams, loanParams); + } + +public: + void + run() override + { + auto const argument = arg(); + auto const numIterations = [s = arg()]() -> int { + int defaultNum = 5; + if (s.empty()) + return defaultNum; + try + { + std::size_t pos; + auto const r = stoi(s, &pos); + if (pos != s.size()) + return defaultNum; + return r; + } + catch (...) + { + return defaultNum; + } + }(); + + using namespace jtx; + + auto const updateInterval = std::min(numIterations / 5, 100); + + for (int i = 0; i < numIterations; ++i) + { + if (i % updateInterval == 0) + testcase << "Random Loan Test iteration " << (i + 1) << "/" + << numIterations; + testRandomLoan(); + } + } +}; + +class LoanArbitrary_test : public LoanBatch_test +{ + void + run() override + { + using namespace jtx; + + BrokerParameters const brokerParams{ + .vaultDeposit = 10000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + // .managementFeeRate = TenthBips16{5919}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = Account("lender"), + .counter = Account("borrower"), + .principalRequest = Number{10000, 0}, + // .interest = TenthBips32{0}, + // .payTotal = 5816, + .payInterval = 150}; + + runLoan(AssetType::XRP, brokerParams, loanParams); + } +}; + +BEAST_DEFINE_TESTSUITE(Loan, tx, ripple); +BEAST_DEFINE_TESTSUITE_MANUAL(LoanBatch, tx, ripple); +BEAST_DEFINE_TESTSUITE_MANUAL(LoanArbitrary, tx, ripple); + +} // namespace test +} // namespace ripple diff --git a/src/test/app/MPToken_test.cpp b/src/test/app/MPToken_test.cpp index e9740e67de..4b8eb871a2 100644 --- a/src/test/app/MPToken_test.cpp +++ b/src/test/app/MPToken_test.cpp @@ -1171,7 +1171,7 @@ class MPToken_test : public beast::unit_test::suite env(credentials::accept(bob, credIssuer1, credType)); env.close(); - MPTTester mptAlice(env, alice, {}); + MPTTester mptAlice(env, alice); env.close(); mptAlice.create({ @@ -1213,7 +1213,7 @@ class MPToken_test : public beast::unit_test::suite env(credentials::accept(bob, credIssuer1, credType)); env.close(); - MPTTester mptAlice(env, alice, {}); + MPTTester mptAlice(env, alice); env.close(); mptAlice.create({ @@ -1293,7 +1293,7 @@ class MPToken_test : public beast::unit_test::suite env(credentials::accept(carol, credIssuer2, credType)); env.close(); - MPTTester mptAlice(env, alice, {}); + MPTTester mptAlice(env, alice); env.close(); mptAlice.create({ diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index a46168c774..99e1ad2063 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -21,6 +21,8 @@ #include #include #include +#include +#include #include #include @@ -88,14 +90,14 @@ class Vault_test : public beast::unit_test::suite this]() -> std::tuple { auto const vault = env.le(keylet); BEAST_EXPECT(vault != nullptr); - if (asset.raw().holds() && !asset.raw().native()) + if (!asset.integral()) BEAST_EXPECT(vault->at(sfScale) == 6); else BEAST_EXPECT(vault->at(sfScale) == 0); auto const shares = env.le(keylet::mptIssuance(vault->at(sfShareMPTID))); BEAST_EXPECT(shares != nullptr); - if (asset.raw().holds() && !asset.raw().native()) + if (!asset.integral()) BEAST_EXPECT(shares->at(sfAssetScale) == 6); else BEAST_EXPECT(shares->at(sfAssetScale) == 0); @@ -521,7 +523,7 @@ class Vault_test : public beast::unit_test::suite } } - if (!asset.raw().native() && asset.raw().holds()) + if (!asset.integral()) { testcase(prefix + " temporary authorization for 3rd party"); env(trust(erin, asset(1000))); @@ -689,12 +691,13 @@ class Vault_test : public beast::unit_test::suite test(env, issuer, owner, asset, vault); }; - testCase( - [&](Env& env, - Account const& issuer, - Account const& owner, - Asset const& asset, - Vault& vault) { + auto testDisabled = [&](TER resultAfterCreate = temDISABLED) { + return [&, resultAfterCreate]( + Env& env, + Account const& issuer, + Account const& owner, + Asset const& asset, + Vault& vault) { testcase("disabled single asset vault"); auto [tx, keylet] = @@ -703,7 +706,7 @@ class Vault_test : public beast::unit_test::suite { auto tx = vault.set({.owner = owner, .id = keylet.key}); - env(tx, ter{temDISABLED}); + env(tx, data("test"), ter{resultAfterCreate}); } { @@ -711,7 +714,7 @@ class Vault_test : public beast::unit_test::suite {.depositor = owner, .id = keylet.key, .amount = asset(10)}); - env(tx, ter{temDISABLED}); + env(tx, ter{resultAfterCreate}); } { @@ -719,7 +722,7 @@ class Vault_test : public beast::unit_test::suite {.depositor = owner, .id = keylet.key, .amount = asset(10)}); - env(tx, ter{temDISABLED}); + env(tx, ter{resultAfterCreate}); } { @@ -728,15 +731,49 @@ class Vault_test : public beast::unit_test::suite .id = keylet.key, .holder = owner, .amount = asset(10)}); - env(tx, ter{temDISABLED}); + env(tx, ter{resultAfterCreate}); } { auto tx = vault.del({.owner = owner, .id = keylet.key}); + env(tx, ter{resultAfterCreate}); + } + }; + }; + + testCase( + testDisabled(), + {.features = testable_amendments() - featureSingleAssetVault}); + + testCase( + testDisabled(tecNO_ENTRY), + {.features = testable_amendments() - featureMPTokensV1}); + + testCase( + [&](Env& env, + Account const& issuer, + Account const& owner, + Asset const& asset, + Vault& vault) { + testcase("disabled permissioned domains"); + + auto [tx, keylet] = + vault.create({.owner = owner, .asset = asset}); + env(tx); + + tx[sfFlags] = tx[sfFlags].asUInt() | tfVaultPrivate; + tx[sfDomainID] = to_string(base_uint<256>(42ul)); + env(tx, ter{temDISABLED}); + + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + env(tx, data("Test")); + + tx[sfDomainID] = to_string(base_uint<256>(13ul)); env(tx, ter{temDISABLED}); } }, - {.features = testable_amendments() - featureSingleAssetVault}); + {.features = testable_amendments() - featurePermissionedDomains}); testCase([&](Env& env, Account const& issuer, @@ -1749,7 +1786,8 @@ class Vault_test : public beast::unit_test::suite mptt.create( {.flags = tfMPTCanTransfer | tfMPTCanLock | (args.enableClawback ? tfMPTCanClawback : none) | - (args.requireAuth ? tfMPTRequireAuth : none)}); + (args.requireAuth ? tfMPTRequireAuth : none), + .mutableFlags = tmfMPTCanMutateCanTransfer}); PrettyAsset asset = mptt.issuanceID(); mptt.authorize({.account = owner}); mptt.authorize({.account = depositor}); @@ -2467,6 +2505,53 @@ class Vault_test : public beast::unit_test::suite env(tx2, ter{tecWRONG_ASSET}); env.close(); } + + testCase([this]( + Env& env, + Account const&, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT non-transferable"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + tx = vault.deposit( + {.depositor = depositor, + .id = keylet.key, + .amount = asset(100)}); + env(tx); + env.close(); + + // Remove CanTransfer + mptt.set({.mutableFlags = tmfMPTClearCanTransfer}); + env.close(); + + env(tx, ter{tecNO_AUTH}); + env.close(); + + tx = vault.withdraw( + {.depositor = depositor, + .id = keylet.key, + .amount = asset(100)}); + + env(tx, ter{tecNO_AUTH}); + env.close(); + + // Restore CanTransfer + mptt.set({.mutableFlags = tmfMPTSetCanTransfer}); + env.close(); + + env(tx); + env.close(); + + // Delete vault with zero balance + env(vault.del({.owner = owner, .id = keylet.key})); + }); } void @@ -2479,6 +2564,7 @@ class Vault_test : public beast::unit_test::suite int initialXRP = 1000; Number initialIOU = 200; double transferRate = 1.0; + bool charlieRipple = true; }; auto testCase = @@ -2504,8 +2590,21 @@ class Vault_test : public beast::unit_test::suite PrettyAsset const asset = issuer["IOU"]; env.trust(asset(1000), owner); - env.trust(asset(1000), charlie); env(pay(issuer, owner, asset(args.initialIOU))); + env.close(); + if (!args.charlieRipple) + { + env(fset(issuer, 0, asfDefaultRipple)); + env.close(); + env.trust(asset(1000), charlie); + env.close(); + env(pay(issuer, charlie, asset(args.initialIOU))); + env.close(); + env(fset(issuer, asfDefaultRipple)); + } + else + env.trust(asset(1000), charlie); + env.close(); env(rate(issuer, args.transferRate)); env.close(); @@ -2883,6 +2982,94 @@ class Vault_test : public beast::unit_test::suite env(tx1); }); + testCase( + [&, this]( + Env& env, + Account const& owner, + Account const& issuer, + Account const& charlie, + auto vaultAccount, + Vault& vault, + PrettyAsset const& asset, + std::function issuanceId) { + testcase("IOU non-transferable"); + + auto [tx, keylet] = + vault.create({.owner = owner, .asset = asset}); + tx[sfScale] = 0; + env(tx); + env.close(); + + // Turn on noripple on the pseudo account's trust line. + // Charlie's is already set. + env(trust(issuer, vaultAccount(keylet)["IOU"], tfSetNoRipple), + THISLINE); + + { + // Charlie cannot deposit + auto tx = vault.deposit( + {.depositor = charlie, + .id = keylet.key, + .amount = asset(100)}); + env(tx, ter{terNO_RIPPLE}, THISLINE); + env.close(); + } + + { + PrettyAsset shares = issuanceId(keylet); + auto tx1 = vault.deposit( + {.depositor = owner, + .id = keylet.key, + .amount = asset(100)}); + env(tx1, THISLINE); + env.close(); + + // Charlie cannot receive funds + auto tx2 = vault.withdraw( + {.depositor = owner, + .id = keylet.key, + .amount = shares(100)}); + tx2[sfDestination] = charlie.human(); + env(tx2, ter{terNO_RIPPLE}, THISLINE); + env.close(); + + { + // Create MPToken for shares held by Charlie + Json::Value tx{Json::objectValue}; + tx[sfAccount] = charlie.human(); + tx[sfMPTokenIssuanceID] = + to_string(shares.raw().get().getMptID()); + tx[sfTransactionType] = jss::MPTokenAuthorize; + env(tx); + env.close(); + } + env(pay(owner, charlie, shares(100)), THISLINE); + env.close(); + + // Charlie cannot withdraw + auto tx3 = vault.withdraw( + {.depositor = charlie, + .id = keylet.key, + .amount = shares(100)}); + env(tx3, ter{terNO_RIPPLE}); + env.close(); + + env(pay(charlie, owner, shares(100)), THISLINE); + env.close(); + } + + tx = vault.withdraw( + {.depositor = owner, + .id = keylet.key, + .amount = asset(100)}); + env(tx, THISLINE); + env.close(); + + // Delete vault with zero balance + env(vault.del({.owner = owner, .id = keylet.key}), THISLINE); + }, + {.charlieRipple = false}); + testCase( [&, this]( Env& env, @@ -4544,7 +4731,7 @@ class Vault_test : public beast::unit_test::suite BEAST_EXPECT(checkString(vault, sfAssetsAvailable, "50")); BEAST_EXPECT(checkString(vault, sfAssetsMaximum, "1000")); BEAST_EXPECT(checkString(vault, sfAssetsTotal, "50")); - BEAST_EXPECT(checkString(vault, sfLossUnrealized, "0")); + BEAST_EXPECT(!vault.isMember(sfLossUnrealized.getJsonName())); auto const strShareID = strHex(sle->at(sfShareMPTID)); BEAST_EXPECT(checkString(vault, sfShareMPTID, strShareID)); diff --git a/src/test/basics/Number_test.cpp b/src/test/basics/Number_test.cpp index f24c0b35e1..acebf86cc5 100644 --- a/src/test/basics/Number_test.cpp +++ b/src/test/basics/Number_test.cpp @@ -744,6 +744,115 @@ public: BEAST_EXPECT(Number(-100, -30000).truncate() == Number(0, 0)); } + void + testRounding() + { + // Test that rounding works as expected. + testcase("Rounding"); + + using NumberRoundings = std::map; + + std::map const expected{ + // Positive numbers + {Number{13, -1}, + {{Number::to_nearest, 1}, + {Number::towards_zero, 1}, + {Number::downward, 1}, + {Number::upward, 2}}}, + {Number{23, -1}, + {{Number::to_nearest, 2}, + {Number::towards_zero, 2}, + {Number::downward, 2}, + {Number::upward, 3}}}, + {Number{15, -1}, + {{Number::to_nearest, 2}, + {Number::towards_zero, 1}, + {Number::downward, 1}, + {Number::upward, 2}}}, + {Number{25, -1}, + {{Number::to_nearest, 2}, + {Number::towards_zero, 2}, + {Number::downward, 2}, + {Number::upward, 3}}}, + {Number{152, -2}, + {{Number::to_nearest, 2}, + {Number::towards_zero, 1}, + {Number::downward, 1}, + {Number::upward, 2}}}, + {Number{252, -2}, + {{Number::to_nearest, 3}, + {Number::towards_zero, 2}, + {Number::downward, 2}, + {Number::upward, 3}}}, + {Number{17, -1}, + {{Number::to_nearest, 2}, + {Number::towards_zero, 1}, + {Number::downward, 1}, + {Number::upward, 2}}}, + {Number{27, -1}, + {{Number::to_nearest, 3}, + {Number::towards_zero, 2}, + {Number::downward, 2}, + {Number::upward, 3}}}, + + // Negative numbers + {Number{-13, -1}, + {{Number::to_nearest, -1}, + {Number::towards_zero, -1}, + {Number::downward, -2}, + {Number::upward, -1}}}, + {Number{-23, -1}, + {{Number::to_nearest, -2}, + {Number::towards_zero, -2}, + {Number::downward, -3}, + {Number::upward, -2}}}, + {Number{-15, -1}, + {{Number::to_nearest, -2}, + {Number::towards_zero, -1}, + {Number::downward, -2}, + {Number::upward, -1}}}, + {Number{-25, -1}, + {{Number::to_nearest, -2}, + {Number::towards_zero, -2}, + {Number::downward, -3}, + {Number::upward, -2}}}, + {Number{-152, -2}, + {{Number::to_nearest, -2}, + {Number::towards_zero, -1}, + {Number::downward, -2}, + {Number::upward, -1}}}, + {Number{-252, -2}, + {{Number::to_nearest, -3}, + {Number::towards_zero, -2}, + {Number::downward, -3}, + {Number::upward, -2}}}, + {Number{-17, -1}, + {{Number::to_nearest, -2}, + {Number::towards_zero, -1}, + {Number::downward, -2}, + {Number::upward, -1}}}, + {Number{-27, -1}, + {{Number::to_nearest, -3}, + {Number::towards_zero, -2}, + {Number::downward, -3}, + {Number::upward, -2}}}, + }; + + for (auto const& [num, roundings] : expected) + { + for (auto const& [mode, val] : roundings) + { + NumberRoundModeGuard g{mode}; + auto const res = static_cast(num); + BEAST_EXPECTS( + res == val, + to_string(num) + " with mode " + std::to_string(mode) + + " expected " + std::to_string(val) + " got " + + std::to_string(res)); + } + } + } + void run() override { @@ -765,6 +874,7 @@ public: test_inc_dec(); test_toSTAmount(); test_truncate(); + testRounding(); } }; diff --git a/src/test/jtx.h b/src/test/jtx.h index 3d3a4f41f8..b53d1ec152 100644 --- a/src/test/jtx.h +++ b/src/test/jtx.h @@ -71,6 +71,7 @@ #include #include #include +#include #include #include #include diff --git a/src/test/jtx/Env.h b/src/test/jtx/Env.h index 68d8d3e53f..69f5f968c2 100644 --- a/src/test/jtx/Env.h +++ b/src/test/jtx/Env.h @@ -251,7 +251,9 @@ public: * * @param suite_ the current unit_test::suite */ - Env(beast::unit_test::suite& suite_) : Env(suite_, envconfig()) + Env(beast::unit_test::suite& suite_, + beast::severities::Severity thresh = beast::severities::kError) + : Env(suite_, envconfig(), nullptr, thresh) { } diff --git a/src/test/jtx/JTx.h b/src/test/jtx/JTx.h index 36127f1843..054587be71 100644 --- a/src/test/jtx/JTx.h +++ b/src/test/jtx/JTx.h @@ -59,6 +59,9 @@ struct JTx // Functions that sign something else after the mainSigners, such as // sfCounterpartySignature std::vector> postSigners; + // Metadata about the unit test itself + // The line where the JTx was constructed + std::optional testLine = std::nullopt; JTx() = default; JTx(JTx const&) = default; diff --git a/src/test/jtx/TestHelpers.h b/src/test/jtx/TestHelpers.h index 5d919add47..d87c955e30 100644 --- a/src/test/jtx/TestHelpers.h +++ b/src/test/jtx/TestHelpers.h @@ -177,6 +177,29 @@ public: } }; +struct stAmountField : public JTxField +{ + using SF = SF_AMOUNT; + using SV = STAmount; + using OV = Json::Value; + using base = JTxField; + +protected: + using base::value_; + +public: + explicit stAmountField(SF const& sfield, SV const& value) + : JTxField(sfield, value) + { + } + + OV + value() const override + { + return value_.getJson(JsonOptions::none); + } +}; + struct blobField : public JTxField { using SF = SF_VL; @@ -292,6 +315,8 @@ using simpleField = JTxFieldWrapper>; */ auto const data = JTxFieldWrapper(sfData); +auto const amount = JTxFieldWrapper(sfAmount); + // TODO We only need this long "requires" clause as polyfill, for C++20 // implementations which are missing header. Replace with // `std::ranges::range`, and accordingly use std::ranges::begin/end @@ -714,6 +739,110 @@ checkMetrics( line); } +/* LoanBroker */ +/******************************************************************************/ + +namespace loanBroker { + +Json::Value +set(AccountID const& account, uint256 const& vaultId, std::uint32_t flags = 0); + +// Use "del" because "delete" is a reserved word in C++. +Json::Value +del(AccountID const& account, uint256 const& brokerID, std::uint32_t flags = 0); + +Json::Value +coverDeposit( + AccountID const& account, + uint256 const& brokerID, + STAmount const& amount, + std::uint32_t flags = 0); + +Json::Value +coverWithdraw( + AccountID const& account, + uint256 const& brokerID, + STAmount const& amount, + std::uint32_t flags = 0); + +// Must specify at least one of loanBrokerID or amount. +Json::Value +coverClawback(AccountID const& account, std::uint32_t flags = 0); + +auto const loanBrokerID = JTxFieldWrapper(sfLoanBrokerID); + +auto const managementFeeRate = + valueUnitWrapper(sfManagementFeeRate); + +auto const debtMaximum = simpleField(sfDebtMaximum); + +auto const coverRateMinimum = + valueUnitWrapper(sfCoverRateMinimum); + +auto const coverRateLiquidation = + valueUnitWrapper(sfCoverRateLiquidation); + +auto const destination = JTxFieldWrapper(sfDestination); + +} // namespace loanBroker + +/* Loan */ +/******************************************************************************/ +namespace loan { + +Json::Value +set(AccountID const& account, + uint256 const& loanBrokerID, + Number principalRequested, + std::uint32_t flags = 0); + +auto const counterparty = JTxFieldWrapper(sfCounterparty); + +// For `CounterPartySignature`, use `sig(sfCounterpartySignature, ...)` + +auto const loanOriginationFee = simpleField(sfLoanOriginationFee); + +auto const loanServiceFee = simpleField(sfLoanServiceFee); + +auto const latePaymentFee = simpleField(sfLatePaymentFee); + +auto const closePaymentFee = simpleField(sfClosePaymentFee); + +auto const overpaymentFee = + valueUnitWrapper(sfOverpaymentFee); + +auto const interestRate = + valueUnitWrapper(sfInterestRate); + +auto const lateInterestRate = + valueUnitWrapper(sfLateInterestRate); + +auto const closeInterestRate = + valueUnitWrapper(sfCloseInterestRate); + +auto const overpaymentInterestRate = + valueUnitWrapper(sfOverpaymentInterestRate); + +auto const paymentTotal = simpleField(sfPaymentTotal); + +auto const paymentInterval = simpleField(sfPaymentInterval); + +auto const gracePeriod = simpleField(sfGracePeriod); + +Json::Value +manage(AccountID const& account, uint256 const& loanID, std::uint32_t flags); + +Json::Value +del(AccountID const& account, uint256 const& loanID, std::uint32_t flags = 0); + +Json::Value +pay(AccountID const& account, + uint256 const& loanID, + STAmount const& amount, + std::uint32_t flags = 0); + +} // namespace loan + } // namespace jtx } // namespace test } // namespace ripple diff --git a/src/test/jtx/amount.h b/src/test/jtx/amount.h index 80b8dfa7c5..81a452559d 100644 --- a/src/test/jtx/amount.h +++ b/src/test/jtx/amount.h @@ -232,6 +232,25 @@ public: { return {asset_}; } + + bool + integral() const + { + return asset_.integral(); + } + + bool + native() const + { + return asset_.native(); + } + + template + bool + holds() const + { + return asset_.holds(); + } }; //------------------------------------------------------------------------------ @@ -276,6 +295,21 @@ struct XRP_t return {TOut{v} * dropsPerXRP}; } + /** Returns an amount of XRP as PrettyAmount, + which is trivially convertable to STAmount + + @param v The Number of XRP (not drops). May be fractional. + */ + PrettyAmount + operator()(Number v) const + { + auto const c = dropsPerXRP.drops(); + auto const d = std::int64_t(v * c); + if (Number(d) / c != v) + Throw("unrepresentable"); + return {d}; + } + PrettyAmount operator()(double v) const { diff --git a/src/test/jtx/batch.h b/src/test/jtx/batch.h index ab235c293f..2baa52fd06 100644 --- a/src/test/jtx/batch.h +++ b/src/test/jtx/batch.h @@ -68,8 +68,7 @@ public: inner( Json::Value const& txn, std::uint32_t const& sequence, - std::optional const& ticket = std::nullopt, - std::optional const& fee = std::nullopt) + std::optional const& ticket = std::nullopt) : txn_(txn), seq_(sequence), ticket_(ticket) { txn_[jss::SigningPubKey] = ""; diff --git a/src/test/jtx/impl/Env.cpp b/src/test/jtx/impl/Env.cpp index ce5f6f150c..cb88313461 100644 --- a/src/test/jtx/impl/Env.cpp +++ b/src/test/jtx/impl/Env.cpp @@ -446,14 +446,16 @@ Env::postconditions( ParsedResult const& parsed, Json::Value const& jr) { - bool bad = !test.expect(parsed.ter, "apply: No ter result!"); + auto const line = jt.testLine ? " (" + to_string(*jt.testLine) + ")" : ""; + bool bad = !test.expect(parsed.ter, "apply: No ter result!" + line); bad = (jt.ter && parsed.ter && !test.expect( *parsed.ter == *jt.ter, "apply: Got " + transToken(*parsed.ter) + " (" + transHuman(*parsed.ter) + "); Expected " + - transToken(*jt.ter) + " (" + transHuman(*jt.ter) + ")")); + transToken(*jt.ter) + " (" + transHuman(*jt.ter) + ")" + + line)); using namespace std::string_literals; bad = (jt.rpcCode && !test.expect( @@ -465,21 +467,21 @@ Env::postconditions( : "NO RESULT") + " (" + parsed.rpcMessage + "); Expected " + RPC::get_error_info(jt.rpcCode->first).token.c_str() + " (" + - jt.rpcCode->second + ")")) || + jt.rpcCode->second + ")" + line)) || bad; // If we have an rpcCode (just checked), then the rpcException check is // optional - the 'error' field may not be defined, but if it is, it must // match rpcError. - bad = - (jt.rpcException && - !test.expect( - (jt.rpcCode && parsed.rpcError.empty()) || - (parsed.rpcError == jt.rpcException->first && - (!jt.rpcException->second || - parsed.rpcException == *jt.rpcException->second)), - "apply: Got RPC result "s + parsed.rpcError + " (" + - parsed.rpcException + "); Expected " + jt.rpcException->first + - " (" + jt.rpcException->second.value_or("n/a") + ")")) || + bad = (jt.rpcException && + !test.expect( + (jt.rpcCode && parsed.rpcError.empty()) || + (parsed.rpcError == jt.rpcException->first && + (!jt.rpcException->second || + parsed.rpcException == *jt.rpcException->second)), + "apply: Got RPC result "s + parsed.rpcError + " (" + + parsed.rpcException + "); Expected " + + jt.rpcException->first + " (" + + jt.rpcException->second.value_or("n/a") + ")" + line)) || bad; if (bad) { diff --git a/src/test/jtx/impl/TestHelpers.cpp b/src/test/jtx/impl/TestHelpers.cpp index 71f44c691e..618ecba71c 100644 --- a/src/test/jtx/impl/TestHelpers.cpp +++ b/src/test/jtx/impl/TestHelpers.cpp @@ -375,6 +375,134 @@ allpe(AccountID const& a, Issue const& iss) iss.account); }; +/* LoanBroker */ +/******************************************************************************/ + +namespace loanBroker { + +Json::Value +set(AccountID const& account, uint256 const& vaultId, uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanBrokerSet; + jv[sfAccount] = to_string(account); + jv[sfVaultID] = to_string(vaultId); + jv[sfFlags] = flags; + return jv; +} + +Json::Value +del(AccountID const& account, uint256 const& brokerID, uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanBrokerDelete; + jv[sfAccount] = to_string(account); + jv[sfLoanBrokerID] = to_string(brokerID); + jv[sfFlags] = flags; + return jv; +} + +Json::Value +coverDeposit( + AccountID const& account, + uint256 const& brokerID, + STAmount const& amount, + uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanBrokerCoverDeposit; + jv[sfAccount] = to_string(account); + jv[sfLoanBrokerID] = to_string(brokerID); + jv[sfAmount] = amount.getJson(JsonOptions::none); + jv[sfFlags] = flags; + return jv; +} + +Json::Value +coverWithdraw( + AccountID const& account, + uint256 const& brokerID, + STAmount const& amount, + uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanBrokerCoverWithdraw; + jv[sfAccount] = to_string(account); + jv[sfLoanBrokerID] = to_string(brokerID); + jv[sfAmount] = amount.getJson(JsonOptions::none); + jv[sfFlags] = flags; + return jv; +} + +Json::Value +coverClawback(AccountID const& account, std::uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanBrokerCoverClawback; + jv[sfAccount] = to_string(account); + jv[sfFlags] = flags; + return jv; +} + +} // namespace loanBroker + +/* Loan */ +/******************************************************************************/ +namespace loan { + +Json::Value +set(AccountID const& account, + uint256 const& loanBrokerID, + Number principalRequested, + std::uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanSet; + jv[sfAccount] = to_string(account); + jv[sfLoanBrokerID] = to_string(loanBrokerID); + jv[sfPrincipalRequested] = to_string(principalRequested); + jv[sfFlags] = flags; + return jv; +} + +Json::Value +manage(AccountID const& account, uint256 const& loanID, std::uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanManage; + jv[sfAccount] = to_string(account); + jv[sfLoanID] = to_string(loanID); + jv[sfFlags] = flags; + return jv; +} + +Json::Value +del(AccountID const& account, uint256 const& loanID, std::uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanDelete; + jv[sfAccount] = to_string(account); + jv[sfLoanID] = to_string(loanID); + jv[sfFlags] = flags; + return jv; +} + +Json::Value +pay(AccountID const& account, + uint256 const& loanID, + STAmount const& amount, + std::uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanPay; + jv[sfAccount] = to_string(account); + jv[sfLoanID] = to_string(loanID); + jv[sfAmount] = amount.getJson(); + jv[sfFlags] = flags; + return jv; +} + +} // namespace loan } // namespace jtx } // namespace test } // namespace ripple diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index aaa5e433f2..a7c714db16 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -81,17 +81,67 @@ MPTTester::MPTTester(Env& env, Account const& issuer, MPTInit const& arg) env_.require(owners(it.second, 0)); } } + if (arg.create) + create(*arg.create); } -void -MPTTester::create(MPTCreate const& arg) +MPTTester::MPTTester( + Env& env, + Account const& issuer, + MPTID const& id, + std::vector const& holders, + bool close) + : env_(env) + , issuer_(issuer) + , holders_(makeHolders(holders)) + , id_(id) + , close_(close) { - if (id_) - Throw("MPT can't be reused"); - id_ = makeMptID(env_.seq(issuer_), issuer_); +} + +static MPTCreate +makeMPTCreate(MPTInitDef const& arg) +{ + if (arg.pay) + return { + .maxAmt = arg.maxAmt, + .transferFee = arg.transferFee, + .pay = {{arg.holders, *arg.pay}}, + .flags = arg.flags, + .authHolder = arg.authHolder}; + return { + .maxAmt = arg.maxAmt, + .transferFee = arg.transferFee, + .authorize = arg.holders, + .flags = arg.flags, + .authHolder = arg.authHolder}; +} + +MPTTester::MPTTester(MPTInitDef const& arg) + : MPTTester{ + arg.env, + arg.issuer, + MPTInit{ + .fund = arg.fund, + .close = arg.close, + .create = makeMPTCreate(arg)}} +{ +} + +MPTTester::operator MPT() const +{ + if (!id_) + Throw("MPT has not been created"); + return MPT("", *id_); +} + +Json::Value +MPTTester::createjv(MPTCreate const& arg) +{ + if (!arg.issuer) + Throw("MPTTester::createjv: issuer is not set"); Json::Value jv; - jv[sfAccount] = issuer_.human(); - jv[sfTransactionType] = jss::MPTokenIssuanceCreate; + jv[sfAccount] = arg.issuer->human(); if (arg.assetScale) jv[sfAssetScale] = *arg.assetScale; if (arg.transferFee) @@ -104,6 +154,25 @@ MPTTester::create(MPTCreate const& arg) jv[sfDomainID] = to_string(*arg.domainID); if (arg.mutableFlags) jv[sfMutableFlags] = *arg.mutableFlags; + jv[sfTransactionType] = jss::MPTokenIssuanceCreate; + + return jv; +} + +void +MPTTester::create(MPTCreate const& arg) +{ + if (id_) + Throw("MPT can't be reused"); + id_ = makeMptID(env_.seq(issuer_), issuer_); + Json::Value jv = createjv( + {.issuer = issuer_, + .maxAmt = arg.maxAmt, + .assetScale = arg.assetScale, + .transferFee = arg.transferFee, + .metadata = arg.metadata, + .mutableFlags = arg.mutableFlags, + .domainID = arg.domainID}); if (submit(arg, jv) != tesSUCCESS) { // Verify issuance doesn't exist @@ -114,26 +183,62 @@ MPTTester::create(MPTCreate const& arg) id_.reset(); } else + { env_.require(mptflags(*this, arg.flags.value_or(0))); + auto authAndPay = [&](auto const& accts, auto const&& getAcct) { + for (auto const& it : accts) + { + authorize({.account = getAcct(it)}); + if ((arg.flags.value_or(0) & tfMPTRequireAuth) && + arg.authHolder) + authorize({.account = issuer_, .holder = getAcct(it)}); + if (arg.pay && arg.pay->first.empty()) + pay(issuer_, getAcct(it), arg.pay->second); + } + if (arg.pay) + { + for (auto const& p : arg.pay->first) + pay(issuer_, p, arg.pay->second); + } + }; + if (arg.authorize) + { + if (arg.authorize->empty()) + authAndPay(holders_, [](auto const& it) { return it.second; }); + else + authAndPay(*arg.authorize, [](auto const& it) { return it; }); + } + else if (arg.pay) + { + if (arg.pay->first.empty()) + authAndPay(holders_, [](auto const& it) { return it.second; }); + else + authAndPay(arg.pay->first, [](auto const& it) { return it; }); + } + } +} + +Json::Value +MPTTester::destroyjv(MPTDestroy const& arg) +{ + Json::Value jv; + if (!arg.issuer || !arg.id) + Throw("MPTTester::destroyjv: issuer/id is not set"); + jv[sfAccount] = arg.issuer->human(); + jv[sfMPTokenIssuanceID] = to_string(*arg.id); + jv[sfTransactionType] = jss::MPTokenIssuanceDestroy; + + return jv; } void MPTTester::destroy(MPTDestroy const& arg) { - Json::Value jv; - if (arg.issuer) - jv[sfAccount] = arg.issuer->human(); - else - jv[sfAccount] = issuer_.human(); - if (arg.id) - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } - jv[sfTransactionType] = jss::MPTokenIssuanceDestroy; + if (!arg.id && !id_) + Throw("MPT has not been created"); + Json::Value jv = destroyjv( + {.issuer = arg.issuer ? arg.issuer : issuer_, + .id = arg.id ? arg.id : id_}); submit(arg, jv); } @@ -146,25 +251,32 @@ MPTTester::holder(std::string const& holder_) const return it->second; } +Json::Value +MPTTester::authorizejv(MPTAuthorize const& arg) +{ + Json::Value jv; + if (!arg.account || !arg.id) + Throw( + "MPTTester::authorizejv: issuer/id is not set"); + jv[sfAccount] = arg.account->human(); + jv[sfMPTokenIssuanceID] = to_string(*arg.id); + if (arg.holder) + jv[sfHolder] = arg.holder->human(); + jv[sfTransactionType] = jss::MPTokenAuthorize; + + return jv; +} + void MPTTester::authorize(MPTAuthorize const& arg) { - Json::Value jv; - if (arg.account) - jv[sfAccount] = arg.account->human(); - else - jv[sfAccount] = issuer_.human(); - jv[sfTransactionType] = jss::MPTokenAuthorize; - if (arg.id) - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } - if (arg.holder) - jv[sfHolder] = arg.holder->human(); + if (!arg.id && !id_) + Throw("MPT has not been created"); + Json::Value jv = authorizejv({ + .account = arg.account ? arg.account : issuer_, + .holder = arg.holder, + .id = arg.id ? arg.id : id_, + }); if (auto const result = submit(arg, jv); result == tesSUCCESS) { // Issuer authorizes @@ -220,24 +332,34 @@ MPTTester::authorize(MPTAuthorize const& arg) } void -MPTTester::set(MPTSet const& arg) +MPTTester::authorizeHolders(Holders const& holders) +{ + for (auto const& holder : holders) + { + authorize({.account = holder}); + } +} + +Json::Value +MPTTester::setjv(MPTSet const& arg) { Json::Value jv; - if (arg.account) - jv[sfAccount] = arg.account->human(); - else - jv[sfAccount] = issuer_.human(); - jv[sfTransactionType] = jss::MPTokenIssuanceSet; - if (arg.id) - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + if (!arg.account || !arg.id) + Throw("MPTTester::setjv: issuer/id is not set"); + jv[sfAccount] = arg.account->human(); + jv[sfMPTokenIssuanceID] = to_string(*arg.id); if (arg.holder) - jv[sfHolder] = arg.holder->human(); + { + std::visit( + [&jv](T const& holder) { + if constexpr (std::is_same_v) + jv[sfHolder] = holder.human(); + else if constexpr (std::is_same_v) + jv[sfHolder] = toBase58(holder); + }, + *arg.holder); + } + if (arg.delegate) jv[sfDelegate] = arg.delegate->human(); if (arg.domainID) @@ -248,7 +370,27 @@ MPTTester::set(MPTSet const& arg) jv[sfTransferFee] = *arg.transferFee; if (arg.metadata) jv[sfMPTokenMetadata] = strHex(*arg.metadata); - if (submit(arg, jv) == tesSUCCESS && (arg.flags || arg.mutableFlags)) + jv[sfTransactionType] = jss::MPTokenIssuanceSet; + + return jv; +} + +void +MPTTester::set(MPTSet const& arg) +{ + if (!arg.id && !id_) + Throw("MPT has not been created"); + Json::Value jv = setjv( + {.account = arg.account ? arg.account : issuer_, + .holder = arg.holder, + .id = arg.id ? arg.id : id_, + .mutableFlags = arg.mutableFlags, + .transferFee = arg.transferFee, + .metadata = arg.metadata, + .delegate = arg.delegate, + .domainID = arg.domainID}); + if (submit(arg, jv) == tesSUCCESS && + (arg.flags.value_or(0) || arg.mutableFlags)) { auto require = [&](std::optional const& holder, bool unchanged) { @@ -300,8 +442,9 @@ MPTTester::set(MPTSet const& arg) }; if (arg.account) require(std::nullopt, arg.holder.has_value()); - if (arg.holder) - require(*arg.holder, false); + if (auto const account = + (arg.holder ? std::get_if(&(*arg.holder)) : nullptr)) + require(*account, false); } } @@ -474,6 +617,13 @@ MPTTester::mpt(std::int64_t amount) const return ripple::test::jtx::MPT(issuer_.name(), *id_)(amount); } +MPTTester::operator Asset() const +{ + if (!id_) + Throw("MPT has not been created"); + return Asset(*id_); +} + std::int64_t MPTTester::getBalance(Account const& account) const { @@ -512,6 +662,12 @@ MPTTester::operator[](std::string const& name) const return MPT(name, issuanceID()); } +PrettyAmount +MPTTester::operator()(std::uint64_t amount) const +{ + return MPT("", issuanceID())(amount); +} + } // namespace jtx } // namespace test } // namespace ripple diff --git a/src/test/jtx/impl/testline.cpp b/src/test/jtx/impl/testline.cpp new file mode 100644 index 0000000000..722dc33bff --- /dev/null +++ b/src/test/jtx/impl/testline.cpp @@ -0,0 +1,15 @@ +#include + +namespace ripple { +namespace test { +namespace jtx { + +void +testline::operator()(Env&, JTx& jt) const +{ + jt.testLine = line_; +} + +} // namespace jtx +} // namespace test +} // namespace ripple diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index 422afa8fab..e24fc9d327 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -33,6 +33,8 @@ namespace jtx { class MPTTester; +auto const MPTDEXFlags = tfMPTCanTrade | tfMPTCanTransfer; + // Check flags settings on MPT create class mptflags { @@ -86,31 +88,59 @@ public: operator()(Env& env) const; }; -struct MPTInit -{ - std::vector holders = {}; - PrettyAmount const xrp = XRP(10'000); - PrettyAmount const xrpHolders = XRP(10'000); - bool fund = true; - bool close = true; -}; -static MPTInit const mptInitNoFund{.fund = false}; +using Holders = std::vector; struct MPTCreate { + static inline std::vector AllHolders = {}; + std::optional issuer = std::nullopt; std::optional maxAmt = std::nullopt; std::optional assetScale = std::nullopt; std::optional transferFee = std::nullopt; std::optional metadata = std::nullopt; std::optional ownerCount = std::nullopt; std::optional holderCount = std::nullopt; - bool fund = true; + // authorize if seated. + // if empty vector then authorize all holders + std::optional> authorize = std::nullopt; + // pay if seated. if authorize is not seated then authorize. + // if empty vector then pay to either authorize or all holders. + std::optional, std::uint64_t>> pay = + std::nullopt; std::optional flags = {0}; std::optional mutableFlags = std::nullopt; + bool authHolder = false; std::optional domainID = std::nullopt; std::optional err = std::nullopt; }; +struct MPTInit +{ + Holders holders = {}; + PrettyAmount const xrp = XRP(10'000); + PrettyAmount const xrpHolders = XRP(10'000); + bool fund = true; + bool close = true; + // create MPTIssuanceID if seated and follow rules for MPTCreate args + std::optional create = std::nullopt; +}; +static MPTInit const mptInitNoFund{.fund = false}; + +struct MPTInitDef +{ + Env& env; + Account issuer; + Holders holders = {}; + std::uint16_t transferFee = 0; + std::optional pay = std::nullopt; + std::uint32_t flags = MPTDEXFlags; + bool authHolder = false; + bool fund = false; + bool close = true; + std::optional maxAmt = std::nullopt; + std::optional err = std::nullopt; +}; + struct MPTDestroy { std::optional issuer = std::nullopt; @@ -135,7 +165,7 @@ struct MPTAuthorize struct MPTSet { std::optional account = std::nullopt; - std::optional holder = std::nullopt; + std::optional> holder = std::nullopt; std::optional id = std::nullopt; std::optional ownerCount = std::nullopt; std::optional holderCount = std::nullopt; @@ -151,26 +181,49 @@ struct MPTSet class MPTTester { Env& env_; - Account const& issuer_; + Account const issuer_; std::unordered_map const holders_; std::optional id_; bool close_; public: MPTTester(Env& env, Account const& issuer, MPTInit const& constr = {}); + MPTTester(MPTInitDef const& constr); + MPTTester( + Env& env, + Account const& issuer, + MPTID const& id, + std::vector const& holders = {}, + bool close = true); + operator MPT() const; void create(MPTCreate const& arg = MPTCreate{}); + static Json::Value + createjv(MPTCreate const& arg = MPTCreate{}); + void destroy(MPTDestroy const& arg = MPTDestroy{}); + static Json::Value + destroyjv(MPTDestroy const& arg = MPTDestroy{}); + void authorize(MPTAuthorize const& arg = MPTAuthorize{}); + static Json::Value + authorizejv(MPTAuthorize const& arg = MPTAuthorize{}); + + void + authorizeHolders(Holders const& holders); + void set(MPTSet const& set = {}); + static Json::Value + setjv(MPTSet const& set = {}); + [[nodiscard]] bool checkDomainID(std::optional expected) const; @@ -237,8 +290,13 @@ public: MPT operator[](std::string const& name) const; + PrettyAmount + operator()(std::uint64_t amount) const; + + operator Asset() const; + private: - using SLEP = std::shared_ptr; + using SLEP = SLE::const_pointer; bool forObject( std::function const& cb, diff --git a/src/test/jtx/testline.h b/src/test/jtx/testline.h new file mode 100644 index 0000000000..65dd3b7d92 --- /dev/null +++ b/src/test/jtx/testline.h @@ -0,0 +1,34 @@ +#ifndef XRPL_TEST_JTX_TESTLINE_H_INCLUDED +#define XRPL_TEST_JTX_TESTLINE_H_INCLUDED + +#include + +namespace ripple { +namespace test { +namespace jtx { + +/** Store the line number of the current test in a JTx. + + Intended to help debug failing transaction submission tests. +*/ +class testline +{ +private: + int line_; + +public: + explicit testline(int line) : line_(line) + { + } + + void + operator()(Env&, JTx& jt) const; +}; + +#define THISLINE testline(__LINE__) + +} // namespace jtx +} // namespace test +} // namespace ripple + +#endif diff --git a/src/test/protocol/STParsedJSON_test.cpp b/src/test/protocol/STParsedJSON_test.cpp index 1e1e1fb9f4..12408c272e 100644 --- a/src/test/protocol/STParsedJSON_test.cpp +++ b/src/test/protocol/STParsedJSON_test.cpp @@ -743,63 +743,63 @@ class STParsedJSON_test : public beast::unit_test::suite { Json::Value j; int const minInt32 = -2147483648; - j[sfDummyInt32] = minInt32; + j[sfLoanScale] = minInt32; STParsedJSONObject obj("Test", j); BEAST_EXPECT(obj.object.has_value()); - if (BEAST_EXPECT(obj.object->isFieldPresent(sfDummyInt32))) - BEAST_EXPECT(obj.object->getFieldI32(sfDummyInt32) == minInt32); + if (BEAST_EXPECT(obj.object->isFieldPresent(sfLoanScale))) + BEAST_EXPECT(obj.object->getFieldI32(sfLoanScale) == minInt32); } // max value { Json::Value j; int const maxInt32 = 2147483647; - j[sfDummyInt32] = maxInt32; + j[sfLoanScale] = maxInt32; STParsedJSONObject obj("Test", j); BEAST_EXPECT(obj.object.has_value()); - if (BEAST_EXPECT(obj.object->isFieldPresent(sfDummyInt32))) - BEAST_EXPECT(obj.object->getFieldI32(sfDummyInt32) == maxInt32); + if (BEAST_EXPECT(obj.object->isFieldPresent(sfLoanScale))) + BEAST_EXPECT(obj.object->getFieldI32(sfLoanScale) == maxInt32); } // max uint value { Json::Value j; unsigned int const maxUInt32 = 2147483647u; - j[sfDummyInt32] = maxUInt32; + j[sfLoanScale] = maxUInt32; STParsedJSONObject obj("Test", j); BEAST_EXPECT(obj.object.has_value()); - if (BEAST_EXPECT(obj.object->isFieldPresent(sfDummyInt32))) + if (BEAST_EXPECT(obj.object->isFieldPresent(sfLoanScale))) BEAST_EXPECT( - obj.object->getFieldI32(sfDummyInt32) == + obj.object->getFieldI32(sfLoanScale) == static_cast(maxUInt32)); } // Test with string value { Json::Value j; - j[sfDummyInt32] = "2147483647"; + j[sfLoanScale] = "2147483647"; STParsedJSONObject obj("Test", j); BEAST_EXPECT(obj.object.has_value()); - if (BEAST_EXPECT(obj.object->isFieldPresent(sfDummyInt32))) + if (BEAST_EXPECT(obj.object->isFieldPresent(sfLoanScale))) BEAST_EXPECT( - obj.object->getFieldI32(sfDummyInt32) == 2147483647u); + obj.object->getFieldI32(sfLoanScale) == 2147483647u); } // Test with string negative value { Json::Value j; int value = -2147483648; - j[sfDummyInt32] = std::to_string(value); + j[sfLoanScale] = std::to_string(value); STParsedJSONObject obj("Test", j); BEAST_EXPECT(obj.object.has_value()); - if (BEAST_EXPECT(obj.object->isFieldPresent(sfDummyInt32))) - BEAST_EXPECT(obj.object->getFieldI32(sfDummyInt32) == value); + if (BEAST_EXPECT(obj.object->isFieldPresent(sfLoanScale))) + BEAST_EXPECT(obj.object->getFieldI32(sfLoanScale) == value); } // Test out of range value for int32 (negative) { Json::Value j; - j[sfDummyInt32] = "-2147483649"; + j[sfLoanScale] = "-2147483649"; STParsedJSONObject obj("Test", j); BEAST_EXPECT(!obj.object.has_value()); } @@ -807,7 +807,7 @@ class STParsedJSON_test : public beast::unit_test::suite // Test out of range value for int32 (positive) { Json::Value j; - j[sfDummyInt32] = 2147483648u; + j[sfLoanScale] = 2147483648u; STParsedJSONObject obj("Test", j); BEAST_EXPECT(!obj.object.has_value()); } @@ -815,7 +815,7 @@ class STParsedJSON_test : public beast::unit_test::suite // Test string value out of range { Json::Value j; - j[sfDummyInt32] = "2147483648"; + j[sfLoanScale] = "2147483648"; STParsedJSONObject obj("Test", j); BEAST_EXPECT(!obj.object.has_value()); } @@ -823,7 +823,7 @@ class STParsedJSON_test : public beast::unit_test::suite // Test bad_type (arrayValue) { Json::Value j; - j[sfDummyInt32] = Json::Value(Json::arrayValue); + j[sfLoanScale] = Json::Value(Json::arrayValue); STParsedJSONObject obj("Test", j); BEAST_EXPECT(!obj.object.has_value()); } @@ -831,7 +831,7 @@ class STParsedJSON_test : public beast::unit_test::suite // Test bad_type (objectValue) { Json::Value j; - j[sfDummyInt32] = Json::Value(Json::objectValue); + j[sfLoanScale] = Json::Value(Json::objectValue); STParsedJSONObject obj("Test", j); BEAST_EXPECT(!obj.object.has_value()); } diff --git a/src/xrpld/app/ledger/detail/LedgerReplayTask.cpp b/src/xrpld/app/ledger/detail/LedgerReplayTask.cpp index a9d2657f9b..a8847e59a9 100644 --- a/src/xrpld/app/ledger/detail/LedgerReplayTask.cpp +++ b/src/xrpld/app/ledger/detail/LedgerReplayTask.cpp @@ -299,7 +299,7 @@ LedgerReplayTask::addDelta(std::shared_ptr const& delta) deltas_.empty() || deltas_.back()->ledgerSeq_ + 1 == delta->ledgerSeq_, "ripple::LedgerReplayTask::addDelta : no deltas or consecutive " - "sequence", ); + "sequence"); deltas_.push_back(delta); } } diff --git a/src/xrpld/app/ledger/detail/OpenLedger.cpp b/src/xrpld/app/ledger/detail/OpenLedger.cpp index 5bba544e31..64a080053f 100644 --- a/src/xrpld/app/ledger/detail/OpenLedger.cpp +++ b/src/xrpld/app/ledger/detail/OpenLedger.cpp @@ -124,8 +124,11 @@ OpenLedger::accept( auto const txId = tx->getTransactionID(); // skip batch txns + // The flag should only be settable if Batch feature is enabled. If + // Batch is not enabled, the flag is always invalid, so don't relay it + // regardless. // LCOV_EXCL_START - if (tx->isFlag(tfInnerBatchTxn) && rules.enabled(featureBatch)) + if (tx->isFlag(tfInnerBatchTxn)) { XRPL_ASSERT( txpair.second && txpair.second->isFieldPresent(sfParentBatchID), diff --git a/src/xrpld/app/misc/LendingHelpers.h b/src/xrpld/app/misc/LendingHelpers.h new file mode 100644 index 0000000000..559af28a47 --- /dev/null +++ b/src/xrpld/app/misc/LendingHelpers.h @@ -0,0 +1,444 @@ +#ifndef XRPL_APP_MISC_LENDINGHELPERS_H_INCLUDED +#define XRPL_APP_MISC_LENDINGHELPERS_H_INCLUDED + +#include +#include + +namespace ripple { + +struct PreflightContext; + +// Lending protocol has dependencies, so capture them here. +bool +checkLendingProtocolDependencies(PreflightContext const& ctx); + +static constexpr std::uint32_t secondsInYear = 365 * 24 * 60 * 60; + +Number +loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval); + +/// Ensure the periodic payment is always rounded consistently +inline Number +roundPeriodicPayment( + Asset const& asset, + Number const& periodicPayment, + std::int32_t scale) +{ + return roundToAsset(asset, periodicPayment, scale, Number::upward); +} + +/* Represents the breakdown of amounts to be paid and changes applied to the + * Loan object while processing a loan payment. + * + * This structure is returned after processing a loan payment transaction and + * captures the amounts that need to be paid. The actual ledger entry changes + * are made in LoanPay based on this structure values. + * + * The sum of principalPaid, interestPaid, and feePaid represents the total + * amount to be deducted from the borrower's account. The valueChange field + * tracks whether the loan's total value increased or decreased beyond normal + * amortization. + * + * This structure is explained in the XLS-66 spec, section 3.2.4.2 (Payment + * Processing). + */ +struct LoanPaymentParts +{ + // The amount of principal paid that reduces the loan balance. + // This amount is subtracted from sfPrincipalOutstanding in the Loan object + // and paid to the Vault + Number principalPaid = numZero; + + // The total amount of interest paid to the Vault. + // This includes: + // - Tracked interest from the amortization schedule + // - Untracked interest (e.g., late payment penalty interest) + // This value is always non-negative. + Number interestPaid = numZero; + + // The change in the loan's total value outstanding. + // - If valueChange < 0: Loan value decreased + // - If valueChange > 0: Loan value increased + // - If valueChange = 0: No value adjustment + // + // For regular on-time payments, this is always 0. Non-zero values occur + // when: + // - Overpayments reduce the loan balance beyond the scheduled amount + // - Late payments add penalty interest to the loan value + // - Early full payment may increase or decrease the loan value based on + // terms + Number valueChange = numZero; + + /* The total amount of fees paid to the Broker. + * This includes: + * - Tracked management fees from the amortization schedule + * - Untracked fees (e.g., late payment fees, service fees, origination + * fees) This value is always non-negative. + */ + Number feePaid = numZero; + + LoanPaymentParts& + operator+=(LoanPaymentParts const& other); + + bool + operator==(LoanPaymentParts const& other) const; +}; + +/* Describes the initial computed properties of a loan. + * + * This structure contains the fundamental calculated values that define a + * loan's payment structure and amortization schedule. These properties are + * computed: + * - At loan creation (LoanSet transaction) + * - When loan terms change (e.g., after an overpayment that reduces the loan + * balance) + */ +struct LoanProperties +{ + // The unrounded amount to be paid at each regular payment period. + // Calculated using the standard amortization formula based on principal, + // interest rate, and number of payments. + // The actual amount paid in the LoanPay transaction must be rounded up to + // the precision of the asset and loan. + Number periodicPayment; + + // The total amount the borrower will pay over the life of the loan. + // Equal to periodicPayment * paymentsRemaining. + // This includes principal, interest, and management fees. + Number totalValueOutstanding; + + // The total management fee that will be paid to the broker over the + // loan's lifetime. This is a percentage of the total interest (gross) + // as specified by the broker's management fee rate. + Number managementFeeOwedToBroker; + + // The scale (decimal places) used for rounding all loan amounts. + // This is the maximum of: + // - The asset's native scale + // - A minimum scale required to represent the periodic payment accurately + // All loan state values (principal, interest, fees) are rounded to this + // scale. + std::int32_t loanScale; + + // The principal portion of the first payment. + Number firstPaymentPrincipal; +}; + +/** This structure captures the parts of a loan state. + * + * Whether the values are raw (unrounded) or rounded will depend on how it was + * computed. + * + * Many of the fields can be derived from each other, but they're all provided + * here to reduce code duplication and possible mistakes. + * e.g. + * * interestOutstanding = valueOutstanding - principalOutstanding + * * interestDue = interestOutstanding - managementFeeDue + */ +struct LoanState +{ + // Total value still due to be paid by the borrower. + Number valueOutstanding; + // Principal still due to be paid by the borrower. + Number principalOutstanding; + // Interest still due to be paid to the Vault. + // This is a portion of interestOutstanding + Number interestDue; + // Management fee still due to be paid to the broker. + // This is a portion of interestOutstanding + Number managementFeeDue; + + // Interest still due to be paid by the borrower. + Number + interestOutstanding() const + { + XRPL_ASSERT_PARTS( + interestDue + managementFeeDue == + valueOutstanding - principalOutstanding, + "ripple::LoanState::interestOutstanding", + "other values add up correctly"); + return interestDue + managementFeeDue; + } +}; + +// Some values get re-rounded to the vault scale any time they are adjusted. In +// addition, they are prevented from ever going below zero. This helps avoid +// accumulated rounding errors and leftover dust amounts. +template +void +adjustImpreciseNumber( + NumberProxy value, + Number const& adjustment, + Asset const& asset, + int vaultScale) +{ + value = roundToAsset(asset, value + adjustment, vaultScale); + + if (*value < beast::zero) + value = 0; +} + +inline int +getVaultScale(SLE::const_ref vaultSle) +{ + if (!vaultSle) + return Number::minExponent - 1; // LCOV_EXCL_LINE + return vaultSle->at(sfAssetsTotal).exponent(); +} + +TER +checkLoanGuards( + Asset const& vaultAsset, + Number const& principalRequested, + bool expectInterest, + std::uint32_t paymentTotal, + LoanProperties const& properties, + beast::Journal j); + +LoanState +computeRawLoanState( + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t const paymentRemaining, + TenthBips32 const managementFeeRate); + +LoanState +computeRawLoanState( + Number const& periodicPayment, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t const paymentRemaining, + TenthBips32 const managementFeeRate); + +// Constructs a valid LoanState object from arbitrary inputs +LoanState +constructLoanState( + Number const& totalValueOutstanding, + Number const& principalOutstanding, + Number const& managementFeeOutstanding); + +// Constructs a valid LoanState object from a Loan object, which always has +// rounded values +LoanState +constructRoundedLoanState(SLE::const_ref loan); + +Number +computeManagementFee( + Asset const& asset, + Number const& interest, + TenthBips32 managementFeeRate, + std::int32_t scale); + +Number +computeFullPaymentInterest( + Number const& rawPrincipalOutstanding, + Number const& periodicRate, + NetClock::time_point parentCloseTime, + std::uint32_t paymentInterval, + std::uint32_t prevPaymentDate, + std::uint32_t startDate, + TenthBips32 closeInterestRate); + +Number +computeFullPaymentInterest( + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentRemaining, + NetClock::time_point parentCloseTime, + std::uint32_t paymentInterval, + std::uint32_t prevPaymentDate, + std::uint32_t startDate, + TenthBips32 closeInterestRate); + +namespace detail { +// These classes and functions should only be accessed by LendingHelper +// functions and unit tests + +enum class PaymentSpecialCase { none, final, extra }; + +/* Represents a single loan payment component parts. + +* This structure captures the "delta" (change) values that will be applied to +* the tracked fields in the Loan ledger object when a payment is processed. +* +* These are called "deltas" because they represent the amount by which each +* corresponding field in the Loan object will be reduced. +* They are "tracked" as they change tracked loan values. +*/ +struct PaymentComponents +{ + // The change in total value outstanding for this payment. + // This amount will be subtracted from sfTotalValueOutstanding in the Loan + // object. Equal to the sum of trackedPrincipalDelta, + // trackedInterestPart(), and trackedManagementFeeDelta. + Number trackedValueDelta; + + // The change in principal outstanding for this payment. + // This amount will be subtracted from sfPrincipalOutstanding in the Loan + // object, representing the portion of the payment that reduces the + // original loan amount. + Number trackedPrincipalDelta; + + // The change in management fee outstanding for this payment. + // This amount will be subtracted from sfManagementFeeOutstanding in the + // Loan object. This represents only the tracked management fees from the + // amortization schedule and does not include additional untracked fees + // (such as late payment fees) that go directly to the broker. + Number trackedManagementFeeDelta; + + // Indicates if this payment has special handling requirements. + // - none: Regular scheduled payment + // - final: The last payment that closes out the loan + // - extra: An additional payment beyond the regular schedule (overpayment) + PaymentSpecialCase specialCase = PaymentSpecialCase::none; + + // Calculates the tracked interest portion of this payment. + // This is derived from the other components as: + // trackedValueDelta - trackedPrincipalDelta - trackedManagementFeeDelta + // + // @return The amount of tracked interest included in this payment that + // will be paid to the vault. + Number + trackedInterestPart() const; +}; + +/* Extends PaymentComponents with untracked payment amounts. + * + * This structure adds untracked fees and interest to the base + * PaymentComponents, representing amounts that don't affect the Loan object's + * tracked state but are still part of the total payment due from the borrower. + * + * Untracked amounts include: + * - Late payment fees that go directly to the Broker + * - Late payment penalty interest that goes directly to the Vault + * - Service fees + * + * The key distinction is that tracked amounts reduce the Loan object's state + * (sfTotalValueOutstanding, sfPrincipalOutstanding, + * sfManagementFeeOutstanding), while untracked amounts are paid directly to the + * recipient without affecting the loan's amortization schedule. + */ +struct ExtendedPaymentComponents : public PaymentComponents +{ + // Additional management fees that go directly to the Broker. + // This includes fees not part of the standard amortization schedule + // (e.g., late fees, service fees, origination fees). + // This value may be negative, though the final value returned in + // LoanPaymentParts.feePaid will never be negative. + Number untrackedManagementFee; + + // Additional interest that goes directly to the Vault. + // This includes interest not part of the standard amortization schedule + // (e.g., late payment penalty interest). + // This value may be negative, though the final value returned in + // LoanPaymentParts.interestPaid will never be negative. + Number untrackedInterest; + + // The complete amount due from the borrower for this payment. + // Calculated as: trackedValueDelta + untrackedInterest + + // untrackedManagementFee + // + // This value is used to validate that the payment amount provided by the + // borrower is sufficient to cover all components of the payment. + Number totalDue; + + ExtendedPaymentComponents( + PaymentComponents const& p, + Number fee, + Number interest = numZero) + : PaymentComponents(p) + , untrackedManagementFee(fee) + , untrackedInterest(interest) + , totalDue( + trackedValueDelta + untrackedInterest + untrackedManagementFee) + { + } +}; + +/* Represents the differences between two loan states. + * + * This structure is used to capture the change in each component of a loan's + * state, typically when computing the difference between two LoanState objects + * (e.g., before and after a payment). It is a convenient way to capture changes + * in each component. How that difference is used depends on the context. + */ +struct LoanStateDeltas +{ + // The difference in principal outstanding between two loan states. + Number principal; + + // The difference in interest due between two loan states. + Number interest; + + // The difference in management fee outstanding between two loan states. + Number managementFee; + + /* Calculates the total change across all components. + * @return The sum of principal, interest, and management fee deltas. + */ + Number + total() const + { + return principal + interest + managementFee; + } + + // Ensures all delta values are non-negative. + void + nonNegative(); +}; + +PaymentComponents +computePaymentComponents( + Asset const& asset, + std::int32_t scale, + Number const& totalValueOutstanding, + Number const& principalOutstanding, + Number const& managementFeeOutstanding, + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentRemaining, + TenthBips16 managementFeeRate); + +} // namespace detail + +detail::LoanStateDeltas +operator-(LoanState const& lhs, LoanState const& rhs); + +LoanState +operator-(LoanState const& lhs, detail::LoanStateDeltas const& rhs); + +LoanState +operator+(LoanState const& lhs, detail::LoanStateDeltas const& rhs); + +LoanProperties +computeLoanProperties( + Asset const& asset, + Number principalOutstanding, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining, + TenthBips32 managementFeeRate, + std::int32_t minimumScale); + +bool +isRounded(Asset const& asset, Number const& value, std::int32_t scale); + +// Indicates what type of payment is being made. +// regular, late, and full are mutually exclusive. +// overpayment is an "add on" to a regular payment, and follows that path with +// potential extra work at the end. +enum class LoanPaymentType { regular = 0, late, full, overpayment }; + +Expected +loanMakePayment( + Asset const& asset, + ApplyView& view, + SLE::ref loan, + SLE::const_ref brokerSle, + STAmount const& amount, + LoanPaymentType const paymentType, + beast::Journal j); + +} // namespace ripple + +#endif // XRPL_APP_MISC_LENDINGHELPERS_H_INCLUDED diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp index 6609815d3b..3367bcd6a8 100644 --- a/src/xrpld/app/misc/NetworkOPs.cpp +++ b/src/xrpld/app/misc/NetworkOPs.cpp @@ -1692,10 +1692,11 @@ NetworkOPsImp::apply(std::unique_lock& batchLock) app_.getHashRouter().shouldRelay(e.transaction->getID()); if (auto const sttx = *(e.transaction->getSTransaction()); toSkip && - // Skip relaying if it's an inner batch txn and batch - // feature is enabled - !(sttx.isFlag(tfInnerBatchTxn) && - newOL->rules().enabled(featureBatch))) + // Skip relaying if it's an inner batch txn. The flag should + // only be set if the Batch feature is enabled. If Batch is + // not enabled, the flag is always invalid, so don't relay + // it regardless. + !sttx.isFlag(tfInnerBatchTxn)) { protocol::TMTransaction tx; Serializer s; @@ -3058,9 +3059,11 @@ NetworkOPsImp::pubProposedTransaction( std::shared_ptr const& transaction, TER result) { - // never publish an inner txn inside a batch txn - if (transaction->isFlag(tfInnerBatchTxn) && - ledger->rules().enabled(featureBatch)) + // never publish an inner txn inside a batch txn. The flag should + // only be set if the Batch feature is enabled. If Batch is not + // enabled, the flag is always invalid, so don't publish it + // regardless. + if (transaction->isFlag(tfInnerBatchTxn)) return; MultiApiJson jvObj = diff --git a/src/xrpld/app/misc/detail/LendingHelpers.cpp b/src/xrpld/app/misc/detail/LendingHelpers.cpp new file mode 100644 index 0000000000..8020b47ba9 --- /dev/null +++ b/src/xrpld/app/misc/detail/LendingHelpers.cpp @@ -0,0 +1,2058 @@ +#include +// DO NOT REMOVE forces header file include to sort first +#include + +namespace ripple { + +bool +checkLendingProtocolDependencies(PreflightContext const& ctx) +{ + return ctx.rules.enabled(featureSingleAssetVault) && + VaultCreate::checkExtraFeatures(ctx); +} + +LoanPaymentParts& +LoanPaymentParts::operator+=(LoanPaymentParts const& other) +{ + XRPL_ASSERT( + + other.principalPaid >= beast::zero, + "ripple::LoanPaymentParts::operator+= : other principal " + "non-negative"); + XRPL_ASSERT( + other.interestPaid >= beast::zero, + "ripple::LoanPaymentParts::operator+= : other interest paid " + "non-negative"); + XRPL_ASSERT( + other.feePaid >= beast::zero, + "ripple::LoanPaymentParts::operator+= : other fee paid " + "non-negative"); + + principalPaid += other.principalPaid; + interestPaid += other.interestPaid; + valueChange += other.valueChange; + feePaid += other.feePaid; + return *this; +} + +bool +LoanPaymentParts::operator==(LoanPaymentParts const& other) const +{ + return principalPaid == other.principalPaid && + interestPaid == other.interestPaid && + valueChange == other.valueChange && feePaid == other.feePaid; +} + +/* Converts annualized interest rate to per-payment-period rate. + * The rate is prorated based on the payment interval in seconds. + * + * Equation (1) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval) +{ + // Need floating point math, since we're dividing by a large number + return tenthBipsOfValue(Number(paymentInterval), interestRate) / + secondsInYear; +} + +/* Checks if a value is already rounded to the specified scale. + * Returns true if rounding down and rounding up produce the same result, + * indicating no further precision exists beyond the scale. + */ +bool +isRounded(Asset const& asset, Number const& value, std::int32_t scale) +{ + return roundToAsset(asset, value, scale, Number::downward) == + roundToAsset(asset, value, scale, Number::upward); +} + +namespace detail { + +void +LoanStateDeltas::nonNegative() +{ + if (principal < beast::zero) + principal = numZero; + if (interest < beast::zero) + interest = numZero; + if (managementFee < beast::zero) + managementFee = numZero; +} + +/* Computes (1 + periodicRate)^paymentsRemaining for amortization calculations. + * + * Equation (5) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +computeRaisedRate(Number const& periodicRate, std::uint32_t paymentsRemaining) +{ + return power(1 + periodicRate, paymentsRemaining); +} + +/* Computes the payment factor used in standard amortization formulas. + * This factor converts principal to periodic payment amount. + * + * Equation (6) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +computePaymentFactor( + Number const& periodicRate, + std::uint32_t paymentsRemaining) +{ + // For zero interest, payment factor is simply 1/paymentsRemaining + if (periodicRate == beast::zero) + return Number{1} / paymentsRemaining; + + Number const raisedRate = + computeRaisedRate(periodicRate, paymentsRemaining); + + return (periodicRate * raisedRate) / (raisedRate - 1); +} + +/* Calculates the periodic payment amount using standard amortization formula. + * For interest-free loans, returns principal divided equally across payments. + * + * Equation (7) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +loanPeriodicPayment( + Number const& principalOutstanding, + Number const& periodicRate, + std::uint32_t paymentsRemaining) +{ + if (principalOutstanding == 0 || paymentsRemaining == 0) + return 0; + + // Interest-free loans: equal principal payments + if (periodicRate == beast::zero) + return principalOutstanding / paymentsRemaining; + + return principalOutstanding * + computePaymentFactor(periodicRate, paymentsRemaining); +} + +/* Calculates the periodic payment amount from annualized interest rate. + * Converts the annual rate to periodic rate before computing payment. + * + * Equation (7) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +loanPeriodicPayment( + Number const& principalOutstanding, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining) +{ + if (principalOutstanding == 0 || paymentsRemaining == 0) + return 0; + + Number const periodicRate = loanPeriodicRate(interestRate, paymentInterval); + + return loanPeriodicPayment( + principalOutstanding, periodicRate, paymentsRemaining); +} + +/* Reverse-calculates principal from periodic payment amount. + * Used to determine theoretical principal at any point in the schedule. + * + * Equation (10) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +loanPrincipalFromPeriodicPayment( + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentsRemaining) +{ + if (periodicRate == 0) + return periodicPayment * paymentsRemaining; + + return periodicPayment / + computePaymentFactor(periodicRate, paymentsRemaining); +} + +/* Splits gross interest into net interest (to vault) and management fee (to + * broker). Returns pair of (net interest, management fee). + * + * Equation (33) from XLS-66 spec, Section A-2 Equation Glossary + */ +std::pair +computeInterestAndFeeParts( + Number const& interest, + TenthBips16 managementFeeRate) +{ + auto const fee = tenthBipsOfValue(interest, managementFeeRate); + + return std::make_pair(interest - fee, fee); +} + +/* + * Computes the interest and management fee parts from interest amount. + * + * Equation (33) from XLS-66 spec, Section A-2 Equation Glossary + */ +std::pair +computeInterestAndFeeParts( + Asset const& asset, + Number const& interest, + TenthBips16 managementFeeRate, + std::int32_t loanScale) +{ + auto const fee = + computeManagementFee(asset, interest, managementFeeRate, loanScale); + + return std::make_pair(interest - fee, fee); +} + +/* Calculates penalty interest accrued on overdue payments. + * Returns 0 if payment is not late. + * + * Equation (16) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +loanLatePaymentInterest( + Number const& principalOutstanding, + TenthBips32 lateInterestRate, + NetClock::time_point parentCloseTime, + std::uint32_t nextPaymentDueDate) +{ + auto const now = parentCloseTime.time_since_epoch().count(); + + // If the payment is not late by any amount of time, then there's no late + // interest + if (now <= nextPaymentDueDate) + return 0; + + // Equation (3) from XLS-66 spec, Section A-2 Equation Glossary + auto const secondsOverdue = now - nextPaymentDueDate; + + auto const rate = loanPeriodicRate(lateInterestRate, secondsOverdue); + + return principalOutstanding * rate; +} + +/* Calculates interest accrued since the last payment based on time elapsed. + * Returns 0 if loan is paid ahead of schedule. + * + * Equation (27) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +loanAccruedInterest( + Number const& principalOutstanding, + Number const& periodicRate, + NetClock::time_point parentCloseTime, + std::uint32_t startDate, + std::uint32_t prevPaymentDate, + std::uint32_t paymentInterval) +{ + if (periodicRate == beast::zero) + return numZero; + + auto const lastPaymentDate = std::max(prevPaymentDate, startDate); + auto const now = parentCloseTime.time_since_epoch().count(); + + // If the loan has been paid ahead, then "lastPaymentDate" is in the future, + // and no interest has accrued. + if (now <= lastPaymentDate) + return numZero; + + // Equation (4) from XLS-66 spec, Section A-2 Equation Glossary + auto const secondsSinceLastPayment = now - lastPaymentDate; + + // Division is more likely to introduce rounding errors, which will then get + // amplified by multiplication. Therefore, we first multiply, and only then + // divide. + return principalOutstanding * periodicRate * secondsSinceLastPayment / + paymentInterval; +} + +/* Applies a payment to the loan state and returns the breakdown of amounts + * paid. + * + * This is the core function that updates the Loan ledger object fields based on + * a computed payment. + + * The function is templated to work with both direct Number/uint32_t values + * (for testing/simulation) and ValueProxy types (for actual ledger updates). + */ +template +LoanPaymentParts +doPayment( + ExtendedPaymentComponents const& payment, + NumberProxy& totalValueOutstandingProxy, + NumberProxy& principalOutstandingProxy, + NumberProxy& managementFeeOutstandingProxy, + UInt32Proxy& paymentRemainingProxy, + UInt32Proxy& prevPaymentDateProxy, + UInt32OptionalProxy& nextDueDateProxy, + std::uint32_t paymentInterval) +{ + XRPL_ASSERT_PARTS( + nextDueDateProxy, + "ripple::detail::doPayment", + "Next due date proxy set"); + + if (payment.specialCase == PaymentSpecialCase::final) + { + XRPL_ASSERT_PARTS( + principalOutstandingProxy == payment.trackedPrincipalDelta, + "ripple::detail::doPayment", + "Full principal payment"); + XRPL_ASSERT_PARTS( + totalValueOutstandingProxy == payment.trackedValueDelta, + "ripple::detail::doPayment", + "Full value payment"); + XRPL_ASSERT_PARTS( + managementFeeOutstandingProxy == payment.trackedManagementFeeDelta, + "ripple::detail::doPayment", + "Full management fee payment"); + + // Mark the loan as complete + paymentRemainingProxy = 0; + + // Record when the final payment was made + prevPaymentDateProxy = *nextDueDateProxy; + + // Clear the next due date. Setting it to 0 causes + // it to be removed from the Loan ledger object, saving space. + nextDueDateProxy = 0; + + // Zero out all tracked loan balances to mark the loan as paid off. + // These will be removed from the Loan object since they're default + // values. + principalOutstandingProxy = 0; + totalValueOutstandingProxy = 0; + managementFeeOutstandingProxy = 0; + } + else + { + // For regular payments (not overpayments), advance the payment schedule + if (payment.specialCase != PaymentSpecialCase::extra) + { + paymentRemainingProxy -= 1; + + prevPaymentDateProxy = nextDueDateProxy; + nextDueDateProxy += paymentInterval; + } + XRPL_ASSERT_PARTS( + principalOutstandingProxy > payment.trackedPrincipalDelta, + "ripple::detail::doPayment", + "Partial principal payment"); + XRPL_ASSERT_PARTS( + totalValueOutstandingProxy > payment.trackedValueDelta, + "ripple::detail::doPayment", + "Partial value payment"); + // Management fees are expected to be relatively small, and could get to + // zero before the loan is paid off + XRPL_ASSERT_PARTS( + managementFeeOutstandingProxy >= payment.trackedManagementFeeDelta, + "ripple::detail::doPayment", + "Valid management fee"); + + // Apply the payment deltas to reduce the outstanding balances + principalOutstandingProxy -= payment.trackedPrincipalDelta; + totalValueOutstandingProxy -= payment.trackedValueDelta; + managementFeeOutstandingProxy -= payment.trackedManagementFeeDelta; + } + + // Principal can never exceed total value (principal is part of total value) + XRPL_ASSERT_PARTS( + // Use an explicit cast because the template parameter can be + // ValueProxy or Number + static_cast(principalOutstandingProxy) <= + static_cast(totalValueOutstandingProxy), + "ripple::detail::doPayment", + "principal does not exceed total"); + + XRPL_ASSERT_PARTS( + // Use an explicit cast because the template parameter can be + // ValueProxy or Number + static_cast(managementFeeOutstandingProxy) >= beast::zero, + "ripple::detail::doPayment", + "fee outstanding stays valid"); + + return LoanPaymentParts{ + // Principal paid is straightforward - it's the tracked delta + .principalPaid = payment.trackedPrincipalDelta, + + // Interest paid combines: + // 1. Tracked interest from the amortization schedule + // (derived from the tracked deltas) + // 2. Untracked interest (e.g., late payment penalties) + .interestPaid = + payment.trackedInterestPart() + payment.untrackedInterest, + + // Value change represents how the loan's total value changed beyond + // normal amortization. + .valueChange = payment.untrackedInterest, + + // Fee paid combines: + // 1. Tracked management fees from the amortization schedule + // 2. Untracked fees (e.g., late payment fees, service fees) + .feePaid = + payment.trackedManagementFeeDelta + payment.untrackedManagementFee}; +} + +/* Simulates an overpayment to validate it won't break the loan's amortization. + * + * When a borrower pays more than the scheduled amount, the loan needs to be + * re-amortized with a lower principal. This function performs that calculation + * in a "sandbox" using temporary variables, allowing the caller to validate + * the result before committing changes to the actual ledger. + * + * The function preserves accumulated rounding errors across the re-amortization + * to ensure the loan state remains consistent with its payment history. + */ +Expected +tryOverpayment( + Asset const& asset, + std::int32_t loanScale, + ExtendedPaymentComponents const& overpaymentComponents, + Number& totalValueOutstanding, + Number& principalOutstanding, + Number& managementFeeOutstanding, + Number& periodicPayment, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + Number const& periodicRate, + std::uint32_t paymentRemaining, + std::uint32_t prevPaymentDate, + std::optional nextDueDate, + TenthBips16 const managementFeeRate, + beast::Journal j) +{ + // Calculate what the loan state SHOULD be theoretically (at full precision) + auto const raw = computeRawLoanState( + periodicPayment, periodicRate, paymentRemaining, managementFeeRate); + + // Get the actual loan state (with accumulated rounding from past payments) + auto const rounded = constructLoanState( + totalValueOutstanding, principalOutstanding, managementFeeOutstanding); + + // Calculate the accumulated rounding errors. These need to be preserved + // across the re-amortization to maintain consistency with the loan's + // payment history. Without preserving these errors, the loan could end + // up with a different total value than what the borrower has actually paid. + auto const errors = rounded - raw; + + // Compute the new principal by applying the overpayment to the raw + // (theoretical) principal. Use max with 0 to ensure we never go negative. + auto const newRawPrincipal = std::max( + raw.principalOutstanding - overpaymentComponents.trackedPrincipalDelta, + Number{0}); + + // Compute new loan properties based on the reduced principal. This + // recalculates the periodic payment, total value, and management fees + // for the remaining payment schedule. + auto newLoanProperties = computeLoanProperties( + asset, + newRawPrincipal, + interestRate, + paymentInterval, + paymentRemaining, + managementFeeRate, + loanScale); + + JLOG(j.debug()) << "new periodic payment: " + << newLoanProperties.periodicPayment + << ", new total value: " + << newLoanProperties.totalValueOutstanding + << ", first payment principal: " + << newLoanProperties.firstPaymentPrincipal; + + // Calculate what the new loan state should be with the new periodic payment + auto const newRaw = computeRawLoanState( + newLoanProperties.periodicPayment, + periodicRate, + paymentRemaining, + managementFeeRate) + + errors; + + JLOG(j.debug()) << "new raw value: " << newRaw.valueOutstanding + << ", principal: " << newRaw.principalOutstanding + << ", interest gross: " << newRaw.interestOutstanding(); + // Update the loan state variables with the new values PLUS the preserved + // rounding errors. This ensures the loan's tracked state remains + // consistent with its payment history. + + principalOutstanding = std::clamp( + roundToAsset( + asset, newRaw.principalOutstanding, loanScale, Number::upward), + numZero, + rounded.principalOutstanding); + totalValueOutstanding = std::clamp( + roundToAsset( + asset, + principalOutstanding + newRaw.interestOutstanding(), + loanScale, + Number::upward), + numZero, + rounded.valueOutstanding); + managementFeeOutstanding = std::clamp( + roundToAsset(asset, newRaw.managementFeeDue, loanScale), + numZero, + rounded.managementFeeDue); + + auto const newRounded = constructLoanState( + totalValueOutstanding, principalOutstanding, managementFeeOutstanding); + + // Update newLoanProperties so that checkLoanGuards can make an accurate + // evaluation. + newLoanProperties.totalValueOutstanding = newRounded.valueOutstanding; + + JLOG(j.debug()) << "new rounded value: " << newRounded.valueOutstanding + << ", principal: " << newRounded.principalOutstanding + << ", interest gross: " << newRounded.interestOutstanding(); + + // Update the periodic payment to reflect the re-amortized schedule + periodicPayment = newLoanProperties.periodicPayment; + + // check that the loan is still valid + if (auto const ter = checkLoanGuards( + asset, + principalOutstanding, + // The loan may have been created with interest, but for + // small interest amounts, that may have already been paid + // off. Check what's still outstanding. This should + // guarantee that the interest checks pass. + newRounded.interestOutstanding() != beast::zero, + paymentRemaining, + newLoanProperties, + j)) + { + JLOG(j.warn()) << "Principal overpayment would cause the loan to be in " + "an invalid state. Ignore the overpayment"; + + return Unexpected(tesSUCCESS); + } + + // Validate that all computed properties are reasonable. These checks should + // never fail under normal circumstances, but we validate defensively. + if (newLoanProperties.periodicPayment <= 0 || + newLoanProperties.totalValueOutstanding <= 0 || + newLoanProperties.managementFeeOwedToBroker < 0) + { + // LCOV_EXCL_START + JLOG(j.warn()) << "Overpayment not allowed: Computed loan " + "properties are invalid. Does " + "not compute. TotalValueOutstanding: " + << newLoanProperties.totalValueOutstanding + << ", PeriodicPayment : " + << newLoanProperties.periodicPayment + << ", ManagementFeeOwedToBroker: " + << newLoanProperties.managementFeeOwedToBroker; + return Unexpected(tesSUCCESS); + // LCOV_EXCL_STOP + } + + auto const deltas = rounded - newRounded; + + auto const hypotheticalValueOutstanding = + rounded.valueOutstanding - deltas.principal; + + // Calculate how the loan's value changed due to the overpayment. + // This should be negative (value decreased) or zero. A principal + // overpayment should never increase the loan's value. + auto const valueChange = + newRounded.valueOutstanding - hypotheticalValueOutstanding; + if (valueChange > 0) + { + JLOG(j.warn()) << "Principal overpayment would increase the value of " + "the loan. Ignore the overpayment"; + return Unexpected(tesSUCCESS); + } + + return LoanPaymentParts{ + // Principal paid is the reduction in principal outstanding + .principalPaid = deltas.principal, + // Interest paid is the reduction in interest due + .interestPaid = + deltas.interest + overpaymentComponents.untrackedInterest, + // Value change includes both the reduction from paying down principal + // (negative) and any untracked interest penalties (positive, e.g., if + // the overpayment itself incurs a fee) + .valueChange = + valueChange + overpaymentComponents.trackedInterestPart(), + // Fee paid includes both the reduction in tracked management fees and + // any untracked fees on the overpayment itself + .feePaid = deltas.managementFee + + overpaymentComponents.untrackedManagementFee}; +} + +/* Validates and applies an overpayment to the loan state. + * + * This function acts as a wrapper around tryOverpayment(), performing the + * re-amortization calculation in a sandbox (using temporary copies of the + * loan state), then validating the results before committing them to the + * actual ledger via the proxy objects. + * + * The two-step process (try in sandbox, then commit) ensures that if the + * overpayment would leave the loan in an invalid state, we can reject it + * gracefully without corrupting the ledger data. + */ +template +Expected +doOverpayment( + Asset const& asset, + std::int32_t loanScale, + ExtendedPaymentComponents const& overpaymentComponents, + NumberProxy& totalValueOutstandingProxy, + NumberProxy& principalOutstandingProxy, + NumberProxy& managementFeeOutstandingProxy, + NumberProxy& periodicPaymentProxy, + TenthBips32 const interestRate, + std::uint32_t const paymentInterval, + Number const& periodicRate, + std::uint32_t const paymentRemaining, + std::uint32_t const prevPaymentDate, + std::optional const nextDueDate, + TenthBips16 const managementFeeRate, + beast::Journal j) +{ + // Create temporary copies of the loan state that can be safely modified + // and discarded if the overpayment doesn't work out. This prevents + // corrupting the actual ledger data if validation fails. + Number totalValueOutstanding = totalValueOutstandingProxy; + Number principalOutstanding = principalOutstandingProxy; + Number managementFeeOutstanding = managementFeeOutstandingProxy; + Number periodicPayment = periodicPaymentProxy; + + JLOG(j.debug()) + << "overpayment components:" + << ", totalValue before: " << *totalValueOutstandingProxy + << ", valueDelta: " << overpaymentComponents.trackedValueDelta + << ", principalDelta: " << overpaymentComponents.trackedPrincipalDelta + << ", managementFeeDelta: " + << overpaymentComponents.trackedManagementFeeDelta + << ", interestPart: " << overpaymentComponents.trackedInterestPart() + << ", untrackedInterest: " << overpaymentComponents.untrackedInterest + << ", totalDue: " << overpaymentComponents.totalDue + << ", payments remaining :" << paymentRemaining; + + // Attempt to re-amortize the loan with the overpayment applied. + // This modifies the temporary copies, leaving the proxies unchanged. + auto const ret = tryOverpayment( + asset, + loanScale, + overpaymentComponents, + totalValueOutstanding, + principalOutstanding, + managementFeeOutstanding, + periodicPayment, + interestRate, + paymentInterval, + periodicRate, + paymentRemaining, + prevPaymentDate, + nextDueDate, + managementFeeRate, + j); + if (!ret) + return Unexpected(ret.error()); + + auto const& loanPaymentParts = *ret; + + // Safety check: the principal must have decreased. If it didn't (or + // increased!), something went wrong in the calculation and we should + // reject the overpayment. + if (principalOutstandingProxy <= principalOutstanding) + { + // LCOV_EXCL_START + JLOG(j.warn()) << "Overpayment not allowed: principal " + << "outstanding did not decrease. Before: " + << *principalOutstandingProxy + << ". After: " << principalOutstanding; + return Unexpected(tesSUCCESS); + // LCOV_EXCL_STOP + } + + // The proxies still hold the original (pre-overpayment) values, which + // allows us to compute deltas and verify they match what we expect + // from the overpaymentComponents and loanPaymentParts. + + XRPL_ASSERT_PARTS( + overpaymentComponents.trackedPrincipalDelta == + principalOutstandingProxy - principalOutstanding, + "ripple::detail::doOverpayment", + "principal change agrees"); + + XRPL_ASSERT_PARTS( + overpaymentComponents.trackedManagementFeeDelta == + managementFeeOutstandingProxy - managementFeeOutstanding, + "ripple::detail::doOverpayment", + "no fee change"); + + // I'm not 100% sure the following asserts are correct. If in doubt, and + // everything else works, remove any that cause trouble. + + JLOG(j.debug()) << "valueChange: " << loanPaymentParts.valueChange + << ", totalValue before: " << *totalValueOutstandingProxy + << ", totalValue after: " << totalValueOutstanding + << ", totalValue delta: " + << (totalValueOutstandingProxy - totalValueOutstanding) + << ", principalDelta: " + << overpaymentComponents.trackedPrincipalDelta + << ", principalPaid: " << loanPaymentParts.principalPaid + << ", Computed difference: " + << overpaymentComponents.trackedPrincipalDelta - + (totalValueOutstandingProxy - totalValueOutstanding); + + XRPL_ASSERT_PARTS( + loanPaymentParts.valueChange == + totalValueOutstanding - + (totalValueOutstandingProxy - + overpaymentComponents.trackedPrincipalDelta) + + overpaymentComponents.trackedInterestPart(), + "ripple::detail::doOverpayment", + "interest paid agrees"); + + XRPL_ASSERT_PARTS( + overpaymentComponents.trackedPrincipalDelta == + loanPaymentParts.principalPaid, + "ripple::detail::doOverpayment", + "principal payment matches"); + + XRPL_ASSERT_PARTS( + loanPaymentParts.feePaid == + overpaymentComponents.untrackedManagementFee + + overpaymentComponents.trackedManagementFeeDelta, + "ripple::detail::doOverpayment", + "fee payment matches"); + + // All validations passed, so update the proxy objects (which will + // modify the actual Loan ledger object) + totalValueOutstandingProxy = totalValueOutstanding; + principalOutstandingProxy = principalOutstanding; + managementFeeOutstandingProxy = managementFeeOutstanding; + periodicPaymentProxy = periodicPayment; + + return loanPaymentParts; +} + +/* Computes the payment components for a late payment. + * + * A late payment is made after the grace period has expired and includes: + * 1. All components of a regular periodic payment + * 2. Late payment penalty interest (accrued since the due date) + * 3. Late payment fee charged by the broker + * + * The late penalty interest increases the loan's total value (the borrower + * owes more than scheduled), while the regular payment components follow + * the normal amortization schedule. + * + * Implements equation (15) from XLS-66 spec, Section A-2 Equation Glossary + */ +Expected +computeLatePayment( + Asset const& asset, + ApplyView const& view, + Number const& principalOutstanding, + std::int32_t nextDueDate, + ExtendedPaymentComponents const& periodic, + TenthBips32 lateInterestRate, + std::int32_t loanScale, + Number const& latePaymentFee, + STAmount const& amount, + TenthBips16 managementFeeRate, + beast::Journal j) +{ + // Check if the due date has passed. If not, reject the payment as + // being too soon + if (!hasExpired(view, nextDueDate)) + return Unexpected(tecTOO_SOON); + + // Calculate the penalty interest based on how long the payment is overdue. + auto const latePaymentInterest = loanLatePaymentInterest( + principalOutstanding, + lateInterestRate, + view.parentCloseTime(), + nextDueDate); + + // Round the late interest and split it between the vault (net interest) + // and the broker (management fee portion). This lambda ensures we + // round before splitting to maintain precision. + auto const [roundedLateInterest, roundedLateManagementFee] = [&]() { + auto const interest = + roundToAsset(asset, latePaymentInterest, loanScale); + return computeInterestAndFeeParts( + asset, interest, managementFeeRate, loanScale); + }(); + + XRPL_ASSERT( + roundedLateInterest >= 0, + "ripple::detail::computeLatePayment : valid late interest"); + XRPL_ASSERT_PARTS( + periodic.specialCase != PaymentSpecialCase::extra, + "ripple::detail::computeLatePayment", + "no extra parts to this payment"); + + // Create the late payment components by copying the regular periodic + // payment and adding the late penalties. We use a lambda to construct + // this to keep the logic clear. This preserves all the other fields without + // having to enumerate them. + + ExtendedPaymentComponents const late = [&]() { + auto inner = periodic; + + return ExtendedPaymentComponents{ + inner, + // Untracked management fee includes: + // 1. Regular service fee (from periodic.untrackedManagementFee) + // 2. Late payment fee (fixed penalty) + // 3. Management fee portion of late interest + periodic.untrackedManagementFee + latePaymentFee + + roundedLateManagementFee, + + // Untracked interest includes: + // 1. Any untracked interest from the regular payment (usually 0) + // 2. Late penalty interest (increases loan value) + // This positive value indicates the loan's value increased due + // to the late payment. + periodic.untrackedInterest + roundedLateInterest}; + }(); + + XRPL_ASSERT_PARTS( + isRounded(asset, late.totalDue, loanScale), + "ripple::detail::computeLatePayment", + "total due is rounded"); + + // Check that the borrower provided enough funds to cover the late payment. + // The late payment is more expensive than a regular payment due to the + // penalties. + if (amount < late.totalDue) + { + JLOG(j.warn()) << "Late loan payment amount is insufficient. Due: " + << late.totalDue << ", paid: " << amount; + return Unexpected(tecINSUFFICIENT_PAYMENT); + } + + return late; +} + +/* Computes payment components for paying off a loan early (before final + * payment). + * + * A full payment closes the loan immediately, paying off all outstanding + * balances plus a prepayment penalty and any accrued interest since the last + * payment. This is different from the final scheduled payment, which has no + * prepayment penalty. + * + * The function calculates: + * - Accrued interest since last payment (time-based) + * - Prepayment penalty (percentage of remaining principal) + * - Close payment fee (fixed fee for early closure) + * - All remaining principal and outstanding fees + * + * The loan's value may increase or decrease depending on whether the prepayment + * penalty exceeds the scheduled interest that would have been paid. + * + * Implements equation (26) from XLS-66 spec, Section A-2 Equation Glossary + */ +Expected +computeFullPayment( + Asset const& asset, + ApplyView& view, + Number const& principalOutstanding, + Number const& managementFeeOutstanding, + Number const& periodicPayment, + std::uint32_t paymentRemaining, + std::uint32_t prevPaymentDate, + std::uint32_t const startDate, + std::uint32_t const paymentInterval, + TenthBips32 const closeInterestRate, + std::int32_t loanScale, + Number const& totalInterestOutstanding, + Number const& periodicRate, + Number const& closePaymentFee, + STAmount const& amount, + TenthBips16 managementFeeRate, + beast::Journal j) +{ + // Full payment must be made before the final scheduled payment. + if (paymentRemaining <= 1) + { + // If this is the last payment, it has to be a regular payment + JLOG(j.warn()) << "Last payment cannot be a full payment."; + return Unexpected(tecKILLED); + } + + // Calculate the theoretical principal based on the payment schedule. + // This raw (unrounded) value is used to compute interest and penalties + // accurately. + Number const rawPrincipalOutstanding = loanPrincipalFromPeriodicPayment( + periodicPayment, periodicRate, paymentRemaining); + + // Full payment interest includes both accrued interest (time since last + // payment) and prepayment penalty (for closing early). + auto const fullPaymentInterest = computeFullPaymentInterest( + rawPrincipalOutstanding, + periodicRate, + view.parentCloseTime(), + paymentInterval, + prevPaymentDate, + startDate, + closeInterestRate); + + // Split the full payment interest into net interest (to vault) and + // management fee (to broker), applying proper rounding. + auto const [roundedFullInterest, roundedFullManagementFee] = [&]() { + auto const interest = roundToAsset( + asset, fullPaymentInterest, loanScale, Number::downward); + auto const parts = computeInterestAndFeeParts( + asset, interest, managementFeeRate, loanScale); + return std::make_tuple(parts.first, parts.second); + }(); + + ExtendedPaymentComponents const full{ + PaymentComponents{ + // Pay off all tracked outstanding balances: principal, interest, + // and fees. + // This marks the loan as complete (final payment). + .trackedValueDelta = principalOutstanding + + totalInterestOutstanding + managementFeeOutstanding, + .trackedPrincipalDelta = principalOutstanding, + + // All outstanding management fees are paid. This zeroes out the + // tracked fee balance. + .trackedManagementFeeDelta = managementFeeOutstanding, + .specialCase = PaymentSpecialCase::final, + }, + + // Untracked management fee includes: + // 1. Close payment fee (fixed fee for early closure) + // 2. Management fee on the full payment interest + // 3. Minus the outstanding tracked fee (already accounted for above) + // This can be negative because the outstanding fee is subtracted, but + // it gets combined with trackedManagementFeeDelta in the final + // accounting. + closePaymentFee + roundedFullManagementFee - managementFeeOutstanding, + + // Value change represents the difference between what the loan was + // expected to earn (totalInterestOutstanding) and what it actually + // earns (roundedFullInterest with prepayment penalty). + // - Positive: Prepayment penalty exceeds scheduled interest (loan value + // increases) + // - Negative: Prepayment penalty is less than scheduled interest (loan + // value decreases) + roundedFullInterest - totalInterestOutstanding, + }; + + XRPL_ASSERT_PARTS( + isRounded(asset, full.totalDue, loanScale), + "ripple::detail::computeFullPayment", + "total due is rounded"); + + JLOG(j.trace()) << "computeFullPayment result: periodicPayment: " + << periodicPayment << ", periodicRate: " << periodicRate + << ", paymentRemaining: " << paymentRemaining + << ", rawPrincipalOutstanding: " << rawPrincipalOutstanding + << ", fullPaymentInterest: " << fullPaymentInterest + << ", roundedFullInterest: " << roundedFullInterest + << ", roundedFullManagementFee: " + << roundedFullManagementFee + << ", untrackedInterest: " << full.untrackedInterest; + + if (amount < full.totalDue) + // If the payment is less than the full payment amount, it's not + // sufficient to be a full payment. + return Unexpected(tecINSUFFICIENT_PAYMENT); + + return full; +} + +Number +PaymentComponents::trackedInterestPart() const +{ + return trackedValueDelta - + (trackedPrincipalDelta + trackedManagementFeeDelta); +} + +/* Computes the breakdown of a regular periodic payment into principal, + * interest, and management fee components. + * + * This function determines how a single scheduled payment should be split among + * the three tracked loan components. The calculation accounts for accumulated + * rounding errors. + * + * The algorithm: + * 1. Calculate what the loan state SHOULD be after this payment (target) + * 2. Compare current state to target to get deltas + * 3. Adjust deltas to handle rounding artifacts and edge cases + * 4. Ensure deltas don't exceed available balances or payment amount + * + * Special handling for the final payment: all remaining balances are paid off + * regardless of the periodic payment amount. + */ +PaymentComponents +computePaymentComponents( + Asset const& asset, + std::int32_t scale, + Number const& totalValueOutstanding, + Number const& principalOutstanding, + Number const& managementFeeOutstanding, + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentRemaining, + TenthBips16 managementFeeRate) +{ + XRPL_ASSERT_PARTS( + isRounded(asset, totalValueOutstanding, scale) && + isRounded(asset, principalOutstanding, scale) && + isRounded(asset, managementFeeOutstanding, scale), + "ripple::detail::computePaymentComponents", + "Outstanding values are rounded"); + XRPL_ASSERT_PARTS( + paymentRemaining > 0, + "ripple::detail::computePaymentComponents", + "some payments remaining"); + + auto const roundedPeriodicPayment = + roundPeriodicPayment(asset, periodicPayment, scale); + + // Final payment: pay off everything remaining, ignoring the normal + // periodic payment amount. This ensures the loan completes cleanly. + if (paymentRemaining == 1 || + totalValueOutstanding <= roundedPeriodicPayment) + { + // If there's only one payment left, we need to pay off each of the loan + // parts. + return PaymentComponents{ + .trackedValueDelta = totalValueOutstanding, + .trackedPrincipalDelta = principalOutstanding, + .trackedManagementFeeDelta = managementFeeOutstanding, + .specialCase = PaymentSpecialCase::final}; + } + + // Calculate what the loan state SHOULD be after this payment (the target). + // This is computed at full precision using the theoretical amortization. + LoanState const trueTarget = computeRawLoanState( + periodicPayment, periodicRate, paymentRemaining - 1, managementFeeRate); + + // Round the target to the loan's scale to match how actual loan values + // are stored. + LoanState const roundedTarget = LoanState{ + .valueOutstanding = + roundToAsset(asset, trueTarget.valueOutstanding, scale), + .principalOutstanding = + roundToAsset(asset, trueTarget.principalOutstanding, scale), + .interestDue = roundToAsset(asset, trueTarget.interestDue, scale), + .managementFeeDue = + roundToAsset(asset, trueTarget.managementFeeDue, scale)}; + + // Get the current actual loan state from the ledger values + LoanState const currentLedgerState = constructLoanState( + totalValueOutstanding, principalOutstanding, managementFeeOutstanding); + + // The difference between current and target states gives us the payment + // components. Any discrepancies from accumulated rounding are captured + // here. + + LoanStateDeltas deltas = currentLedgerState - roundedTarget; + + // Rounding can occasionally produce negative deltas. Zero them out. + deltas.nonNegative(); + + XRPL_ASSERT_PARTS( + deltas.principal <= currentLedgerState.principalOutstanding, + "ripple::detail::computePaymentComponents", + "principal delta not greater than outstanding"); + + // Cap each component to never exceed what's actually outstanding + deltas.principal = + std::min(deltas.principal, currentLedgerState.principalOutstanding); + + XRPL_ASSERT_PARTS( + deltas.interest <= currentLedgerState.interestDue, + "ripple::detail::computePaymentComponents", + "interest due delta not greater than outstanding"); + + // Cap interest to both the outstanding amount AND what's left of the + // periodic payment after principal is paid + deltas.interest = std::min( + {deltas.interest, + std::max(numZero, roundedPeriodicPayment - deltas.principal), + currentLedgerState.interestDue}); + + XRPL_ASSERT_PARTS( + deltas.managementFee <= currentLedgerState.managementFeeDue, + "ripple::detail::computePaymentComponents", + "management fee due delta not greater than outstanding"); + + // Cap management fee to both the outstanding amount AND what's left of the + // periodic payment after principal and interest are paid + deltas.managementFee = std::min( + {deltas.managementFee, + roundedPeriodicPayment - (deltas.principal + deltas.interest), + currentLedgerState.managementFeeDue}); + + // The shortage must never be negative, which indicates that the parts are + // trying to take more than the whole payment. The excess can be positive, + // which indicates that we're not going to take the whole payment amount, + // but if so, it must be small. + auto takeFrom = [](Number& component, Number& excess) { + if (excess > beast::zero) + { + auto part = std::min(component, excess); + component -= part; + excess -= part; + } + XRPL_ASSERT_PARTS( + excess >= beast::zero, + "ripple::detail::computePaymentComponents", + "excess non-negative"); + }; + // Helper to reduce deltas when they collectively exceed a limit. + // Order matters: we prefer to reduce interest first (most flexible), + // then management fee, then principal (least flexible). + auto addressExcess = [&takeFrom](LoanStateDeltas& deltas, Number& excess) { + // This order is based on where errors are the least problematic + takeFrom(deltas.interest, excess); + takeFrom(deltas.managementFee, excess); + takeFrom(deltas.principal, excess); + }; + + // Check if deltas exceed the total outstanding value. This should never + // happen due to earlier caps, but handle it defensively. + Number totalOverpayment = + deltas.total() - currentLedgerState.valueOutstanding; + + if (totalOverpayment > beast::zero) + { + // LCOV_EXCL_START + UNREACHABLE( + "ripple::detail::computePaymentComponents : payment exceeded loan " + "state"); + addressExcess(deltas, totalOverpayment); + // LCOV_EXCL_STOP + } + + // Check if deltas exceed the periodic payment amount. Reduce if needed. + Number shortage = roundedPeriodicPayment - deltas.total(); + + XRPL_ASSERT_PARTS( + isRounded(asset, shortage, scale), + "ripple::detail::computePaymentComponents", + "shortage is rounded"); + + if (shortage < beast::zero) + { + // Deltas exceed payment amount - reduce them proportionally + Number excess = -shortage; + addressExcess(deltas, excess); + shortage = -excess; + } + + // At this point, shortage >= 0 means we're paying less than the full + // periodic payment (due to rounding or component caps). + // shortage < 0 would mean we're trying to pay more than allowed (bug). + XRPL_ASSERT_PARTS( + shortage >= beast::zero, + "ripple::detail::computePaymentComponents", + "no shortage or excess"); + + // Final validation that all components are valid + XRPL_ASSERT_PARTS( + deltas.total() == + deltas.principal + deltas.interest + deltas.managementFee, + "ripple::detail::computePaymentComponents", + "total value adds up"); + + XRPL_ASSERT_PARTS( + deltas.principal >= beast::zero && + deltas.principal <= currentLedgerState.principalOutstanding, + "ripple::detail::computePaymentComponents", + "valid principal result"); + XRPL_ASSERT_PARTS( + deltas.interest >= beast::zero && + deltas.interest <= currentLedgerState.interestDue, + "ripple::detail::computePaymentComponents", + "valid interest result"); + XRPL_ASSERT_PARTS( + deltas.managementFee >= beast::zero && + deltas.managementFee <= currentLedgerState.managementFeeDue, + "ripple::detail::computePaymentComponents", + "valid fee result"); + + XRPL_ASSERT_PARTS( + deltas.principal + deltas.interest + deltas.managementFee > beast::zero, + "ripple::detail::computePaymentComponents", + "payment parts add to payment"); + + // Final safety clamp to ensure no value exceeds its outstanding balance + return PaymentComponents{ + .trackedValueDelta = std::clamp( + deltas.total(), numZero, currentLedgerState.valueOutstanding), + .trackedPrincipalDelta = std::clamp( + deltas.principal, numZero, currentLedgerState.principalOutstanding), + .trackedManagementFeeDelta = std::clamp( + deltas.managementFee, numZero, currentLedgerState.managementFeeDue), + }; +} + +/* Computes payment components for an overpayment scenario. + * + * An overpayment occurs when a borrower pays more than the scheduled periodic + * payment amount. The overpayment is treated as extra principal reduction, + * but incurs a fee and potentially a penalty interest charge. + * + * The calculation (Section 3.2.4.2.3 from XLS-66 spec): + * 1. Calculate gross penalty interest on the overpayment amount + * 2. Split the gross interest into net interest and management fee + * 3. Calculate the penalty fee + * 4. Determine the principal portion by subtracting the interest (gross) and + * management fee from the overpayment amount + * + * Unlike regular payments which follow the amortization schedule, overpayments + * apply to principal, reducing the loan balance and future interest costs. + * + * Equations (20), (21) and (22) from XLS-66 spec, Section A-2 Equation Glossary + */ +ExtendedPaymentComponents +computeOverpaymentComponents( + Asset const& asset, + int32_t const loanScale, + Number const& overpayment, + TenthBips32 const overpaymentInterestRate, + TenthBips32 const overpaymentFeeRate, + TenthBips16 const managementFeeRate) +{ + XRPL_ASSERT( + overpayment > 0 && isRounded(asset, overpayment, loanScale), + "ripple::detail::computeOverpaymentComponents : valid overpayment " + "amount"); + + // First, deduct the fixed overpayment fee from the total amount. + // This reduces the effective payment that will be applied to the loan. + // Equation (22) from XLS-66 spec, Section A-2 Equation Glossary + Number const overpaymentFee = roundToAsset( + asset, tenthBipsOfValue(overpayment, overpaymentFeeRate), loanScale); + + // Calculate the penalty interest on the effective payment amount. + // This interest doesn't follow the normal amortization schedule - it's + // a one-time charge for paying early. + // Equation (20) and (21) from XLS-66 spec, Section A-2 Equation Glossary + auto const [rawOverpaymentInterest, _] = [&]() { + Number const interest = + tenthBipsOfValue(overpayment, overpaymentInterestRate); + return detail::computeInterestAndFeeParts(interest, managementFeeRate); + }(); + + // Round the penalty interest components to the loan scale + auto const [roundedOverpaymentInterest, roundedOverpaymentManagementFee] = + [&]() { + Number const interest = + roundToAsset(asset, rawOverpaymentInterest, loanScale); + return detail::computeInterestAndFeeParts( + asset, interest, managementFeeRate, loanScale); + }(); + + auto const result = detail::ExtendedPaymentComponents{ + // Build the payment components, after fees and penalty + // interest are deducted, the remainder goes entirely to principal + // reduction. + detail::PaymentComponents{ + .trackedValueDelta = overpayment - overpaymentFee, + .trackedPrincipalDelta = overpayment - roundedOverpaymentInterest - + roundedOverpaymentManagementFee - overpaymentFee, + .trackedManagementFeeDelta = roundedOverpaymentManagementFee, + .specialCase = detail::PaymentSpecialCase::extra}, + // Untracked management fee is the fixed overpayment fee + overpaymentFee, + // Untracked interest is the penalty interest charged for + // overpaying. + // This is positive, representing a one-time cost, but it's + // typically + // much smaller than the interest savings from reducing + // principal. + roundedOverpaymentInterest}; + XRPL_ASSERT_PARTS( + result.trackedInterestPart() == roundedOverpaymentInterest, + "ripple::detail::computeOverpaymentComponents", + "valid interest computation"); + return result; +} + +} // namespace detail + +detail::LoanStateDeltas +operator-(LoanState const& lhs, LoanState const& rhs) +{ + detail::LoanStateDeltas result{ + .principal = lhs.principalOutstanding - rhs.principalOutstanding, + .interest = lhs.interestDue - rhs.interestDue, + .managementFee = lhs.managementFeeDue - rhs.managementFeeDue, + }; + + return result; +} + +LoanState +operator-(LoanState const& lhs, detail::LoanStateDeltas const& rhs) +{ + LoanState result{ + .valueOutstanding = lhs.valueOutstanding - rhs.total(), + .principalOutstanding = lhs.principalOutstanding - rhs.principal, + .interestDue = lhs.interestDue - rhs.interest, + .managementFeeDue = lhs.managementFeeDue - rhs.managementFee, + }; + + return result; +} + +LoanState +operator+(LoanState const& lhs, detail::LoanStateDeltas const& rhs) +{ + LoanState result{ + .valueOutstanding = lhs.valueOutstanding + rhs.total(), + .principalOutstanding = lhs.principalOutstanding + rhs.principal, + .interestDue = lhs.interestDue + rhs.interest, + .managementFeeDue = lhs.managementFeeDue + rhs.managementFee, + }; + + return result; +} + +TER +checkLoanGuards( + Asset const& vaultAsset, + Number const& principalRequested, + bool expectInterest, + std::uint32_t paymentTotal, + LoanProperties const& properties, + beast::Journal j) +{ + auto const totalInterestOutstanding = + properties.totalValueOutstanding - principalRequested; + // Guard 1: if there is no computed total interest over the life of the + // loan for a non-zero interest rate, we cannot properly amortize the + // loan + if (expectInterest && totalInterestOutstanding <= 0) + { + // Unless this is a zero-interest loan, there must be some interest + // due on the loan, even if it's (measurable) dust + JLOG(j.warn()) << "Loan for " << principalRequested + << " with interest has no interest due"; + return tecPRECISION_LOSS; + } + // Guard 1a: If there is any interest computed over the life of the + // loan, for a zero interest rate, something went sideways. + if (!expectInterest && totalInterestOutstanding > 0) + { + // LCOV_EXCL_START + JLOG(j.warn()) << "Loan for " << principalRequested + << " with no interest has interest due"; + return tecINTERNAL; + // LCOV_EXCL_STOP + } + + // Guard 2: if the principal portion of the first periodic payment is + // too small to be accurately represented with the given rounding mode, + // raise an error + if (properties.firstPaymentPrincipal <= 0) + { + // Check that some true (unrounded) principal is paid each period. + // Since the first payment pays the least principal, if it's good, + // they'll all be good. Note that the outstanding principal is + // rounded, and may not change right away. + JLOG(j.warn()) << "Loan is unable to pay principal."; + return tecPRECISION_LOSS; + } + + // Guard 3: If the periodic payment is so small that it can't even be + // rounded to a representable value, then the loan can't be paid. Also, + // avoids dividing by 0. + auto const roundedPayment = roundPeriodicPayment( + vaultAsset, properties.periodicPayment, properties.loanScale); + if (roundedPayment == beast::zero) + { + JLOG(j.warn()) << "Loan Periodic payment (" + << properties.periodicPayment << ") rounds to 0. "; + return tecPRECISION_LOSS; + } + + // Guard 4: if the rounded periodic payment is large enough that the + // loan can't be amortized in the specified number of payments, raise an + // error + { + NumberRoundModeGuard mg(Number::upward); + + if (std::int64_t const computedPayments{ + properties.totalValueOutstanding / roundedPayment}; + computedPayments != paymentTotal) + { + JLOG(j.warn()) << "Loan Periodic payment (" + << properties.periodicPayment << ") rounding (" + << roundedPayment << ") on a total value of " + << properties.totalValueOutstanding + << " can not complete the loan in the specified " + "number of payments (" + << computedPayments << " != " << paymentTotal << ")"; + return tecPRECISION_LOSS; + } + } + return tesSUCCESS; +} + +/* + * This function calculates the full payment interest accrued since the last + * payment, plus any prepayment penalty. + * + * Equations (27) and (28) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +computeFullPaymentInterest( + Number const& rawPrincipalOutstanding, + Number const& periodicRate, + NetClock::time_point parentCloseTime, + std::uint32_t paymentInterval, + std::uint32_t prevPaymentDate, + std::uint32_t startDate, + TenthBips32 closeInterestRate) +{ + auto const accruedInterest = detail::loanAccruedInterest( + rawPrincipalOutstanding, + periodicRate, + parentCloseTime, + startDate, + prevPaymentDate, + paymentInterval); + XRPL_ASSERT( + accruedInterest >= 0, + "ripple::detail::computeFullPaymentInterest : valid accrued " + "interest"); + + // Equation (28) from XLS-66 spec, Section A-2 Equation Glossary + auto const prepaymentPenalty = closeInterestRate == beast::zero + ? Number{} + : tenthBipsOfValue(rawPrincipalOutstanding, closeInterestRate); + + XRPL_ASSERT( + prepaymentPenalty >= 0, + "ripple::detail::computeFullPaymentInterest : valid prepayment " + "interest"); + + // Part of equation (27) from XLS-66 spec, Section A-2 Equation Glossary + return accruedInterest + prepaymentPenalty; +} + +Number +computeFullPaymentInterest( + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentRemaining, + NetClock::time_point parentCloseTime, + std::uint32_t paymentInterval, + std::uint32_t prevPaymentDate, + std::uint32_t startDate, + TenthBips32 closeInterestRate) +{ + Number const rawPrincipalOutstanding = + detail::loanPrincipalFromPeriodicPayment( + periodicPayment, periodicRate, paymentRemaining); + + return computeFullPaymentInterest( + rawPrincipalOutstanding, + periodicRate, + parentCloseTime, + paymentInterval, + prevPaymentDate, + startDate, + closeInterestRate); +} + +/* Calculates the theoretical loan state at maximum precision for a given point + * in the amortization schedule. + * + * This function computes what the loan's outstanding balances should be based + * on the periodic payment amount and number of payments remaining, + * without considering any rounding that may have been applied to the actual + * Loan object's state. This "raw" (unrounded) state is used as a target for + * computing payment components and validating that the loan's tracked state + * hasn't drifted too far from the theoretical values. + * + * The raw state serves several purposes: + * 1. Computing the expected payment breakdown (principal, interest, fees) + * 2. Detecting and correcting rounding errors that accumulate over time + * 3. Validating that overpayments are calculated correctly + * 4. Ensuring the loan will be fully paid off at the end of its term + * + * If paymentRemaining is 0, returns a fully zeroed-out LoanState, + * representing a completely paid-off loan. + */ +LoanState +computeRawLoanState( + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t const paymentRemaining, + TenthBips32 const managementFeeRate) +{ + if (paymentRemaining == 0) + { + return LoanState{ + .valueOutstanding = 0, + .principalOutstanding = 0, + .interestDue = 0, + .managementFeeDue = 0}; + } + + // Equation (30) from XLS-66 spec, Section A-2 Equation Glossary + Number const rawTotalValueOutstanding = periodicPayment * paymentRemaining; + + Number const rawPrincipalOutstanding = + detail::loanPrincipalFromPeriodicPayment( + periodicPayment, periodicRate, paymentRemaining); + + // Equation (31) from XLS-66 spec, Section A-2 Equation Glossary + Number const rawInterestOutstandingGross = + rawTotalValueOutstanding - rawPrincipalOutstanding; + + // Equation (32) from XLS-66 spec, Section A-2 Equation Glossary + Number const rawManagementFeeOutstanding = + tenthBipsOfValue(rawInterestOutstandingGross, managementFeeRate); + + // Equation (33) from XLS-66 spec, Section A-2 Equation Glossary + Number const rawInterestOutstandingNet = + rawInterestOutstandingGross - rawManagementFeeOutstanding; + + return LoanState{ + .valueOutstanding = rawTotalValueOutstanding, + .principalOutstanding = rawPrincipalOutstanding, + .interestDue = rawInterestOutstandingNet, + .managementFeeDue = rawManagementFeeOutstanding}; +}; + +LoanState +computeRawLoanState( + Number const& periodicPayment, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t const paymentRemaining, + TenthBips32 const managementFeeRate) +{ + return computeRawLoanState( + periodicPayment, + loanPeriodicRate(interestRate, paymentInterval), + paymentRemaining, + managementFeeRate); +} + +/* Constructs a LoanState from rounded Loan ledger object values. + * + * This function creates a LoanState structure from the three tracked values + * stored in a Loan ledger object. Unlike calculateRawLoanState(), which + * computes theoretical unrounded values, this function works with values + * that have already been rounded to the loan's scale. + * + * The key difference from calculateRawLoanState(): + * - calculateRawLoanState: Computes theoretical values at full precision + * - constructRoundedLoanState: Builds state from actual rounded ledger values + * + * The interestDue field is derived from the other three values rather than + * stored directly, since it can be calculated as: + * interestDue = totalValueOutstanding - principalOutstanding - + * managementFeeOutstanding + * + * This ensures consistency across the codebase and prevents copy-paste errors + * when creating LoanState objects from Loan ledger data. + */ +LoanState +constructLoanState( + Number const& totalValueOutstanding, + Number const& principalOutstanding, + Number const& managementFeeOutstanding) +{ + // This implementation is pretty trivial, but ensures the calculations + // are consistent everywhere, and reduces copy/paste errors. + return LoanState{ + .valueOutstanding = totalValueOutstanding, + .principalOutstanding = principalOutstanding, + .interestDue = totalValueOutstanding - principalOutstanding - + managementFeeOutstanding, + .managementFeeDue = managementFeeOutstanding}; +} + +LoanState +constructRoundedLoanState(SLE::const_ref loan) +{ + return constructLoanState( + loan->at(sfTotalValueOutstanding), + loan->at(sfPrincipalOutstanding), + loan->at(sfManagementFeeOutstanding)); +} + +/* + * This function calculates the fee owed to the broker based on the asset, + * value, and management fee rate. + * + * Equation (32) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +computeManagementFee( + Asset const& asset, + Number const& value, + TenthBips32 managementFeeRate, + std::int32_t scale) +{ + return roundToAsset( + asset, + tenthBipsOfValue(value, managementFeeRate), + scale, + Number::downward); +} + +/* + * Given the loan parameters, compute the derived properties of the loan. + */ +LoanProperties +computeLoanProperties( + Asset const& asset, + Number principalOutstanding, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining, + TenthBips32 managementFeeRate, + std::int32_t minimumScale) +{ + auto const periodicRate = loanPeriodicRate(interestRate, paymentInterval); + XRPL_ASSERT( + interestRate == 0 || periodicRate > 0, + "ripple::computeLoanProperties : valid rate"); + + auto const periodicPayment = detail::loanPeriodicPayment( + principalOutstanding, periodicRate, paymentsRemaining); + + auto const [totalValueOutstanding, loanScale] = [&]() { + NumberRoundModeGuard mg(Number::to_nearest); + // Use STAmount's internal rounding instead of roundToAsset, because + // we're going to use this result to determine the scale for all the + // other rounding. + + // Equation (30) from XLS-66 spec, Section A-2 Equation Glossary + STAmount amount{asset, periodicPayment * paymentsRemaining}; + + // Base the loan scale on the total value, since that's going to be + // the biggest number involved (barring unusual parameters for late, + // full, or over payments) + auto const loanScale = std::max(minimumScale, amount.exponent()); + XRPL_ASSERT_PARTS( + (amount.integral() && loanScale == 0) || + (!amount.integral() && + loanScale >= static_cast(amount).exponent()), + "ripple::computeLoanProperties", + "loanScale value fits expectations"); + + // We may need to truncate the total value because of the minimum + // scale + amount = roundToAsset(asset, amount, loanScale, Number::to_nearest); + + return std::make_pair(amount, loanScale); + }(); + + // Since we just figured out the loan scale, we haven't been able to + // validate that the principal fits in it, so to allow this function to + // succeed, round it here, and let the caller do the validation. + principalOutstanding = roundToAsset( + asset, principalOutstanding, loanScale, Number::to_nearest); + + // E +loanMakePayment( + Asset const& asset, + ApplyView& view, + SLE::ref loan, + SLE::const_ref brokerSle, + STAmount const& amount, + LoanPaymentType const paymentType, + beast::Journal j) +{ + using namespace Lending; + + auto principalOutstandingProxy = loan->at(sfPrincipalOutstanding); + auto paymentRemainingProxy = loan->at(sfPaymentRemaining); + + if (paymentRemainingProxy == 0 || principalOutstandingProxy == 0) + { + // Loan complete this is already checked in LoanPay::preclaim() + // LCOV_EXCL_START + JLOG(j.warn()) << "Loan is already paid off."; + return Unexpected(tecKILLED); + // LCOV_EXCL_STOP + } + + auto totalValueOutstandingProxy = loan->at(sfTotalValueOutstanding); + auto managementFeeOutstandingProxy = loan->at(sfManagementFeeOutstanding); + + // Next payment due date must be set unless the loan is complete + auto nextDueDateProxy = loan->at(sfNextPaymentDueDate); + if (*nextDueDateProxy == 0) + { + JLOG(j.warn()) << "Loan next payment due date is not set."; + return Unexpected(tecINTERNAL); + } + + std::int32_t const loanScale = loan->at(sfLoanScale); + + TenthBips32 const interestRate{loan->at(sfInterestRate)}; + + Number const serviceFee = loan->at(sfLoanServiceFee); + TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; + + Number const periodicPayment = loan->at(sfPeriodicPayment); + + auto prevPaymentDateProxy = loan->at(sfPreviousPaymentDate); + std::uint32_t const startDate = loan->at(sfStartDate); + + std::uint32_t const paymentInterval = loan->at(sfPaymentInterval); + + // Compute the periodic rate that will be used for calculations + // throughout + Number const periodicRate = loanPeriodicRate(interestRate, paymentInterval); + XRPL_ASSERT( + interestRate == 0 || periodicRate > 0, + "ripple::loanMakePayment : valid rate"); + + XRPL_ASSERT( + *totalValueOutstandingProxy > 0, + "ripple::loanMakePayment : valid total value"); + + view.update(loan); + + // ------------------------------------------------------------- + // A late payment not flagged as late overrides all other options. + if (paymentType != LoanPaymentType::late && + hasExpired(view, nextDueDateProxy)) + { + // If the payment is late, and the late flag was not set, it's not + // valid + JLOG(j.warn()) << "Loan payment is overdue. Use the tfLoanLatePayment " + "transaction " + "flag to make a late payment. Loan was created on " + << startDate << ", prev payment due date is " + << prevPaymentDateProxy << ", next payment due date is " + << nextDueDateProxy << ", ledger time is " + << view.parentCloseTime().time_since_epoch().count(); + return Unexpected(tecEXPIRED); + } + + // ------------------------------------------------------------- + // full payment handling + if (paymentType == LoanPaymentType::full) + { + TenthBips32 const closeInterestRate{loan->at(sfCloseInterestRate)}; + Number const closePaymentFee = + roundToAsset(asset, loan->at(sfClosePaymentFee), loanScale); + + LoanState const roundedLoanState = constructLoanState( + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy); + + if (auto const fullPaymentComponents = detail::computeFullPayment( + asset, + view, + principalOutstandingProxy, + managementFeeOutstandingProxy, + periodicPayment, + paymentRemainingProxy, + prevPaymentDateProxy, + startDate, + paymentInterval, + closeInterestRate, + loanScale, + roundedLoanState.interestDue, + periodicRate, + closePaymentFee, + amount, + managementFeeRate, + j)) + { + return doPayment( + *fullPaymentComponents, + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy, + paymentRemainingProxy, + prevPaymentDateProxy, + nextDueDateProxy, + paymentInterval); + } + else if (fullPaymentComponents.error()) + // error() will be the TER returned if a payment is not made. It + // will only evaluate to true if it's unsuccessful. Otherwise, + // tesSUCCESS means nothing was done, so continue. + return Unexpected(fullPaymentComponents.error()); + + // LCOV_EXCL_START + UNREACHABLE("ripple::loanMakePayment : invalid full payment result"); + JLOG(j.error()) << "Full payment computation failed unexpectedly."; + return Unexpected(tecINTERNAL); + // LCOV_EXCL_STOP + } + + // ------------------------------------------------------------- + // compute the periodic payment info that will be needed whether the + // payment is late or regular + detail::ExtendedPaymentComponents periodic{ + detail::computePaymentComponents( + asset, + loanScale, + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy, + periodicPayment, + periodicRate, + paymentRemainingProxy, + managementFeeRate), + serviceFee}; + XRPL_ASSERT_PARTS( + periodic.trackedPrincipalDelta >= 0, + "ripple::loanMakePayment", + "regular payment valid principal"); + + // ------------------------------------------------------------- + // late payment handling + if (paymentType == LoanPaymentType::late) + { + TenthBips32 const lateInterestRate{loan->at(sfLateInterestRate)}; + Number const latePaymentFee = loan->at(sfLatePaymentFee); + + if (auto const latePaymentComponents = detail::computeLatePayment( + asset, + view, + principalOutstandingProxy, + nextDueDateProxy, + periodic, + lateInterestRate, + loanScale, + latePaymentFee, + amount, + managementFeeRate, + j)) + { + return doPayment( + *latePaymentComponents, + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy, + paymentRemainingProxy, + prevPaymentDateProxy, + nextDueDateProxy, + paymentInterval); + } + else if (latePaymentComponents.error()) + { + // error() will be the TER returned if a payment is not made. It + // will only evaluate to true if it's unsuccessful. + return Unexpected(latePaymentComponents.error()); + } + + // LCOV_EXCL_START + UNREACHABLE("ripple::loanMakePayment : invalid late payment result"); + JLOG(j.error()) << "Late payment computation failed unexpectedly."; + return Unexpected(tecINTERNAL); + // LCOV_EXCL_STOP + } + + // ------------------------------------------------------------- + // regular periodic payment handling + + XRPL_ASSERT_PARTS( + paymentType == LoanPaymentType::regular || + paymentType == LoanPaymentType::overpayment, + "ripple::loanMakePayment", + "regular payment type"); + + // Keep a running total of the actual parts paid + LoanPaymentParts totalParts; + Number totalPaid; + std::size_t numPayments = 0; + + while ((amount >= (totalPaid + periodic.totalDue)) && + paymentRemainingProxy > 0 && + numPayments < loanMaximumPaymentsPerTransaction) + { + // Try to make more payments + XRPL_ASSERT_PARTS( + periodic.trackedPrincipalDelta >= 0, + "ripple::loanMakePayment", + "payment pays non-negative principal"); + + totalPaid += periodic.totalDue; + totalParts += detail::doPayment( + periodic, + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy, + paymentRemainingProxy, + prevPaymentDateProxy, + nextDueDateProxy, + paymentInterval); + ++numPayments; + + XRPL_ASSERT_PARTS( + (periodic.specialCase == detail::PaymentSpecialCase::final) == + (paymentRemainingProxy == 0), + "ripple::loanMakePayment", + "final payment is the final payment"); + + // Don't compute the next payment if this was the last payment + if (periodic.specialCase == detail::PaymentSpecialCase::final) + break; + + periodic = detail::ExtendedPaymentComponents{ + detail::computePaymentComponents( + asset, + loanScale, + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy, + periodicPayment, + periodicRate, + paymentRemainingProxy, + managementFeeRate), + serviceFee}; + } + + if (numPayments == 0) + { + JLOG(j.warn()) << "Regular loan payment amount is insufficient. Due: " + << periodic.totalDue << ", paid: " << amount; + return Unexpected(tecINSUFFICIENT_PAYMENT); + } + + XRPL_ASSERT_PARTS( + totalParts.principalPaid + totalParts.interestPaid + + totalParts.feePaid == + totalPaid, + "ripple::loanMakePayment", + "payment parts add up"); + XRPL_ASSERT_PARTS( + totalParts.valueChange == 0, + "ripple::loanMakePayment", + "no value change"); + + // ------------------------------------------------------------- + // overpayment handling + if (paymentType == LoanPaymentType::overpayment && + loan->isFlag(lsfLoanOverpayment) && paymentRemainingProxy > 0 && + totalPaid < amount && numPayments < loanMaximumPaymentsPerTransaction) + { + TenthBips32 const overpaymentInterestRate{ + loan->at(sfOverpaymentInterestRate)}; + TenthBips32 const overpaymentFeeRate{loan->at(sfOverpaymentFee)}; + + // It shouldn't be possible for the overpayment to be greater than + // totalValueOutstanding, because that would have been processed as + // another normal payment. But cap it just in case. + Number const overpayment = + std::min(amount - totalPaid, *totalValueOutstandingProxy); + + detail::ExtendedPaymentComponents const overpaymentComponents = + detail::computeOverpaymentComponents( + asset, + loanScale, + overpayment, + overpaymentInterestRate, + overpaymentFeeRate, + managementFeeRate); + + // Don't process an overpayment if the whole amount (or more!) + // gets eaten by fees and interest. + if (overpaymentComponents.trackedPrincipalDelta > 0) + { + XRPL_ASSERT_PARTS( + overpaymentComponents.untrackedInterest >= beast::zero, + "ripple::loanMakePayment", + "overpayment penalty did not reduce value of loan"); + // Can't just use `periodicPayment` here, because it might + // change + auto periodicPaymentProxy = loan->at(sfPeriodicPayment); + if (auto const overResult = detail::doOverpayment( + asset, + loanScale, + overpaymentComponents, + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy, + periodicPaymentProxy, + interestRate, + paymentInterval, + periodicRate, + paymentRemainingProxy, + prevPaymentDateProxy, + nextDueDateProxy, + managementFeeRate, + j)) + totalParts += *overResult; + else if (overResult.error()) + // error() will be the TER returned if a payment is not + // made. It will only evaluate to true if it's unsuccessful. + // Otherwise, tesSUCCESS means nothing was done, so + // continue. + return Unexpected(overResult.error()); + } + } + + // Check the final results are rounded, to double-check that the + // intermediate steps were rounded. + XRPL_ASSERT( + isRounded(asset, totalParts.principalPaid, loanScale) && + totalParts.principalPaid >= beast::zero, + "ripple::loanMakePayment : total principal paid is valid"); + XRPL_ASSERT( + isRounded(asset, totalParts.interestPaid, loanScale) && + totalParts.interestPaid >= beast::zero, + "ripple::loanMakePayment : total interest paid is valid"); + XRPL_ASSERT( + isRounded(asset, totalParts.valueChange, loanScale), + "ripple::loanMakePayment : loan value change is valid"); + XRPL_ASSERT( + isRounded(asset, totalParts.feePaid, loanScale) && + totalParts.feePaid >= beast::zero, + "ripple::loanMakePayment : fee paid is valid"); + return totalParts; +} +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/Batch.cpp b/src/xrpld/app/tx/detail/Batch.cpp index 4ffdd2d57c..8c1c515021 100644 --- a/src/xrpld/app/tx/detail/Batch.cpp +++ b/src/xrpld/app/tx/detail/Batch.cpp @@ -282,7 +282,8 @@ Batch::preflight(PreflightContext const& ctx) return temREDUNDANT; } - if (stx.getFieldU16(sfTransactionType) == ttBATCH) + auto const txType = stx.getFieldU16(sfTransactionType); + if (txType == ttBATCH) { JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " << "batch cannot have an inner batch txn. " @@ -290,6 +291,14 @@ Batch::preflight(PreflightContext const& ctx) return temINVALID; } + if (std::any_of( + disabledTxTypes.begin(), + disabledTxTypes.end(), + [txType](auto const& disabled) { return txType == disabled; })) + { + return temINVALID_INNER_BATCH; + } + if (!(stx.getFlags() & tfInnerBatchTxn)) { JLOG(ctx.j.debug()) @@ -302,7 +311,6 @@ Batch::preflight(PreflightContext const& ctx) if (auto const ret = checkSignatureFields(stx, hash)) return ret; - /* Placeholder for field that will be added by Lending Protocol // Note that the CounterpartySignature is optional, and should not be // included, but if it is, ensure it doesn't contain a signature. if (stx.isFieldPresent(sfCounterpartySignature)) @@ -315,7 +323,6 @@ Batch::preflight(PreflightContext const& ctx) return ret; } } - */ auto const innerAccount = stx.getAccountID(sfAccount); if (auto const preflightResult = ripple::preflight( @@ -412,13 +419,11 @@ Batch::preflightSigValidated(PreflightContext const& ctx) // inner account to the required signers set. if (innerAccount != outerAccount) requiredSigners.insert(innerAccount); - /* Placeholder for field that will be added by Lending Protocol // Some transactions have a Counterparty, who must also sign the // transaction if they are not the outer account if (auto const counterparty = rb.at(~sfCounterparty); counterparty && counterparty != outerAccount) requiredSigners.insert(*counterparty); - */ } // Validation Batch Signers diff --git a/src/xrpld/app/tx/detail/Batch.h b/src/xrpld/app/tx/detail/Batch.h index 07863a5f33..cf4ca87cdb 100644 --- a/src/xrpld/app/tx/detail/Batch.h +++ b/src/xrpld/app/tx/detail/Batch.h @@ -54,6 +54,24 @@ public: TER doApply() override; + + static constexpr auto disabledTxTypes = std::to_array({ + ttVAULT_CREATE, + ttVAULT_SET, + ttVAULT_DELETE, + ttVAULT_DEPOSIT, + ttVAULT_WITHDRAW, + ttVAULT_CLAWBACK, + ttLOAN_BROKER_SET, + ttLOAN_BROKER_DELETE, + ttLOAN_BROKER_COVER_DEPOSIT, + ttLOAN_BROKER_COVER_WITHDRAW, + ttLOAN_BROKER_COVER_CLAWBACK, + ttLOAN_SET, + ttLOAN_DELETE, + ttLOAN_MANAGE, + ttLOAN_PAY, + }); }; } // namespace ripple diff --git a/src/xrpld/app/tx/detail/Change.cpp b/src/xrpld/app/tx/detail/Change.cpp index 77d098ea0f..f78eaf0f61 100644 --- a/src/xrpld/app/tx/detail/Change.cpp +++ b/src/xrpld/app/tx/detail/Change.cpp @@ -38,7 +38,11 @@ NotTEC Transactor::invokePreflight(PreflightContext const& ctx) { // 0 means "Allow any flags" - if (auto const ret = preflight0(ctx, 0)) + // The check for tfChangeMask is gated by LendingProtocol because that + // feature introduced this parameter, and it's not worth adding another + // amendment just for this. + if (auto const ret = preflight0( + ctx, ctx.rules.enabled(featureLendingProtocol) ? tfChangeMask : 0)) return ret; auto account = ctx.tx.getAccountID(sfAccount); diff --git a/src/xrpld/app/tx/detail/InvariantCheck.cpp b/src/xrpld/app/tx/detail/InvariantCheck.cpp index f7f67ee1d6..c9632146b6 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.cpp +++ b/src/xrpld/app/tx/detail/InvariantCheck.cpp @@ -89,6 +89,8 @@ enum Privilege { 0x0400, // The transaction MAY delete an MPT object. May not create. mustModifyVault = 0x0800, // The transaction must modify, delete or create, a vault + mayModifyVault = + 0x1000, // The transaction MAY modify, delete or create, a vault }; constexpr Privilege operator|(Privilege lhs, Privilege rhs) @@ -498,10 +500,10 @@ void AccountRootsDeletedClean::visitEntry( bool isDelete, std::shared_ptr const& before, - std::shared_ptr const&) + std::shared_ptr const& after) { if (isDelete && before && before->getType() == ltACCOUNT_ROOT) - accountsDeleted_.emplace_back(before); + accountsDeleted_.emplace_back(before, after); } bool @@ -518,7 +520,8 @@ AccountRootsDeletedClean::finalize( // be logged [[maybe_unused]] bool const enforce = view.rules().enabled(featureInvariantsV1_1) || - view.rules().enabled(featureSingleAssetVault); + view.rules().enabled(featureSingleAssetVault) || + view.rules().enabled(featureLendingProtocol); auto const objectExists = [&view, enforce, &j](auto const& keylet) { (void)enforce; @@ -548,9 +551,33 @@ AccountRootsDeletedClean::finalize( return false; }; - for (auto const& accountSLE : accountsDeleted_) + for (auto const& [before, after] : accountsDeleted_) { - auto const accountID = accountSLE->getAccountID(sfAccount); + auto const accountID = before->getAccountID(sfAccount); + // An account should not be deleted with a balance + if (after->at(sfBalance) != beast::zero) + { + JLOG(j.fatal()) << "Invariant failed: account deletion left " + "behind a non-zero balance"; + XRPL_ASSERT( + enforce, + "ripple::AccountRootsDeletedClean::finalize : " + "deleted account has zero balance"); + if (enforce) + return false; + } + // An account should not be deleted with a non-zero owner count + if (after->at(sfOwnerCount) != 0) + { + JLOG(j.fatal()) << "Invariant failed: account deletion left " + "behind a non-zero owner count"; + XRPL_ASSERT( + enforce, + "ripple::AccountRootsDeletedClean::finalize : " + "deleted account has zero owner count"); + if (enforce) + return false; + } // Simple types for (auto const& [keyletfunc, _, __] : directAccountKeylets) { @@ -577,9 +604,9 @@ AccountRootsDeletedClean::finalize( // also be deleted. e.g. AMM, Vault, etc. for (auto const& field : getPseudoAccountFields()) { - if (accountSLE->isFieldPresent(*field)) + if (before->isFieldPresent(*field)) { - auto const key = accountSLE->getFieldH256(*field); + auto const key = before->getFieldH256(*field); if (objectExists(keylet::unchecked(key)) && enforce) return false; } @@ -1038,7 +1065,9 @@ ValidNewAccountRoot::finalize( if (hasPrivilege(tx, createAcct | createPseudoAcct) && result == tesSUCCESS) { bool const pseudoAccount = - (pseudoAccount_ && view.rules().enabled(featureSingleAssetVault)); + (pseudoAccount_ && + (view.rules().enabled(featureSingleAssetVault) || + view.rules().enabled(featureLendingProtocol))); if (pseudoAccount && !hasPrivilege(tx, createPseudoAcct)) { @@ -1450,7 +1479,12 @@ ValidMPTIssuance::visitEntry( if (isDelete) mptokensDeleted_++; else if (!before) + { mptokensCreated_++; + MPTIssue const mptIssue{after->at(sfMPTokenIssuanceID)}; + if (mptIssue.getIssuer() == after->at(sfAccount)) + mptCreatedByIssuer_ = true; + } } } @@ -1464,6 +1498,25 @@ ValidMPTIssuance::finalize( { if (result == tesSUCCESS) { + auto const& rules = view.rules(); + [[maybe_unused]] + bool enforceCreatedByIssuer = rules.enabled(featureSingleAssetVault) || + rules.enabled(featureLendingProtocol); + if (mptCreatedByIssuer_) + { + JLOG(j.fatal()) + << "Invariant failed: MPToken created for the MPT issuer"; + // The comment above starting with "assert(enforce)" explains this + // assert. + XRPL_ASSERT_PARTS( + enforceCreatedByIssuer, + "ripple::ValidMPTIssuance::finalize", + "no issuer MPToken"); + if (enforceCreatedByIssuer) + return false; + } + + auto const txnType = tx.getTxnType(); if (hasPrivilege(tx, createMPTIssuance)) { if (mptIssuancesCreated_ == 0) @@ -1506,15 +1559,14 @@ ValidMPTIssuance::finalize( return mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 1; } + bool const lendingProtocolEnabled = + view.rules().enabled(featureLendingProtocol); // ttESCROW_FINISH may authorize an MPT, but it can't have the // mayAuthorizeMPT privilege, because that may cause // non-amendment-gated side effects. - bool const enforceEscrowFinish = (tx.getTxnType() == ttESCROW_FINISH) && - (view.rules().enabled(featureSingleAssetVault) - /* - TODO: Uncomment when LendingProtocol is defined - || view.rules().enabled(featureLendingProtocol)*/ - ); + bool const enforceEscrowFinish = (txnType == ttESCROW_FINISH) && + (view.rules().enabled(featureSingleAssetVault) || + lendingProtocolEnabled); if (hasPrivilege(tx, mustAuthorizeMPT | mayAuthorizeMPT) || enforceEscrowFinish) { @@ -1532,6 +1584,14 @@ ValidMPTIssuance::finalize( "succeeded but deleted issuances"; return false; } + else if ( + lendingProtocolEnabled && + mptokensCreated_ + mptokensDeleted_ > 1) + { + JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded " + "but created/deleted bad number mptokens"; + return false; + } else if ( submittedByIssuer && (mptokensCreated_ > 0 || mptokensDeleted_ > 0)) @@ -1555,7 +1615,7 @@ ValidMPTIssuance::finalize( return true; } - if (tx.getTxnType() == ttESCROW_FINISH) + if (txnType == ttESCROW_FINISH) { // ttESCROW_FINISH may authorize an MPT, but it can't have the // mayAuthorizeMPT privilege, because that may cause @@ -1773,8 +1833,6 @@ ValidPseudoAccounts::finalize( beast::Journal const& j) { bool const enforce = view.rules().enabled(featureSingleAssetVault); - - // The comment above starting with "assert(enforce)" explains this assert. XRPL_ASSERT( errors_.empty() || enforce, "ripple::ValidPseudoAccounts::finalize : no bad " @@ -2183,6 +2241,401 @@ ValidAMM::finalize( //------------------------------------------------------------------------------ +void +NoModifiedUnmodifiableFields::visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) +{ + if (isDelete || !before) + // Creation and deletion are ignored + return; + + changedEntries_.emplace(before, after); +} + +bool +NoModifiedUnmodifiableFields::finalize( + STTx const& tx, + TER const, + XRPAmount const, + ReadView const& view, + beast::Journal const& j) +{ + static auto const fieldChanged = + [](auto const& before, auto const& after, auto const& field) { + bool const beforeField = before->isFieldPresent(field); + bool const afterField = after->isFieldPresent(field); + return beforeField != afterField || + (afterField && before->at(field) != after->at(field)); + }; + for (auto const& slePair : changedEntries_) + { + auto const& before = slePair.first; + auto const& after = slePair.second; + auto const type = after->getType(); + bool bad = false; + [[maybe_unused]] bool enforce = false; + switch (type) + { + case ltLOAN_BROKER: + /* + * We check this invariant regardless of lending protocol + * amendment status, allowing for detection and logging of + * potential issues even when the amendment is disabled. + */ + enforce = view.rules().enabled(featureLendingProtocol); + bad = fieldChanged(before, after, sfLedgerEntryType) || + fieldChanged(before, after, sfLedgerIndex) || + fieldChanged(before, after, sfSequence) || + fieldChanged(before, after, sfOwnerNode) || + fieldChanged(before, after, sfVaultNode) || + fieldChanged(before, after, sfVaultID) || + fieldChanged(before, after, sfAccount) || + fieldChanged(before, after, sfOwner) || + fieldChanged(before, after, sfManagementFeeRate) || + fieldChanged(before, after, sfCoverRateMinimum) || + fieldChanged(before, after, sfCoverRateLiquidation); + break; + case ltLOAN: + /* + * We check this invariant regardless of lending protocol + * amendment status, allowing for detection and logging of + * potential issues even when the amendment is disabled. + */ + enforce = view.rules().enabled(featureLendingProtocol); + bad = fieldChanged(before, after, sfLedgerEntryType) || + fieldChanged(before, after, sfLedgerIndex) || + fieldChanged(before, after, sfSequence) || + fieldChanged(before, after, sfOwnerNode) || + fieldChanged(before, after, sfLoanBrokerNode) || + fieldChanged(before, after, sfLoanBrokerID) || + fieldChanged(before, after, sfBorrower) || + fieldChanged(before, after, sfLoanOriginationFee) || + fieldChanged(before, after, sfLoanServiceFee) || + fieldChanged(before, after, sfLatePaymentFee) || + fieldChanged(before, after, sfClosePaymentFee) || + fieldChanged(before, after, sfOverpaymentFee) || + fieldChanged(before, after, sfInterestRate) || + fieldChanged(before, after, sfLateInterestRate) || + fieldChanged(before, after, sfCloseInterestRate) || + fieldChanged(before, after, sfOverpaymentInterestRate) || + fieldChanged(before, after, sfStartDate) || + fieldChanged(before, after, sfPaymentInterval) || + fieldChanged(before, after, sfGracePeriod) || + fieldChanged(before, after, sfLoanScale); + break; + default: + /* + * We check this invariant regardless of lending protocol + * amendment status, allowing for detection and logging of + * potential issues even when the amendment is disabled. + * + * We use the lending protocol as a gate, even though + * all transactions are affected because that's when it + * was added. + */ + enforce = view.rules().enabled(featureLendingProtocol); + bad = fieldChanged(before, after, sfLedgerEntryType) || + fieldChanged(before, after, sfLedgerIndex); + } + XRPL_ASSERT( + !bad || enforce, + "ripple::NoModifiedUnmodifiableFields::finalize : no bad " + "changes or enforce invariant"); + if (bad) + { + JLOG(j.fatal()) + << "Invariant failed: changed an unchangable field for " + << tx.getTransactionID(); + if (enforce) + return false; + } + } + return true; +} + +//------------------------------------------------------------------------------ + +void +ValidLoanBroker::visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) +{ + if (after) + { + if (after->getType() == ltLOAN_BROKER) + { + auto& broker = brokers_[after->key()]; + broker.brokerBefore = before; + broker.brokerAfter = after; + } + else if ( + after->getType() == ltACCOUNT_ROOT && + after->isFieldPresent(sfLoanBrokerID)) + { + auto const& loanBrokerID = after->at(sfLoanBrokerID); + // create an entry if one doesn't already exist + brokers_.emplace(loanBrokerID, BrokerInfo{}); + } + else if (after->getType() == ltRIPPLE_STATE) + { + lines_.emplace_back(after); + } + else if (after->getType() == ltMPTOKEN) + { + mpts_.emplace_back(after); + } + } +} + +bool +ValidLoanBroker::goodZeroDirectory( + ReadView const& view, + SLE::const_ref dir, + beast::Journal const& j) const +{ + auto const next = dir->at(~sfIndexNext); + auto const prev = dir->at(~sfIndexPrevious); + if ((prev && *prev) || (next && *next)) + { + JLOG(j.fatal()) << "Invariant failed: Loan Broker with zero " + "OwnerCount has multiple directory pages"; + return false; + } + auto indexes = dir->getFieldV256(sfIndexes); + if (indexes.size() > 1) + { + JLOG(j.fatal()) + << "Invariant failed: Loan Broker with zero " + "OwnerCount has multiple indexes in the Directory root"; + return false; + } + if (indexes.size() == 1) + { + auto const index = indexes.value().front(); + auto const sle = view.read(keylet::unchecked(index)); + if (!sle) + { + JLOG(j.fatal()) + << "Invariant failed: Loan Broker directory corrupt"; + return false; + } + if (sle->getType() != ltRIPPLE_STATE && sle->getType() != ltMPTOKEN) + { + JLOG(j.fatal()) + << "Invariant failed: Loan Broker with zero " + "OwnerCount has an unexpected entry in the directory"; + return false; + } + } + + return true; +} + +bool +ValidLoanBroker::finalize( + STTx const& tx, + TER const, + XRPAmount const, + ReadView const& view, + beast::Journal const& j) +{ + // Loan Brokers will not exist on ledger if the Lending Protocol amendment + // is not enabled, so there's no need to check it. + + for (auto const& line : lines_) + { + for (auto const& field : {&sfLowLimit, &sfHighLimit}) + { + auto const account = + view.read(keylet::account(line->at(*field).getIssuer())); + // This Invariant doesn't know about the rules for Trust Lines, so + // if the account is missing, don't treat it as an error. This + // loop is only concerned with finding Broker pseudo-accounts + if (account && account->isFieldPresent(sfLoanBrokerID)) + { + auto const& loanBrokerID = account->at(sfLoanBrokerID); + // create an entry if one doesn't already exist + brokers_.emplace(loanBrokerID, BrokerInfo{}); + } + } + } + for (auto const& mpt : mpts_) + { + auto const account = view.read(keylet::account(mpt->at(sfAccount))); + // This Invariant doesn't know about the rules for MPTokens, so + // if the account is missing, don't treat is as an error. This + // loop is only concerned with finding Broker pseudo-accounts + if (account && account->isFieldPresent(sfLoanBrokerID)) + { + auto const& loanBrokerID = account->at(sfLoanBrokerID); + // create an entry if one doesn't already exist + brokers_.emplace(loanBrokerID, BrokerInfo{}); + } + } + + for (auto const& [brokerID, broker] : brokers_) + { + auto const& after = broker.brokerAfter + ? broker.brokerAfter + : view.read(keylet::loanbroker(brokerID)); + + if (!after) + { + JLOG(j.fatal()) << "Invariant failed: Loan Broker missing"; + return false; + } + + auto const& before = broker.brokerBefore; + + // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3123-invariants + // If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most + // one node (the root), which will only hold entries for `RippleState` + // or `MPToken` objects. + if (after->at(sfOwnerCount) == 0) + { + auto const dir = view.read(keylet::ownerDir(after->at(sfAccount))); + if (dir) + { + if (!goodZeroDirectory(view, dir, j)) + { + return false; + } + } + } + if (before && before->at(sfLoanSequence) > after->at(sfLoanSequence)) + { + JLOG(j.fatal()) << "Invariant failed: Loan Broker sequence number " + "decreased"; + return false; + } + if (after->at(sfDebtTotal) < 0) + { + JLOG(j.fatal()) + << "Invariant failed: Loan Broker debt total is negative"; + return false; + } + if (after->at(sfCoverAvailable) < 0) + { + JLOG(j.fatal()) + << "Invariant failed: Loan Broker cover available is negative"; + return false; + } + auto const vault = view.read(keylet::vault(after->at(sfVaultID))); + if (!vault) + { + JLOG(j.fatal()) + << "Invariant failed: Loan Broker vault ID is invalid"; + return false; + } + auto const& vaultAsset = vault->at(sfAsset); + if (after->at(sfCoverAvailable) < accountHolds( + view, + after->at(sfAccount), + vaultAsset, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j)) + { + JLOG(j.fatal()) << "Invariant failed: Loan Broker cover available " + "is less than pseudo-account asset balance"; + return false; + } + } + return true; +} + +//------------------------------------------------------------------------------ + +void +ValidLoan::visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) +{ + if (after && after->getType() == ltLOAN) + { + loans_.emplace_back(before, after); + } +} + +bool +ValidLoan::finalize( + STTx const& tx, + TER const, + XRPAmount const, + ReadView const& view, + beast::Journal const& j) +{ + // Loans will not exist on ledger if the Lending Protocol amendment + // is not enabled, so there's no need to check it. + + for (auto const& [before, after] : loans_) + { + // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3223-invariants + // If `Loan.PaymentRemaining = 0` then the loan MUST be fully paid off + if (after->at(sfPaymentRemaining) == 0 && + (after->at(sfTotalValueOutstanding) != beast::zero || + after->at(sfPrincipalOutstanding) != beast::zero || + after->at(sfManagementFeeOutstanding) != beast::zero)) + { + JLOG(j.fatal()) << "Invariant failed: Loan with zero payments " + "remaining has not been paid off"; + return false; + } + // If `Loan.PaymentRemaining != 0` then the loan MUST NOT be fully paid + // off + if (after->at(sfPaymentRemaining) != 0 && + after->at(sfTotalValueOutstanding) == beast::zero && + after->at(sfPrincipalOutstanding) == beast::zero && + after->at(sfManagementFeeOutstanding) == beast::zero) + { + JLOG(j.fatal()) << "Invariant failed: Loan with zero payments " + "remaining has not been paid off"; + return false; + } + if (before && + (before->isFlag(lsfLoanOverpayment) != + after->isFlag(lsfLoanOverpayment))) + { + JLOG(j.fatal()) + << "Invariant failed: Loan Overpayment flag changed"; + return false; + } + // Must not be negative - STNumber + for (auto const field : + {&sfLoanServiceFee, + &sfLatePaymentFee, + &sfClosePaymentFee, + &sfPrincipalOutstanding, + &sfTotalValueOutstanding, + &sfManagementFeeOutstanding}) + { + if (after->at(*field) < 0) + { + JLOG(j.fatal()) << "Invariant failed: " << field->getName() + << " is negative "; + return false; + } + } + // Must be positive - STNumber + for (auto const field : { + &sfPeriodicPayment, + }) + { + if (after->at(*field) <= 0) + { + JLOG(j.fatal()) << "Invariant failed: " << field->getName() + << " is zero or negative "; + return false; + } + } + } + return true; +} + ValidVault::Vault ValidVault::Vault::make(SLE const& from) { @@ -2332,7 +2785,8 @@ ValidVault::finalize( return true; // Not a vault operation } - else if (!hasPrivilege(tx, mustModifyVault)) // TODO: mayModifyVault + else if (!(hasPrivilege(tx, mustModifyVault) || + hasPrivilege(tx, mayModifyVault))) { JLOG(j.fatal()) << // "Invariant failed: vault updated by a wrong transaction type"; @@ -2547,7 +3001,8 @@ ValidVault::finalize( } if (!beforeVault_.empty() && - afterVault.lossUnrealized != beforeVault_[0].lossUnrealized) + afterVault.lossUnrealized != beforeVault_[0].lossUnrealized && + txnType != ttLOAN_MANAGE && txnType != ttLOAN_PAY) { JLOG(j.fatal()) << // "Invariant failed: vault transaction must not change loss " @@ -3095,6 +3550,13 @@ ValidVault::finalize( return result; } + case ttLOAN_SET: + case ttLOAN_MANAGE: + case ttLOAN_PAY: { + // TBD + return true; + } + default: // LCOV_EXCL_START UNREACHABLE( diff --git a/src/xrpld/app/tx/detail/InvariantCheck.h b/src/xrpld/app/tx/detail/InvariantCheck.h index 97d51f0fab..a142040a0f 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.h +++ b/src/xrpld/app/tx/detail/InvariantCheck.h @@ -177,7 +177,14 @@ public: */ class AccountRootsDeletedClean { - std::vector> accountsDeleted_; + // Pair is . Before is used for most of the checks, so that + // if, for example, an object ID field is cleared, but the object is not + // deleted, it can still be found. After is used specifically for any checks + // that are expected as part of the deletion, such as zeroing out the + // balance. + std::vector< + std::pair, std::shared_ptr>> + accountsDeleted_; public: void @@ -569,6 +576,9 @@ class ValidMPTIssuance std::uint32_t mptokensCreated_ = 0; std::uint32_t mptokensDeleted_ = 0; + // non-MPT transactions may attempt to create + // MPToken by an issuer + bool mptCreatedByIssuer_ = false; public: void @@ -735,6 +745,114 @@ private: }; /** + * @brief Invariants: Some fields are unmodifiable + * + * Check that any fields specified as unmodifiable are not modified when the + * object is modified. Creation and deletion are ignored. + * + */ +class NoModifiedUnmodifiableFields +{ + // Pair is . + std::set> changedEntries_; + +public: + void + visitEntry( + bool, + std::shared_ptr const&, + std::shared_ptr const&); + + bool + finalize( + STTx const&, + TER const, + XRPAmount const, + ReadView const&, + beast::Journal const&); +}; + +/** + * @brief Invariants: Loan brokers are internally consistent + * + * 1. If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most one + * node (the root), which will only hold entries for `RippleState` or + * `MPToken` objects. + * + */ +class ValidLoanBroker +{ + // Not all of these elements will necessarily be populated. Remaining items + // will be looked up as needed. + struct BrokerInfo + { + SLE::const_pointer brokerBefore = nullptr; + // After is used for most of the checks, except + // those that check changed values. + SLE::const_pointer brokerAfter = nullptr; + }; + // Collect all the LoanBrokers found directly or indirectly through + // pseudo-accounts. Key is the brokerID / index. It will be used to find the + // LoanBroker object if brokerBefore and brokerAfter are nullptr + std::map brokers_; + // Collect all the modified trust lines. Their high and low accounts will be + // loaded to look for LoanBroker pseudo-accounts. + std::vector lines_; + // Collect all the modified MPTokens. Their accounts will be loaded to look + // for LoanBroker pseudo-accounts. + std::vector mpts_; + + bool + goodZeroDirectory( + ReadView const& view, + SLE::const_ref dir, + beast::Journal const& j) const; + +public: + void + visitEntry( + bool, + std::shared_ptr const&, + std::shared_ptr const&); + + bool + finalize( + STTx const&, + TER const, + XRPAmount const, + ReadView const&, + beast::Journal const&); +}; + +/** + * @brief Invariants: Loans are internally consistent + * + * 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0` + * + */ +class ValidLoan +{ + // Pair is . After is used for most of the checks, except + // those that check changed values. + std::vector> loans_; + +public: + void + visitEntry( + bool, + std::shared_ptr const&, + std::shared_ptr const&); + + bool + finalize( + STTx const&, + TER const, + XRPAmount const, + ReadView const&, + beast::Journal const&); +}; + +/* * @brief Invariants: Vault object and MPTokenIssuance for vault shares * * - vault deleted and vault created is empty @@ -824,7 +942,10 @@ using InvariantChecks = std::tuple< ValidPermissionedDomain, ValidPermissionedDEX, ValidAMM, + NoModifiedUnmodifiableFields, ValidPseudoAccounts, + ValidLoanBroker, + ValidLoan, ValidVault>; /** diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp new file mode 100644 index 0000000000..26e978697c --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp @@ -0,0 +1,340 @@ +#include +// +#include + +namespace ripple { + +bool +LoanBrokerCoverClawback::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +NotTEC +LoanBrokerCoverClawback::preflight(PreflightContext const& ctx) +{ + auto const brokerID = ctx.tx[~sfLoanBrokerID]; + auto const amount = ctx.tx[~sfAmount]; + + if (!brokerID && !amount) + return temINVALID; + + if (brokerID && *brokerID == beast::zero) + return temINVALID; + + if (amount) + { + // XRP has no counterparty, and thus nobody can claw it back + if (amount->native()) + return temBAD_AMOUNT; + + // Zero is OK, and indicates "take it all" (down to the minimum cover) + if (*amount < beast::zero) + return temBAD_AMOUNT; + + // This should be redundant + if (!isLegalNet(*amount)) + return temBAD_AMOUNT; // LCOV_EXCL_LINE + + if (!brokerID) + { + if (amount->holds()) + return temINVALID; + + auto const account = ctx.tx[sfAccount]; + // Since we don't have a LoanBrokerID, holder _should_ be the loan + // broker's pseudo-account, but we don't know yet whether it is, so + // use a generic placeholder name. + auto const holder = amount->getIssuer(); + if (holder == account || holder == beast::zero) + return temINVALID; + } + } + + return tesSUCCESS; +} + +Expected +determineBrokerID(ReadView const& view, STTx const& tx) +{ + // If the broker ID was provided in the transaction, that's all we + // need. + if (auto const brokerID = tx[~sfLoanBrokerID]) + return *brokerID; + + // If the broker ID was not provided, and the amount is either + // absent or holds a non-IOU - including MPT, something went wrong, + // because that should have been rejected in preflight(). + auto const dstAmount = tx[~sfAmount]; + if (!dstAmount || !dstAmount->holds()) + return Unexpected{tecINTERNAL}; // LCOV_EXCL_LINE + + // Every trust line is bidirectional. Both sides are simultaneously + // issuer and holder. For this transaction, the Account is acting as + // a holder, and clawing back funds from the LoanBroker + // Pseudo-account acting as holder. If the Amount is an IOU, and the + // `issuer` field specified in that Amount is a LoanBroker + // Pseudo-account, we can get the LoanBrokerID from there. + // + // Thus, Amount.issuer _should_ be the loan broker's + // pseudo-account, but we don't know yet whether it is. + auto const maybePseudo = dstAmount->getIssuer(); + auto const sle = view.read(keylet::account(maybePseudo)); + + // If the account was not found, the transaction can't go further. + if (!sle) + return Unexpected{tecNO_ENTRY}; + + // If the account was found, and has a LoanBrokerID (and therefore + // is a pseudo-account), that's the + // answer we need. + if (auto const brokerID = sle->at(~sfLoanBrokerID)) + return *brokerID; + + // If the account does not have a LoanBrokerID, the transaction + // can't go further, even if it's a different type of Pseudo-account. + return Unexpected{tecOBJECT_NOT_FOUND}; + // Or tecWRONG_ASSET? +} + +Expected +determineAsset( + ReadView const& view, + AccountID const& account, + AccountID const& brokerPseudoAccountID, + STAmount const& amount) +{ + if (amount.holds()) + return amount.asset(); + + // An IOU has an issue, which could be either end of the trust line. + // This check only applies to IOUs + auto const holder = amount.getIssuer(); + + // holder can be the submitting account (the issuer of the asset) if a + // LoanBrokerID was provided in the transaction. + if (holder == account) + { + return amount.asset(); + } + else if (holder == brokerPseudoAccountID) + { + // We want the asset to match the vault asset, so use the account as the + // issuer + return Issue{amount.getCurrency(), account}; + } + else + return Unexpected(tecWRONG_ASSET); +} + +Expected +determineClawAmount( + SLE const& sleBroker, + Asset const& vaultAsset, + std::optional const& amount) +{ + auto const maxClawAmount = [&]() { + // Always round the minimum required up + NumberRoundModeGuard mg1(Number::upward); + auto const minRequiredCover = tenthBipsOfValue( + sleBroker[sfDebtTotal], TenthBips32(sleBroker[sfCoverRateMinimum])); + // The subtraction probably won't round, but round down if it does. + NumberRoundModeGuard mg2(Number::downward); + return sleBroker[sfCoverAvailable] - minRequiredCover; + }(); + if (maxClawAmount <= beast::zero) + return Unexpected(tecINSUFFICIENT_FUNDS); + + // Use the vaultAsset here, because it will be the right type in all + // circumstances. The amount may be an IOU indicating the pseudo-account's + // asset, which is correct, but not what is needed here. + if (!amount || *amount == beast::zero) + return STAmount{vaultAsset, maxClawAmount}; + Number const magnitude{*amount}; + if (magnitude > maxClawAmount) + return STAmount{vaultAsset, maxClawAmount}; + return STAmount{vaultAsset, magnitude}; +} + +template +static TER +preclaimHelper( + PreclaimContext const& ctx, + SLE const& sleIssuer, + STAmount const& clawAmount); + +template <> +TER +preclaimHelper( + PreclaimContext const& ctx, + SLE const& sleIssuer, + STAmount const& clawAmount) +{ + // If AllowTrustLineClawback is not set or NoFreeze is set, return no + // permission + if (!(sleIssuer.isFlag(lsfAllowTrustLineClawback)) || + (sleIssuer.isFlag(lsfNoFreeze))) + return tecNO_PERMISSION; + + return tesSUCCESS; +} + +template <> +TER +preclaimHelper( + PreclaimContext const& ctx, + SLE const& sleIssuer, + STAmount const& clawAmount) +{ + auto const issuanceKey = + keylet::mptIssuance(clawAmount.get().getMptID()); + auto const sleIssuance = ctx.view.read(issuanceKey); + if (!sleIssuance) + return tecOBJECT_NOT_FOUND; + + if (!sleIssuance->isFlag(lsfMPTCanClawback)) + return tecNO_PERMISSION; + + // With all the checking already done, this should be impossible + if (sleIssuance->at(sfIssuer) != sleIssuer[sfAccount]) + return tecINTERNAL; // LCOV_EXCL_LINE + + return tesSUCCESS; +} + +TER +LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const findBrokerID = determineBrokerID(ctx.view, tx); + if (!findBrokerID) + return findBrokerID.error(); + auto const brokerID = *findBrokerID; + auto const amount = tx[~sfAmount]; + + auto const sleBroker = ctx.view.read(keylet::loanbroker(brokerID)); + if (!sleBroker) + { + JLOG(ctx.j.warn()) << "LoanBroker does not exist."; + return tecNO_ENTRY; + } + + auto const brokerPseudoAccountID = sleBroker->at(sfAccount); + + auto const vault = ctx.view.read(keylet::vault(sleBroker->at(sfVaultID))); + if (!vault) + { + // LCOV_EXCL_START + JLOG(ctx.j.fatal()) << "Vault is missing for Broker " << brokerID; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + auto const vaultAsset = vault->at(sfAsset); + + if (vaultAsset.native()) + { + JLOG(ctx.j.warn()) << "Cannot clawback native asset."; + return tecNO_PERMISSION; + } + + // Only the issuer of the vault asset can claw it back from the broker's + // cover funds. + if (vaultAsset.getIssuer() != account) + { + JLOG(ctx.j.warn()) << "Account is not the issuer of the vault asset."; + return tecNO_PERMISSION; + } + + if (amount) + { + auto const findAsset = + determineAsset(ctx.view, account, brokerPseudoAccountID, *amount); + if (!findAsset) + return findAsset.error(); + auto const txAsset = *findAsset; + if (txAsset != vaultAsset) + { + JLOG(ctx.j.warn()) << "Account is the correct issuer, but trying " + "to clawback the wrong asset from LoanBroker"; + return tecWRONG_ASSET; + } + } + + auto const findClawAmount = + determineClawAmount(*sleBroker, vaultAsset, amount); + if (!findClawAmount) + { + JLOG(ctx.j.warn()) << "LoanBroker cover is already at minimum."; + return findClawAmount.error(); + } + STAmount const clawAmount = *findClawAmount; + + // Explicitly check the balance of the trust line / MPT to make sure the + // balance is actually there. It should always match `sfCoverAvailable`, so + // if there isn't, this is an internal error. + if (accountHolds( + ctx.view, + brokerPseudoAccountID, + vaultAsset, + fhIGNORE_FREEZE, + ahIGNORE_AUTH, + ctx.j) < clawAmount) + return tecINTERNAL; // tecINSUFFICIENT_FUNDS; LCOV_EXCL_LINE + + // Check if the vault asset issuer has the correct flags + auto const sleIssuer = + ctx.view.read(keylet::account(vaultAsset.getIssuer())); + return std::visit( + [&](T const&) { + return preclaimHelper(ctx, *sleIssuer, clawAmount); + }, + vaultAsset.value()); +} + +TER +LoanBrokerCoverClawback::doApply() +{ + auto const& tx = ctx_.tx; + auto const account = tx[sfAccount]; + auto const findBrokerID = determineBrokerID(view(), tx); + if (!findBrokerID) + return tecINTERNAL; // LCOV_EXCL_LINE + auto const brokerID = *findBrokerID; + auto const amount = tx[~sfAmount]; + + auto sleBroker = view().peek(keylet::loanbroker(brokerID)); + if (!sleBroker) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const brokerPseudoID = *sleBroker->at(sfAccount); + + auto const vault = view().read(keylet::vault(sleBroker->at(sfVaultID))); + if (!vault) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const vaultAsset = vault->at(sfAsset); + + auto const findClawAmount = + determineClawAmount(*sleBroker, vaultAsset, amount); + if (!findClawAmount) + return tecINTERNAL; // LCOV_EXCL_LINE + STAmount const clawAmount = *findClawAmount; + // Just for paranoia's sake + if (clawAmount.native()) + return tecINTERNAL; // LCOV_EXCL_LINE + + // Decrease the LoanBroker's CoverAvailable by Amount + sleBroker->at(sfCoverAvailable) -= clawAmount; + view().update(sleBroker); + + // Transfer assets from pseudo-account to depositor. + return accountSend( + view(), brokerPseudoID, account, clawAmount, j_, WaiveTransferFee::Yes); +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.h b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.h new file mode 100644 index 0000000000..183d3c4479 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.h @@ -0,0 +1,34 @@ +#ifndef XRPL_TX_LOANBROKERCOVERCLAWBACK_H_INCLUDED +#define XRPL_TX_LOANBROKERCOVERCLAWBACK_H_INCLUDED + +#include + +namespace ripple { + +class LoanBrokerCoverClawback : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanBrokerCoverClawback(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp new file mode 100644 index 0000000000..4e9e0e9c05 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp @@ -0,0 +1,123 @@ +#include +// +#include + +namespace ripple { + +bool +LoanBrokerCoverDeposit::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +NotTEC +LoanBrokerCoverDeposit::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfLoanBrokerID] == beast::zero) + return temINVALID; + + auto const dstAmount = ctx.tx[sfAmount]; + if (dstAmount <= beast::zero) + return temBAD_AMOUNT; + + if (!isLegalNet(dstAmount)) + return temBAD_AMOUNT; + + return tesSUCCESS; +} + +TER +LoanBrokerCoverDeposit::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const brokerID = tx[sfLoanBrokerID]; + auto const amount = tx[sfAmount]; + + auto const sleBroker = ctx.view.read(keylet::loanbroker(brokerID)); + if (!sleBroker) + { + JLOG(ctx.j.warn()) << "LoanBroker does not exist."; + return tecNO_ENTRY; + } + if (account != sleBroker->at(sfOwner)) + { + JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker."; + return tecNO_PERMISSION; + } + auto const vault = ctx.view.read(keylet::vault(sleBroker->at(sfVaultID))); + if (!vault) + { + // LCOV_EXCL_START + JLOG(ctx.j.fatal()) << "Vault is missing for Broker " << brokerID; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + auto const vaultAsset = vault->at(sfAsset); + if (amount.asset() != vaultAsset) + return tecWRONG_ASSET; + + auto const pseudoAccountID = sleBroker->at(sfAccount); + // Cannot transfer a non-transferable Asset + if (auto const ret = + canTransfer(ctx.view, vaultAsset, account, pseudoAccountID)) + return ret; + // Cannot transfer a frozen Asset + if (auto const ret = checkFrozen(ctx.view, account, vaultAsset)) + return ret; + // Pseudo-account cannot receive if asset is deep frozen + if (auto const ret = checkDeepFrozen(ctx.view, pseudoAccountID, vaultAsset)) + return ret; + // Cannot transfer unauthorized asset + if (auto const ret = + requireAuth(ctx.view, vaultAsset, account, AuthType::StrongAuth)) + return ret; + + if (accountHolds( + ctx.view, + account, + vaultAsset, + FreezeHandling::fhZERO_IF_FROZEN, + AuthHandling::ahZERO_IF_UNAUTHORIZED, + ctx.j) < amount) + return tecINSUFFICIENT_FUNDS; + + return tesSUCCESS; +} + +TER +LoanBrokerCoverDeposit::doApply() +{ + auto const& tx = ctx_.tx; + + auto const brokerID = tx[sfLoanBrokerID]; + auto const amount = tx[sfAmount]; + + auto broker = view().peek(keylet::loanbroker(brokerID)); + if (!broker) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const brokerPseudoID = broker->at(sfAccount); + + // Transfer assets from depositor to pseudo-account. + if (auto ter = accountSend( + view(), + account_, + brokerPseudoID, + amount, + j_, + WaiveTransferFee::Yes)) + return ter; + + // Increase the LoanBroker's CoverAvailable by Amount + broker->at(sfCoverAvailable) += amount; + view().update(broker); + + return tesSUCCESS; +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.h b/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.h new file mode 100644 index 0000000000..23863b479c --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.h @@ -0,0 +1,34 @@ +#ifndef XRPL_TX_LOANBROKERCOVERDEPOSIT_H_INCLUDED +#define XRPL_TX_LOANBROKERCOVERDEPOSIT_H_INCLUDED + +#include + +namespace ripple { + +class LoanBrokerCoverDeposit : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanBrokerCoverDeposit(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp new file mode 100644 index 0000000000..1fd5a1a471 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp @@ -0,0 +1,173 @@ +#include +// +#include +#include + +#include + +namespace ripple { + +bool +LoanBrokerCoverWithdraw::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +NotTEC +LoanBrokerCoverWithdraw::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfLoanBrokerID] == beast::zero) + return temINVALID; + + auto const dstAmount = ctx.tx[sfAmount]; + if (dstAmount <= beast::zero) + return temBAD_AMOUNT; + + if (!isLegalNet(dstAmount)) + return temBAD_AMOUNT; + + if (auto const destination = ctx.tx[~sfDestination]) + { + if (*destination == beast::zero) + { + return temMALFORMED; + } + } + + return tesSUCCESS; +} + +TER +LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const brokerID = tx[sfLoanBrokerID]; + auto const amount = tx[sfAmount]; + + auto const dstAcct = tx[~sfDestination].value_or(account); + + auto const sleBroker = ctx.view.read(keylet::loanbroker(brokerID)); + if (!sleBroker) + { + JLOG(ctx.j.warn()) << "LoanBroker does not exist."; + return tecNO_ENTRY; + } + if (account != sleBroker->at(sfOwner)) + { + JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker."; + return tecNO_PERMISSION; + } + auto const vault = ctx.view.read(keylet::vault(sleBroker->at(sfVaultID))); + if (!vault) + { + // LCOV_EXCL_START + JLOG(ctx.j.fatal()) << "Vault is missing for Broker " << brokerID; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + auto const vaultAsset = vault->at(sfAsset); + if (amount.asset() != vaultAsset) + return tecWRONG_ASSET; + + // The broker's pseudo-account is the source of funds. + auto const pseudoAccountID = sleBroker->at(sfAccount); + // Cannot transfer a non-transferable Asset + if (auto const ret = + canTransfer(ctx.view, vaultAsset, pseudoAccountID, dstAcct)) + return ret; + + // Withdrawal to a 3rd party destination account is essentially a transfer. + // Enforce all the usual asset transfer checks. + AuthType authType = AuthType::WeakAuth; + if (account != dstAcct) + { + if (auto const ret = canWithdraw(ctx.view, tx)) + return ret; + + // The destination account must have consented to receive the asset by + // creating a RippleState or MPToken + authType = AuthType::StrongAuth; + } + + // Destination MPToken must exist (if asset is an MPT) + if (auto const ter = requireAuth(ctx.view, vaultAsset, dstAcct, authType)) + return ter; + + // Check for freezes, unless sending directly to the issuer + if (dstAcct != vaultAsset.getIssuer()) + { + // Cannot send a frozen Asset + if (auto const ret = checkFrozen(ctx.view, pseudoAccountID, vaultAsset)) + return ret; + // Destination account cannot receive if asset is deep frozen + if (auto const ret = checkDeepFrozen(ctx.view, dstAcct, vaultAsset)) + return ret; + } + + auto const coverAvail = sleBroker->at(sfCoverAvailable); + // Cover Rate is in 1/10 bips units + auto const currentDebtTotal = sleBroker->at(sfDebtTotal); + auto const minimumCover = [&]() { + // Always round the minimum required up. + // Applies to `tenthBipsOfValue` as well as `roundToAsset`. + NumberRoundModeGuard mg(Number::upward); + return roundToAsset( + vaultAsset, + tenthBipsOfValue( + currentDebtTotal, + TenthBips32(sleBroker->at(sfCoverRateMinimum))), + currentDebtTotal.exponent()); + }(); + if (coverAvail < amount) + return tecINSUFFICIENT_FUNDS; + if ((coverAvail - amount) < minimumCover) + return tecINSUFFICIENT_FUNDS; + + if (accountHolds( + ctx.view, + pseudoAccountID, + vaultAsset, + FreezeHandling::fhZERO_IF_FROZEN, + AuthHandling::ahZERO_IF_UNAUTHORIZED, + ctx.j) < amount) + return tecINSUFFICIENT_FUNDS; + + return tesSUCCESS; +} + +TER +LoanBrokerCoverWithdraw::doApply() +{ + auto const& tx = ctx_.tx; + + auto const brokerID = tx[sfLoanBrokerID]; + auto const amount = tx[sfAmount]; + auto const dstAcct = tx[~sfDestination].value_or(account_); + + auto broker = view().peek(keylet::loanbroker(brokerID)); + if (!broker) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const brokerPseudoID = *broker->at(sfAccount); + + // Decrease the LoanBroker's CoverAvailable by Amount + broker->at(sfCoverAvailable) -= amount; + view().update(broker); + + return doWithdraw( + view(), + tx, + account_, + dstAcct, + brokerPseudoID, + mPriorBalance, + amount, + j_); +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.h b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.h new file mode 100644 index 0000000000..eab2c9e60f --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.h @@ -0,0 +1,34 @@ +#ifndef XRPL_TX_LOANBROKERCOVERWITHDRAW_H_INCLUDED +#define XRPL_TX_LOANBROKERCOVERWITHDRAW_H_INCLUDED + +#include + +namespace ripple { + +class LoanBrokerCoverWithdraw : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanBrokerCoverWithdraw(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp new file mode 100644 index 0000000000..f3dd781bb5 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp @@ -0,0 +1,197 @@ +#include +// +#include + +namespace ripple { + +bool +LoanBrokerDelete::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +NotTEC +LoanBrokerDelete::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfLoanBrokerID] == beast::zero) + return temINVALID; + + return tesSUCCESS; +} + +TER +LoanBrokerDelete::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const brokerID = tx[sfLoanBrokerID]; + + auto const sleBroker = ctx.view.read(keylet::loanbroker(brokerID)); + if (!sleBroker) + { + JLOG(ctx.j.warn()) << "LoanBroker does not exist."; + return tecNO_ENTRY; + } + + auto const brokerOwner = sleBroker->at(sfOwner); + + if (account != brokerOwner) + { + JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker."; + return tecNO_PERMISSION; + } + if (auto const ownerCount = sleBroker->at(sfOwnerCount); ownerCount != 0) + { + JLOG(ctx.j.warn()) << "LoanBrokerDelete: Owner count is " << ownerCount; + return tecHAS_OBLIGATIONS; + } + if (auto const debtTotal = sleBroker->at(sfDebtTotal); + debtTotal != beast::zero) + { + // Any remaining debt should have been wiped out by the last Loan + // Delete. This check is purely defensive. + auto const vault = + ctx.view.read(keylet::vault(sleBroker->at(sfVaultID))); + if (!vault) + return tefINTERNAL; // LCOV_EXCL_LINE + auto const asset = vault->at(sfAsset); + auto const scale = getVaultScale(vault); + + auto const rounded = + roundToAsset(asset, debtTotal, scale, Number::towards_zero); + + if (rounded != beast::zero) + { + // LCOV_EXCL_START + JLOG(ctx.j.warn()) << "LoanBrokerDelete: Debt total is " + << debtTotal << ", which rounds to " << rounded; + return tecHAS_OBLIGATIONS; + // LCOV_EXCL_START + } + } + + auto const vault = ctx.view.read(keylet::vault(sleBroker->at(sfVaultID))); + if (!vault) + { + // LCOV_EXCL_START + JLOG(ctx.j.fatal()) << "Vault is missing for Broker " << brokerID; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + Asset const asset = vault->at(sfAsset); + + auto const coverAvailable = + STAmount{asset, sleBroker->at(sfCoverAvailable)}; + // If there are assets in the cover, broker will receive them on deletion. + // So we need to check if the broker owner is deep frozen for that asset. + if (coverAvailable > beast::zero) + { + if (auto const ret = checkDeepFrozen(ctx.view, brokerOwner, asset)) + { + JLOG(ctx.j.warn()) << "Broker owner account is frozen."; + return ret; + } + } + + return tesSUCCESS; +} + +TER +LoanBrokerDelete::doApply() +{ + auto const& tx = ctx_.tx; + + auto const brokerID = tx[sfLoanBrokerID]; + + // Delete the loan broker + auto broker = view().peek(keylet::loanbroker(brokerID)); + if (!broker) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const vaultID = broker->at(sfVaultID); + auto const sleVault = view().read(keylet::vault(vaultID)); + if (!sleVault) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const vaultPseudoID = sleVault->at(sfAccount); + auto const vaultAsset = sleVault->at(sfAsset); + + auto const brokerPseudoID = broker->at(sfAccount); + + if (!view().dirRemove( + keylet::ownerDir(account_), + broker->at(sfOwnerNode), + broker->key(), + false)) + { + return tefBAD_LEDGER; // LCOV_EXCL_LINE + } + if (!view().dirRemove( + keylet::ownerDir(vaultPseudoID), + broker->at(sfVaultNode), + broker->key(), + false)) + { + return tefBAD_LEDGER; // LCOV_EXCL_LINE + } + + { + auto const coverAvailable = + STAmount{vaultAsset, broker->at(sfCoverAvailable)}; + if (auto const ter = accountSend( + view(), + brokerPseudoID, + account_, + coverAvailable, + j_, + WaiveTransferFee::Yes)) + return ter; + } + + if (auto ter = removeEmptyHolding(view(), brokerPseudoID, vaultAsset, j_)) + return ter; + + auto brokerPseudoSLE = view().peek(keylet::account(brokerPseudoID)); + if (!brokerPseudoSLE) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // Making the payment and removing the empty holding should have deleted any + // obligations associated with the broker or broker pseudo-account. + if (*brokerPseudoSLE->at(sfBalance)) + { + JLOG(j_.warn()) << "LoanBrokerDelete: Pseudo-account has a balance"; + return tecHAS_OBLIGATIONS; // LCOV_EXCL_LINE + } + if (brokerPseudoSLE->at(sfOwnerCount) != 0) + { + JLOG(j_.warn()) + << "LoanBrokerDelete: Pseudo-account still owns objects"; + return tecHAS_OBLIGATIONS; // LCOV_EXCL_LINE + } + if (auto const directory = keylet::ownerDir(brokerPseudoID); + view().read(directory)) + { + JLOG(j_.warn()) << "LoanBrokerDelete: Pseudo-account has a directory"; + return tecHAS_OBLIGATIONS; // LCOV_EXCL_LINE + } + + view().erase(brokerPseudoSLE); + + view().erase(broker); + + { + auto owner = view().peek(keylet::account(account_)); + if (!owner) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // Decreases the owner count by two: one for the LoanBroker object, and + // one for the pseudo-account. + adjustOwnerCount(view(), owner, -2, j_); + } + + return tesSUCCESS; +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanBrokerDelete.h b/src/xrpld/app/tx/detail/LoanBrokerDelete.h new file mode 100644 index 0000000000..8466fe4f95 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerDelete.h @@ -0,0 +1,34 @@ +#ifndef XRPL_TX_LOANBROKERDELETE_H_INCLUDED +#define XRPL_TX_LOANBROKERDELETE_H_INCLUDED + +#include + +namespace ripple { + +class LoanBrokerDelete : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanBrokerDelete(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/LoanBrokerSet.cpp b/src/xrpld/app/tx/detail/LoanBrokerSet.cpp new file mode 100644 index 0000000000..c2e6effd7a --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerSet.cpp @@ -0,0 +1,215 @@ +#include +// +#include + +namespace ripple { + +bool +LoanBrokerSet::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +NotTEC +LoanBrokerSet::preflight(PreflightContext const& ctx) +{ + using namespace Lending; + + auto const& tx = ctx.tx; + if (auto const data = tx[~sfData]; data && !data->empty() && + !validDataLength(tx[~sfData], maxDataPayloadLength)) + return temINVALID; + if (!validNumericRange(tx[~sfManagementFeeRate], maxManagementFeeRate)) + return temINVALID; + if (!validNumericRange(tx[~sfCoverRateMinimum], maxCoverRate)) + return temINVALID; + if (!validNumericRange(tx[~sfCoverRateLiquidation], maxCoverRate)) + return temINVALID; + if (!validNumericRange( + tx[~sfDebtMaximum], Number(maxMPTokenAmount), Number(0))) + return temINVALID; + + if (tx.isFieldPresent(sfLoanBrokerID)) + { + // Fixed fields can not be specified if we're modifying an existing + // LoanBroker Object + if (tx.isFieldPresent(sfManagementFeeRate) || + tx.isFieldPresent(sfCoverRateMinimum) || + tx.isFieldPresent(sfCoverRateLiquidation)) + return temINVALID; + + if (tx[sfLoanBrokerID] == beast::zero) + return temINVALID; + } + + if (auto const vaultID = tx.at(~sfVaultID)) + { + if (*vaultID == beast::zero) + return temINVALID; + } + + { + auto const minimumZero = tx[~sfCoverRateMinimum].value_or(0) == 0; + auto const liquidationZero = + tx[~sfCoverRateLiquidation].value_or(0) == 0; + // Both must be zero or non-zero. + if (minimumZero != liquidationZero) + { + return temINVALID; + } + } + + return tesSUCCESS; +} + +TER +LoanBrokerSet::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const vaultID = tx[sfVaultID]; + + if (auto const brokerID = tx[~sfLoanBrokerID]) + { + auto const sleBroker = ctx.view.read(keylet::loanbroker(*brokerID)); + if (!sleBroker) + { + JLOG(ctx.j.warn()) << "LoanBroker does not exist."; + return tecNO_ENTRY; + } + if (vaultID != sleBroker->at(sfVaultID)) + { + JLOG(ctx.j.warn()) + << "Can not change VaultID on an existing LoanBroker."; + return tecNO_PERMISSION; + } + if (account != sleBroker->at(sfOwner)) + { + JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker."; + return tecNO_PERMISSION; + } + } + else + { + auto const sleVault = ctx.view.read(keylet::vault(vaultID)); + if (!sleVault) + { + JLOG(ctx.j.warn()) << "Vault does not exist."; + return tecNO_ENTRY; + } + if (account != sleVault->at(sfOwner)) + { + JLOG(ctx.j.warn()) << "Account is not the owner of the Vault."; + return tecNO_PERMISSION; + } + if (auto const ter = canAddHolding(ctx.view, sleVault->at(sfAsset))) + return ter; + } + return tesSUCCESS; +} + +TER +LoanBrokerSet::doApply() +{ + auto const& tx = ctx_.tx; + auto& view = ctx_.view(); + + if (auto const brokerID = tx[~sfLoanBrokerID]) + { + // Modify an existing LoanBroker + auto broker = view.peek(keylet::loanbroker(*brokerID)); + if (!broker) + { + // This should be impossible + // LCOV_EXCL_START + JLOG(j_.fatal()) << "LoanBroker does not exist."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + if (auto const data = tx[~sfData]) + broker->at(sfData) = *data; + if (auto const debtMax = tx[~sfDebtMaximum]) + broker->at(sfDebtMaximum) = *debtMax; + + view.update(broker); + } + else + { + // Create a new LoanBroker pointing back to the given Vault + auto const vaultID = tx[sfVaultID]; + auto const sleVault = view.read(keylet::vault(vaultID)); + if (!sleVault) + { + // This should be impossible + // LCOV_EXCL_START + JLOG(j_.fatal()) << "Vault does not exist."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + auto const vaultPseudoID = sleVault->at(sfAccount); + auto const sequence = tx.getSeqValue(); + + auto owner = view.peek(keylet::account(account_)); + if (!owner) + { + // This should be impossible + // LCOV_EXCL_START + JLOG(j_.fatal()) << "Account does not exist."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + auto broker = + std::make_shared(keylet::loanbroker(account_, sequence)); + + if (auto const ter = dirLink(view, account_, broker)) + return ter; // LCOV_EXCL_LINE + if (auto const ter = dirLink(view, vaultPseudoID, broker, sfVaultNode)) + return ter; // LCOV_EXCL_LINE + + // Increases the owner count by two: one for the LoanBroker object, and + // one for the pseudo-account. + adjustOwnerCount(view, owner, 2, j_); + auto const ownerCount = owner->at(sfOwnerCount); + if (mPriorBalance < view.fees().accountReserve(ownerCount)) + return tecINSUFFICIENT_RESERVE; + + auto maybePseudo = + createPseudoAccount(view, broker->key(), sfLoanBrokerID); + if (!maybePseudo) + return maybePseudo.error(); // LCOV_EXCL_LINE + auto& pseudo = *maybePseudo; + auto pseudoId = pseudo->at(sfAccount); + + if (auto ter = addEmptyHolding( + view, pseudoId, mPriorBalance, sleVault->at(sfAsset), j_)) + return ter; + + // Initialize data fields: + broker->at(sfSequence) = sequence; + broker->at(sfVaultID) = vaultID; + broker->at(sfOwner) = account_; + broker->at(sfAccount) = pseudoId; + // The LoanSequence indexes loans created by this broker, starting at 1 + broker->at(sfLoanSequence) = 1; + if (auto const data = tx[~sfData]) + broker->at(sfData) = *data; + if (auto const rate = tx[~sfManagementFeeRate]) + broker->at(sfManagementFeeRate) = *rate; + if (auto const debtMax = tx[~sfDebtMaximum]) + broker->at(sfDebtMaximum) = *debtMax; + if (auto const coverMin = tx[~sfCoverRateMinimum]) + broker->at(sfCoverRateMinimum) = *coverMin; + if (auto const coverLiq = tx[~sfCoverRateLiquidation]) + broker->at(sfCoverRateLiquidation) = *coverLiq; + + view.insert(broker); + } + + return tesSUCCESS; +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanBrokerSet.h b/src/xrpld/app/tx/detail/LoanBrokerSet.h new file mode 100644 index 0000000000..39ed9bcd61 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerSet.h @@ -0,0 +1,34 @@ +#ifndef XRPL_TX_LOANBROKERSET_H_INCLUDED +#define XRPL_TX_LOANBROKERSET_H_INCLUDED + +#include + +namespace ripple { + +class LoanBrokerSet : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanBrokerSet(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/LoanDelete.cpp b/src/xrpld/app/tx/detail/LoanDelete.cpp new file mode 100644 index 0000000000..87ff4d594b --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanDelete.cpp @@ -0,0 +1,133 @@ +#include +// +#include + +namespace ripple { + +bool +LoanDelete::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +NotTEC +LoanDelete::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfLoanID] == beast::zero) + return temINVALID; + + return tesSUCCESS; +} + +TER +LoanDelete::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const loanID = tx[sfLoanID]; + + auto const loanSle = ctx.view.read(keylet::loan(loanID)); + if (!loanSle) + { + JLOG(ctx.j.warn()) << "Loan does not exist."; + return tecNO_ENTRY; + } + if (loanSle->at(sfPaymentRemaining) > 0) + { + JLOG(ctx.j.warn()) << "Active loan can not be deleted."; + return tecHAS_OBLIGATIONS; + } + + auto const loanBrokerID = loanSle->at(sfLoanBrokerID); + auto const loanBrokerSle = ctx.view.read(keylet::loanbroker(loanBrokerID)); + if (!loanBrokerSle) + { + // should be impossible + return tecINTERNAL; // LCOV_EXCL_LINE + } + if (loanBrokerSle->at(sfOwner) != account && + loanSle->at(sfBorrower) != account) + { + JLOG(ctx.j.warn()) + << "Account is not Loan Broker Owner or Loan Borrower."; + return tecNO_PERMISSION; + } + + return tesSUCCESS; +} + +TER +LoanDelete::doApply() +{ + auto const& tx = ctx_.tx; + auto& view = ctx_.view(); + + auto const loanID = tx[sfLoanID]; + auto const loanSle = view.peek(keylet::loan(loanID)); + if (!loanSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const borrower = loanSle->at(sfBorrower); + auto const borrowerSle = view.peek(keylet::account(borrower)); + if (!borrowerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + auto const brokerID = loanSle->at(sfLoanBrokerID); + auto const brokerSle = view.peek(keylet::loanbroker(brokerID)); + if (!brokerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const brokerPseudoAccount = brokerSle->at(sfAccount); + + auto const vaultSle = view.peek(keylet ::vault(brokerSle->at(sfVaultID))); + if (!vaultSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // Remove LoanID from Directory of the LoanBroker pseudo-account. + if (!view.dirRemove( + keylet::ownerDir(brokerPseudoAccount), + loanSle->at(sfLoanBrokerNode), + loanID, + false)) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + // Remove LoanID from Directory of the Borrower. + if (!view.dirRemove( + keylet::ownerDir(borrower), + loanSle->at(sfOwnerNode), + loanID, + false)) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // Delete the Loan object + view.erase(loanSle); + + // Decrement the LoanBroker's owner count. + // The broker's owner count is solely for the number of outstanding loans, + // and is distinct from the broker's pseudo-account's owner count + adjustOwnerCount(view, brokerSle, -1, j_); + // If there are no loans left, then any remaining debt must be forgiven, + // because there is no other way to pay it back. + if (brokerSle->at(sfOwnerCount) == 0) + { + auto debtTotalProxy = brokerSle->at(sfDebtTotal); + if (*debtTotalProxy != beast::zero) + { + XRPL_ASSERT_PARTS( + roundToAsset( + vaultSle->at(sfAsset), + debtTotalProxy, + getVaultScale(vaultSle), + Number::towards_zero) == beast::zero, + "ripple::LoanDelete::doApply", + "last loan, remaining debt rounds to zero"); + debtTotalProxy = 0; + } + } + // Decrement the borrower's owner count + adjustOwnerCount(view, borrowerSle, -1, j_); + + return tesSUCCESS; +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanDelete.h b/src/xrpld/app/tx/detail/LoanDelete.h new file mode 100644 index 0000000000..cbc37dec14 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanDelete.h @@ -0,0 +1,34 @@ +#ifndef XRPL_TX_LOANDELETE_H_INCLUDED +#define XRPL_TX_LOANDELETE_H_INCLUDED + +#include + +namespace ripple { + +class LoanDelete : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanDelete(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/LoanManage.cpp b/src/xrpld/app/tx/detail/LoanManage.cpp new file mode 100644 index 0000000000..adf08d71bf --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanManage.cpp @@ -0,0 +1,420 @@ +#include +// +#include + +#include + +namespace ripple { + +bool +LoanManage::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +std::uint32_t +LoanManage::getFlagsMask(PreflightContext const& ctx) +{ + return tfLoanManageMask; +} + +NotTEC +LoanManage::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfLoanID] == beast::zero) + return temINVALID; + + // Flags are mutually exclusive + if (auto const flagField = ctx.tx[~sfFlags]; flagField && *flagField) + { + auto const flags = *flagField & tfUniversalMask; + if ((flags & (flags - 1)) != 0) + { + JLOG(ctx.j.warn()) + << "LoanManage: Only one of tfLoanDefault, tfLoanImpair, or " + "tfLoanUnimpair can be set."; + return temINVALID_FLAG; + } + } + + return tesSUCCESS; +} + +TER +LoanManage::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const loanID = tx[sfLoanID]; + + auto const loanSle = ctx.view.read(keylet::loan(loanID)); + if (!loanSle) + { + JLOG(ctx.j.warn()) << "Loan does not exist."; + return tecNO_ENTRY; + } + // Impairment only allows certain transitions. + // 1. Once it's in default, it can't be changed. + // 2. It can get worse: unimpaired -> impaired -> default + // or unimpaired -> default + // 3. It can get better: impaired -> unimpaired + // 4. If it's in a state, it can't be put in that state again. + if (loanSle->isFlag(lsfLoanDefault)) + { + JLOG(ctx.j.warn()) + << "Loan is in default. A defaulted loan can not be modified."; + return tecNO_PERMISSION; + } + if (loanSle->isFlag(lsfLoanImpaired) && tx.isFlag(tfLoanImpair)) + { + JLOG(ctx.j.warn()) + << "Loan is impaired. A loan can not be impaired twice."; + return tecNO_PERMISSION; + } + if (!(loanSle->isFlag(lsfLoanImpaired) || + loanSle->isFlag(lsfLoanDefault)) && + (tx.isFlag(tfLoanUnimpair))) + { + JLOG(ctx.j.warn()) + << "Loan is unimpaired. Can not be unimpaired again."; + return tecNO_PERMISSION; + } + if (loanSle->at(sfPaymentRemaining) == 0) + { + JLOG(ctx.j.warn()) << "Loan is fully paid. A loan can not be modified " + "after it is fully paid."; + return tecNO_PERMISSION; + } + if (tx.isFlag(tfLoanDefault) && + !hasExpired( + ctx.view, + loanSle->at(sfNextPaymentDueDate) + loanSle->at(sfGracePeriod))) + { + JLOG(ctx.j.warn()) + << "A loan can not be defaulted before the next payment due date."; + return tecTOO_SOON; + } + + auto const loanBrokerID = loanSle->at(sfLoanBrokerID); + auto const loanBrokerSle = ctx.view.read(keylet::loanbroker(loanBrokerID)); + if (!loanBrokerSle) + { + // should be impossible + return tecINTERNAL; // LCOV_EXCL_LINE + } + if (loanBrokerSle->at(sfOwner) != account) + { + JLOG(ctx.j.warn()) + << "LoanBroker for Loan does not belong to the account. LoanModify " + "can only be submitted by the Loan Broker."; + return tecNO_PERMISSION; + } + + return tesSUCCESS; +} + +static Number +owedToVault(SLE::ref loanSle) +{ + // Spec section 3.2.3.2, defines the default amount as + // + // DefaultAmount = (Loan.PrincipalOutstanding + Loan.InterestOutstanding) + // + // Loan.InterestOutstanding is not stored directly on ledger. + // It is computed as + // + // Loan.TotalValueOutstanding - Loan.PrincipalOutstanding - + // Loan.ManagementFeeOutstanding + // + // Add that to the original formula, and you get this: + return loanSle->at(sfTotalValueOutstanding) - + loanSle->at(sfManagementFeeOutstanding); +} + +TER +LoanManage::defaultLoan( + ApplyView& view, + SLE::ref loanSle, + SLE::ref brokerSle, + SLE::ref vaultSle, + Asset const& vaultAsset, + beast::Journal j) +{ + // Calculate the amount of the Default that First-Loss Capital covers: + + std::int32_t const loanScale = loanSle->at(sfLoanScale); + auto brokerDebtTotalProxy = brokerSle->at(sfDebtTotal); + + Number const totalDefaultAmount = owedToVault(loanSle); + + // Apply the First-Loss Capital to the Default Amount + TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; + TenthBips32 const coverRateLiquidation{ + brokerSle->at(sfCoverRateLiquidation)}; + auto const defaultCovered = [&]() { + // Always round the minimum required up. + NumberRoundModeGuard mg(Number::upward); + auto const minimumCover = + tenthBipsOfValue(brokerDebtTotalProxy.value(), coverRateMinimum); + // Round the liquidation amount up, too + return roundToAsset( + vaultAsset, + /* + * This formula is from the XLS-66 spec, section 3.2.3.2 (State + * Changes), specifically "if the `tfLoanDefault` flag is set" / + * "Apply the First-Loss Capital to the Default Amount" + */ + std::min( + tenthBipsOfValue(minimumCover, coverRateLiquidation), + totalDefaultAmount), + loanScale); + }(); + + auto const vaultDefaultAmount = totalDefaultAmount - defaultCovered; + + // Update the Vault object: + + // The vault may be at a different scale than the loan. Reduce rounding + // errors during the accounting by rounding some of the values to that + // scale. + auto const vaultScale = getVaultScale(vaultSle); + + { + // Decrease the Total Value of the Vault: + auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); + auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); + + if (vaultTotalProxy < vaultDefaultAmount) + { + // LCOV_EXCL_START + JLOG(j.warn()) + << "Vault total assets is less than the vault default amount"; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + auto const vaultDefaultRounded = roundToAsset( + vaultAsset, vaultDefaultAmount, vaultScale, Number::downward); + vaultTotalProxy -= vaultDefaultRounded; + // Increase the Asset Available of the Vault by liquidated First-Loss + // Capital and any unclaimed funds amount: + vaultAvailableProxy += defaultCovered; + if (*vaultAvailableProxy > *vaultTotalProxy && !vaultAsset.integral()) + { + auto const difference = vaultAvailableProxy - vaultTotalProxy; + JLOG(j.debug()) + << "Vault assets available: " << *vaultAvailableProxy << "(" + << vaultAvailableProxy.value().exponent() + << "), Total: " << *vaultTotalProxy << "(" + << vaultTotalProxy.value().exponent() + << "), Difference: " << difference << "(" + << difference.exponent() << ")"; + if (vaultAvailableProxy.value().exponent() - difference.exponent() > + 13) + { + // If the difference is dust, bring the total up to match + // the available + JLOG(j.debug()) + << "Difference between vault assets available and total is " + "dust. Set both to the larger value."; + vaultTotalProxy = vaultAvailableProxy; + } + } + if (*vaultAvailableProxy > *vaultTotalProxy) + { + JLOG(j.warn()) << "Vault assets available must not be greater " + "than assets outstanding. Available: " + << *vaultAvailableProxy + << ", Total: " << *vaultTotalProxy; + return tecLIMIT_EXCEEDED; + } + + // The loss has been realized + if (loanSle->isFlag(lsfLoanImpaired)) + { + auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); + if (vaultLossUnrealizedProxy < totalDefaultAmount) + { + // LCOV_EXCL_START + JLOG(j.warn()) + << "Vault unrealized loss is less than the default amount"; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + vaultLossUnrealizedProxy -= totalDefaultAmount; + } + view.update(vaultSle); + } + + // Update the LoanBroker object: + + { + auto const asset = *vaultSle->at(sfAsset); + + // Decrease the Debt of the LoanBroker: + adjustImpreciseNumber( + brokerDebtTotalProxy, -totalDefaultAmount, asset, vaultScale); + // Decrease the First-Loss Capital Cover Available: + auto coverAvailableProxy = brokerSle->at(sfCoverAvailable); + if (coverAvailableProxy < defaultCovered) + { + // LCOV_EXCL_START + JLOG(j.warn()) + << "LoanBroker cover available is less than amount covered"; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + coverAvailableProxy -= defaultCovered; + view.update(brokerSle); + } + + // Update the Loan object: + loanSle->setFlag(lsfLoanDefault); + + loanSle->at(sfTotalValueOutstanding) = 0; + loanSle->at(sfPaymentRemaining) = 0; + loanSle->at(sfPrincipalOutstanding) = 0; + loanSle->at(sfManagementFeeOutstanding) = 0; + // Zero out the next due date. Since it's default, it'll be removed from + // the object. + loanSle->at(sfNextPaymentDueDate) = 0; + view.update(loanSle); + + // Return funds from the LoanBroker pseudo-account to the + // Vault pseudo-account: + return accountSend( + view, + brokerSle->at(sfAccount), + vaultSle->at(sfAccount), + STAmount{vaultAsset, defaultCovered}, + j, + WaiveTransferFee::Yes); +} + +TER +LoanManage::impairLoan( + ApplyView& view, + SLE::ref loanSle, + SLE::ref vaultSle, + beast::Journal j) +{ + Number const lossUnrealized = owedToVault(loanSle); + + // Update the Vault object(set "paper loss") + auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); + vaultLossUnrealizedProxy += lossUnrealized; + if (vaultLossUnrealizedProxy > + vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable)) + { + // Having a loss greater than the vault's unavailable assets + // will leave the vault in an invalid / inconsistent state. + JLOG(j.warn()) << "Vault unrealized loss is too large, and will " + "corrupt the vault."; + return tecLIMIT_EXCEEDED; + } + view.update(vaultSle); + + // Update the Loan object + loanSle->setFlag(lsfLoanImpaired); + auto loanNextDueProxy = loanSle->at(sfNextPaymentDueDate); + if (!hasExpired(view, loanNextDueProxy)) + { + // loan payment is not yet late - + // move the next payment due date to now + loanNextDueProxy = view.parentCloseTime().time_since_epoch().count(); + } + view.update(loanSle); + + return tesSUCCESS; +} + +TER +LoanManage::unimpairLoan( + ApplyView& view, + SLE::ref loanSle, + SLE::ref vaultSle, + beast::Journal j) +{ + // Update the Vault object(clear "paper loss") + auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); + Number const lossReversed = owedToVault(loanSle); + if (vaultLossUnrealizedProxy < lossReversed) + { + // LCOV_EXCL_START + JLOG(j.warn()) + << "Vault unrealized loss is less than the amount to be cleared"; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + vaultLossUnrealizedProxy -= lossReversed; + view.update(vaultSle); + + // Update the Loan object + loanSle->clearFlag(lsfLoanImpaired); + auto const paymentInterval = loanSle->at(sfPaymentInterval); + auto const normalPaymentDueDate = + std::max(loanSle->at(sfPreviousPaymentDate), loanSle->at(sfStartDate)) + + paymentInterval; + if (!hasExpired(view, normalPaymentDueDate)) + { + // loan was unimpaired within the payment interval + loanSle->at(sfNextPaymentDueDate) = normalPaymentDueDate; + } + else + { + // loan was unimpaired after the original payment due date + loanSle->at(sfNextPaymentDueDate) = + view.parentCloseTime().time_since_epoch().count() + paymentInterval; + } + view.update(loanSle); + + return tesSUCCESS; +} + +TER +LoanManage::doApply() +{ + auto const& tx = ctx_.tx; + auto& view = ctx_.view(); + + auto const loanID = tx[sfLoanID]; + auto const loanSle = view.peek(keylet::loan(loanID)); + if (!loanSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + auto const brokerID = loanSle->at(sfLoanBrokerID); + auto const brokerSle = view.peek(keylet::loanbroker(brokerID)); + if (!brokerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + auto const vaultSle = view.peek(keylet ::vault(brokerSle->at(sfVaultID))); + if (!vaultSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const vaultAsset = vaultSle->at(sfAsset); + + // Valid flag combinations are checked in preflight. No flags is valid - + // just a noop. + if (tx.isFlag(tfLoanDefault)) + { + if (auto const ter = + defaultLoan(view, loanSle, brokerSle, vaultSle, vaultAsset, j_)) + return ter; + } + else if (tx.isFlag(tfLoanImpair)) + { + if (auto const ter = impairLoan(view, loanSle, vaultSle, j_)) + return ter; + } + else if (tx.isFlag(tfLoanUnimpair)) + { + if (auto const ter = unimpairLoan(view, loanSle, vaultSle, j_)) + return ter; + } + + return tesSUCCESS; +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanManage.h b/src/xrpld/app/tx/detail/LoanManage.h new file mode 100644 index 0000000000..dde1023cad --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanManage.h @@ -0,0 +1,66 @@ +#ifndef XRPL_TX_LOANMANAGE_H_INCLUDED +#define XRPL_TX_LOANMANAGE_H_INCLUDED + +#include + +namespace ripple { + +class LoanManage : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanManage(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static std::uint32_t + getFlagsMask(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + /** Helper function that might be needed by other transactors + */ + static TER + defaultLoan( + ApplyView& view, + SLE::ref loanSle, + SLE::ref brokerSle, + SLE::ref vaultSle, + Asset const& vaultAsset, + beast::Journal j); + + /** Helper function that might be needed by other transactors + */ + static TER + impairLoan( + ApplyView& view, + SLE::ref loanSle, + SLE::ref vaultSle, + beast::Journal j); + + /** Helper function that might be needed by other transactors + */ + static TER + unimpairLoan( + ApplyView& view, + SLE::ref loanSle, + SLE::ref vaultSle, + beast::Journal j); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/LoanPay.cpp b/src/xrpld/app/tx/detail/LoanPay.cpp new file mode 100644 index 0000000000..43f19743a7 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanPay.cpp @@ -0,0 +1,602 @@ +#include +// +#include +#include + +#include +#include +#include + +#include + +namespace ripple { + +bool +LoanPay::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +std::uint32_t +LoanPay::getFlagsMask(PreflightContext const& ctx) +{ + return tfLoanPayMask; +} + +NotTEC +LoanPay::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfLoanID] == beast::zero) + return temINVALID; + + if (ctx.tx[sfAmount] <= beast::zero) + return temBAD_AMOUNT; + + // The loan payment flags are all mutually exclusive. If more than one is + // set, the tx is malformed. + static_assert( + (tfLoanLatePayment | tfLoanFullPayment | tfLoanOverpayment) == + ~(tfLoanPayMask | tfUniversal)); + auto const flagsSet = ctx.tx.getFlags() & ~(tfLoanPayMask | tfUniversal); + if (std::popcount(flagsSet) > 1) + { + JLOG(ctx.j.warn()) << "Only one LoanPay flag can be set per tx. " + << flagsSet << " is too many."; + return temINVALID_FLAG; + } + + return tesSUCCESS; +} + +XRPAmount +LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx) +{ + using namespace Lending; + + auto const normalCost = Transactor::calculateBaseFee(view, tx); + + if (tx.isFlag(tfLoanFullPayment) || tx.isFlag(tfLoanLatePayment)) + // The loan will be making one set of calculations for one full or late + // payment + return normalCost; + + // The fee is based on the potential number of payments, unless the loan is + // being fully paid off. + auto const amount = tx[sfAmount]; + auto const loanID = tx[sfLoanID]; + + auto const loanSle = view.read(keylet::loan(loanID)); + if (!loanSle) + // Let preclaim worry about the error for this + return normalCost; + + if (loanSle->at(sfPaymentRemaining) <= loanPaymentsPerFeeIncrement) + { + // If there are fewer than loanPaymentsPerFeeIncrement payments left to + // pay, we can skip the computations. + return normalCost; + } + + if (hasExpired(view, loanSle->at(sfNextPaymentDueDate))) + // If the payment is late, and the late payment flag is not set, it'll + // fail + return normalCost; + + auto const brokerSle = + view.read(keylet::loanbroker(loanSle->at(sfLoanBrokerID))); + if (!brokerSle) + // Let preclaim worry about the error for this + return normalCost; + auto const vaultSle = view.read(keylet::vault(brokerSle->at(sfVaultID))); + if (!vaultSle) + // Let preclaim worry about the error for this + return normalCost; + + auto const asset = vaultSle->at(sfAsset); + + if (asset != amount.asset()) + // Let preclaim worry about the error for this + return normalCost; + + auto const scale = loanSle->at(sfLoanScale); + + auto const regularPayment = + roundPeriodicPayment(asset, loanSle->at(sfPeriodicPayment), scale) + + loanSle->at(sfLoanServiceFee); + + // If making an overpayment, count it as a full payment because it will do + // about the same amount of work, if not more. + NumberRoundModeGuard mg( + tx.isFlag(tfLoanOverpayment) ? Number::upward : Number::downward); + // Estimate how many payments will be made + Number const numPaymentEstimate = + static_cast(amount / regularPayment); + + // Charge one base fee per paymentsPerFeeIncrement payments, rounding up. + Number::setround(Number::upward); + auto const feeIncrements = std::max( + std::int64_t(1), + static_cast( + numPaymentEstimate / loanPaymentsPerFeeIncrement)); + + return feeIncrements * normalCost; +} + +TER +LoanPay::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const loanID = tx[sfLoanID]; + auto const amount = tx[sfAmount]; + + auto const loanSle = ctx.view.read(keylet::loan(loanID)); + if (!loanSle) + { + JLOG(ctx.j.warn()) << "Loan does not exist."; + return tecNO_ENTRY; + } + + if (loanSle->at(sfBorrower) != account) + { + JLOG(ctx.j.warn()) << "Loan does not belong to the account."; + return tecNO_PERMISSION; + } + + if (tx.isFlag(tfLoanOverpayment) && !loanSle->isFlag(lsfLoanOverpayment)) + { + JLOG(ctx.j.warn()) + << "Requested overpayment on a loan that doesn't allow it"; + return temINVALID_FLAG; + } + + auto const principalOutstanding = loanSle->at(sfPrincipalOutstanding); + TenthBips32 const interestRate{loanSle->at(sfInterestRate)}; + auto const paymentRemaining = loanSle->at(sfPaymentRemaining); + TenthBips32 const lateInterestRate{loanSle->at(sfLateInterestRate)}; + + if (paymentRemaining == 0 || principalOutstanding == 0) + { + JLOG(ctx.j.warn()) << "Loan is already paid off."; + return tecKILLED; + } + + auto const loanBrokerID = loanSle->at(sfLoanBrokerID); + auto const loanBrokerSle = ctx.view.read(keylet::loanbroker(loanBrokerID)); + if (!loanBrokerSle) + { + // This should be impossible + // LCOV_EXCL_START + JLOG(ctx.j.fatal()) << "LoanBroker does not exist."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + auto const vaultID = loanBrokerSle->at(sfVaultID); + auto const vaultSle = ctx.view.read(keylet::vault(vaultID)); + if (!vaultSle) + { + // This should be impossible + // LCOV_EXCL_START + JLOG(ctx.j.fatal()) << "Vault does not exist."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + auto const asset = vaultSle->at(sfAsset); + auto const vaultPseudoAccount = vaultSle->at(sfAccount); + + if (amount.asset() != asset) + { + JLOG(ctx.j.warn()) << "Loan amount does not match the Vault asset."; + return tecWRONG_ASSET; + } + + if (auto const ret = checkFrozen(ctx.view, account, asset)) + { + JLOG(ctx.j.warn()) << "Borrower account is frozen."; + return ret; + } + if (auto const ret = checkDeepFrozen(ctx.view, vaultPseudoAccount, asset)) + { + JLOG(ctx.j.warn()) + << "Vault pseudo-account can not receive funds (deep frozen)."; + return ret; + } + if (auto const ret = requireAuth(ctx.view, asset, account)) + { + JLOG(ctx.j.warn()) << "Borrower account is not authorized."; + return ret; + } + // Make sure the borrower has enough funds to make the payment! + // Do not support "partial payments" - if the transaction says to pay X, + // then the account must have X available, even if the loan payment takes + // less. + if (auto const balance = accountSpendable( + ctx.view, + account, + asset, + fhZERO_IF_FROZEN, + ahZERO_IF_UNAUTHORIZED, + ctx.j); + balance < amount) + { + JLOG(ctx.j.warn()) << "Payment amount too large. Amount: " + << to_string(amount.getJson()) + << ". Balance: " << to_string(balance.getJson()); + return tecINSUFFICIENT_FUNDS; + } + + return tesSUCCESS; +} + +TER +LoanPay::doApply() +{ + auto const& tx = ctx_.tx; + auto& view = ctx_.view(); + + auto const amount = tx[sfAmount]; + + auto const loanID = tx[sfLoanID]; + auto const loanSle = view.peek(keylet::loan(loanID)); + if (!loanSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + std::int32_t const loanScale = loanSle->at(sfLoanScale); + + auto const brokerID = loanSle->at(sfLoanBrokerID); + auto const brokerSle = view.peek(keylet::loanbroker(brokerID)); + if (!brokerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const brokerOwner = brokerSle->at(sfOwner); + auto const brokerPseudoAccount = brokerSle->at(sfAccount); + auto const vaultID = brokerSle->at(sfVaultID); + auto const vaultSle = view.peek(keylet::vault(vaultID)); + if (!vaultSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const vaultPseudoAccount = vaultSle->at(sfAccount); + auto const asset = *vaultSle->at(sfAsset); + + // Determine where to send the broker's fee + auto coverAvailableProxy = brokerSle->at(sfCoverAvailable); + TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; + auto debtTotalProxy = brokerSle->at(sfDebtTotal); + + // Send the broker fee to the owner if they have sufficient cover available, + // _and_ if the owner can receive funds. If not, so as not to block the + // payment, add it to the cover balance (send it to the broker pseudo + // account). + // + // Normally freeze status is checked in preflight, but we do it here to + // avoid duplicating the check. It'll claim a fee either way. + bool const sendBrokerFeeToOwner = [&]() { + // Round the minimum required cover up to be conservative. This ensures + // CoverAvailable never drops below the theoretical minimum, protecting + // the broker's solvency. + NumberRoundModeGuard mg(Number::upward); + return coverAvailableProxy >= + roundToAsset( + asset, + tenthBipsOfValue(debtTotalProxy.value(), coverRateMinimum), + loanScale) && + !isDeepFrozen(view, brokerOwner, asset); + }(); + + auto const brokerPayee = + sendBrokerFeeToOwner ? brokerOwner : brokerPseudoAccount; + auto const brokerPayeeSle = view.peek(keylet::account(brokerPayee)); + if (!sendBrokerFeeToOwner) + { + // If we can't send the fee to the owner, and the pseudo-account is + // frozen, then we have to fail the payment. + if (auto const ret = checkDeepFrozen(view, brokerPayee, asset)) + { + JLOG(j_.warn()) + << "Both Loan Broker and Loan Broker pseudo-account " + "can not receive funds (deep frozen)."; + return ret; + } + } + + //------------------------------------------------------ + // Loan object state changes + + // Unimpair the loan if it was impaired. Do this before the payment is + // attempted, so the original values can be used. If the payment fails, this + // change will be discarded. + if (loanSle->isFlag(lsfLoanImpaired)) + { + LoanManage::unimpairLoan(view, loanSle, vaultSle, j_); + } + + LoanPaymentType const paymentType = [&tx]() { + // preflight already checked that at most one flag is set. + if (tx.isFlag(tfLoanLatePayment)) + return LoanPaymentType::late; + if (tx.isFlag(tfLoanFullPayment)) + return LoanPaymentType::full; + if (tx.isFlag(tfLoanOverpayment)) + return LoanPaymentType::overpayment; + return LoanPaymentType::regular; + }(); + + Expected const paymentParts = loanMakePayment( + asset, view, loanSle, brokerSle, amount, paymentType, j_); + + if (!paymentParts) + { + XRPL_ASSERT_PARTS( + paymentParts.error(), + "ripple::LoanPay::doApply", + "payment error is an error"); + return paymentParts.error(); + } + + // If the payment computation completed without error, the loanSle object + // has been modified. + view.update(loanSle); + + XRPL_ASSERT_PARTS( + // It is possible to pay 0 principal + paymentParts->principalPaid >= 0, + "ripple::LoanPay::doApply", + "valid principal paid"); + XRPL_ASSERT_PARTS( + // It is possible to pay 0 interest + paymentParts->interestPaid >= 0, + "ripple::LoanPay::doApply", + "valid interest paid"); + XRPL_ASSERT_PARTS( + // It should not be possible to pay 0 total + paymentParts->principalPaid + paymentParts->interestPaid > 0, + "ripple::LoanPay::doApply", + "valid total paid"); + XRPL_ASSERT_PARTS( + paymentParts->feePaid >= 0, + "ripple::LoanPay::doApply", + "valid fee paid"); + + if (paymentParts->principalPaid < 0 || paymentParts->interestPaid < 0 || + paymentParts->feePaid < 0) + { + // LCOV_EXCL_START + JLOG(j_.fatal()) << "Loan payment computation returned invalid values."; + return tecLIMIT_EXCEEDED; + // LCOV_EXCL_STOP + } + + JLOG(j_.debug()) << "Loan Pay: principal paid: " + << paymentParts->principalPaid + << ", interest paid: " << paymentParts->interestPaid + << ", fee paid: " << paymentParts->feePaid + << ", value change: " << paymentParts->valueChange; + + //------------------------------------------------------ + // LoanBroker object state changes + view.update(brokerSle); + + auto assetsAvailableProxy = vaultSle->at(sfAssetsAvailable); + auto assetsTotalProxy = vaultSle->at(sfAssetsTotal); + + // The vault may be at a different scale than the loan. Reduce rounding + // errors during the payment by rounding some of the values to that scale. + auto const vaultScale = assetsTotalProxy.value().exponent(); + + auto const totalPaidToVaultRaw = + paymentParts->principalPaid + paymentParts->interestPaid; + auto const totalPaidToVaultRounded = + roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::downward); + XRPL_ASSERT_PARTS( + !asset.integral() || totalPaidToVaultRaw == totalPaidToVaultRounded, + "ripple::LoanPay::doApply", + "rounding does nothing for integral asset"); + // Account for value changes when reducing the broker's debt: + // - Positive value change (from full/late/overpayments): Subtract from the + // amount credited toward debt to avoid over-reducing the debt. + // - Negative value change (from full/overpayments): Add to the amount + // credited toward debt,effectively increasing the debt reduction. + auto const totalPaidToVaultForDebt = + totalPaidToVaultRaw - paymentParts->valueChange; + + auto const totalPaidToBroker = paymentParts->feePaid; + + XRPL_ASSERT_PARTS( + (totalPaidToVaultRaw + totalPaidToBroker) == + (paymentParts->principalPaid + paymentParts->interestPaid + + paymentParts->feePaid), + "ripple::LoanPay::doApply", + "payments add up"); + + // Decrease LoanBroker Debt by the amount paid, add the Loan value change + // (which might be negative). totalPaidToVaultForDebt may be negative, + // increasing the debt + XRPL_ASSERT_PARTS( + isRounded(asset, totalPaidToVaultForDebt, loanScale), + "ripple::LoanPay::doApply", + "totalPaidToVaultForDebt rounding good"); + // Despite our best efforts, it's possible for rounding errors to accumulate + // in the loan broker's debt total. This is because the broker may have more + // than one loan with significantly different scales. + adjustImpreciseNumber( + debtTotalProxy, -totalPaidToVaultForDebt, asset, vaultScale); + + //------------------------------------------------------ + // Vault object state changes + view.update(vaultSle); + + Number const assetsAvailableBefore = *assetsAvailableProxy; + Number const pseudoAccountBalanceBefore = accountHolds( + view, + vaultPseudoAccount, + asset, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j_); + + { + XRPL_ASSERT_PARTS( + assetsAvailableBefore == pseudoAccountBalanceBefore, + "ripple::LoanPay::doApply", + "vault pseudo balance agrees before"); + + assetsAvailableProxy += totalPaidToVaultRounded; + assetsTotalProxy += paymentParts->valueChange; + + XRPL_ASSERT_PARTS( + *assetsAvailableProxy <= *assetsTotalProxy, + "ripple::LoanPay::doApply", + "assets available must not be greater than assets outstanding"); + + if (*assetsAvailableProxy > *assetsTotalProxy) + { + // LCOV_EXCL_START + return tecINTERNAL; + // LCOV_EXCL_STOP + } + } + + JLOG(j_.debug()) << "total paid to vault raw: " << totalPaidToVaultRaw + << ", total paid to vault rounded: " + << totalPaidToVaultRounded + << ", total paid to broker: " << totalPaidToBroker + << ", amount from transaction: " << amount; + + // Move funds + XRPL_ASSERT_PARTS( + totalPaidToVaultRounded + totalPaidToBroker <= amount, + "ripple::LoanPay::doApply", + "amount is sufficient"); + + if (!sendBrokerFeeToOwner) + { + // If there is not enough first-loss capital, add the fee to First Loss + // Cover Pool. Note that this moves the entire fee - it does not attempt + // to split it. The broker can Withdraw it later if they want, or leave + // it for future needs. + coverAvailableProxy += totalPaidToBroker; + } + +#if !NDEBUG + auto const accountBalanceBefore = accountSpendable( + view, account_, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); + auto const vaultBalanceBefore = account_ == vaultPseudoAccount + ? STAmount{asset, 0} + : accountSpendable( + view, + vaultPseudoAccount, + asset, + fhIGNORE_FREEZE, + ahIGNORE_AUTH, + j_); + auto const brokerBalanceBefore = account_ == brokerPayee + ? STAmount{asset, 0} + : accountSpendable( + view, brokerPayee, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); +#endif + + if (totalPaidToVaultRounded != beast::zero) + { + if (auto const ter = requireAuth( + view, asset, vaultPseudoAccount, AuthType::StrongAuth)) + return ter; + } + + if (totalPaidToBroker != beast::zero) + { + if (brokerPayee == account_) + { + // The broker may have deleted their holding. Recreate it if needed + if (auto const ter = addEmptyHolding( + view, + brokerPayee, + brokerPayeeSle->at(sfBalance).value().xrp(), + asset, + j_); + ter && ter != tecDUPLICATE) + // ignore tecDUPLICATE. That means the holding already exists, + // and is fine here + return ter; + } + if (auto const ter = + requireAuth(view, asset, brokerPayee, AuthType::StrongAuth)) + return ter; + } + + if (auto const ter = accountSendMulti( + view, + account_, + asset, + {{vaultPseudoAccount, totalPaidToVaultRounded}, + {brokerPayee, totalPaidToBroker}}, + j_, + WaiveTransferFee::Yes)) + return ter; + + Number const assetsAvailableAfter = *assetsAvailableProxy; + Number const pseudoAccountBalanceAfter = accountHolds( + view, + vaultPseudoAccount, + asset, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j_); + XRPL_ASSERT_PARTS( + assetsAvailableAfter == pseudoAccountBalanceAfter, + "ripple::LoanPay::doApply", + "vault pseudo balance agrees after"); + +#if !NDEBUG + auto const accountBalanceAfter = accountSpendable( + view, account_, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); + auto const vaultBalanceAfter = account_ == vaultPseudoAccount + ? STAmount{asset, 0} + : accountSpendable( + view, + vaultPseudoAccount, + asset, + fhIGNORE_FREEZE, + ahIGNORE_AUTH, + j_); + auto const brokerBalanceAfter = account_ == brokerPayee + ? STAmount{asset, 0} + : accountSpendable( + view, brokerPayee, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); + + XRPL_ASSERT_PARTS( + accountBalanceBefore + vaultBalanceBefore + brokerBalanceBefore == + accountBalanceAfter + vaultBalanceAfter + brokerBalanceAfter, + "ripple::LoanPay::doApply", + "funds are conserved (with rounding)"); + XRPL_ASSERT_PARTS( + accountBalanceAfter >= beast::zero, + "ripple::LoanPay::doApply", + "positive account balance"); + XRPL_ASSERT_PARTS( + accountBalanceAfter < accountBalanceBefore || + account_ == asset.getIssuer(), + "ripple::LoanPay::doApply", + "account balance decreased"); + XRPL_ASSERT_PARTS( + vaultBalanceAfter >= beast::zero && brokerBalanceAfter >= beast::zero, + "ripple::LoanPay::doApply", + "positive vault and broker balances"); + XRPL_ASSERT_PARTS( + vaultBalanceAfter >= vaultBalanceBefore, + "ripple::LoanPay::doApply", + "vault balance did not decrease"); + XRPL_ASSERT_PARTS( + brokerBalanceAfter >= brokerBalanceBefore, + "ripple::LoanPay::doApply", + "broker balance did not decrease"); + XRPL_ASSERT_PARTS( + vaultBalanceAfter > vaultBalanceBefore || + brokerBalanceAfter > brokerBalanceBefore, + "ripple::LoanPay::doApply", + "vault and/or broker balance increased"); +#endif + + return tesSUCCESS; +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanPay.h b/src/xrpld/app/tx/detail/LoanPay.h new file mode 100644 index 0000000000..3f8eb16d04 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanPay.h @@ -0,0 +1,40 @@ +#ifndef XRPL_TX_LOANPAY_H_INCLUDED +#define XRPL_TX_LOANPAY_H_INCLUDED + +#include + +namespace ripple { + +class LoanPay : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanPay(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static std::uint32_t + getFlagsMask(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/LoanSet.cpp b/src/xrpld/app/tx/detail/LoanSet.cpp new file mode 100644 index 0000000000..838e774cae --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanSet.cpp @@ -0,0 +1,632 @@ +#include +// +#include + +#include + +namespace ripple { + +bool +LoanSet::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +std::uint32_t +LoanSet::getFlagsMask(PreflightContext const& ctx) +{ + return tfLoanSetMask; +} + +NotTEC +LoanSet::preflight(PreflightContext const& ctx) +{ + using namespace Lending; + + auto const& tx = ctx.tx; + + // Special case for Batch inner transactions + if (tx.isFlag(tfInnerBatchTxn) && ctx.rules.enabled(featureBatch) && + !tx.isFieldPresent(sfCounterparty)) + { + auto const parentBatchId = ctx.parentBatchId.value_or(uint256{0}); + JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " + << "no Counterparty for inner LoanSet transaction."; + return temBAD_SIGNER; + } + + // These extra hoops are because STObjects cannot be Proxy'd from STObject. + auto const counterPartySig = [&tx]() -> std::optional { + if (tx.isFieldPresent(sfCounterpartySignature)) + return tx.getFieldObject(sfCounterpartySignature); + return std::nullopt; + }(); + if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig) + { + JLOG(ctx.j.warn()) + << "LoanSet transaction must have a CounterpartySignature."; + return temBAD_SIGNER; + } + + if (counterPartySig) + { + if (auto const ret = ripple::detail::preflightCheckSigningKey( + *counterPartySig, ctx.j)) + return ret; + } + + if (auto const data = tx[~sfData]; data && !data->empty() && + !validDataLength(tx[~sfData], maxDataPayloadLength)) + return temINVALID; + for (auto const& field : + {&sfLoanServiceFee, &sfLatePaymentFee, &sfClosePaymentFee}) + { + if (!validNumericMinimum(tx[~*field])) + return temINVALID; + } + // Principal Requested is required + if (auto const p = tx[sfPrincipalRequested]; p <= 0) + return temINVALID; + else if (!validNumericRange(tx[~sfLoanOriginationFee], p)) + return temINVALID; + if (!validNumericRange(tx[~sfInterestRate], maxInterestRate)) + return temINVALID; + if (!validNumericRange(tx[~sfOverpaymentFee], maxOverpaymentFee)) + return temINVALID; + if (!validNumericRange(tx[~sfLateInterestRate], maxLateInterestRate)) + return temINVALID; + if (!validNumericRange(tx[~sfCloseInterestRate], maxCloseInterestRate)) + return temINVALID; + if (!validNumericRange( + tx[~sfOverpaymentInterestRate], maxOverpaymentInterestRate)) + return temINVALID; + + if (auto const paymentTotal = tx[~sfPaymentTotal]; + paymentTotal && *paymentTotal <= 0) + return temINVALID; + + if (auto const paymentInterval = tx[~sfPaymentInterval]; + !validNumericMinimum(paymentInterval, LoanSet::minPaymentInterval)) + return temINVALID; + + else if (!validNumericRange( + tx[~sfGracePeriod], + paymentInterval.value_or(LoanSet::defaultPaymentInterval))) + return temINVALID; + + // Copied from preflight2 + if (counterPartySig) + { + if (auto const ret = ripple::detail::preflightCheckSimulateKeys( + ctx.flags, *counterPartySig, ctx.j)) + return *ret; + } + + if (auto const brokerID = ctx.tx[~sfLoanBrokerID]; + brokerID && *brokerID == beast::zero) + return temINVALID; + + return tesSUCCESS; +} + +NotTEC +LoanSet::checkSign(PreclaimContext const& ctx) +{ + if (auto ret = Transactor::checkSign(ctx)) + return ret; + + // Counter signer is optional. If it's not specified, it's assumed to be + // `LoanBroker.Owner`. Note that we have not checked whether the + // loanbroker exists at this point. + auto const counterSigner = [&]() -> std::optional { + if (auto const c = ctx.tx.at(~sfCounterparty)) + return c; + + if (auto const broker = + ctx.view.read(keylet::loanbroker(ctx.tx[sfLoanBrokerID]))) + return broker->at(sfOwner); + return std::nullopt; + }(); + if (!counterSigner) + return temBAD_SIGNER; + + // Counterparty signature is optional. Presence is checked in preflight. + if (!ctx.tx.isFieldPresent(sfCounterpartySignature)) + return tesSUCCESS; + auto const counterSig = ctx.tx.getFieldObject(sfCounterpartySignature); + return Transactor::checkSign( + ctx.view, + ctx.flags, + ctx.parentBatchId, + *counterSigner, + counterSig, + ctx.j); +} + +XRPAmount +LoanSet::calculateBaseFee(ReadView const& view, STTx const& tx) +{ + auto const normalCost = Transactor::calculateBaseFee(view, tx); + + // Compute the additional cost of each signature in the + // CounterpartySignature, whether a single signature or a multisignature + XRPAmount const baseFee = view.fees().base; + + // Counterparty signature is optional, but getFieldObject will return an + // empty object if it's not present. + auto const counterSig = tx.getFieldObject(sfCounterpartySignature); + // Each signer adds one more baseFee to the minimum required fee + // for the transaction. Note that unlike the base class, the single signer + // is counted if present. It will only be absent in a batch inner + // transaction. + std::size_t const signerCount = [&counterSig]() { + // Compute defensively. Assure that "tx" cannot be accessed and cause + // confusion or miscalculations. + return counterSig.isFieldPresent(sfSigners) + ? counterSig.getFieldArray(sfSigners).size() + : (counterSig.isFieldPresent(sfTxnSignature) ? 1 : 0); + }(); + + return normalCost + (signerCount * baseFee); +} + +std::vector> const& +LoanSet::getValueFields() +{ + static std::vector> const valueFields{ + ~sfPrincipalRequested, + ~sfLoanOriginationFee, + ~sfLoanServiceFee, + ~sfLatePaymentFee, + ~sfClosePaymentFee + // Overpayment fee is really a rate. Don't check it here. + }; + + return valueFields; +} + +static std::uint32_t +getStartDate(ReadView const& view) +{ + return view.info().closeTime.time_since_epoch().count(); +} + +TER +LoanSet::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + { + // Check for numeric overflow of the schedule before we load any + // objects. The Grace Period for the last payment ends at: + // startDate + (paymentInterval * paymentTotal) + gracePeriod. + // If that value is larger than "maxTime", the value + // overflows, and we kill the transaction. + using timeType = decltype(sfNextPaymentDueDate)::type::value_type; + static_assert(std::is_same_v); + timeType constexpr maxTime = std::numeric_limits::max(); + static_assert(maxTime == 4'294'967'295); + + auto const timeAvailable = maxTime - getStartDate(ctx.view); + + auto const interval = + ctx.tx.at(~sfPaymentInterval).value_or(defaultPaymentInterval); + auto const total = + ctx.tx.at(~sfPaymentTotal).value_or(defaultPaymentTotal); + auto const grace = + ctx.tx.at(~sfGracePeriod).value_or(defaultGracePeriod); + + // The grace period can't be larger than the interval. Check it first, + // mostly so that unit tests can test that specific case. + if (grace > timeAvailable) + { + JLOG(ctx.j.warn()) << "Grace period exceeds protocol time limit."; + return tecKILLED; + } + + if (interval > timeAvailable) + { + JLOG(ctx.j.warn()) + << "Payment interval exceeds protocol time limit."; + return tecKILLED; + } + + if (total > timeAvailable) + { + JLOG(ctx.j.warn()) << "Payment total exceeds protocol time limit."; + return tecKILLED; + } + + auto const timeLastPayment = timeAvailable - grace; + + if (timeLastPayment / interval < total) + { + JLOG(ctx.j.warn()) << "Last payment due date, or grace period for " + "last payment exceeds protocol time limit."; + return tecKILLED; + } + } + + auto const account = tx[sfAccount]; + auto const brokerID = tx[sfLoanBrokerID]; + + auto const brokerSle = ctx.view.read(keylet::loanbroker(brokerID)); + if (!brokerSle) + { + // This can only be hit if there's a counterparty specified, otherwise + // it'll fail in the signature check + JLOG(ctx.j.warn()) << "LoanBroker does not exist."; + return tecNO_ENTRY; + } + auto const brokerOwner = brokerSle->at(sfOwner); + auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner); + if (account != brokerOwner && counterparty != brokerOwner) + { + JLOG(ctx.j.warn()) << "Neither Account nor Counterparty are the owner " + "of the LoanBroker."; + return tecNO_PERMISSION; + } + auto const brokerPseudo = brokerSle->at(sfAccount); + + auto const borrower = counterparty == brokerOwner ? account : counterparty; + if (auto const borrowerSle = ctx.view.read(keylet::account(borrower)); + !borrowerSle) + { + // It may not be possible to hit this case, because it'll fail the + // signature check with terNO_ACCOUNT. + JLOG(ctx.j.warn()) << "Borrower does not exist."; + return terNO_ACCOUNT; + } + + auto const vault = ctx.view.read(keylet::vault(brokerSle->at(sfVaultID))); + if (!vault) + // Should be impossible + return tefBAD_LEDGER; // LCOV_EXCL_LINE + Asset const asset = vault->at(sfAsset); + + auto const vaultPseudo = vault->at(sfAccount); + + // Check that relevant values can be represented as the vault asset type. + // This check is almost duplicated in doApply, but that check is done after + // the overall loan scale is known. This is mostly only relevant for + // integral (non-IOU) types + { + for (auto const& field : getValueFields()) + { + if (auto const value = tx[field]; + value && STAmount{asset, *value} != *value) + { + JLOG(ctx.j.warn()) << field.f->getName() << " (" << *value + << ") can not be represented as a(n) " + << to_string(asset) << "."; + return tecPRECISION_LOSS; + } + } + } + + if (auto const ter = canAddHolding(ctx.view, asset)) + return ter; + + // vaultPseudo is going to send funds, so it can't be frozen. + if (auto const ret = checkFrozen(ctx.view, vaultPseudo, asset)) + { + JLOG(ctx.j.warn()) << "Vault pseudo-account is frozen."; + return ret; + } + + // brokerPseudo is the fallback account to receive LoanPay fees, even if the + // broker owner is unable to accept them. Don't create the loan if it is + // deep frozen. + if (auto const ret = checkDeepFrozen(ctx.view, brokerPseudo, asset)) + { + JLOG(ctx.j.warn()) << "Broker pseudo-account is frozen."; + return ret; + } + + // borrower is eventually going to have to pay back the loan, so it can't be + // frozen now. It is also going to receive funds, so it can't be deep + // frozen, but being frozen is a prerequisite for being deep frozen, so + // checking the one is sufficient. + if (auto const ret = checkFrozen(ctx.view, borrower, asset)) + { + JLOG(ctx.j.warn()) << "Borrower account is frozen."; + return ret; + } + // brokerOwner is going to receive funds if there's an origination fee, so + // it can't be deep frozen + if (auto const ret = checkDeepFrozen(ctx.view, brokerOwner, asset)) + { + JLOG(ctx.j.warn()) << "Broker owner account is frozen."; + return ret; + } + + return tesSUCCESS; +} + +TER +LoanSet::doApply() +{ + auto const& tx = ctx_.tx; + auto& view = ctx_.view(); + + auto const brokerID = tx[sfLoanBrokerID]; + + auto const brokerSle = view.peek(keylet::loanbroker(brokerID)); + if (!brokerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const brokerOwner = brokerSle->at(sfOwner); + auto const brokerOwnerSle = view.peek(keylet::account(brokerOwner)); + if (!brokerOwnerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + auto const vaultSle = view.peek(keylet ::vault(brokerSle->at(sfVaultID))); + if (!vaultSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const vaultPseudo = vaultSle->at(sfAccount); + Asset const vaultAsset = vaultSle->at(sfAsset); + + auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner); + auto const borrower = counterparty == brokerOwner ? account_ : counterparty; + auto const borrowerSle = view.peek(keylet::account(borrower)); + if (!borrowerSle) + { + return tefBAD_LEDGER; // LCOV_EXCL_LINE + } + + auto const brokerPseudo = brokerSle->at(sfAccount); + auto const brokerPseudoSle = view.peek(keylet::account(brokerPseudo)); + if (!brokerPseudoSle) + { + return tefBAD_LEDGER; // LCOV_EXCL_LINE + } + auto const principalRequested = tx[sfPrincipalRequested]; + + auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); + auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); + auto const vaultScale = getVaultScale(vaultSle); + if (vaultAvailableProxy < principalRequested) + { + JLOG(j_.warn()) + << "Insufficient assets available in the Vault to fund the loan."; + return tecINSUFFICIENT_FUNDS; + } + + TenthBips32 const interestRate{tx[~sfInterestRate].value_or(0)}; + + auto const paymentInterval = + tx[~sfPaymentInterval].value_or(defaultPaymentInterval); + auto const paymentTotal = tx[~sfPaymentTotal].value_or(defaultPaymentTotal); + + auto const properties = computeLoanProperties( + vaultAsset, + principalRequested, + interestRate, + paymentInterval, + paymentTotal, + TenthBips16{brokerSle->at(sfManagementFeeRate)}, + vaultScale); + + // Check that relevant values won't lose precision. This is mostly only + // relevant for IOU assets. + { + for (auto const& field : getValueFields()) + { + if (auto const value = tx[field]; + value && !isRounded(vaultAsset, *value, properties.loanScale)) + { + JLOG(j_.warn()) + << field.f->getName() << " (" << *value + << ") has too much precision. Total loan value is " + << properties.totalValueOutstanding << " with a scale of " + << properties.loanScale; + return tecPRECISION_LOSS; + } + } + } + + if (auto const ret = checkLoanGuards( + vaultAsset, + principalRequested, + interestRate != beast::zero, + paymentTotal, + properties, + j_)) + return ret; + + // Check that the other computed values are valid + if (properties.managementFeeOwedToBroker < 0 || + properties.totalValueOutstanding <= 0 || + properties.periodicPayment <= 0) + { + // LCOV_EXCL_START + JLOG(j_.warn()) + << "Computed loan properties are invalid. Does not compute."; + return tecINTERNAL; + // LCOV_EXCL_STOP + } + + LoanState const state = constructLoanState( + properties.totalValueOutstanding, + principalRequested, + properties.managementFeeOwedToBroker); + + auto const originationFee = tx[~sfLoanOriginationFee].value_or(Number{}); + + auto const loanAssetsToBorrower = principalRequested - originationFee; + + auto const newDebtDelta = principalRequested + state.interestDue; + auto const newDebtTotal = brokerSle->at(sfDebtTotal) + newDebtDelta; + if (auto const debtMaximum = brokerSle->at(sfDebtMaximum); + debtMaximum != 0 && debtMaximum < newDebtTotal) + { + JLOG(j_.warn()) + << "Loan would exceed the maximum debt limit of the LoanBroker."; + return tecLIMIT_EXCEEDED; + } + TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; + { + // Round the minimum required cover up to be conservative. This ensures + // CoverAvailable never drops below the theoretical minimum, protecting + // the broker's solvency. + NumberRoundModeGuard mg(Number::upward); + if (brokerSle->at(sfCoverAvailable) < + tenthBipsOfValue(newDebtTotal, coverRateMinimum)) + { + JLOG(j_.warn()) + << "Insufficient first-loss capital to cover the loan."; + return tecINSUFFICIENT_FUNDS; + } + } + + adjustOwnerCount(view, borrowerSle, 1, j_); + { + auto const ownerCount = borrowerSle->at(sfOwnerCount); + auto const balance = account_ == borrower + ? mPriorBalance + : borrowerSle->at(sfBalance).value().xrp(); + if (balance < view.fees().accountReserve(ownerCount)) + return tecINSUFFICIENT_RESERVE; + } + + // Account for the origination fee using two payments + // + // 1. Transfer loanAssetsAvailable (principalRequested - originationFee) + // from vault pseudo-account to the borrower. + // Create a holding for the borrower if one does not already exist. + + XRPL_ASSERT_PARTS( + borrower == account_ || borrower == counterparty, + "ripple::LoanSet::doApply", + "borrower signed transaction"); + if (auto const ter = addEmptyHolding( + view, + borrower, + borrowerSle->at(sfBalance).value().xrp(), + vaultAsset, + j_); + ter && ter != tecDUPLICATE) + // ignore tecDUPLICATE. That means the holding already exists, and + // is fine here + return ter; + + if (auto const ter = + requireAuth(view, vaultAsset, borrower, AuthType::StrongAuth)) + return ter; + + // 2. Transfer originationFee, if any, from vault pseudo-account to + // LoanBroker owner. + if (originationFee != beast::zero) + { + // Create the holding if it doesn't already exist (necessary for MPTs). + // The owner may have deleted their MPT / line at some point. + XRPL_ASSERT_PARTS( + brokerOwner == account_ || brokerOwner == counterparty, + "ripple::LoanSet::doApply", + "broker owner signed transaction"); + + if (auto const ter = addEmptyHolding( + view, + brokerOwner, + brokerOwnerSle->at(sfBalance).value().xrp(), + vaultAsset, + j_); + ter && ter != tecDUPLICATE) + // ignore tecDUPLICATE. That means the holding already exists, + // and is fine here + return ter; + + if (auto const ter = requireAuth( + view, vaultAsset, brokerOwner, AuthType::StrongAuth)) + return ter; + } + + if (auto const ter = accountSendMulti( + view, + vaultPseudo, + vaultAsset, + {{borrower, loanAssetsToBorrower}, {brokerOwner, originationFee}}, + j_, + WaiveTransferFee::Yes)) + return ter; + + // Get shortcuts to the loan property values + auto const startDate = getStartDate(view); + auto loanSequenceProxy = brokerSle->at(sfLoanSequence); + + // Create the loan + auto loan = + std::make_shared(keylet::loan(brokerID, *loanSequenceProxy)); + + // Prevent copy/paste errors + auto setLoanField = + [&loan, &tx](auto const& field, std::uint32_t const defValue = 0) { + // at() is smart enough to unseat a default field set to the default + // value + loan->at(field) = tx[field].value_or(defValue); + }; + + // Set required and fixed tx fields + loan->at(sfLoanScale) = properties.loanScale; + loan->at(sfStartDate) = startDate; + loan->at(sfPaymentInterval) = paymentInterval; + loan->at(sfLoanSequence) = *loanSequenceProxy; + loan->at(sfLoanBrokerID) = brokerID; + loan->at(sfBorrower) = borrower; + // Set all other transaction fields directly from the transaction + if (tx.isFlag(tfLoanOverpayment)) + loan->setFlag(lsfLoanOverpayment); + setLoanField(~sfLoanOriginationFee); + setLoanField(~sfLoanServiceFee); + setLoanField(~sfLatePaymentFee); + setLoanField(~sfClosePaymentFee); + setLoanField(~sfOverpaymentFee); + setLoanField(~sfInterestRate); + setLoanField(~sfLateInterestRate); + setLoanField(~sfCloseInterestRate); + setLoanField(~sfOverpaymentInterestRate); + setLoanField(~sfGracePeriod, defaultGracePeriod); + // Set dynamic / computed fields to their initial values + loan->at(sfPrincipalOutstanding) = principalRequested; + loan->at(sfPeriodicPayment) = properties.periodicPayment; + loan->at(sfTotalValueOutstanding) = properties.totalValueOutstanding; + loan->at(sfManagementFeeOutstanding) = properties.managementFeeOwedToBroker; + loan->at(sfPreviousPaymentDate) = 0; + loan->at(sfNextPaymentDueDate) = startDate + paymentInterval; + loan->at(sfPaymentRemaining) = paymentTotal; + view.insert(loan); + + // Update the balances in the vault + vaultAvailableProxy -= principalRequested; + vaultTotalProxy += state.interestDue; + XRPL_ASSERT_PARTS( + *vaultAvailableProxy <= *vaultTotalProxy, + "ripple::LoanSet::doApply", + "assets available must not be greater than assets outstanding"); + view.update(vaultSle); + + // Update the balances in the loan broker + adjustImpreciseNumber( + brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale); + // The broker's owner count is solely for the number of outstanding loans, + // and is distinct from the broker's pseudo-account's owner count + adjustOwnerCount(view, brokerSle, 1, j_); + loanSequenceProxy += 1; + // The sequence should be extremely unlikely to roll over, but fail if it + // does + if (loanSequenceProxy == 0) + return tecMAX_SEQUENCE_REACHED; + view.update(brokerSle); + + // Put the loan into the pseudo-account's directory + if (auto const ter = dirLink(view, brokerPseudo, loan, sfLoanBrokerNode)) + return ter; + // Borrower is the owner of the loan + if (auto const ter = dirLink(view, borrower, loan, sfOwnerNode)) + return ter; + + return tesSUCCESS; +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanSet.h b/src/xrpld/app/tx/detail/LoanSet.h new file mode 100644 index 0000000000..91f3960891 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanSet.h @@ -0,0 +1,59 @@ +#ifndef XRPL_TX_LOANSET_H_INCLUDED +#define XRPL_TX_LOANSET_H_INCLUDED + +#include +#include + +namespace ripple { + +class LoanSet : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanSet(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static std::uint32_t + getFlagsMask(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static NotTEC + checkSign(PreclaimContext const& ctx); + + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx); + + static std::vector> const& + getValueFields(); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + +public: + static std::uint32_t constexpr minPaymentTotal = 1; + static std::uint32_t constexpr defaultPaymentTotal = 1; + static_assert(defaultPaymentTotal >= minPaymentTotal); + + static std::uint32_t constexpr minPaymentInterval = 60; + static std::uint32_t constexpr defaultPaymentInterval = 60; + static_assert(defaultPaymentInterval >= minPaymentInterval); + + static std::uint32_t constexpr defaultGracePeriod = 60; + static_assert(defaultGracePeriod >= minPaymentInterval); +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/MPTokenAuthorize.cpp b/src/xrpld/app/tx/detail/MPTokenAuthorize.cpp index b8728d23cf..ef4dc39733 100644 --- a/src/xrpld/app/tx/detail/MPTokenAuthorize.cpp +++ b/src/xrpld/app/tx/detail/MPTokenAuthorize.cpp @@ -113,7 +113,8 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } - if (!ctx.view.exists(keylet::account(*holderID))) + auto const sleHolder = ctx.view.read(keylet::account(*holderID)); + if (!sleHolder) return tecNO_DST; auto const sleMptIssuance = @@ -143,6 +144,12 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) keylet::mptoken(ctx.tx[sfMPTokenIssuanceID], *holderID))) return tecOBJECT_NOT_FOUND; + // Can't unauthorize the pseudo-accounts because they are implicitly + // always authorized. No need to amendment gate since Vault and LoanBroker + // can only be created if the Vault amendment is enabled. + if (isPseudoAccount(ctx.view, *holderID, {&sfVaultID, &sfLoanBrokerID})) + return tecNO_PERMISSION; + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/SetTrust.cpp b/src/xrpld/app/tx/detail/SetTrust.cpp index d881425960..cc50e556d4 100644 --- a/src/xrpld/app/tx/detail/SetTrust.cpp +++ b/src/xrpld/app/tx/detail/SetTrust.cpp @@ -295,7 +295,9 @@ SetTrust::preclaim(PreclaimContext const& ctx) else return tecINTERNAL; // LCOV_EXCL_LINE } - else if (sleDst->isFieldPresent(sfVaultID)) + else if ( + sleDst->isFieldPresent(sfVaultID) || + sleDst->isFieldPresent(sfLoanBrokerID)) { if (!ctx.view.exists(keylet::line(id, uDstAccountID, currency))) return tecNO_PERMISSION; diff --git a/src/xrpld/app/tx/detail/Transactor.cpp b/src/xrpld/app/tx/detail/Transactor.cpp index 2f62a142c0..cef93418c2 100644 --- a/src/xrpld/app/tx/detail/Transactor.cpp +++ b/src/xrpld/app/tx/detail/Transactor.cpp @@ -231,13 +231,24 @@ Transactor::preflight2(PreflightContext const& ctx) // regardless of success or failure return *ret; + // Skip signature check on batch inner transactions + if (ctx.tx.isFlag(tfInnerBatchTxn) && !ctx.rules.enabled(featureBatch)) + return tesSUCCESS; + // Do not add any checks after this point that are relevant for + // batch inner transactions. They will be skipped. + auto const sigValid = checkValidity( ctx.app.getHashRouter(), ctx.tx, ctx.rules, ctx.app.config()); if (sigValid.first == Validity::SigBad) - { + { // LCOV_EXCL_START JLOG(ctx.j.debug()) << "preflight2: bad signature. " << sigValid.second; - return temINVALID; // LCOV_EXCL_LINE + return temINVALID; + // LCOV_EXCL_STOP } + + // Do not add any checks after this point that are relevant for + // batch inner transactions. They will be skipped. + return tesSUCCESS; } @@ -670,13 +681,25 @@ NotTEC Transactor::checkSign( ReadView const& view, ApplyFlags flags, + std::optional const& parentBatchId, AccountID const& idAccount, STObject const& sigObject, beast::Journal const j) { + { + auto const sle = view.read(keylet::account(idAccount)); + + if (view.rules().enabled(featureLendingProtocol) && + isPseudoAccount(sle)) + // Pseudo-accounts can't sign transactions. This check is gated on + // the Lending Protocol amendment because that's the project it was + // added under, and it doesn't justify another amendment + return tefBAD_AUTH; + } + auto const pkSigner = sigObject.getFieldVL(sfSigningPubKey); // Ignore signature check on batch inner transactions - if (sigObject.isFlag(tfInnerBatchTxn) && view.rules().enabled(featureBatch)) + if (parentBatchId && view.rules().enabled(featureBatch)) { // Defensive Check: These values are also checked in Batch::preflight if (sigObject.isFieldPresent(sfTxnSignature) || !pkSigner.empty() || @@ -729,7 +752,8 @@ Transactor::checkSign(PreclaimContext const& ctx) auto const idAccount = ctx.tx.isFieldPresent(sfDelegate) ? ctx.tx.getAccountID(sfDelegate) : ctx.tx.getAccountID(sfAccount); - return checkSign(ctx.view, ctx.flags, idAccount, ctx.tx, ctx.j); + return checkSign( + ctx.view, ctx.flags, ctx.parentBatchId, idAccount, ctx.tx, ctx.j); } NotTEC diff --git a/src/xrpld/app/tx/detail/Transactor.h b/src/xrpld/app/tx/detail/Transactor.h index 17ef62e607..0e67ba70c4 100644 --- a/src/xrpld/app/tx/detail/Transactor.h +++ b/src/xrpld/app/tx/detail/Transactor.h @@ -285,6 +285,7 @@ protected: checkSign( ReadView const& view, ApplyFlags flags, + std::optional const& parentBatchId, AccountID const& idAccount, STObject const& sigObject, beast::Journal const j); @@ -306,14 +307,26 @@ protected: template static bool - validNumericRange(std::optional value, T max, T min = {}); + validNumericRange(std::optional value, T max, T min = T{}); template static bool validNumericRange( std::optional value, unit::ValueUnit max, - unit::ValueUnit min = {}); + unit::ValueUnit min = unit::ValueUnit{}); + + /// Minimum will usually be zero. + template + static bool + validNumericMinimum(std::optional value, T min = T{}); + + /// Minimum will usually be zero. + template + static bool + validNumericMinimum( + std::optional value, + unit::ValueUnit min = unit::ValueUnit{}); private: std::pair @@ -440,6 +453,24 @@ Transactor::validNumericRange( return validNumericRange(value, max.value(), min.value()); } +template +bool +Transactor::validNumericMinimum(std::optional value, T min) +{ + if (!value) + return true; + return value >= min; +} + +template +bool +Transactor::validNumericMinimum( + std::optional value, + unit::ValueUnit min) +{ + return validNumericMinimum(value, min.value()); +} + } // namespace ripple #endif diff --git a/src/xrpld/app/tx/detail/VaultCreate.cpp b/src/xrpld/app/tx/detail/VaultCreate.cpp index bd761528d5..ece7346541 100644 --- a/src/xrpld/app/tx/detail/VaultCreate.cpp +++ b/src/xrpld/app/tx/detail/VaultCreate.cpp @@ -41,8 +41,11 @@ VaultCreate::checkExtraFeatures(PreflightContext const& ctx) if (!ctx.rules.enabled(featureMPTokensV1)) return false; - return !ctx.tx.isFieldPresent(sfDomainID) || - ctx.rules.enabled(featurePermissionedDomains); + if (ctx.tx.isFieldPresent(sfDomainID) && + !ctx.rules.enabled(featurePermissionedDomains)) + return false; + + return true; } std::uint32_t diff --git a/src/xrpld/app/tx/detail/VaultDeposit.cpp b/src/xrpld/app/tx/detail/VaultDeposit.cpp index 05fd70de4e..549f033f6a 100644 --- a/src/xrpld/app/tx/detail/VaultDeposit.cpp +++ b/src/xrpld/app/tx/detail/VaultDeposit.cpp @@ -55,41 +55,19 @@ VaultDeposit::preclaim(PreclaimContext const& ctx) if (!vault) return tecNO_ENTRY; - auto const account = ctx.tx[sfAccount]; + auto const& account = ctx.tx[sfAccount]; auto const assets = ctx.tx[sfAmount]; auto const vaultAsset = vault->at(sfAsset); if (assets.asset() != vaultAsset) return tecWRONG_ASSET; - if (vaultAsset.native()) - ; // No special checks for XRP - else if (vaultAsset.holds()) + auto const& vaultAccount = vault->at(sfAccount); + if (auto ter = canTransfer(ctx.view, vaultAsset, account, vaultAccount); + !isTesSuccess(ter)) { - auto mptID = vaultAsset.get().getMptID(); - auto issuance = ctx.view.read(keylet::mptIssuance(mptID)); - if (!issuance) - return tecOBJECT_NOT_FOUND; - if (!issuance->isFlag(lsfMPTCanTransfer)) - { - // LCOV_EXCL_START - JLOG(ctx.j.error()) - << "VaultDeposit: vault assets are non-transferable."; - return tecNO_AUTH; - // LCOV_EXCL_STOP - } - } - else if (vaultAsset.holds()) - { - auto const issuer = - ctx.view.read(keylet::account(vaultAsset.getIssuer())); - if (!issuer) - { - // LCOV_EXCL_START - JLOG(ctx.j.error()) - << "VaultDeposit: missing issuer of vault assets."; - return tefINTERNAL; - // LCOV_EXCL_STOP - } + JLOG(ctx.j.debug()) + << "VaultDeposit: vault assets are non-transferable."; + return ter; } auto const mptIssuanceID = vault->at(sfShareMPTID); diff --git a/src/xrpld/app/tx/detail/VaultSet.cpp b/src/xrpld/app/tx/detail/VaultSet.cpp index 170a850a36..eff47621fa 100644 --- a/src/xrpld/app/tx/detail/VaultSet.cpp +++ b/src/xrpld/app/tx/detail/VaultSet.cpp @@ -33,8 +33,11 @@ namespace ripple { bool VaultSet::checkExtraFeatures(PreflightContext const& ctx) { - return !ctx.tx.isFieldPresent(sfDomainID) || - ctx.rules.enabled(featurePermissionedDomains); + if (ctx.tx.isFieldPresent(sfDomainID) && + !ctx.rules.enabled(featurePermissionedDomains)) + return false; + + return true; } NotTEC diff --git a/src/xrpld/app/tx/detail/VaultWithdraw.cpp b/src/xrpld/app/tx/detail/VaultWithdraw.cpp index 807fecb1f3..f36508846f 100644 --- a/src/xrpld/app/tx/detail/VaultWithdraw.cpp +++ b/src/xrpld/app/tx/detail/VaultWithdraw.cpp @@ -42,13 +42,10 @@ VaultWithdraw::preflight(PreflightContext const& ctx) if (ctx.tx[sfAmount] <= beast::zero) return temBAD_AMOUNT; - if (auto const destination = ctx.tx[~sfDestination]; - destination.has_value()) + if (auto const destination = ctx.tx[~sfDestination]) { if (*destination == beast::zero) { - JLOG(ctx.j.debug()) - << "VaultWithdraw: zero/empty destination account."; return temMALFORMED; } } @@ -69,35 +66,15 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) if (assets.asset() != vaultAsset && assets.asset() != vaultShare) return tecWRONG_ASSET; - if (vaultAsset.native()) - ; // No special checks for XRP - else if (vaultAsset.holds()) + auto const& vaultAccount = vault->at(sfAccount); + auto const& account = ctx.tx[sfAccount]; + auto const& dstAcct = ctx.tx[~sfDestination].value_or(account); + if (auto ter = canTransfer(ctx.view, vaultAsset, vaultAccount, dstAcct); + !isTesSuccess(ter)) { - auto mptID = vaultAsset.get().getMptID(); - auto issuance = ctx.view.read(keylet::mptIssuance(mptID)); - if (!issuance) - return tecOBJECT_NOT_FOUND; - if (!issuance->isFlag(lsfMPTCanTransfer)) - { - // LCOV_EXCL_START - JLOG(ctx.j.error()) - << "VaultWithdraw: vault assets are non-transferable."; - return tecNO_AUTH; - // LCOV_EXCL_STOP - } - } - else if (vaultAsset.holds()) - { - auto const issuer = - ctx.view.read(keylet::account(vaultAsset.getIssuer())); - if (!issuer) - { - // LCOV_EXCL_START - JLOG(ctx.j.error()) - << "VaultWithdraw: missing issuer of vault assets."; - return tefINTERNAL; - // LCOV_EXCL_STOP - } + JLOG(ctx.j.debug()) + << "VaultWithdraw: vault assets are non-transferable."; + return ter; } // Enforce valid withdrawal policy @@ -109,23 +86,8 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) // LCOV_EXCL_STOP } - auto const account = ctx.tx[sfAccount]; - auto const dstAcct = ctx.tx[~sfDestination].value_or(account); - auto const sleDst = ctx.view.read(keylet::account(dstAcct)); - if (sleDst == nullptr) - return account == dstAcct ? tecINTERNAL : tecNO_DST; - - if (sleDst->isFlag(lsfRequireDestTag) && - !ctx.tx.isFieldPresent(sfDestinationTag)) - return tecDST_TAG_NEEDED; // Cannot send without a tag - - // Withdrawal to a 3rd party destination account is essentially a transfer, - // via shares in the vault. Enforce all the usual asset transfer checks. - if (account != dstAcct && sleDst->isFlag(lsfDepositAuth)) - { - if (!ctx.view.exists(keylet::depositPreauth(dstAcct, account))) - return tecNO_PERMISSION; - } + if (auto const ret = canWithdraw(ctx.view, ctx.tx)) + return ret; // If sending to Account (i.e. not a transfer), we will also create (only // if authorized) a trust line or MPToken as needed, in doApply(). @@ -295,43 +257,16 @@ VaultWithdraw::doApply() } auto const dstAcct = ctx_.tx[~sfDestination].value_or(account_); - if (!vaultAsset.native() && // - dstAcct != vaultAsset.getIssuer() && // - dstAcct == account_) - { - if (auto const ter = addEmptyHolding( - view(), account_, mPriorBalance, vaultAsset, j_); - !isTesSuccess(ter) && ter != tecDUPLICATE) - return ter; - } - // Transfer assets from vault to depositor or destination account. - if (auto const ter = accountSend( - view(), - vaultAccount, - dstAcct, - assetsWithdrawn, - j_, - WaiveTransferFee::Yes); - !isTesSuccess(ter)) - return ter; - - // Sanity check - if (accountHolds( - view(), - vaultAccount, - assetsWithdrawn.asset(), - FreezeHandling::fhIGNORE_FREEZE, - AuthHandling::ahIGNORE_AUTH, - j_) < beast::zero) - { - // LCOV_EXCL_START - JLOG(j_.error()) << "VaultWithdraw: negative balance of vault assets."; - return tefINTERNAL; - // LCOV_EXCL_STOP - } - - return tesSUCCESS; + return doWithdraw( + view(), + ctx_.tx, + account_, + dstAcct, + vaultAccount, + mPriorBalance, + assetsWithdrawn, + j_); } } // namespace ripple diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index 76dca3970f..eef199fa35 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -1286,8 +1286,23 @@ PeerImp::handleTransaction( // Charge strongly for attempting to relay a txn with tfInnerBatchTxn // LCOV_EXCL_START - if (stx->isFlag(tfInnerBatchTxn) && - getCurrentTransactionRules()->enabled(featureBatch)) + /* + There is no need to check whether the featureBatch amendment is + enabled. + + * If the `tfInnerBatchTxn` flag is set, and the amendment is + enabled, then it's an invalid transaction because inner batch + transactions should not be relayed. + * If the `tfInnerBatchTxn` flag is set, and the amendment is *not* + enabled, then the transaction is malformed because it's using an + "unknown" flag. There's no need to waste the resources to send it + to the transaction engine. + + We don't normally check transaction validity at this level, but + since we _need_ to check it when the amendment is enabled, we may as + well drop it if the flag is set regardless. + */ + if (stx->isFlag(tfInnerBatchTxn)) { JLOG(p_journal_.warn()) << "Ignoring Network relayed Tx containing " "tfInnerBatchTxn (handleTransaction)."; @@ -2857,8 +2872,23 @@ PeerImp::checkTransaction( { // charge strongly for relaying batch txns // LCOV_EXCL_START - if (stx->isFlag(tfInnerBatchTxn) && - getCurrentTransactionRules()->enabled(featureBatch)) + /* + There is no need to check whether the featureBatch amendment is + enabled. + + * If the `tfInnerBatchTxn` flag is set, and the amendment is + enabled, then it's an invalid transaction because inner batch + transactions should not be relayed. + * If the `tfInnerBatchTxn` flag is set, and the amendment is *not* + enabled, then the transaction is malformed because it's using an + "unknown" flag. There's no need to waste the resources to send it + to the transaction engine. + + We don't normally check transaction validity at this level, but + since we _need_ to check it when the amendment is enabled, we may as + well drop it if the flag is set regardless. + */ + if (stx->isFlag(tfInnerBatchTxn)) { JLOG(p_journal_.warn()) << "Ignoring Network relayed Tx containing " "tfInnerBatchTxn (checkSignature)."; diff --git a/src/xrpld/rpc/detail/TransactionSign.cpp b/src/xrpld/rpc/detail/TransactionSign.cpp index aa7c706a19..9f4cec763b 100644 --- a/src/xrpld/rpc/detail/TransactionSign.cpp +++ b/src/xrpld/rpc/detail/TransactionSign.cpp @@ -1097,7 +1097,11 @@ checkMultiSignFields(Json::Value const& jvRequest) if (!tx_json.isMember(sfSigningPubKey.getJsonName())) return RPC::missing_field_error("tx_json.SigningPubKey"); - if (!tx_json[sfSigningPubKey.getJsonName()].asString().empty()) + // Multi-signing into a signature_target object field is fine, + // because it means the signature is not for the transaction + // Account. + if (!jvRequest.isMember(jss::signature_target) && + !tx_json[sfSigningPubKey.getJsonName()].asString().empty()) return RPC::make_error( rpcINVALID_PARAMS, "When multi-signing 'tx_json.SigningPubKey' must be empty."); diff --git a/src/xrpld/rpc/handlers/AccountInfo.cpp b/src/xrpld/rpc/handlers/AccountInfo.cpp index 0a36993d65..62d54499b2 100644 --- a/src/xrpld/rpc/handlers/AccountInfo.cpp +++ b/src/xrpld/rpc/handlers/AccountInfo.cpp @@ -150,6 +150,28 @@ doAccountInfo(RPC::JsonContext& context) result[jss::account_flags] = std::move(acctFlags); + auto const pseudoFields = getPseudoAccountFields(); + for (auto const& pseudoField : pseudoFields) + { + if (sleAccepted->isFieldPresent(*pseudoField)) + { + std::string name = pseudoField->fieldName; + if (name.ends_with("ID")) + { + // Remove the ID suffix from the field name. + name = name.substr(0, name.size() - 2); + XRPL_ASSERT_PARTS( + !name.empty(), + "ripple::doAccountInfo", + "name is not empty"); + } + // ValidPseudoAccounts invariant guarantees that only one field + // can be set + result[jss::pseudo_account][jss::type] = name; + break; + } + } + // The document[https://xrpl.org/account_info.html#account_info] states // that signer_lists is a bool, however assigning any string value // works. Do not allow this. This check is for api Version 2 onwards diff --git a/src/xrpld/rpc/handlers/LedgerEntry.cpp b/src/xrpld/rpc/handlers/LedgerEntry.cpp index cead16c04d..a16bbd3a97 100644 --- a/src/xrpld/rpc/handlers/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/LedgerEntry.cpp @@ -400,6 +400,46 @@ parseLedgerHashes(Json::Value const& params, Json::StaticString const fieldName) return parseObjectID(params, fieldName, "hex string"); } +static Expected +parseLoanBroker(Json::Value const& params, Json::StaticString const fieldName) +{ + if (!params.isObject()) + { + return parseObjectID(params, fieldName, "hex string"); + } + + auto const id = LedgerEntryHelpers::requiredAccountID( + params, jss::owner, "malformedOwner"); + if (!id) + return Unexpected(id.error()); + auto const seq = + LedgerEntryHelpers::requiredUInt32(params, jss::seq, "malformedSeq"); + if (!seq) + return Unexpected(seq.error()); + + return keylet::loanbroker(*id, *seq).key; +} + +static Expected +parseLoan(Json::Value const& params, Json::StaticString const fieldName) +{ + if (!params.isObject()) + { + return parseObjectID(params, fieldName, "hex string"); + } + + auto const id = LedgerEntryHelpers::requiredUInt256( + params, jss::loan_broker_id, "malformedOwner"); + if (!id) + return Unexpected(id.error()); + auto const seq = LedgerEntryHelpers::requiredUInt32( + params, jss::loan_seq, "malformedSeq"); + if (!seq) + return Unexpected(seq.error()); + + return keylet::loan(*id, *seq).key; +} + static Expected parseMPToken(Json::Value const& params, Json::StaticString const fieldName) {