mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-21 22:30:57 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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().
|
||||
|
||||
Reference in New Issue
Block a user