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

@@ -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().