From f60e2986270310969f01a2f44228cf40bdb080e2 Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Thu, 23 Oct 2025 15:51:30 -0400 Subject: [PATCH] Extend LoanBroaker and Loan unit-tests. (#5863) - Add convenience functions to MPT test-framework. --- src/test/app/LoanBroker_test.cpp | 323 ++++++++++++++++++ src/test/app/Loan_test.cpp | 223 ++++++++++++ src/test/jtx/mpt.h | 2 +- src/xrpld/app/misc/LendingHelpers.h | 3 + .../app/tx/detail/LoanBrokerCoverClawback.cpp | 2 +- .../app/tx/detail/LoanBrokerCoverWithdraw.cpp | 2 +- src/xrpld/app/tx/detail/LoanBrokerDelete.cpp | 12 +- src/xrpld/app/tx/detail/LoanDelete.cpp | 4 +- src/xrpld/app/tx/detail/LoanManage.cpp | 2 + 9 files changed, 562 insertions(+), 11 deletions(-) diff --git a/src/test/app/LoanBroker_test.cpp b/src/test/app/LoanBroker_test.cpp index 7fee30943c..259be872ed 100644 --- a/src/test/app/LoanBroker_test.cpp +++ b/src/test/app/LoanBroker_test.cpp @@ -851,12 +851,335 @@ class LoanBroker_test : public beast::unit_test::suite 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))); + env.close(); + return PrettyAsset(issuer["IOU"]); + }(); + + env(pay(issuer, alice, asset(100'000))); + env.close(); + + 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; + + env(vault.deposit( + {.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)})); + env.close(); + + auto const brokerKeylet = + keylet::loanbroker(alice.id(), env.seq(alice)); + env(set(alice, vaultInfo.vaultID)); + env.close(); + + auto broker = env.le(brokerKeylet); + if (!BEAST_EXPECT(broker)) + return; + + if (brokerTest == CoverDeposit) + { + // preclaim: tecWRONG_ASET + env(coverDeposit(alice, brokerKeylet.key, issuer["BAD"](10)), + ter(tecWRONG_ASSET)); + + // preclaim: tecINSUFFICIENT_FUNDS + env(pay(alice, issuer, asset(100'000 - 50))); + env.close(); + env(coverDeposit(alice, brokerKeylet.key, vaultInfo.asset(10)), + ter(tecINSUFFICIENT_FUNDS)); + + // preclaim: tecFROZEN + env(fset(issuer, asfGlobalFreeze)); + env.close(); + env(coverDeposit(alice, brokerKeylet.key, vaultInfo.asset(10)), + ter(tecFROZEN)); + } + else + // Fund the cover deposit + env(coverDeposit(alice, brokerKeylet.key, vaultInfo.asset(10))); + env.close(); + + if (brokerTest == CoverWithdraw) + { + // preclaim: tecWRONG_ASSSET + env(coverWithdraw(alice, brokerKeylet.key, issuer["BAD"](10)), + ter(tecWRONG_ASSET)); + + // preclaim: tecNO_DST + Account const bogus{"bogus"}; + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(bogus), + ter(tecNO_DST)); + + // preclaim: tecDST_TAG_NEEDED + Account const dest{"dest"}; + env.fund(XRP(1'000), dest); + env(fset(dest, asfRequireDest)); + env.close(); + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(dest), + ter(tecDST_TAG_NEEDED)); + + // preclaim: tecNO_PERMISSION + env(fclear(dest, asfRequireDest)); + env(fset(dest, asfDepositAuth)); + env.close(); + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(dest), + ter(tecNO_PERMISSION)); + + // preclaim: tecFROZEN + env(trust(dest, asset(1'000))); + env(fclear(dest, asfDepositAuth)); + env(fset(issuer, asfGlobalFreeze)); + env.close(); + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(dest), + ter(tecFROZEN)); + + // preclaim:: tecFROZEN (deep frozen) + env(fclear(issuer, asfGlobalFreeze)); + env(trust( + issuer, asset(1'000), dest, tfSetFreeze | tfSetDeepFreeze)); + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(dest), + ter(tecFROZEN)); + } + + if (brokerTest == CoverClawback) + { + if (asset.holds()) + { + // preclaim: AllowTrustLineClaback is not set + env(coverClawback(issuer), + loanBrokerID(brokerKeylet.key), + amount(vaultInfo.asset(2)), + ter(tecNO_PERMISSION)); + + // preclaim: NoFreeze is set + env(fset(issuer, asfAllowTrustLineClawback | asfNoFreeze)); + env.close(); + env(coverClawback(issuer), + loanBrokerID(brokerKeylet.key), + amount(vaultInfo.asset(2)), + ter(tecNO_PERMISSION)); + } + else + { + // preclaim: MPTCanClawback is not set or MPTCAnLock is not set + env(coverClawback(issuer), + loanBrokerID(brokerKeylet.key), + amount(vaultInfo.asset(2)), + ter(tecNO_PERMISSION)); + } + 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)); + + // preclaim: tecHAS_OBLIGATIONS + env(del(alice, brokerKeylet.key), ter(tecHAS_OBLIGATIONS)); + } + else + env(del(alice, brokerKeylet.key)); + + if (brokerTest == Set) + { + if (asset.holds()) + { + env(fclear(issuer, asfDefaultRipple)); + env.close(); + // preclaim: DefaultRipple is not set + env(set(alice, vaultInfo.vaultID), ter(terNO_RIPPLE)); + + env(fset(issuer, asfDefaultRipple)); + env.close(); + } + + auto const amt = env.balance(alice) - + env.current()->fees().accountReserve(env.ownerCount(alice)); + env(pay(alice, issuer, amt)); + + // preclaim:: tecINSUFFICIENT_RESERVE + env(set(alice, vaultInfo.vaultID), ter(tecINSUFFICIENT_RESERVE)); + } + } + + 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)); + + // 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); + // MPTCanLock is not set + testLoanBroker( + [&](Env& env, Account const& issuer, Account const& alice) -> MPT { + MPTTester mpt( + {.env = env, + .issuer = issuer, + .holders = {alice}, + .flags = MPTDEXFlags | tfMPTCanClawback}); + 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); + } + public: void run() override { testDisabled(); testLifecycle(); + testInvalidLoanBrokerCoverClawback(); + testInvalidLoanBrokerCoverDeposit(); + testInvalidLoanBrokerCoverWithdraw(); + testInvalidLoanBrokerDelete(); + testInvalidLoanBrokerSet(); // TODO: Write clawback failure tests with an issuer / MPT that doesn't // have the right flags set. diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index c42367332c..fefa7fc552 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -2827,6 +2827,224 @@ class Loan_test : public beast::unit_test::suite pass(); } + 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))); + env(pay(issuer, lender, IOU(5'000'000))); + 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); + + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); + + env(fset(issuer, asfGlobalFreeze)); + env.close(); + + // preclaim: tecFROZEN + env(pay(borrower, loanKeylet.key, debtMaximumRequest), ter(tecFROZEN)); + env.close(); + + env(fclear(issuer, asfGlobalFreeze)); + 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)); + env(trust( + issuer, + (*pseudoBroker)["IOU"](1'000), + *pseudoBroker, + tfSetFreeze | tfSetDeepFreeze)); + env.close(); + + // preclaim: tecFROZEN due to deep frozen + env(pay(borrower, loanKeylet.key, debtMaximumRequest), ter(tecFROZEN)); + env.close(); + + // Only one needs to be unfrozen + env(trust( + issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze)); + env.close(); + + env(pay(borrower, loanKeylet.key, debtMaximumRequest)); + 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)); + } + + 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 + + // 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)); + }); + } + public: void run() override @@ -2841,6 +3059,11 @@ public: testRPC(); testBasicMath(); + + testInvalidLoanDelete(); + testInvalidLoanManage(); + testInvalidLoanPay(); + testInvalidLoanSet(); } }; diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index f84241b309..e24fc9d327 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -296,7 +296,7 @@ public: operator Asset() const; private: - using SLEP = std::shared_ptr; + using SLEP = SLE::const_pointer; bool forObject( std::function const& cb, diff --git a/src/xrpld/app/misc/LendingHelpers.h b/src/xrpld/app/misc/LendingHelpers.h index bd12843257..874ec9887e 100644 --- a/src/xrpld/app/misc/LendingHelpers.h +++ b/src/xrpld/app/misc/LendingHelpers.h @@ -1549,8 +1549,11 @@ loanMakePayment( 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); diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp index e2358d8ace..d278f733d6 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp @@ -81,7 +81,7 @@ determineBrokerID(ReadView const& view, STTx const& tx) auto const dstAmount = tx[~sfAmount]; if (!dstAmount || !dstAmount->holds()) - return Unexpected{tecINTERNAL}; + return Unexpected{tecINTERNAL}; // LCOV_EXCL_LINE // 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 diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp index 894e69cac8..50793adb1e 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp @@ -209,7 +209,7 @@ LoanBrokerCoverWithdraw::doApply() Payment::getMaxSourceAmount(brokerPseudoID, amount); SLE::pointer sleDst = view().peek(keylet::account(dstAcct)); if (!sleDst) - return tecINTERNAL; + return tecINTERNAL; // LCOV_EXCL_LINE Payment::RipplePaymentParams paymentParams{ .ctx = ctx_, diff --git a/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp index 2a35d83c31..90703fa5b9 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp @@ -89,7 +89,7 @@ LoanBrokerDelete::doApply() broker->key(), false)) { - return tefBAD_LEDGER; + return tefBAD_LEDGER; // LCOV_EXCL_LINE } if (!view().dirRemove( keylet::ownerDir(vaultPseudoID), @@ -97,7 +97,7 @@ LoanBrokerDelete::doApply() broker->key(), false)) { - return tefBAD_LEDGER; + return tefBAD_LEDGER; // LCOV_EXCL_LINE } { @@ -118,26 +118,26 @@ LoanBrokerDelete::doApply() auto brokerPseudoSLE = view().peek(keylet::account(brokerPseudoID)); if (!brokerPseudoSLE) - return tefBAD_LEDGER; + 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; + return tecHAS_OBLIGATIONS; // LCOV_EXCL_LINE } if (brokerPseudoSLE->at(sfOwnerCount) != 0) { JLOG(j_.warn()) << "LoanBrokerDelete: Pseudo-account still owns objects"; - return tecHAS_OBLIGATIONS; + 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; + return tecHAS_OBLIGATIONS; // LCOV_EXCL_LINE } view().erase(brokerPseudoSLE); diff --git a/src/xrpld/app/tx/detail/LoanDelete.cpp b/src/xrpld/app/tx/detail/LoanDelete.cpp index 218880a41c..ff91039da9 100644 --- a/src/xrpld/app/tx/detail/LoanDelete.cpp +++ b/src/xrpld/app/tx/detail/LoanDelete.cpp @@ -107,14 +107,14 @@ LoanDelete::doApply() loanSle->at(sfLoanBrokerNode), loanID, false)) - return tefBAD_LEDGER; + 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; + return tefBAD_LEDGER; // LCOV_EXCL_LINE // Delete the Loan object view.erase(loanSle); diff --git a/src/xrpld/app/tx/detail/LoanManage.cpp b/src/xrpld/app/tx/detail/LoanManage.cpp index 00e742f4bb..7b3dcd205b 100644 --- a/src/xrpld/app/tx/detail/LoanManage.cpp +++ b/src/xrpld/app/tx/detail/LoanManage.cpp @@ -241,9 +241,11 @@ LoanManage::defaultLoan( 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; }