fix: Exempt loan default from asset freeze (#7932)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com>
Co-authored-by: Ayaz Salikhov <mathbunnyru@users.noreply.github.com>
This commit is contained in:
Timur Yalymov
2026-08-19 13:43:40 +00:00
committed by GitHub
parent 3adf2d40b5
commit 368ff1afce
7 changed files with 448 additions and 7 deletions

View File

@@ -7,6 +7,7 @@
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/LedgerFormats.h> // IWYU pragma: keep
#include <xrpl/protocol/Protocol.h>
@@ -21,6 +22,7 @@
#include <cstdint>
#include <expected>
#include <optional>
#include <string_view>
#include <utility>
@@ -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<LoanDefaultFreezeExemptAccounts>
getLoanDefaultFreezeExemptAccounts(ReadView const& view, STTx const& tx);
static constexpr std::uint32_t kSecondsInYear = 365 * 24 * 60 * 60;
Number

View File

@@ -2,6 +2,7 @@
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/STAmount.h>
@@ -11,6 +12,7 @@
#include <xrpl/protocol/XRPAmount.h>
#include <map>
#include <optional>
#include <vector>
namespace xrpl {
@@ -70,7 +72,8 @@ private:
STTx const& tx,
beast::Journal const& j,
bool enforce,
bool fixOverrideFreeze);
bool fixOverrideFreeze,
std::optional<LoanDefaultFreezeExemptAccounts> const& loanDefaultAccounts);
static bool
validateFrozenState(
@@ -80,7 +83,8 @@ private:
beast::Journal const& j,
bool enforce,
bool globalFreeze,
bool fixOverrideFreeze);
bool fixOverrideFreeze,
std::optional<LoanDefaultFreezeExemptAccounts> const& loanDefaultAccounts);
};
} // namespace xrpl

View File

@@ -12,6 +12,7 @@
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/Rules.h>
@@ -20,12 +21,15 @@
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/Units.h>
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <expected>
#include <optional>
#include <string_view>
#include <utility>
@@ -77,6 +81,40 @@ checkLendingProtocolDependencies(Rules const& rules, STTx const& tx)
return true;
}
std::optional<LoanDefaultFreezeExemptAccounts>
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)
{

View File

@@ -4,7 +4,9 @@
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
@@ -17,6 +19,7 @@
#include <xrpl/tx/invariants/InvariantCheckPrivilege.h>
#include <algorithm>
#include <optional>
#include <utility>
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<LoanDefaultFreezeExemptAccounts> 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<LoanDefaultFreezeExemptAccounts> 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<Issue>() &&
loanDefaultAccounts->asset.get<Issue>().currency ==
change.line->at(sfBalance).get<Issue>().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.

View File

@@ -7,6 +7,7 @@
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
@@ -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<MPTIssue>() &&
loanDefaultAccounts->asset.get<MPTIssue>().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;

View File

@@ -2,18 +2,27 @@
// DO NOT REMOVE
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/fee.h>
#include <test/jtx/pay.h>
#include <test/jtx/sig.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/Units.h>
#include <cstdint>
@@ -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();
}
};

View File

@@ -5,6 +5,7 @@
#include <test/jtx/amount.h>
#include <test/jtx/credentials.h>
#include <test/jtx/fee.h>
#include <test/jtx/flags.h>
#include <test/jtx/mpt.h>
#include <test/jtx/pay.h>
#include <test/jtx/permissioned_domains.h>
@@ -13,6 +14,7 @@
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_value.h>
#include <xrpl/json/to_string.h>
@@ -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().