mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-22 14:50:54 +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:
@@ -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