From 368ff1afce195cef00debf64d34aa82d72fe707c Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:43:40 +0000 Subject: [PATCH] fix: Exempt loan default from asset freeze (#7932) Co-authored-by: Cursor Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Co-authored-by: Ayaz Salikhov --- include/xrpl/ledger/helpers/LendingHelpers.h | 38 ++++ include/xrpl/tx/invariants/FreezeInvariant.h | 8 +- src/libxrpl/ledger/helpers/LendingHelpers.cpp | 38 ++++ src/libxrpl/tx/invariants/FreezeInvariant.cpp | 63 +++++- src/libxrpl/tx/invariants/MPTInvariant.cpp | 25 ++- src/test/app/lending/LendingHelpers_test.cpp | 98 ++++++++++ .../app/lending/LoanCoverFreezeAuth_test.cpp | 185 ++++++++++++++++++ 7 files changed, 448 insertions(+), 7 deletions(-) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index c69efff964..4aa89ea672 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include // IWYU pragma: keep #include @@ -21,6 +22,7 @@ #include #include +#include #include #include @@ -58,6 +60,42 @@ canApplyToBrokerCover( bool checkLendingProtocolDependencies(Rules const& rules, STTx const& tx); +/** + * The accounts and asset that LoanManage::defaultLoan's fixCleanup3_4_0 + * freeze/lock exemption applies to. + * + * `defaultLoan` moves funds from the LoanBroker pseudo-account to the Vault + * pseudo-account via `accountSend`. Since neither is the vault asset's + * issuer, this is a third-party transfer that transits through the issuer in + * two hops (broker -> issuer, issuer -> vault; see + * `directSendNoLimitIOU`/`directSendNoLimitMPT`), so the exemption must cover + * both the issuer/broker and issuer/vault pairs, not a direct broker/vault + * pair. `asset` scopes it further to the vault's own currency/MPT issuance, + * so an unrelated one the same accounts happen to hold is still protected. + */ +struct LoanDefaultFreezeExemptAccounts +{ + AccountID issuer; + AccountID broker; + AccountID vault; + Asset asset; +}; + +/** + * Resolves the accounts and asset a LoanManage default transaction is + * exempt from freeze/lock for. + * + * @param view Ledger view used to resolve the Loan -> LoanBroker -> Vault + * chain. + * @param tx The transaction under invariant review. + * @return The exempt accounts and asset if `tx` is a `ttLOAN_MANAGE` + * transaction with the `tfLoanDefault` flag set, `fixCleanup3_4_0` is + * enabled, and the loan/broker/vault objects it references can all be + * resolved; `std::nullopt` otherwise. + */ +[[nodiscard]] std::optional +getLoanDefaultFreezeExemptAccounts(ReadView const& view, STTx const& tx); + static constexpr std::uint32_t kSecondsInYear = 365 * 24 * 60 * 60; Number diff --git a/include/xrpl/tx/invariants/FreezeInvariant.h b/include/xrpl/tx/invariants/FreezeInvariant.h index c66e002872..301e464daf 100644 --- a/include/xrpl/tx/invariants/FreezeInvariant.h +++ b/include/xrpl/tx/invariants/FreezeInvariant.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -11,6 +12,7 @@ #include #include +#include #include namespace xrpl { @@ -70,7 +72,8 @@ private: STTx const& tx, beast::Journal const& j, bool enforce, - bool fixOverrideFreeze); + bool fixOverrideFreeze, + std::optional const& loanDefaultAccounts); static bool validateFrozenState( @@ -80,7 +83,8 @@ private: beast::Journal const& j, bool enforce, bool globalFreeze, - bool fixOverrideFreeze); + bool fixOverrideFreeze, + std::optional const& loanDefaultAccounts); }; } // namespace xrpl diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 89b03a03a7..cf1bd4915f 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -20,12 +21,15 @@ #include #include #include +#include +#include #include #include #include #include #include +#include #include #include @@ -77,6 +81,40 @@ checkLendingProtocolDependencies(Rules const& rules, STTx const& tx) return true; } +std::optional +getLoanDefaultFreezeExemptAccounts(ReadView const& view, STTx const& tx) +{ + if (tx.getTxnType() != ttLOAN_MANAGE || !tx.isFlag(tfLoanDefault) || + !view.rules().enabled(fixCleanup3_4_0)) + return std::nullopt; + + // Unlike the broker/vault lookups below, the submitter picks the LoanID, + // so a nonexistent Loan is an ordinary (if unusual) input, not a + // structural impossibility -- exercised directly in LendingHelpers_test. + auto const loanSle = view.read(keylet::loan(tx[sfLoanID])); + if (!loanSle) + return std::nullopt; + + // A Loan can't outlive its LoanBroker (LoanBrokerDelete's preclaim + // rejects deletion while DebtTotal != 0), and a LoanBroker can't outlive + // its Vault (VaultDelete's preclaim has the equivalent guard) -- so these + // two lookups are structurally guaranteed to succeed here. + auto const brokerSle = view.read(keylet::loanBroker(loanSle->at(sfLoanBrokerID))); + if (!brokerSle) + return std::nullopt; // LCOV_EXCL_LINE + + auto const vaultSle = view.read(keylet::vault(brokerSle->at(sfVaultID))); + if (!vaultSle) + return std::nullopt; // LCOV_EXCL_LINE + + Asset const vaultAsset = vaultSle->at(sfAsset); + return LoanDefaultFreezeExemptAccounts{ + .issuer = vaultAsset.getIssuer(), + .broker = brokerSle->at(sfAccount), + .vault = vaultSle->at(sfAccount), + .asset = vaultAsset}; +} + LoanPaymentParts& LoanPaymentParts::operator+=(LoanPaymentParts const& other) { diff --git a/src/libxrpl/tx/invariants/FreezeInvariant.cpp b/src/libxrpl/tx/invariants/FreezeInvariant.cpp index c4340b9aec..d6039eabd8 100644 --- a/src/libxrpl/tx/invariants/FreezeInvariant.cpp +++ b/src/libxrpl/tx/invariants/FreezeInvariant.cpp @@ -4,7 +4,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -17,6 +19,7 @@ #include #include +#include #include namespace xrpl { @@ -75,6 +78,20 @@ TransfersNotFrozen::finalize( [[maybe_unused]] bool const enforce = view.rules().enabled(featureDeepFreeze); bool const fixOverrideFreeze = view.rules().enabled(fixCleanup3_4_0); + /* + * XLS-0066: a broker must be able to default an already-late loan + * regardless of the vault asset's freeze state. LoanManage::defaultLoan + * moves First-Loss Capital from the broker to the vault pseudo-account via + * accountSend, which transits through the issuer in two hops (see + * getLoanDefaultFreezeExemptAccounts), so a frozen issuer would otherwise + * trip this invariant on either hop. Gated behind fixCleanup3_4_0, and + * scoped to exactly the issuer/broker and issuer/vault lines involved for + * the vault's own currency, so ledgers without the amendment (or an + * unrelated frozen currency/line touched by the same transaction) keep + * the current (blocking) behavior. + */ + auto const loanDefaultAccounts = getLoanDefaultFreezeExemptAccounts(view, tx); + return std::ranges::all_of(balanceChanges_, [&](auto const& entry) { auto const& [issue, changes] = entry; auto const issuerSle = findIssuer(issue.account, view); @@ -91,7 +108,8 @@ TransfersNotFrozen::finalize( return !enforce; } - return validateIssuerChanges(issuerSle, changes, tx, j, enforce, fixOverrideFreeze); + return validateIssuerChanges( + issuerSle, changes, tx, j, enforce, fixOverrideFreeze, loanDefaultAccounts); }); } @@ -201,7 +219,8 @@ TransfersNotFrozen::validateIssuerChanges( STTx const& tx, beast::Journal const& j, bool enforce, - bool fixOverrideFreeze) + bool fixOverrideFreeze, + std::optional const& loanDefaultAccounts) { if (!issuer) { @@ -227,7 +246,15 @@ TransfersNotFrozen::validateIssuerChanges( { bool const high = change.line->at(sfLowLimit).getIssuer() == issuer->at(sfAccount); - if (!validateFrozenState(change, high, tx, j, enforce, globalFreeze, fixOverrideFreeze)) + if (!validateFrozenState( + change, + high, + tx, + j, + enforce, + globalFreeze, + fixOverrideFreeze, + loanDefaultAccounts)) { return false; } @@ -244,7 +271,8 @@ TransfersNotFrozen::validateFrozenState( beast::Journal const& j, bool enforce, bool globalFreeze, - bool fixOverrideFreeze) + bool fixOverrideFreeze, + std::optional const& loanDefaultAccounts) { bool const freeze = change.balanceChangeSign < 0 && change.line->isFlag(high ? lsfLowFreeze : lsfHighFreeze); @@ -269,6 +297,33 @@ TransfersNotFrozen::validateFrozenState( return true; } + // XLS-0066: LoanManage::defaultLoan's transfer is exempt from freeze (see + // finalize()). Since neither the broker nor vault pseudo-account is the + // asset's issuer, accountSend routes it as two hops through the issuer + // (broker -> issuer, issuer -> vault), so both the issuer/broker and + // issuer/vault lines are exempt -- but only for the vault's own currency, + // so an unrelated frozen line (a different currency, or one touched by + // the same transaction for some other reason) is still caught. + if (loanDefaultAccounts && loanDefaultAccounts->asset.holds() && + loanDefaultAccounts->asset.get().currency == + change.line->at(sfBalance).get().currency) + { + AccountID const lowAcct = change.line->at(sfLowLimit).getIssuer(); + AccountID const highAcct = change.line->at(sfHighLimit).getIssuer(); + auto const& accts = *loanDefaultAccounts; + auto const isPair = [&](AccountID const& a, AccountID const& b) { + return (lowAcct == a && highAcct == b) || (lowAcct == b && highAcct == a); + }; + if (isPair(accts.issuer, accts.broker) || isPair(accts.issuer, accts.vault)) + { + JLOG(j.debug()) << "Invariant check allowing funds to be moved " + << (change.balanceChangeSign > 0 ? "to" : "from") + << " a frozen trustline for LoanManage default " + << tx.getTransactionID(); + return true; + } + } + JLOG(j.fatal()) << "Invariant failed: Attempting to move frozen funds for " << tx.getTransactionID(); // The comment above starting with "assert(enforce)" explains this assert. diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index d323718bd2..9a7e96e44f 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -840,6 +841,14 @@ ValidMPTTransfer::finalize( if (hasPrivilege(tx, OverrideFreeze)) return true; + // XLS-0066: a broker must be able to default an already-late loan + // regardless of the vault asset's lock state. Gated behind + // fixCleanup3_4_0, and scoped below to exactly the broker/vault + // pseudo-accounts and the vault's own MPT issuance -- see + // FreezeInvariant.cpp's TransfersNotFrozen::finalize for the IOU-side + // equivalent and rationale. + auto const loanDefaultAccounts = getLoanDefaultFreezeExemptAccounts(view, tx); + // DEX transactions (AMM[Create,Deposit], cross-currency payments, offer creates) are // subject to the MPTCanTrade flag in addition to the standard transfer rules. // A payment is only DEX if it is a cross-currency payment. @@ -881,6 +890,13 @@ ValidMPTTransfer::finalize( auto const canTrade = sleIssuance->isFlag(lsfMPTCanTrade); auto const reqAuth = sleIssuance->isFlag(lsfMPTRequireAuth); + // This issuance is the LoanManage default's own vault asset, so the + // broker/vault freeze exemption applies to it -- an unrelated MPT + // issuance the same accounts happen to hold is still caught. + bool const isLoanDefaultAsset = loanDefaultAccounts && + loanDefaultAccounts->asset.holds() && + loanDefaultAccounts->asset.get().getMptID() == mptID; + for (auto const& [account, value] : values) { // Classify each account as a sender or receiver based on whether their MPTAmount @@ -899,8 +915,15 @@ ValidMPTTransfer::finalize( // Check once: if any involved account is frozen, the whole issuance transfer is // considered frozen. Only need to check for frozen if there is a transfer of funds. + // + // The LoanManage default exemption only waives the frozen check, and only for + // the specific broker/vault pseudo-accounts identified above -- authorization is + // still enforced for them, and both checks still apply to every other account. + bool const exemptFromFreeze = isLoanDefaultAsset && loanDefaultAccounts && + (account == loanDefaultAccounts->broker || + account == loanDefaultAccounts->vault); if (!invalidTransfer && - (isFrozen(view, account, *sleIssuance) || + ((!exemptFromFreeze && isFrozen(view, account, *sleIssuance)) || !isAuthorized(view, mptID, account, reqAuth))) { invalidTransfer = true; diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp index 909b617980..32c49feb02 100644 --- a/src/test/app/lending/LendingHelpers_test.cpp +++ b/src/test/app/lending/LendingHelpers_test.cpp @@ -2,18 +2,27 @@ // DO NOT REMOVE #include #include +#include #include +#include +#include +#include +#include #include #include #include #include +#include +#include #include #include #include #include #include +#include #include +#include #include #include @@ -1871,6 +1880,93 @@ public: } } + // Targeted unit test for getLoanDefaultFreezeExemptAccounts(): builds a real + // (XRP, so no trust lines needed) Vault/LoanBroker/Loan chain, then calls + // the function directly against hand-picked, unsubmitted transactions + // (via env.jt(), which never touches the ledger) to exercise every early + // return and the success path precisely. + void + testLoanDefaultFreezeExemptAccounts() + { + using namespace jtx; + using namespace loan; + + testcase("getLoanDefaultFreezeExemptAccounts"); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + Env env{*this}; + Vault const vault{env}; + env.fund(XRP(10'000), lender, borrower); + env.close(); + + auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()}); + env(vaultTx); + env.close(); + env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = XRP(1'000)})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender))); + env(loan_broker::set(lender, vaultKeylet.key)); + env.close(); + + env(set(borrower, brokerKeylet.key, Number{200'000}), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2)); + env.close(); + + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); + + // Not a LoanManage transaction at all. + { + auto const jt = env.jt(jtx::pay(lender, borrower, XRP(1))); + BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx)); + } + + // LoanManage, but not the tfLoanDefault flag. + { + auto const jt = env.jt(manage(lender, loanKeylet.key, tfLoanImpair)); + BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx)); + } + + // tfLoanDefault, but fixCleanup3_4_0 is disabled. + { + env.disableFeature(fixCleanup3_4_0); + auto const jt = env.jt(manage(lender, loanKeylet.key, tfLoanDefault)); + BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx)); + env.enableFeature(fixCleanup3_4_0); + } + + // tfLoanDefault, amendment enabled, but the referenced Loan doesn't + // exist (reusing the broker's own ID as a bogus LoanID, same trick + // testInvalidLoanManage-style tests use elsewhere in this suite). + { + auto const jt = env.jt(manage(lender, brokerKeylet.key, tfLoanDefault)); + BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx)); + } + + // tfLoanDefault, amendment enabled, Loan/LoanBroker/Vault all exist: + // resolves the issuer, broker, vault accounts, and the vault's asset. + { + auto const jt = env.jt(manage(lender, loanKeylet.key, tfLoanDefault)); + auto const result = getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx); + auto const brokerSle = env.le(brokerKeylet); + auto const vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(result); + BEAST_EXPECT(brokerSle); + BEAST_EXPECT(vaultSle); + if (result && brokerSle && vaultSle) + { + BEAST_EXPECT(result->issuer == vaultSle->at(sfAsset).getIssuer()); + BEAST_EXPECT(result->broker == brokerSle->at(sfAccount)); + BEAST_EXPECT(result->vault == vaultSle->at(sfAccount)); + BEAST_EXPECT(result->asset == vaultSle->at(sfAsset)); + } + } + } + void run() override { @@ -1906,6 +2002,8 @@ public: testLoanOriginationExceedsVaultMaximumDispatcher(); testLoanVaultExposureDispatcher(); testLoanPaymentDeltasDispatcher(); + + testLoanDefaultFreezeExemptAccounts(); } }; diff --git a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp index a9b3542c4e..b0c43190c5 100644 --- a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp +++ b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -13,6 +14,7 @@ #include #include +#include #include #include #include @@ -368,6 +370,186 @@ private: }; } + void + testLoanDefaultBypassesFreeze() + { + testcase("LoanManage: default bypasses asset freeze"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + auto const iou = issuer["IOU"]; + + 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 const 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(); + + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1)); + + using tp = NetClock::time_point; + using d = NetClock::duration; + + // Get past the grace period so the loan is defaultable. + if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}}); + } + + // Global freeze trips the post-apply TransfersNotFrozen invariant. + env(fset(issuer, asfGlobalFreeze)); + env.close(); + + // Pre-fixCleanup3_4_0, the invariant blocks the default. + env.disableFeature(fixCleanup3_4_0); + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecINVARIANT_FAILED)); + env.close(); + + // Per XLS-0066, a default must succeed despite the freeze. + env.enableFeature(fixCleanup3_4_0); + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + } + + // A default must bypass an MPT global lock the same way it bypasses IOU + // freeze, including when the loan was already impaired beforehand + // (a different defaultLoan() accounting branch than the un-impaired + // path exercised above) and after an ordinary LoanPay was correctly + // blocked by the same lock. + void + testLoanDefaultBypassesMptLockAfterImpair() + { + testcase("LoanManage: default bypasses MPT lock after impairment"); + using namespace jtx; + using namespace loan; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + Env env(*this); + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + MPTTester mptt( + {.env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset const asset = mptt.issuanceID(); + env(pay(issuer, lender, asset(10'000'000))); + env.close(); + + BrokerInfo const brokerInfo{createVaultAndBroker(env, asset, 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(); + + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1)); + + // Realize a loss via impairment before locking. + env(manage(lender, loanKeylet.key, tfLoanImpair)); + env.close(); + + // Issuer applies a global lock. + mptt.set({.account = issuer, .flags = tfMPTLock}); + env.close(); + + // An ordinary payment is correctly blocked by the lock. + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecLOCKED)); + env.close(); + + using tp = NetClock::time_point; + using d = NetClock::duration; + if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}}); + } + + // Pre-fixCleanup3_4_0 the ValidMPTTransfer invariant blocks the + // default, mirroring the IOU path above. + env.disableFeature(fixCleanup3_4_0); + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecINVARIANT_FAILED)); + env.close(); + + // The default itself must succeed despite the lock. + env.enableFeature(fixCleanup3_4_0); + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + } + + // The exemption must hold for an individually deep-frozen trust line, not + // just a global freeze: deep freeze is what the original report ran into, + // and it takes a different path through validateFrozenState (the frozen + // flag comes off the line rather than off the issuer). + void + testLoanDefaultBypassesDeepFreeze() + { + testcase("LoanManage: default bypasses asset deep freeze"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + auto const iou = issuer["IOU"]; + + 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 const 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(); + + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1)); + + using tp = NetClock::time_point; + using d = NetClock::duration; + + // Get past the grace period so the loan is defaultable. + if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}}); + } + + // The default moves First-Loss Capital off the broker pseudo-account, + // so that is the line to freeze. + auto const brokerSle = env.le(brokerInfo.brokerKeylet()); + if (!BEAST_EXPECT(brokerSle)) + return; + Account const brokerPseudo{"brokerPseudo", brokerSle->at(sfAccount)}; + + env(trust(issuer, brokerPseudo["IOU"](0), tfSetFreeze | tfSetDeepFreeze)); + env.close(); + + // Pre-fixCleanup3_4_0, the invariant blocks the default. + env.disableFeature(fixCleanup3_4_0); + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecINVARIANT_FAILED)); + env.close(); + + // Per XLS-0066, a default must succeed despite the deep freeze. + env.enableFeature(fixCleanup3_4_0); + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + } + void testLoanPayBrokerOwnerMissingTrustline(FeatureBitset features) { @@ -694,6 +876,9 @@ private: runAmendmentIndependent() { testServiceFeeOnBrokerDeepFreeze(); + testLoanDefaultBypassesFreeze(); + testLoanDefaultBypassesDeepFreeze(); + testLoanDefaultBypassesMptLockAfterImpair(); } // Tests run under each entry in amendmentCombinations().