test: Split Loan_test.cpp into topical suites (#7864)

Co-authored-by: Ayaz Salikhov <mathbunnyru@users.noreply.github.com>
This commit is contained in:
Vito Tumas
2026-08-04 17:43:59 +02:00
committed by GitHub
parent e0de716ee6
commit c3ee602002
17 changed files with 10327 additions and 9760 deletions

View File

@@ -295,6 +295,20 @@ public:
return runner_->arg();
}
protected:
/**
* Lets a suite compose other suites (e.g. an aggregator that reruns a
* group of related suites under its own name) via `SuiteInfo::run`.
*
* @return The runner this suite is executing under.
*/
Runner&
runner() const
{
return *runner_;
}
public:
/**
* DEPRECATED
* @return `true` if the test condition indicates success(a false value)

View File

@@ -194,7 +194,7 @@ unsigned-integer-overflow:tests/libxrpl/basics/RangeSet.cpp
unsigned-integer-overflow:test/app/Batch_test.cpp
unsigned-integer-overflow:test/app/ConfidentialTransfer_test.cpp
unsigned-integer-overflow:test/app/Invariants_test.cpp
unsigned-integer-overflow:test/app/Loan_test.cpp
unsigned-integer-overflow:test/app/lending/LoanSecurity_test.cpp
unsigned-integer-overflow:test/app/NFToken_test.cpp
unsigned-integer-overflow:test/app/OfferMPT_test.cpp
unsigned-integer-overflow:test/app/Offer_test.cpp

File diff suppressed because it is too large Load Diff

View File

@@ -290,7 +290,7 @@ class LoanBroker_test : public beast::unit_test::Suite
{
auto const amount = vault.asset(n);
BEAST_EXPECT(broker->at(sfCoverAvailable) == amount.number());
env.require(Balance(pseudoAccount, amount));
env.require(jtx::Balance(pseudoAccount, amount));
}
};
@@ -537,8 +537,8 @@ class LoanBroker_test : public beast::unit_test::Suite
auto const expectedBalance = aliceBalance + coverFunds -
(aliceBalance.value().native() ? STAmount(env.current()->fees().base.value())
: vault.asset(0));
env.require(Balance(alice, expectedBalance));
env.require(Balance(pseudoAccount, vault.asset(kNone)));
env.require(jtx::Balance(alice, expectedBalance));
env.require(jtx::Balance(pseudoAccount, vault.asset(kNone)));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,722 @@
#include <test/app/lending/LoanTestBase.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/credentials.h>
#include <test/jtx/fee.h>
#include <test/jtx/mpt.h>
#include <test/jtx/pay.h>
#include <test/jtx/permissioned_domains.h>
#include <test/jtx/ter.h>
#include <test/jtx/trust.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_value.h>
#include <xrpl/json/to_string.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/Units.h>
#include <cstdint>
namespace xrpl::test {
class LoanCoverFreezeAuth_test : public LoanTestBase
{
private:
void
testSequentialFLCDepletion(FeatureBitset features)
{
testcase << "First-Loss Capital Depletion on Sequential Defaults";
using namespace jtx;
using namespace loan;
using namespace loan_broker;
Env env{*this, features};
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrowerA{"borrowerA"};
Account const borrowerB{"borrowerB"};
env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB);
env.close();
PrettyAsset const asset = xrpIssue();
auto const vaultDepositAmount =
asset(200'000); // Enough for 2 x 50k loans plus interest/fees
auto const brokerInfo = createVaultAndBroker(
env,
asset,
lender,
{
.vaultDeposit = vaultDepositAmount.value(),
.debtMax = 0,
.coverRateMin = TenthBips32(20000), // 20%
.coverDeposit = 21'000,
.managementFeeRate = TenthBips16(100), // 0.1%
.coverRateLiquidation = TenthBips32(100000),
});
auto const brokerKeylet = brokerInfo.brokerKeylet();
// Create two identical loans: each 50,000 XRP principal (scaled down to
// avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal
// + interest) Formula will calculate cover as: 100% × (20% × 100,000) =
// 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first
// default
auto const principalAmount = Number(50'000);
auto const loanPaymentInterval = 2592000; // 30 days
auto const loanGracePeriod = 604800; // 7 days
// Create Loan A
auto loanATx = env.jt(
set(borrowerA, brokerKeylet.key, principalAmount),
Sig(sfCounterpartySignature, lender),
kInterestRate(TenthBips32(500)), // 5%
kPaymentTotal(12),
loan::kPaymentInterval(loanPaymentInterval),
loan::kGracePeriod(loanGracePeriod),
Fee(XRP(10))); // Sufficient fee for multi-sig transaction
env(loanATx);
env.close();
auto const loanAKeylet = keylet::loan(brokerKeylet.key, 1);
// Create Loan B
auto loanBTx = env.jt(
set(borrowerB, brokerKeylet.key, principalAmount),
Sig(sfCounterpartySignature, lender),
kInterestRate(TenthBips32(500)), // 5%
kPaymentTotal(12),
loan::kPaymentInterval(loanPaymentInterval),
loan::kGracePeriod(loanGracePeriod),
Fee(XRP(10))); // Sufficient fee for multi-sig transaction
env(loanBTx);
env.close();
auto const loanBKeylet = keylet::loan(brokerKeylet.key, 2);
auto loanASle = env.le(loanAKeylet);
if (!BEAST_EXPECT(loanASle))
return;
// Advance time past grace period for both loans to be defaultable
auto const loanANextDue = loanASle->at(sfNextPaymentDueDate);
auto const loanAGrace = loanASle->at(sfGracePeriod);
env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60});
env(manage(lender, loanAKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
env.close();
// Verify Loan A is defaulted
loanASle = env.le(loanAKeylet);
if (!BEAST_EXPECT(loanASle))
return;
BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault));
BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0);
// Check broker state after first default (from committed ledger)
auto brokerSle = env.le(brokerKeylet);
if (!BEAST_EXPECT(brokerSle))
return;
auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal);
auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable);
// DebtTotal should have decreased by Loan A's debt
BEAST_EXPECT(afterFirstDebtTotal == 50'134);
// CoverAvailable should have decreased significantly
BEAST_EXPECT(afterFirstCoverAvailable == 946);
env(manage(lender, loanBKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
brokerSle = env.le(brokerKeylet);
if (!BEAST_EXPECT(brokerSle))
return;
auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal);
auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable);
BEAST_EXPECT(afterSecondDebtTotal == 0);
BEAST_EXPECT(afterSecondCoverAvailable == 0);
}
// Tests that vault withdrawals work correctly when the vault has unrealized
// loss from an impaired loan, ensuring the invariant check properly
// accounts for the loss.
void
testWithdrawReflectsUnrealizedLoss(FeatureBitset features)
{
using namespace jtx;
using namespace loan;
using namespace std::chrono_literals;
testcase("Vault withdraw reflects sfLossUnrealized");
// Test constants
static constexpr std::int64_t kInitialFunding = 1'000'000;
static constexpr std::int64_t kLenderInitialIou = 5'000'000;
static constexpr std::int64_t kDepositorInitialIou = 1'000'000;
static constexpr std::int64_t kBorrowerInitialIou = 100'000;
static constexpr std::int64_t kDepositAmount = 5'000;
static constexpr std::int64_t kPrincipalAmount = 99;
static constexpr std::uint64_t kExpectedSharesPerDepositor = 5'000'000'000;
static constexpr std::uint32_t kLocalPaymentInterval = 600;
static constexpr std::uint32_t kLocalPaymentTotal = 2;
Env env{*this, features};
// Setup accounts
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const depositorA{"lpA"};
Account const depositorB{"lpB"};
Account const borrower{"borrowerA"};
env.fund(XRP(kInitialFunding), issuer, lender, depositorA, depositorB, borrower);
env.close();
// Setup trust lines
PrettyAsset const iouAsset = issuer[iouCurrency_];
env(trust(lender, iouAsset(10'000'000)));
env(trust(depositorA, iouAsset(10'000'000)));
env(trust(depositorB, iouAsset(10'000'000)));
env(trust(borrower, iouAsset(10'000'000)));
env.close();
// Fund accounts with IOUs
env(pay(issuer, lender, iouAsset(kLenderInitialIou)));
env(pay(issuer, depositorA, iouAsset(kDepositorInitialIou)));
env(pay(issuer, depositorB, iouAsset(kDepositorInitialIou)));
env(pay(issuer, borrower, iouAsset(kBorrowerInitialIou)));
env.close();
// Create vault and broker, then add deposits from two depositors
auto const broker = createVaultAndBroker(env, iouAsset, lender);
Vault v{env};
env(v.deposit({
.depositor = depositorA,
.id = broker.vaultKeylet().key,
.amount = iouAsset(kDepositAmount),
}),
Ter(tesSUCCESS));
env(v.deposit({
.depositor = depositorB,
.id = broker.vaultKeylet().key,
.amount = iouAsset(kDepositAmount),
}),
Ter(tesSUCCESS));
env.close();
// Create a loan
auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
if (!BEAST_EXPECT(sleBroker))
return;
auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence));
env(set(borrower, broker.brokerID, kPrincipalAmount),
Sig(sfCounterpartySignature, lender),
kPaymentTotal(kLocalPaymentTotal),
kPaymentInterval(kLocalPaymentInterval),
Fee(env.current()->fees().base * 2),
Ter(tesSUCCESS));
env.close();
// Impair the loan to create unrealized loss
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
env.close();
// Verify unrealized loss is recorded in the vault
auto const vaultAfterImpair = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(vaultAfterImpair))
return;
BEAST_EXPECT(
vaultAfterImpair->at(sfLossUnrealized) == broker.asset(kPrincipalAmount).value());
// Helper to get share balance for a depositor
auto const shareAsset = vaultAfterImpair->at(sfShareMPTID);
auto const getShareBalance = [&](Account const& depositor) -> std::uint64_t {
auto const token = env.le(keylet::mptoken(shareAsset, depositor.id()));
return token ? token->getFieldU64(sfMPTAmount) : 0;
};
// Verify both depositors have equal shares
auto const sharesLpA = getShareBalance(depositorA);
auto const sharesLpB = getShareBalance(depositorB);
BEAST_EXPECT(sharesLpA == kExpectedSharesPerDepositor);
BEAST_EXPECT(sharesLpB == kExpectedSharesPerDepositor);
BEAST_EXPECT(sharesLpA == sharesLpB);
// Helper to attempt withdrawal
auto const attemptWithdrawShares = [&](Account const& depositor,
std::uint64_t shareAmount,
TER expected) {
STAmount const shareAmt{MPTIssue{shareAsset}, Number(shareAmount)};
env(v.withdraw(
{.depositor = depositor, .id = broker.vaultKeylet().key, .amount = shareAmt}),
Ter(expected));
env.close();
};
// Regression test: Both depositors should successfully withdraw despite
// unrealized loss. Previously failed with invariant violation:
// "withdrawal must change vault and destination balance by equal
// amount". This was caused by sharesToAssetsWithdraw rounding down,
// creating a mismatch where vaultDeltaAssets * -1 != destinationDelta
// when unrealized loss exists.
attemptWithdrawShares(depositorA, sharesLpA, tesSUCCESS);
attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS);
}
void
testServiceFeeOnBrokerDeepFreeze()
{
testcase << "Service Fee On Broker Deep Freeze";
using namespace jtx;
using namespace loan;
Account const issuer("issuer");
Account const borrower("borrower");
Account const broker("broker");
auto const iou = issuer["IOU"];
for (bool const deepFreeze : {true, false})
{
Env env(*this);
auto getCoverBalance = [&](BrokerInfo const& brokerInfo, auto const& accountField) {
if (auto const le = env.le(keylet::loanBroker(brokerInfo.brokerID));
BEAST_EXPECT(le))
{
auto const account = le->at(accountField);
if (auto const sleLine = env.le(keylet::trustLine(account, iou));
BEAST_EXPECT(sleLine))
{
STAmount balance = sleLine->at(sfBalance);
if (account > issuer.id())
balance.negate();
return balance;
}
}
return STAmount{iou};
};
env.fund(XRP(20'000), issuer, broker, borrower);
env.close();
env(trust(broker, iou(20'000'000)));
env(pay(issuer, broker, iou(10'000'000)));
env.close();
auto const brokerInfo = createVaultAndBroker(env, iou, broker);
BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000));
auto const keylet = keylet::loan(brokerInfo.brokerID, 1);
env(set(borrower, brokerInfo.brokerID, 10'000),
Sig(sfCounterpartySignature, broker),
kLoanServiceFee(iou(100).value()),
kPaymentInterval(100),
Fee(XRP(100)));
env.close();
env(trust(borrower, iou(20'000'000)));
// The borrower increases their limit and acquires some IOU so
// they can pay interest
env(pay(issuer, borrower, iou(500)));
env.close();
if (auto const le = env.le(keylet::loan(keylet.key)); BEAST_EXPECT(le))
{
if (deepFreeze)
{
env(trust(issuer, broker["IOU"](0), tfSetFreeze | tfSetDeepFreeze));
env.close();
}
env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100)));
env.close();
if (deepFreeze)
{
// The fee goes to the broker pseudo-account
BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'100));
BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'000));
}
else
{
// The fee goes to the broker account
BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'100));
BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000));
}
}
};
}
void
testLoanPayBrokerOwnerMissingTrustline(FeatureBitset features)
{
testcase << "LoanPay Broker Owner Missing Trustline (PoC)";
using namespace jtx;
using namespace loan;
Account const issuer("issuer");
Account const borrower("borrower");
Account const broker("broker");
auto const iou = issuer["IOU"];
Env env(*this, features);
env.fund(XRP(20'000), issuer, broker, borrower);
env.close();
// Set up trustlines and fund accounts
env(trust(broker, iou(20'000'000)));
env(trust(borrower, iou(20'000'000)));
env(pay(issuer, broker, iou(10'000'000)));
env(pay(issuer, borrower, iou(1'000)));
env.close();
// Create vault and broker
auto const brokerInfo = createVaultAndBroker(env, iou, broker);
// Create a loan first (this creates debt)
auto const keylet = keylet::loan(brokerInfo.brokerID, 1);
env(set(borrower, brokerInfo.brokerID, 10'000),
Sig(sfCounterpartySignature, broker),
kLoanServiceFee(iou(100).value()),
kPaymentInterval(100),
Fee(XRP(100)));
env.close();
// Ensure broker has sufficient cover so brokerPayee == brokerOwner
// We need coverAvailable >= (debtTotal * coverRateMinimum)
// Deposit enough cover to ensure the fee goes to broker owner
// The default coverRateMinimum is 10%, so for a 10,000 loan we need
// at least 1,000 cover. Default cover is 1,000, so we add more to be
// safe.
auto const additionalCover = iou(50'000).value();
env(loan_broker::coverDeposit(broker, brokerInfo.brokerID, STAmount{iou, additionalCover}));
env.close();
// Verify broker owner has a trustline
auto const brokerTrustline = keylet::trustLine(broker, iou);
BEAST_EXPECT(env.le(brokerTrustline) != nullptr);
// Broker owner deletes their trustline
// First, pay any positive balance to issuer to zero it out
auto const brokerBalance = env.balance(broker, iou);
env(pay(broker, issuer, brokerBalance));
env.close();
// Remove the trustline by setting limit to 0
env(trust(broker, iou(0)));
env.close();
// Verify trustline is deleted
BEAST_EXPECT(env.le(brokerTrustline) == nullptr);
// Now borrower tries to make a payment
// We should get a tesSUCCESS instead of a tecNO_LINE.
env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100)), Ter(tesSUCCESS));
env.close();
// Verify trustline is still deleted
BEAST_EXPECT(env.le(brokerTrustline) == nullptr);
// Verify the service fee went to the broker pseudo-account
if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
BEAST_EXPECT(brokerSle))
{
Account const pseudo("pseudo-account", brokerSle->at(sfAccount));
auto const balance = env.balance(pseudo, iou);
// 1,000 default + 50,000 extra + 100 service fee from LoanPay
BEAST_EXPECTS(balance == iou(51'100), to_string(json::Value(balance)));
}
}
void
testLoanPayBrokerOwnerUnauthorizedMPT(FeatureBitset features)
{
testcase << "LoanPay Broker Owner MPT unauthorized";
using namespace jtx;
using namespace loan;
Account const issuer("issuer");
Account const borrower("borrower");
Account const broker("broker");
Env env{*this, features};
env.fund(XRP(20'000), issuer, broker, borrower);
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
PrettyAsset const mpt{mptt.issuanceID()};
// Authorize broker and borrower
mptt.authorize({.account = broker});
mptt.authorize({.account = borrower});
env.close();
// Fund accounts
env(pay(issuer, broker, mpt(10'000'000)));
env(pay(issuer, borrower, mpt(1'000)));
env.close();
// Create vault and broker
auto const brokerInfo = createVaultAndBroker(env, mpt, broker);
// Create a loan first (this creates debt)
auto const keylet = keylet::loan(brokerInfo.brokerID, 1);
env(set(borrower, brokerInfo.brokerID, 10'000),
Sig(sfCounterpartySignature, broker),
kLoanServiceFee(mpt(100).value()),
kPaymentInterval(100),
Fee(XRP(100)));
env.close();
// Ensure broker has sufficient cover so brokerPayee == brokerOwner
// We need coverAvailable >= (debtTotal * coverRateMinimum)
// Deposit enough cover to ensure the fee goes to broker owner
// The default coverRateMinimum is 10%, so for a 10,000 loan we need
// at least 1,000 cover. Default cover is 1,000, so we add more to be
// safe.
auto const additionalCover = mpt(50'000).value();
env(loan_broker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover}));
env.close();
// Verify broker owner is authorized
auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker);
BEAST_EXPECT(env.le(brokerMpt) != nullptr);
// Broker owner unauthorizes.
// First, pay any positive balance to issuer to zero it out
auto const brokerBalance = env.balance(broker, mpt);
env(pay(broker, issuer, brokerBalance));
env.close();
// Then, unauthorize the MPT.
mptt.authorize({.account = broker, .flags = tfMPTUnauthorize});
env.close();
// Verify the MPT is unauthorized.
BEAST_EXPECT(env.le(brokerMpt) == nullptr);
// Now borrower tries to make a payment
// We should get a tesSUCCESS instead of a tecNO_AUTH.
env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS));
env.close();
// Verify the MPT is still unauthorized.
BEAST_EXPECT(env.le(brokerMpt) == nullptr);
// Verify the service fee went to the broker pseudo-account
if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
BEAST_EXPECT(brokerSle))
{
Account const pseudo("pseudo-account", brokerSle->at(sfAccount));
auto const balance = env.balance(pseudo, mpt);
// 1,000 default + 50,000 extra + 100 service fee from LoanPay
BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance)));
}
}
void
testLoanPayBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features)
{
testcase << "LoanPay Broker Owner without permissioned domain of the MPT";
using namespace jtx;
using namespace loan;
Account const issuer("issuer");
Account const borrower("borrower");
Account const broker("broker");
Env env{*this, features};
env.fund(XRP(20'000), issuer, broker, borrower);
env.close();
auto credType = "credential1";
pdomain::Credentials const credentials1 = {{.issuer = issuer, .credType = credType}};
env(pdomain::setTx(issuer, credentials1));
env.close();
auto domainID = pdomain::getNewDomain(env.meta());
env(credentials::create(broker, issuer, credType));
env(credentials::accept(broker, issuer, credType));
env.close();
env(credentials::create(borrower, issuer, credType));
env(credentials::accept(borrower, issuer, credType));
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({
.flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock,
.domainID = domainID,
});
PrettyAsset const mpt{mptt.issuanceID()};
// Authorize broker and borrower
mptt.authorize({.account = broker});
mptt.authorize({.account = borrower});
env.close();
// Fund accounts
env(pay(issuer, broker, mpt(10'000'000)));
env(pay(issuer, borrower, mpt(1'000)));
env.close();
// Create vault and broker
auto const brokerInfo = createVaultAndBroker(env, mpt, broker);
// Create a loan first (this creates debt)
auto const keylet = keylet::loan(brokerInfo.brokerID, 1);
env(set(borrower, brokerInfo.brokerID, 10'000),
Sig(sfCounterpartySignature, broker),
kLoanServiceFee(mpt(100).value()),
kPaymentInterval(100),
Fee(XRP(100)));
env.close();
// Ensure broker has sufficient cover so brokerPayee == brokerOwner
// We need coverAvailable >= (debtTotal * coverRateMinimum)
// Deposit enough cover to ensure the fee goes to broker owner
// The default coverRateMinimum is 10%, so for a 10,000 loan we need
// at least 1,000 cover. Default cover is 1,000, so we add more to be
// safe.
auto const additionalCover = mpt(50'000).value();
env(loan_broker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover}));
env.close();
// Verify broker owner is authorized
auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker);
BEAST_EXPECT(env.le(brokerMpt) != nullptr);
// Remove the credentials for the Broker owner.
// First, pay any positive balance to issuer to zero it out
auto const brokerBalance = env.balance(broker, mpt);
env(pay(broker, issuer, brokerBalance));
env.close();
env(credentials::deleteCred(broker, broker, issuer, credType));
env.close();
// Make sure the broker is not authorized to hold the MPT after we
// deleted the credentials
env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH));
// Now borrower tries to make a payment
// We should get a tesSUCCESS instead of a tecNO_AUTH.
env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS));
env.close();
// Verify broker is still not authorized
env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH));
// Verify the service fee went to the broker pseudo-account
if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
BEAST_EXPECT(brokerSle))
{
Account const pseudo("pseudo-account", brokerSle->at(sfAccount));
auto const balance = env.balance(pseudo, mpt);
// 1,000 default + 50,000 extra + 100 service fee from LoanPay
BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance)));
}
}
void
testLoanSetBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features)
{
testcase << "LoanSet Broker Owner without permissioned domain of the MPT";
using namespace jtx;
using namespace loan;
Account const issuer("issuer");
Account const borrower("borrower");
Account const broker("broker");
Env env{*this, features};
env.fund(XRP(20'000), issuer, broker, borrower);
env.close();
auto credType = "credential1";
pdomain::Credentials const credentials1{{.issuer = issuer, .credType = credType}};
env(pdomain::setTx(issuer, credentials1));
env.close();
auto domainID = pdomain::getNewDomain(env.meta());
// Add credentials for the broker and borrower
env(credentials::create(broker, issuer, credType));
env(credentials::accept(broker, issuer, credType));
env.close();
env(credentials::create(borrower, issuer, credType));
env(credentials::accept(borrower, issuer, credType));
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({
.flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock,
.domainID = domainID,
});
PrettyAsset const mpt{mptt.issuanceID()};
// Authorize broker and borrower
mptt.authorize({.account = broker});
mptt.authorize({.account = borrower});
env.close();
// Fund accounts
env(pay(issuer, broker, mpt(10'000'000)));
env(pay(issuer, borrower, mpt(1'000)));
env.close();
// Create vault and broker
auto const brokerInfo = createVaultAndBroker(env, mpt, broker);
// Remove the credentials for the Broker owner.
// Clear the balance first.
auto const brokerBalance = env.balance(broker, mpt);
env(pay(broker, issuer, brokerBalance));
env.close();
// Delete the credentials
env(credentials::deleteCred(broker, broker, issuer, credType));
env.close();
// Create a loan, this should fail for tecNO_AUTH
env(set(borrower, brokerInfo.brokerID, 10'000),
Sig(sfCounterpartySignature, broker),
kLoanServiceFee(mpt(100).value()),
kPaymentInterval(100),
Fee(XRP(100)),
Ter(tecNO_AUTH));
env.close();
}
void
runAmendmentIndependent()
{
testServiceFeeOnBrokerDeepFreeze();
}
// Tests run under each entry in amendmentCombinations().
void
runAmendmentSensitive(FeatureBitset features)
{
testSequentialFLCDepletion(features);
testWithdrawReflectsUnrealizedLoss(features);
testLoanPayBrokerOwnerMissingTrustline(features);
testLoanPayBrokerOwnerUnauthorizedMPT(features);
testLoanPayBrokerOwnerNoPermissionedDomainMPT(features);
testLoanSetBrokerOwnerNoPermissionedDomainMPT(features);
}
public:
void
run() override
{
runAmendmentIndependent();
for (auto const& features : jtx::amendmentCombinations(
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
runAmendmentSensitive(features);
}
};
BEAST_DEFINE_TESTSUITE(LoanCoverFreezeAuth, tx, xrpl);
} // namespace xrpl::test

View File

@@ -0,0 +1,873 @@
#include <test/app/lending/LoanTestBase.h>
#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/flags.h>
#include <test/jtx/jtx_json.h>
#include <test/jtx/mpt.h>
#include <test/jtx/pay.h>
#include <test/jtx/ter.h>
#include <test/jtx/trust.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <cstdint>
#include <optional>
namespace xrpl::test {
class LoanInvariants_test : public LoanTestBase
{
private:
// Each of these regression tests reproduces a single fuzzer-found (FIND-*)
// scenario against xrpl::detail::computePeriodicPayment /
// loanComputePaymentParts. They're merged into one function, one block
// per finding, because each is a narrow, self-contained repro that
// shares little beyond the surrounding scaffold.
void
testLoanPayComputePeriodicPaymentInvariants(FeatureBitset features)
{
using namespace jtx;
using namespace std::chrono_literals;
using namespace lending;
// From FIND-012
{
testcase << "LoanPay xrpl::detail::computePeriodicPayment : "
"valid rate";
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
BrokerParameters const brokerParams;
env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, lender, borrower);
env.close();
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
using namespace loan;
auto const loanSetFee = Fee(env.current()->fees().base * 2);
Number const principalRequest{640562, -5};
Number const serviceFee{2462611968};
std::uint32_t const numPayments{4294967295 / 800};
auto createJson = env.json(
set(borrower, broker.brokerID, principalRequest),
Fee(loanSetFee),
kLoanServiceFee(serviceFee),
kPaymentTotal(numPayments),
Json(sfCounterpartySignature, json::ValueType::Object));
createJson["CloseInterestRate"] = 55374;
createJson["ClosePaymentFee"] = "3825205248";
createJson["LatePaymentFee"] = "237";
createJson["LoanOriginationFee"] = "0";
createJson["OverpaymentFee"] = 35167;
createJson["OverpaymentInterestRate"] = 1360;
createJson["PaymentInterval"] = 727;
auto const keylet = nextLoanKeylet(env, broker);
createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
// Fails in preclaim because principal requested can't be
// represented as XRP
env(createJson, Ter(tecPRECISION_LOSS));
env.close();
BEAST_EXPECT(!env.le(keylet));
Number const actualPrincipal{6};
createJson[sfPrincipalRequested] = actualPrincipal;
createJson.removeMember(sfSequence.jsonName);
createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
// Fails in doApply because the payment is too small to be
// represented as XRP.
env(createJson, Ter(tecPRECISION_LOSS));
env.close();
}
// From FIND-010
{
testcase << "xrpl::loanComputePaymentParts : valid total interest";
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower);
BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)};
using namespace loan;
auto const loanSetFee = Fee(env.current()->fees().base * 2);
Number const principalRequest{1, 3};
auto createJson = env.json(
set(borrower, broker.brokerID, principalRequest),
Fee(loanSetFee),
Json(sfCounterpartySignature, json::ValueType::Object));
createJson["CloseInterestRate"] = 47299;
createJson["ClosePaymentFee"] = "3985819770";
createJson["InterestRate"] = 92;
createJson["LatePaymentFee"] = "3866894865";
createJson["LoanOriginationFee"] = "0";
createJson["LoanServiceFee"] = "2348810240";
createJson["OverpaymentFee"] = 58545;
createJson["PaymentInterval"] = 60;
createJson["PaymentTotal"] = 1;
createJson["PrincipalRequested"] = "0.000763058";
auto const keylet = nextLoanKeylet(env, broker);
createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
env(createJson);
env.close();
auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}}));
loanPayTx["Amount"]["value"] = "0.000281284125490196";
env(loanPayTx, Ter(tecINSUFFICIENT_PAYMENT));
env.close();
}
// From FIND-009
{
testcase << "xrpl::loanComputePaymentParts : totalPrincipalPaid "
"rounded";
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower);
BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)};
using namespace loan;
auto const loanSetFee = Fee(env.current()->fees().base * 2);
Number const principalRequest{1, 3};
auto createJson = env.json(
set(borrower, broker.brokerID, principalRequest),
Fee(loanSetFee),
Json(sfCounterpartySignature, json::ValueType::Object));
createJson["ClosePaymentFee"] = "0";
createJson["InterestRate"] = 24346;
createJson["LateInterestRate"] = 65535;
createJson["LatePaymentFee"] = "0";
createJson["LoanOriginationFee"] = "218";
createJson["LoanServiceFee"] = "0";
createJson["PaymentInterval"] = 60;
createJson["PaymentTotal"] = 5678;
createJson["PrincipalRequested"] = "9924.81";
auto const keylet = nextLoanKeylet(env, broker);
createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
env(createJson, Ter(tesSUCCESS));
env.close();
auto const baseFee = env.current()->fees().base;
auto const stateBefore = getCurrentState(env, broker, keylet);
{
auto loanPayTx =
env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}}));
Number const amount{3074'745'058'823'529, -12};
BEAST_EXPECT(to_string(amount) == "3074.745058823529");
XRPAmount const payFee{
baseFee *
(amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)};
loanPayTx["Amount"]["value"] = to_string(amount);
env(loanPayTx, Fee(payFee), Ter(tesSUCCESS));
env.close();
}
{
auto loanPayTx =
env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}}));
Number const amount{6732'118'170'944'051, -12};
BEAST_EXPECT(to_string(amount) == "6732.118170944051");
XRPAmount const payFee{
baseFee *
(amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)};
loanPayTx["Amount"]["value"] = to_string(amount);
env(loanPayTx, Fee(payFee), Ter(tesSUCCESS));
env.close();
}
auto const stateAfter = getCurrentState(env, broker, keylet);
// Total interest outstanding is non-negative
BEAST_EXPECT(stateAfter.totalValue >= stateAfter.principalOutstanding);
// Principal paid is non-negative
BEAST_EXPECT(stateBefore.principalOutstanding >= stateAfter.principalOutstanding);
// Total value change is non-negative
BEAST_EXPECT(stateBefore.totalValue >= stateAfter.totalValue);
// Value delta is larger or same as principal delta (meaning
// non-negative interest paid)
BEAST_EXPECT(
(stateBefore.totalValue - stateAfter.totalValue) >=
(stateBefore.principalOutstanding - stateAfter.principalOutstanding));
}
// From FIND-008
{
testcase << "xrpl::loanComputePaymentParts : loanValueChange rounded";
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
PrettyAsset const iouAsset =
createFundedIouAsset(env, issuer, lender, borrower, 100'000'000, 10'000'000);
BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)};
{
auto const coverDepositValue =
broker.asset(broker.params.coverDeposit * 10).value();
env(loan_broker::coverDeposit(lender, broker.brokerID, coverDepositValue));
env.close();
}
using namespace loan;
auto const loanSetFee = Fee(env.current()->fees().base * 2);
Number const principalRequest{1, 3};
auto createJson = env.json(
set(borrower, broker.brokerID, principalRequest),
Fee(loanSetFee),
Json(sfCounterpartySignature, json::ValueType::Object));
createJson["ClosePaymentFee"] = "0";
createJson["InterestRate"] = 12833;
createJson["LateInterestRate"] = 77048;
createJson["LatePaymentFee"] = "0";
createJson["LoanOriginationFee"] = "218";
createJson["LoanServiceFee"] = "0";
createJson["PaymentInterval"] = 752;
createJson["PaymentTotal"] = 5678;
createJson["PrincipalRequested"] = "9924.81";
auto const keylet = nextLoanKeylet(env, broker);
createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
env(createJson, Ter(tesSUCCESS));
env.close();
auto const baseFee = env.current()->fees().base;
auto const stateBefore = getCurrentState(env, broker, keylet);
BEAST_EXPECT(stateBefore.paymentRemaining == 5678);
BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction);
auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}}));
Number const amount{9924'81, -2};
BEAST_EXPECT(to_string(amount) == "9924.81");
XRPAmount const payFee{
baseFee *
(amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)};
loanPayTx["Amount"]["value"] = to_string(amount);
env(loanPayTx, Fee(payFee), Ter(tesSUCCESS));
env.close();
auto const stateAfter = getCurrentState(env, broker, keylet);
BEAST_EXPECT(
stateAfter.paymentRemaining ==
stateBefore.paymentRemaining - kLoanMaximumPaymentsPerTransaction);
}
}
void
testLoanPayDebtDecreaseInvariant(FeatureBitset features)
{
// From FIND-007
testcase << "LoanPay xrpl::LoanPay::doApply : debtDecrease "
"rounding good";
using namespace jtx;
using namespace std::chrono_literals;
using namespace lending;
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower);
BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)};
using namespace loan;
auto const baseFee = env.current()->fees().base;
auto const loanSetFee = Fee(baseFee * 2);
Number const principalRequest{1, 3};
auto createJson = env.json(
set(borrower, broker.brokerID, principalRequest),
Fee(loanSetFee),
Json(sfCounterpartySignature, json::ValueType::Object));
createJson["ClosePaymentFee"] = "0";
createJson["GracePeriod"] = 60;
createJson["InterestRate"] = 24346;
createJson["LateInterestRate"] = 65535;
createJson["LatePaymentFee"] = "0";
createJson["LoanOriginationFee"] = "218";
createJson["LoanServiceFee"] = "0";
createJson["PaymentInterval"] = 60;
createJson["PaymentTotal"] = 5678;
createJson["PrincipalRequested"] = "9924.81";
auto const keylet = nextLoanKeylet(env, broker);
createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
env(createJson, Ter(tesSUCCESS));
env.close();
auto const pseudoAcct = brokerPseudoAccount(env, broker, lender);
VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet);
auto const originalState = getCurrentState(env, broker, keylet);
verifyLoanStatus(originalState);
Number const payment{3'269'349'176'470'588, -12};
XRPAmount const payFee{
baseFee *
((payment / originalState.periodicPayment) / kLoanPaymentsPerFeeIncrement + 1)};
auto loanPayTx =
env.json(pay(borrower, keylet.key, STAmount{broker.asset, payment}), Fee(payFee));
BEAST_EXPECT(to_string(payment) == "3269.349176470588");
env(loanPayTx, Ter(tesSUCCESS));
env.close();
auto const newState = getCurrentState(env, broker, keylet);
BEAST_EXPECT(
isRounded(broker.asset, newState.managementFeeOutstanding, originalState.loanScale));
BEAST_EXPECT(newState.managementFeeOutstanding < originalState.managementFeeOutstanding);
BEAST_EXPECT(isRounded(broker.asset, newState.totalValue, originalState.loanScale));
BEAST_EXPECT(
isRounded(broker.asset, newState.principalOutstanding, originalState.loanScale));
}
void
testAccountSendMptMinAmountInvariant(FeatureBitset features)
{
// (From FIND-006)
testcase << "LoanSet trigger xrpl::accountSendMPT : minimum amount "
"and MPT";
using namespace jtx;
using namespace std::chrono_literals;
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(1'000'000), issuer, lender, borrower);
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
PrettyAsset const mptAsset = mptt.issuanceID();
mptt.authorize({.account = lender});
mptt.authorize({.account = borrower});
env(pay(issuer, lender, mptAsset(2'000'000)));
env(pay(issuer, borrower, mptAsset(1'000)));
env.close();
BrokerInfo const broker{createVaultAndBroker(env, mptAsset, lender)};
using namespace loan;
auto const loanSetFee = Fee(env.current()->fees().base * 2);
Number const principalRequest{1, 3};
auto createJson = env.json(
set(borrower, broker.brokerID, principalRequest),
Fee(loanSetFee),
Json(sfCounterpartySignature, json::ValueType::Object));
createJson["CloseInterestRate"] = 76671;
createJson["ClosePaymentFee"] = "2061925410";
createJson["GracePeriod"] = 434;
createJson["InterestRate"] = 50302;
createJson["LateInterestRate"] = 30322;
createJson["LatePaymentFee"] = "294427911";
createJson["LoanOriginationFee"] = "3250635102";
createJson["LoanServiceFee"] = "9557386";
createJson["OverpaymentFee"] = 51249;
createJson["OverpaymentInterestRate"] = 14304;
createJson["PaymentInterval"] = 434;
createJson["PaymentTotal"] = "2891743748";
createJson["PrincipalRequested"] = "8516.98";
createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
env(createJson, Ter(temINVALID));
env.close();
}
// Verify that LoanPay, LoanBrokerCoverWithdraw, and LoanSet all use the
// same vault-scale minimum cover when fixCleanup3_2_0 is enabled.
// Before the amendment, each transactor computed its minimum cover at a
// different precision (loanScale, debtScale, or the raw unrounded
// tenthBipsOfValue), which could lead to inconsistent decisions for the
// same broker state. After the amendment all three use
// minimumBrokerCover at vaultScale.
void
testMinimumBrokerCoverConsistency(FeatureBitset features)
{
using namespace jtx;
using namespace loan;
using namespace loan_broker;
bool const withAmendment = features[fixCleanup3_2_0];
struct Ctx
{
jtx::Account issuer;
jtx::Account lender;
jtx::Account borrower;
jtx::PrettyAsset iou;
BrokerInfo broker;
BrokerParameters brokerParams;
};
// Shared setup, parametrized by vaultDeposit (the only varying setup
// field across the three scenarios). Each call runs in its own Env
// so multiple invocations within one scenario cannot interfere.
// The caller is responsible for invoking testcase(...) before the
// first runTest call of each scenario.
auto runTest = [&](Number vaultDeposit, auto&& body) {
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(1'000'000'000), issuer, lender, borrower);
env.close();
// Enable clawback on the issuer *before* any trust lines exist
// (asfAllowTrustLineClawback requires an empty owner directory).
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
PrettyAsset const iou = issuer[iouCurrency_];
env(trust(lender, iou(1'000'000'000)));
env(trust(borrower, iou(1'000'000'000)));
env.close();
env(pay(issuer, lender, iou(100'000'000)));
env(pay(issuer, borrower, iou(100'000'000)));
env.close();
// 13.37% — non-round rate produces a messier minimum.
BrokerParameters const brokerParams{
.vaultDeposit = vaultDeposit,
.debtMax = 0,
.coverRateMin = TenthBips32{13'370},
.coverDeposit = 5'000,
.managementFeeRate = TenthBips16{500}};
BrokerInfo const broker = createVaultAndBroker(env, iou, lender, brokerParams);
body(
env,
Ctx{.issuer = issuer,
.lender = lender,
.borrower = borrower,
.iou = iou,
.broker = broker,
.brokerParams = brokerParams});
};
// Scenario 1 — LoanPay
//
// Verify that LoanPay's minimum cover check uses vault scale (not
// loan scale). Before the amendment, different loans could produce
// different fee routing decisions for the same broker-level state.
// Small vault deposit => vaultScale = -12.
testcase("LoanPay minimum cover scale consistency");
{
struct LoanKeylets
{
Keylet tiny;
Keylet big;
};
// Create the tiny + big loans and reduce cover via clawback so
// that subsequent LoanPay calls hit the minimum-cover boundary.
// Used by the two pay-and-check sub-tests below so each can run
// in its own Env.
auto setupLoansAndClawback = [&](Env& env, Ctx const& c) -> std::optional<LoanKeylets> {
Asset const asset{c.iou};
// Create the TINY loan first (while vaultScale is still
// small). principal 0.01, 0% interest, 1 payment =>
// loanScale = vaultScale.
auto const brokerSle1 = env.le(keylet::loanBroker(c.broker.brokerID));
if (!BEAST_EXPECT(brokerSle1))
return std::nullopt;
auto const tinyLoanSeq = brokerSle1->at(sfLoanSequence);
auto const tinyLoanKeylet = keylet::loan(c.broker.brokerID, tinyLoanSeq);
env(set(c.borrower, c.broker.brokerID, Number{1, -2}),
Sig(sfCounterpartySignature, c.lender),
kInterestRate(TenthBips32{0}),
kPaymentTotal(1),
kPaymentInterval(86400 * 365),
Fee(XRP(10)));
env.close();
// Create the BIG loan second. 100% annual interest over 20
// payments pushes totalValueOutstanding high enough that
// loanScale > vaultScale.
auto const brokerSle2 = env.le(keylet::loanBroker(c.broker.brokerID));
if (!BEAST_EXPECT(brokerSle2))
return std::nullopt;
auto const bigLoanSeq = brokerSle2->at(sfLoanSequence);
auto const bigLoanKeylet = keylet::loan(c.broker.brokerID, bigLoanSeq);
env(set(c.borrower, c.broker.brokerID, Number{500}),
Sig(sfCounterpartySignature, c.lender),
kInterestRate(TenthBips32{100'000}),
kPaymentTotal(20),
kPaymentInterval(86400 * 365),
Fee(XRP(10)));
env.close();
// The tiny loan's scale is frozen at the vault's pre-big-loan
// scale, so it is strictly smaller than the big loan's.
// After the big loan is created the vault absorbs its value,
// pushing vaultScale up to match bigLoanScale.
auto const tinyLoanSle = env.le(tinyLoanKeylet);
auto const bigLoanSle = env.le(bigLoanKeylet);
auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
if (!BEAST_EXPECT(tinyLoanSle) || !BEAST_EXPECT(bigLoanSle) ||
!BEAST_EXPECT(vaultSle))
return std::nullopt;
if (!BEAST_EXPECT(tinyLoanSle->at(sfLoanScale) == -12) ||
!BEAST_EXPECT(bigLoanSle->at(sfLoanScale) == -11) ||
!BEAST_EXPECT(getAssetsTotalScale(vaultSle) == -11))
return std::nullopt;
// Use issuer clawback to reduce cover to the minimum the
// clawback transactor allows. Compute the amount as
// initialCover - expectedCoverAfter so we exercise the exact
// clawback rather than relying on the transactor to clip
// down.
//
// Before the amendment the clawback minimum is the
// *unrounded* tenthBipsOfValue — strictly less than the
// rounded-at-vaultScale minimum LoanPay uses for the big
// loan. After the amendment both clawback and LoanPay use
// the same rounded minimum (via minimumBrokerCover), so
// cover lands exactly at that threshold.
Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15}
: Number{1330651855688458000, -15};
Number const clawbackAmount =
Number{c.brokerParams.coverDeposit} - expectedCoverAfter;
env(coverClawback(c.issuer),
kLoanBrokerId(c.broker.brokerID),
kAmount(STAmount{asset, clawbackAmount}));
env.close();
auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID));
if (!BEAST_EXPECT(brokerSle) ||
!BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == expectedCoverAfter))
return std::nullopt;
return LoanKeylets{.tiny = tinyLoanKeylet, .big = bigLoanKeylet};
};
// Pay one loan and report whether the fee went to the broker's
// pseudo account (the fallback when cover < minimum) rather
// than to the owner.
auto feeGoesToPseudo = [&](Env& env, Ctx const& c, Keylet const& loanKeylet) -> bool {
Asset const asset{c.iou};
auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID));
if (!BEAST_EXPECT(brokerSle))
return false;
auto const pseudoAcct = Account("pseudo", brokerSle->at(sfAccount));
auto const pseudoBefore = env.balance(pseudoAcct, c.iou);
auto const payLoan = env.le(loanKeylet);
if (!BEAST_EXPECT(payLoan))
return false;
auto const periodicPayment = payLoan->at(sfPeriodicPayment);
auto const serviceFee = payLoan->at(sfLoanServiceFee);
std::int32_t const loanScale = payLoan->at(sfLoanScale);
auto const payment = roundPeriodicPayment(asset, periodicPayment, loanScale);
auto const payAmt = STAmount{asset, payment + serviceFee};
env(loan::pay(c.borrower, loanKeylet.key, payAmt), Fee(XRP(10)));
env.close();
auto const pseudoAfter = env.balance(pseudoAcct, c.iou);
return pseudoAfter.number() > pseudoBefore.number();
};
// Pay the BIG loan in its own Env so its outcome cannot affect
// the TINY-loan check. With the fix, LoanPay and clawback use
// the same vaultScale minimum (cover == minAtVaultScale =>
// fee to owner). Without the fix, LoanPay uses bigLoanScale=-11,
// rounds up to a larger minimum than what clawback used =>
// cover < min => fee to pseudo.
runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) {
auto const loans = setupLoansAndClawback(env, c);
if (!loans)
return;
BEAST_EXPECT(feeGoesToPseudo(env, c, loans->big) == !withAmendment);
});
// Pay the TINY loan in its own Env. Fee goes to the owner
// either way:
// - With the fix: LoanPay uses vaultScale=-11 (same as
// clawback) => owner.
// - Without the fix: LoanPay uses tinyLoanScale=-12, rounds
// up at -12 (a no-op) => min == cover => owner.
runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) {
auto const loans = setupLoansAndClawback(env, c);
if (!loans)
return;
BEAST_EXPECT(!feeGoesToPseudo(env, c, loans->tiny));
});
}
// Scenario 2 — LoanBrokerCoverWithdraw
//
// Verify that CoverWithdraw's minimum cover check uses vault scale
// (not scale(debtTotal, asset)). Before the amendment, CoverWithdraw
// used:
// roundToAsset(asset, tenthBipsOfValue(debt, rate), scale(debt, asset))
// which could disagree with LoanPay's minimum (which used loanScale).
//
// Use a large vault deposit so that vaultScale (from AssetsTotal) is
// strictly larger than debtScale (from DebtTotal). With
// vaultDeposit = 100,000: after the big loan
// AssetsTotal ≈ 109,500 → vaultScale = -10
// DebtTotal ≈ 10,000 → debtScale = -11
// The one-order-of-magnitude gap makes roundToAsset at -10 truncate
// more aggressively than at -11, exposing the bug.
testcase("CoverWithdraw minimum cover scale consistency");
runTest(
/*vaultDeposit=*/100'000, [&](Env& env, Ctx const& c) {
Asset const asset{c.iou};
// Create only the big loan to push DebtTotal up to ~10,000
// while AssetsTotal stays around 109,500 (dominated by the
// large vault deposit).
env(set(c.borrower, c.broker.brokerID, Number{500}),
Sig(sfCounterpartySignature, c.lender),
kInterestRate(TenthBips32{100'000}),
kPaymentTotal(20),
kPaymentInterval(86400 * 365),
Fee(XRP(10)));
env.close();
// Read broker state and compute both old and new minimums.
auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID));
auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
if (!BEAST_EXPECT(brokerSle) || !BEAST_EXPECT(vaultSle))
return;
auto const coverAvail = brokerSle->at(sfCoverAvailable);
auto const debtTotal = brokerSle->at(sfDebtTotal);
auto const vaultScale = getAssetsTotalScale(vaultSle);
auto const debtScale = scale(debtTotal, asset);
// Sanity: debt scale differs from vault scale for this setup.
BEAST_EXPECT(debtScale < vaultScale);
auto const oldMin = [&]() {
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
return roundToAsset(
asset,
tenthBipsOfValue(debtTotal, TenthBips32{c.brokerParams.coverRateMin}),
debtScale);
}();
auto const newMin = minimumBrokerCover(
debtTotal, TenthBips32{c.brokerParams.coverRateMin}, vaultSle);
// The new (vaultScale) minimum must be strictly larger than
// the old (debtScale) minimum — that is the gap the amendment
// closes.
Number const expectedNewMin{1330650518688500000, -15};
Number const expectedOldMin{1330650518688472000, -15};
BEAST_EXPECT(newMin == expectedNewMin);
BEAST_EXPECT(oldMin == expectedOldMin);
// Try to withdraw so that remaining cover lands between the
// two minimums: oldMin < target < newMin.
auto const target = oldMin + (newMin - oldMin) / 2;
auto const withdrawAmount = STAmount{asset, coverAvail - target};
if (withAmendment)
{
// CoverWithdraw now uses vaultScale: target < newMin
// => FAILS.
env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount),
Ter(tecINSUFFICIENT_FUNDS));
}
else
{
// Old CoverWithdraw uses debtScale: target > oldMin
// => SUCCEEDS.
env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount));
}
env.close();
});
// Scenario 3 — LoanSet
//
// Verify that LoanSet's minimum cover check uses vault scale (not the
// raw unrounded tenthBipsOfValue). Before the amendment, LoanSet
// used tenthBipsOfValue(newDebtTotal, coverRateMinimum) (no
// roundToAsset), while clawback/withdraw used different formulas.
// After the amendment all use minimumBrokerCover at vaultScale, and
// rounding at a coarser scale can absorb a tiny debt increase —
// allowing a loan that would otherwise be rejected.
testcase("LoanSet minimum cover scale consistency");
runTest(
/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) {
// Create the tiny loan (scale -12) AND the big loan (scale
// -11). Both loans are needed so that DebtTotal has a full
// 16-digit mantissa — a "messy" value where roundToAsset at
// vaultScale actually truncates digits and produces a
// different result from the raw tenthBipsOfValue. With only
// the big loan, DebtTotal has ~4 significant digits and
// rounding at scale -11 is a no-op, masking the amendment's
// effect.
env(set(c.borrower, c.broker.brokerID, Number{1, -2}),
Sig(sfCounterpartySignature, c.lender),
kInterestRate(TenthBips32{0}),
kPaymentTotal(1),
kPaymentInterval(86400 * 365),
Fee(XRP(10)));
env.close();
env(set(c.borrower, c.broker.brokerID, Number{500}),
Sig(sfCounterpartySignature, c.lender),
kInterestRate(TenthBips32{100'000}),
kPaymentTotal(20),
kPaymentInterval(86400 * 365),
Fee(XRP(10)));
env.close();
// Clawback to reduce cover to the clawback transactor's
// minimum. Pass the exact amount rather than relying on the
// transactor to clip down; the setup matches Scenario 1 so
// the same residual-cover values apply.
Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15}
: Number{1330651855688458000, -15};
Number const clawbackAmount =
Number{c.brokerParams.coverDeposit} - expectedCoverAfter;
env(coverClawback(c.issuer),
kLoanBrokerId(c.broker.brokerID),
kAmount(c.iou(clawbackAmount)));
env.close();
// Verify scales.
auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
if (!BEAST_EXPECT(vaultSle))
return;
auto const vaultScale = getAssetsTotalScale(vaultSle);
BEAST_EXPECT(vaultScale == -11);
// Now try to create a tiny additional loan. Principal is
// 1e-11 (the smallest value that survives the precision
// check at loanScale = vaultScale = -11), with 0% interest
// and 1 payment.
//
// The tiny debt increase adds ~1.337e-12 to the unrounded
// minimum.
// - Without the amendment: the old LoanSet formula rounds
// up during tenthBipsOfValue (16-digit Number
// normalisation), pushing the minimum past the cover left
// by clawback => tecINSUFFICIENT_FUNDS.
// - With the amendment: minimumBrokerCover rounds at
// vaultScale=-11, which absorbs the tiny increase — the
// rounded minimum stays the same => tesSUCCESS.
auto const tinyPrincipal = Number{1, -11};
if (withAmendment)
{
env(set(c.borrower, c.broker.brokerID, tinyPrincipal),
Sig(sfCounterpartySignature, c.lender),
kInterestRate(TenthBips32{0}),
kPaymentTotal(1),
kPaymentInterval(86400 * 365),
Fee(XRP(10)));
}
else
{
env(set(c.borrower, c.broker.brokerID, tinyPrincipal),
Sig(sfCounterpartySignature, c.lender),
kInterestRate(TenthBips32{0}),
kPaymentTotal(1),
kPaymentInterval(86400 * 365),
Fee(XRP(10)),
Ter(tecINSUFFICIENT_FUNDS));
}
env.close();
});
}
// Tests run under each entry in amendmentCombinations().
void
runAmendmentSensitive(FeatureBitset features)
{
testLoanPayComputePeriodicPaymentInvariants(features);
testLoanPayDebtDecreaseInvariant(features);
testAccountSendMptMinAmountInvariant(features);
testMinimumBrokerCoverConsistency(features);
}
public:
void
run() override
{
for (auto const& features : jtx::amendmentCombinations(
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
runAmendmentSensitive(features);
}
};
BEAST_DEFINE_TESTSUITE(LoanInvariants, tx, xrpl);
} // namespace xrpl::test

View File

@@ -0,0 +1,689 @@
#include <test/app/lending/LoanTestBase.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/batch.h>
#include <test/jtx/fee.h>
#include <test/jtx/flags.h>
#include <test/jtx/jtx_json.h>
#include <test/jtx/mpt.h>
#include <test/jtx/pay.h>
#include <test/jtx/ter.h>
#include <test/jtx/trust.h>
#include <test/jtx/utility.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_value.h>
#include <xrpl/json/to_string.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/tx/transactors/system/Batch.h>
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <map>
#include <string_view>
#include <vector>
namespace xrpl::test {
class LoanLifecycle_test : public LoanTestBase
{
private:
void
testLifecycle(FeatureBitset features)
{
testcase("Lifecycle");
using namespace jtx;
// Create 3 loan brokers: one for XRP, one for an IOU, and one for
// an MPT. That'll require three corresponding SAVs.
Env env(*this, features);
Account const issuer{"issuer"};
// For simplicity, lender will be the sole actor for the vault &
// brokers.
Account const lender{"lender"};
// Borrower only wants to borrow
Account const borrower{"borrower"};
// Evan will attempt to be naughty
Account const evan{"evan"};
// Do not fund alice
Account const alice{"alice"};
// Fund the accounts and trust lines with the same amount so that
// tests can use the same values regardless of the asset.
env.fund(XRP(100'000'000), issuer, noripple(lender, borrower, evan));
env.close();
// Create assets
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
PrettyAsset const iouAsset = issuer[iouCurrency_];
env(trust(lender, iouAsset(10'000'000)));
env(trust(borrower, iouAsset(10'000'000)));
env(trust(evan, iouAsset(10'000'000)));
env(pay(issuer, evan, iouAsset(1'000'000)));
env(pay(issuer, lender, iouAsset(10'000'000)));
// Fund the borrower with enough to cover interest and fees
env(pay(issuer, borrower, iouAsset(10'000)));
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
// Scale the MPT asset a little bit so we can get some interest
PrettyAsset const mptAsset{mptt.issuanceID(), 100};
mptt.authorize({.account = lender});
mptt.authorize({.account = borrower});
mptt.authorize({.account = evan});
env(pay(issuer, lender, mptAsset(10'000'000)));
env(pay(issuer, evan, mptAsset(1'000'000)));
// Fund the borrower with enough to cover interest and fees
env(pay(issuer, borrower, mptAsset(10'000)));
env.close();
std::array const assets{iouAsset, xrpAsset, mptAsset};
// Create vaults and loan brokers
std::vector<BrokerInfo> brokers;
brokers.reserve(assets.size());
for (auto const& asset : assets)
{
brokers.emplace_back(createVaultAndBroker(
env, asset, lender, BrokerParameters{.data = "spam spam spam spam"}));
}
// Create and update Loans
for (auto const& broker : brokers)
{
for (int amountExponent = 3; amountExponent >= 3; --amountExponent)
{
Number const loanAmount{1, amountExponent};
for (int interestExponent = 0; interestExponent >= 0; --interestExponent)
{
testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent);
}
}
if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID));
BEAST_EXPECT(brokerSle))
{
BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0);
auto const coverAvailable = brokerSle->at(sfCoverAvailable);
env(loan_broker::coverWithdraw(
lender, broker.brokerID, STAmount(broker.asset, coverAvailable)));
env.close();
brokerSle = env.le(keylet::loanBroker(broker.brokerID));
BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0);
}
// Verify we can delete the loan broker
env(loan_broker::del(lender, broker.brokerID));
env.close();
}
}
void
testSelfLoan(FeatureBitset features)
{
testcase << "Self Loan";
using namespace jtx;
using namespace std::chrono_literals;
// Create 3 loan brokers: one for XRP, one for an IOU, and one for
// an MPT. That'll require three corresponding SAVs.
Env env(*this, features);
Account const issuer{"issuer"};
// For simplicity, lender will be the sole actor for the vault &
// brokers.
Account const lender{"lender"};
// Fund the accounts and trust lines with the same amount so that
// tests can use the same values regardless of the asset.
env.fund(XRP(100'000'000), issuer, noripple(lender));
env.close();
// Use an XRP asset for simplicity
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
// Create vaults and loan brokers
BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)};
using namespace loan;
auto const loanSetFee = Fee(env.current()->fees().base * 2);
Number const principalRequest{1, 3};
// The LoanSet json can be created without a counterparty signature,
// but it will not pass preflight
auto createJson = env.json(
set(lender, broker.brokerID, broker.asset(principalRequest).value()), Fee(loanSetFee));
env(createJson, Ter(temBAD_SIGNER));
// Adding an empty counterparty signature object also fails, but
// at the RPC level.
createJson = env.json(createJson, Json(sfCounterpartySignature, json::ValueType::Object));
env(createJson, Ter(telENV_RPC_FAILED));
if (auto const jt = env.jt(createJson); BEAST_EXPECT(jt.stx))
{
Serializer s;
jt.stx->add(s);
auto const jr = env.rpc("submit", strHex(s.slice()));
BEAST_EXPECT(jr.isMember(jss::result));
auto const jResult = jr[jss::result];
BEAST_EXPECT(jResult[jss::error] == "invalidTransaction");
BEAST_EXPECT(
jResult[jss::error_exception] ==
"fails local checks: Transaction has bad signature.");
}
// Copy the transaction signature into the counterparty signature.
json::Value counterpartyJson{json::ValueType::Object};
counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature];
counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey];
if (!BEAST_EXPECT(!createJson.isMember(jss::Signers)))
counterpartyJson[sfSigners] = createJson[sfSigners];
// The duplicated signature works
createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson));
env(createJson);
env.close();
auto const startDate = env.current()->header().parentCloseTime;
// Loan is successfully created
{
auto const res = env.rpc("account_objects", lender.human());
auto const objects = res[jss::result][jss::account_objects];
std::map<std::string, std::size_t> types;
BEAST_EXPECT(objects.size() == 4);
for (auto const& object : objects)
{
++types[object[sfLedgerEntryType].asString()];
}
BEAST_EXPECT(types.size() == 4);
for (std::string const type : {"MPToken", "Vault", "LoanBroker", "Loan"})
{
BEAST_EXPECT(types[type] == 1);
}
}
auto const loanID = [&]() {
json::Value params(json::ValueType::Object);
params[jss::account] = lender.human();
params[jss::type] = "Loan";
auto const res = env.rpc("json", "account_objects", to_string(params));
auto const objects = res[jss::result][jss::account_objects];
BEAST_EXPECT(objects.size() == 1);
auto const loan = objects[0u];
BEAST_EXPECT(loan[sfBorrower] == lender.human());
// soeDEFAULT fields are not returned if they're in the default
// state
BEAST_EXPECT(!loan.isMember(sfCloseInterestRate));
BEAST_EXPECT(!loan.isMember(sfClosePaymentFee));
BEAST_EXPECT(loan[sfFlags] == 0);
BEAST_EXPECT(loan[sfGracePeriod] == 60);
BEAST_EXPECT(!loan.isMember(sfInterestRate));
BEAST_EXPECT(!loan.isMember(sfLateInterestRate));
BEAST_EXPECT(!loan.isMember(sfLatePaymentFee));
BEAST_EXPECT(loan[sfLoanBrokerID] == to_string(broker.brokerID));
BEAST_EXPECT(!loan.isMember(sfLoanOriginationFee));
BEAST_EXPECT(loan[sfLoanSequence] == 1);
BEAST_EXPECT(!loan.isMember(sfLoanServiceFee));
BEAST_EXPECT(loan[sfNextPaymentDueDate] == loan[sfStartDate].asUInt() + 60);
BEAST_EXPECT(!loan.isMember(sfOverpaymentFee));
BEAST_EXPECT(!loan.isMember(sfOverpaymentInterestRate));
BEAST_EXPECT(loan[sfPaymentInterval] == 60);
BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000");
BEAST_EXPECT(loan[sfPaymentRemaining] == 1);
BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDueDate));
BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000");
BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000");
BEAST_EXPECT(!loan.isMember(sfLoanScale));
BEAST_EXPECT(loan[sfStartDate].asUInt() == startDate.time_since_epoch().count());
return loan["index"].asString();
}();
auto const loanKeylet{keylet::loan(uint256{std::string_view(loanID)})};
env.close(startDate);
// Make a payment
env(pay(lender, loanKeylet.key, broker.asset(1000)));
}
void
testIssuerLoan()
{
testcase << "Issuer Loan";
using namespace jtx;
using namespace loan;
Account const issuer("issuer");
Account const borrower = issuer;
Account const lender("lender");
Env env(*this);
env.fund(XRP(1'000), issuer, lender);
static constexpr std::int64_t kIssuerBalance = 10'000'000;
MPTTester const asset(
{.env = env, .issuer = issuer, .holders = {lender}, .pay = kIssuerBalance});
BrokerParameters const brokerParams{
.debtMax = 200,
};
auto const broker = createVaultAndBroker(env, asset, lender, brokerParams);
auto const loanSetFee = Fee(env.current()->fees().base * 2);
// Create Loan
env(set(borrower, broker.brokerID, 200), Sig(sfCounterpartySignature, lender), loanSetFee);
env.close();
// Issuer should not create MPToken
BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer)));
// Issuer "borrowed" 200, OutstandingAmount decreased by 200
BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200));
// Pay Loan
auto const loanKeylet = keylet::loan(broker.brokerID, 1);
env(pay(borrower, loanKeylet.key, asset(200)));
env.close();
// Issuer "re-payed" 200, OutstandingAmount increased by 200
BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance));
}
void
testBorrowerIsBroker()
{
testcase("Test Borrower is Broker");
using namespace jtx;
using namespace loan;
Account const broker{"broker"};
Account const issuer{"issuer"};
Account const borrower{"borrower"};
Account const depositor{"depositor"};
auto testLoanAsset = [&](auto&& getMaxDebt, auto const& borrower) {
Env env(*this);
Vault const vault(env);
if (borrower == broker)
{
env.fund(XRP(10'000), broker, issuer, depositor);
}
else
{
env.fund(XRP(10'000), broker, borrower, issuer, depositor);
}
env.close();
auto const xrpFee = XRP(100);
auto const txFee = Fee(xrpFee);
STAmount const debtMaximumRequest = getMaxDebt(env);
auto const& asset = debtMaximumRequest.asset();
auto const initialVault = asset(debtMaximumRequest * 100);
auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset});
env(tx, txFee);
env.close();
env(vault.deposit(
{.depositor = depositor, .id = vaultKeylet.key, .amount = initialVault}),
txFee);
env.close();
auto const brokerKeylet = keylet::loanBroker(broker.id(), env.seq(broker));
env(loan_broker::set(broker, vaultKeylet.key), txFee);
env.close();
auto const serviceFee = 101;
env(set(broker, brokerKeylet.key, debtMaximumRequest),
kCounterparty(borrower),
Sig(sfCounterpartySignature, borrower),
kLoanServiceFee(serviceFee),
kPaymentTotal(10),
txFee);
env.close();
std::uint32_t const loanSequence = 1;
auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence);
auto const brokerBalanceBefore = env.balance(broker, asset);
if (auto const loanSle = env.le(loanKeylet); env.test.BEAST_EXPECT(loanSle))
{
auto const payment = loanSle->at(sfPeriodicPayment);
auto const totalPayment = payment + serviceFee;
env(loan::pay(borrower, loanKeylet.key, asset(totalPayment)), txFee);
env.close();
if (auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle))
{
auto const expected = [&]() {
// The service fee is transferred to the broker if
// a borrower is not the broker
if (borrower != broker)
return brokerBalanceBefore.number() + serviceFee;
// Since a borrower is the broker, the payment is
// transferred to the Vault from the broker but not
// the service fee.
// If the asset is XRP then the broker pays the txFee.
if (asset.native())
return brokerBalanceBefore.number() - payment - xrpFee.number();
return brokerBalanceBefore.number() - payment;
}();
BEAST_EXPECT(env.balance(broker, asset).value() == asset(expected).value());
}
}
};
// Test when a borrower is the broker and is not to verify correct
// service fee transfer in both cases.
for (auto const& borrowerAcct : {broker, borrower})
{
testLoanAsset(
[&](Env&) -> STAmount { return STAmount{XRPAmount{200'000}}; }, borrowerAcct);
testLoanAsset(
[&](Env& env) -> STAmount {
auto const iou = issuer["USD"];
env(trust(broker, iou(1'000'000'000)));
env(trust(depositor, iou(1'000'000'000)));
env(pay(issuer, broker, iou(100'000'000)));
env(pay(issuer, depositor, iou(100'000'000)));
env.close();
return iou(200'000);
},
borrowerAcct);
testLoanAsset(
[&](Env& env) -> STAmount {
MPTTester const mpt(
{.env = env,
.issuer = issuer,
.holders = {broker, depositor},
.pay = 100'000'000});
return mpt(200'000);
},
borrowerAcct);
}
}
void
testIssuerIsBorrower(FeatureBitset features)
{
testcase("RIPD-4096 - Issuer as borrower");
using namespace jtx;
Account const issuer("issuer");
Account const lender("lender");
BrokerParameters const brokerParams{
.vaultDeposit = 100'000,
.debtMax = 0,
.coverRateMin = TenthBips32{0},
.managementFeeRate = TenthBips16{0},
.coverRateLiquidation = TenthBips32{0}};
LoanParameters const loanParams{
.account = lender, .counter = issuer, .principalRequest = Number{10000}};
auto const assetType = AssetType::IOU;
Env env{*this, features};
auto loanResult =
createLoan(env, assetType, brokerParams, loanParams, issuer, lender, issuer);
if (BEAST_EXPECT(loanResult); !loanResult.has_value())
return;
auto broker = std::get<BrokerInfo>(*loanResult);
auto loanKeylet = std::get<Keylet>(*loanResult);
auto pseudoAcct = std::get<Account>(*loanResult);
VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
makeLoanPayments(
env,
broker,
loanParams,
loanKeylet,
verifyLoanStatus,
issuer,
lender,
issuer,
PaymentParameters{.showStepBalances = true});
}
void
testBatchBypassCounterparty(FeatureBitset features)
{
// From FIND-001
testcase << "Batch Bypass Counterparty";
bool const lendingBatchEnabled = !std::ranges::any_of(
Batch::kDisabledTxTypes, [](auto const& disabled) { return disabled == ttLOAN_SET; });
using namespace jtx;
using namespace std::chrono_literals;
Env env(*this, features);
Account const lender{"lender"};
Account const borrower{"borrower"};
BrokerParameters const brokerParams;
env.fund(XRP(brokerParams.vaultDeposit * 100), lender, borrower);
env.close();
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
using namespace loan;
auto const loanSetFee = Fee(env.current()->fees().base * 2);
Number const principalRequest{1, 3};
auto forgedLoanSet = set(borrower, broker.brokerID, principalRequest, 0);
json::Value randomData{json::ValueType::Object};
randomData[jss::SigningPubKey] = json::StaticString{"2600"};
json::Value sigObject{json::ValueType::Object};
sigObject[jss::SigningPubKey] = strHex(lender.pk().slice());
Serializer ss;
ss.add32(HashPrefix::TxSign);
parse(randomData).addWithoutSigningFields(ss);
auto const sig = xrpl::sign(borrower.pk(), borrower.sk(), ss.slice());
sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()});
forgedLoanSet[json::StaticString{"CounterpartySignature"}] = sigObject;
// ? Fails because the lender hasn't signed the tx
env(env.json(forgedLoanSet, Fee(loanSetFee)), Ter(telENV_RPC_FAILED));
auto const seq = env.seq(borrower);
auto const batchFee = batch::calcBatchFee(env, 1, 2);
// ! Should fail because the lender hasn't signed the tx
env(batch::outer(borrower, seq, batchFee, tfAllOrNothing),
batch::Inner(forgedLoanSet, seq + 1),
batch::Inner(pay(borrower, lender, XRP(1)), seq + 2),
Ter(lendingBatchEnabled ? temBAD_SIGNATURE : temINVALID_INNER_BATCH));
env.close();
// ? Check that the loan was NOT created
{
json::Value params(json::ValueType::Object);
params[jss::account] = borrower.human();
params[jss::type] = "Loan";
auto const res = env.rpc("json", "account_objects", to_string(params));
auto const objects = res[jss::result][jss::account_objects];
BEAST_EXPECT(objects.size() == 0);
}
}
// Integration test: full lifecycle of a $1B loan in the bug regime.
// Verifies that the vault collects the economically-correct interest
// income and that conservation holds at the trust-line level.
//
// Pre-fix (closed-form `power(1+r, n) - 1`): vault collected only
// ~$0.058 per $1B due to cancellation of `(1+r)^n - 1` at r*n ~ 5.7e-10.
// Post-fix (hybrid binomial path): vault collects ~$0.38 per $1B,
// matching the value computed independently with arbitrary-precision
// Decimal arithmetic.
void
testFullLifecycleVaultPnLNearZeroRate()
{
testcase("integration: full loan lifecycle, vault interest at near-zero rate");
using namespace jtx;
using namespace jtx::loan;
using namespace std::chrono_literals;
Env env(*this, all_);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(1'000'000), issuer, lender, borrower);
env.close();
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const iouAsset = issuer["USD"];
STAmount const trustLimit{iouAsset.raw(), Number{1, 17}};
env(trust(lender, trustLimit));
env(trust(borrower, trustLimit));
env.close();
env(pay(issuer, lender, iouAsset(5'000'000'000LL)));
env(pay(issuer, borrower, iouAsset(5'000'000'000LL)));
env.close();
auto usdBalance = [&](Account const& a) {
return env.balance(a, iouAsset.raw().get<Issue>()).value();
};
STAmount const borrowerStartBal = usdBalance(borrower);
BrokerParameters const brokerParams{
.vaultDeposit = Number{2, 9},
.debtMax = Number{0},
.coverRateMin = TenthBips32{0},
.coverDeposit = 0,
.managementFeeRate = TenthBips16{0},
.coverRateLiquidation = TenthBips32{0}};
BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)};
auto const vaultBefore = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(vaultBefore))
return;
Number const vaultAvailableBefore = vaultBefore->at(sfAssetsAvailable);
// Loan: $1B principal, 3 payments, 600s interval, rate=1 TenthBips32.
auto const loanSetFee = Fee(env.current()->fees().base * 2);
Number const principalRequest{1, 9};
auto createJson = env.json(
set(borrower, broker.brokerID, principalRequest),
Fee(loanSetFee),
Json(sfCounterpartySignature, json::ValueType::Object));
createJson["InterestRate"] = 1;
createJson["PaymentTotal"] = 3;
createJson["PaymentInterval"] = 600;
auto const loanKeylet = nextLoanKeylet(env, broker);
createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
env(createJson, Ter(tesSUCCESS));
env.close();
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(loanSle))
return;
Number const expectedTotalInterest =
loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfPrincipalOutstanding);
env(pay(borrower, loanKeylet.key, iouAsset(1'500'000'000LL)), Ter(tesSUCCESS));
env.close();
auto const vaultAfter = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(vaultAfter))
return;
Number const vaultAvailableAfter = vaultAfter->at(sfAssetsAvailable);
Number const vaultGain = vaultAvailableAfter - vaultAvailableBefore;
STAmount const borrowerEndBal = usdBalance(borrower);
STAmount const borrowerNetOut = borrowerStartBal - borrowerEndBal;
// Self-consistency: vault gained exactly the expected interest
// computed at LoanSet, and the borrower's outflow matches.
BEAST_EXPECT(vaultGain == expectedTotalInterest);
BEAST_EXPECT(Number(borrowerNetOut) == expectedTotalInterest);
// Mathematical correctness: the total interest for this loan
// configuration is 0.38051750382930729983, calculated
// independently using 50-digit Decimal arithmetic (no
// cancellation possible at that precision). At Number's 19-digit
// mantissa this rounds to 0.38051750382930729 — the literal
// below. The vault's actual gain must agree to within
// sub-microcent precision.
Number const decimalReference{38051750382930729LL, -17};
Number const tolerance{1, -6}; // 1e-6 USD = sub-microcent
Number const error = abs(vaultGain - decimalReference);
BEAST_EXPECTS(
error < tolerance,
"vault gain " + to_string(vaultGain) + " differs from Decimal reference " +
to_string(decimalReference) + " by " + to_string(error) + " — exceeds tolerance " +
to_string(tolerance));
}
void
runAmendmentIndependent()
{
testIssuerLoan();
testBorrowerIsBroker();
testFullLifecycleVaultPnLNearZeroRate();
}
// Tests run under each entry in amendmentCombinations().
void
runAmendmentSensitive(FeatureBitset features)
{
testLifecycle(features);
testSelfLoan(features);
testIssuerIsBorrower(features);
testBatchBypassCounterparty(features);
}
public:
void
run() override
{
runAmendmentIndependent();
for (auto const& features : jtx::amendmentCombinations(
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
runAmendmentSensitive(features);
}
};
BEAST_DEFINE_TESTSUITE(LoanLifecycle, tx, xrpl);
} // namespace xrpl::test

View File

@@ -0,0 +1,561 @@
#include <test/app/lending/LoanTestBase.h>
#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/mpt.h>
#include <test/jtx/noop.h>
#include <test/jtx/offer.h>
#include <test/jtx/pay.h>
#include <test/jtx/seq.h>
#include <test/jtx/sig.h>
#include <test/jtx/tags.h>
#include <test/jtx/ter.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/xor_shift_engine.h>
#include <xrpl/json/json_value.h>
#include <xrpl/json/to_string.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/Units.h>
#include <xrpl/protocol/jss.h>
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <random>
#include <string>
namespace xrpl::test {
class LoanMisc_test : public LoanTestBase
{
private:
void
testRPC(FeatureBitset features)
{
// This will expand as more test cases are added. Some functionality
// is tested in other test functions.
testcase("RPC");
using namespace jtx;
Env env(*this, features);
auto lowerFee = [&]() {
// Run the local fee back down.
while (env.app().getFeeTrack().lowerLocalFee())
;
};
auto const baseFee = env.current()->fees().base;
Account const alice{"alice"};
std::string const borrowerPass = "borrower";
Account const borrower{borrowerPass, KeyType::Ed25519};
auto const lenderPass = "lender";
Account const lender{lenderPass, KeyType::Ed25519};
env.fund(XRP(1'000'000), alice, lender, borrower);
env.close();
env(noop(lender));
env(noop(lender));
env(noop(lender));
env(noop(lender));
env(noop(lender));
env.close();
{
testcase("RPC AccountSet");
json::Value txJson{json::ValueType::Object};
txJson[sfTransactionType] = "AccountSet";
txJson[sfAccount] = borrower.human();
auto const signParams = [&]() {
json::Value signParams{json::ValueType::Object};
signParams[jss::passphrase] = borrowerPass;
signParams[jss::key_type] = "ed25519";
signParams[jss::tx_json] = txJson;
return signParams;
}();
auto const jSign = env.rpc("json", "sign", to_string(signParams));
BEAST_EXPECT(jSign.isMember(jss::result) && jSign[jss::result].isMember(jss::tx_json));
auto txSignResult = jSign[jss::result][jss::tx_json];
auto txSignBlob = jSign[jss::result][jss::tx_blob].asString();
txSignResult.removeMember(jss::hash);
auto const jtx = env.jt(txJson, Sig(borrower));
BEAST_EXPECT(txSignResult == jtx.jv);
lowerFee();
auto const jSubmit = env.rpc("submit", txSignBlob);
BEAST_EXPECT(
jSubmit.isMember(jss::result) &&
jSubmit[jss::result].isMember(jss::engine_result) &&
jSubmit[jss::result][jss::engine_result].asString() == "tesSUCCESS");
lowerFee();
env(jtx.jv, Sig(kNone), Seq(kNone), Fee(kNone), Ter(tefPAST_SEQ));
}
{
testcase("RPC LoanSet - illegal signature_target");
json::Value txJson{json::ValueType::Object};
txJson[sfTransactionType] = "AccountSet";
txJson[sfAccount] = borrower.human();
auto const borrowerSignParams = [&]() {
json::Value params{json::ValueType::Object};
params[jss::passphrase] = borrowerPass;
params[jss::key_type] = "ed25519";
params[jss::signature_target] = "Destination";
params[jss::tx_json] = txJson;
return params;
}();
auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams));
BEAST_EXPECT(
jSignBorrower.isMember(jss::result) &&
jSignBorrower[jss::result].isMember(jss::error) &&
jSignBorrower[jss::result][jss::error] == "invalidParams" &&
jSignBorrower[jss::result].isMember(jss::error_message) &&
jSignBorrower[jss::result][jss::error_message] == "Destination");
}
{
testcase("RPC LoanSet - sign and submit borrower initiated");
// 1. Borrower creates the transaction
json::Value txJson{json::ValueType::Object};
txJson[sfTransactionType] = "LoanSet";
txJson[sfAccount] = borrower.human();
txJson[sfCounterparty] = lender.human();
txJson[sfLoanBrokerID] =
"FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC"
"F83F"
"5C";
txJson[sfPrincipalRequested] = "100000000";
txJson[sfPaymentTotal] = 10000;
txJson[sfPaymentInterval] = 3600;
txJson[sfGracePeriod] = 300;
txJson[sfFlags] = 65536; // tfLoanOverpayment
txJson[sfFee] = to_string(24 * baseFee / 10);
// 2. Borrower signs the transaction
auto const borrowerSignParams = [&]() {
json::Value params{json::ValueType::Object};
params[jss::passphrase] = borrowerPass;
params[jss::key_type] = "ed25519";
params[jss::tx_json] = txJson;
return params;
}();
auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams));
BEAST_EXPECTS(
jSignBorrower.isMember(jss::result) &&
jSignBorrower[jss::result].isMember(jss::tx_json),
to_string(jSignBorrower));
auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json];
auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString();
// 2a. Borrower attempts to submit the transaction. It doesn't
// work
{
lowerFee();
auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob);
BEAST_EXPECT(jSubmitBlob.isMember(jss::result));
auto const jSubmitBlobResult = jSubmitBlob[jss::result];
BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json));
// Transaction fails because the CounterpartySignature is
// missing
BEAST_EXPECT(
jSubmitBlobResult.isMember(jss::engine_result) &&
jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER");
}
// 3. Borrower sends the signed transaction to the lender
// 4. Lender signs the transaction
auto const lenderSignParams = [&]() {
json::Value params{json::ValueType::Object};
params[jss::passphrase] = lenderPass;
params[jss::key_type] = "ed25519";
params[jss::signature_target] = "CounterpartySignature";
params[jss::tx_json] = txBorrowerSignResult;
return params;
}();
auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams));
BEAST_EXPECT(
jSignLender.isMember(jss::result) &&
jSignLender[jss::result].isMember(jss::tx_json));
auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json];
auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString();
// 5. Lender submits the signed transaction blob
lowerFee();
auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob);
BEAST_EXPECT(jSubmitBlob.isMember(jss::result));
auto const jSubmitBlobResult = jSubmitBlob[jss::result];
BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json));
auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json];
// To get far enough to return tecNO_ENTRY means that the
// signatures all validated. Of course the transaction won't
// succeed because no Vault or Broker were created.
BEAST_EXPECTS(
jSubmitBlobResult.isMember(jss::engine_result) &&
jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY",
to_string(jSubmitBlobResult));
BEAST_EXPECT(
!jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error));
// 4-alt. Lender submits the transaction json originally
// received from the Borrower. It gets signed, but is now a
// duplicate, so fails. Borrower could done this instead of
// steps 4 and 5.
lowerFee();
auto const jSubmitJson = env.rpc("json", "submit", to_string(lenderSignParams));
BEAST_EXPECT(jSubmitJson.isMember(jss::result));
auto const jSubmitJsonResult = jSubmitJson[jss::result];
BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json));
auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json];
// Since the previous tx claimed a fee, this duplicate is not
// going anywhere
BEAST_EXPECTS(
jSubmitJsonResult.isMember(jss::engine_result) &&
jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ",
to_string(jSubmitJsonResult));
BEAST_EXPECT(
!jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error));
BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx);
}
{
testcase("RPC LoanSet - sign and submit lender initiated");
// 1. Lender creates the transaction
json::Value txJson{json::ValueType::Object};
txJson[sfTransactionType] = "LoanSet";
txJson[sfAccount] = lender.human();
txJson[sfCounterparty] = borrower.human();
txJson[sfLoanBrokerID] =
"FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC"
"F83F"
"5C";
txJson[sfPrincipalRequested] = "100000000";
txJson[sfPaymentTotal] = 10000;
txJson[sfPaymentInterval] = 3600;
txJson[sfGracePeriod] = 300;
txJson[sfFlags] = 65536; // tfLoanOverpayment
txJson[sfFee] = to_string(24 * baseFee / 10);
// 2. Lender signs the transaction
auto const lenderSignParams = [&]() {
json::Value params{json::ValueType::Object};
params[jss::passphrase] = lenderPass;
params[jss::key_type] = "ed25519";
params[jss::tx_json] = txJson;
return params;
}();
auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams));
BEAST_EXPECT(
jSignLender.isMember(jss::result) &&
jSignLender[jss::result].isMember(jss::tx_json));
auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json];
auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString();
// 2a. Lender attempts to submit the transaction. It doesn't
// work
{
lowerFee();
auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob);
BEAST_EXPECT(jSubmitBlob.isMember(jss::result));
auto const jSubmitBlobResult = jSubmitBlob[jss::result];
BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json));
// Transaction fails because the CounterpartySignature is
// missing
BEAST_EXPECT(
jSubmitBlobResult.isMember(jss::engine_result) &&
jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER");
}
// 3. Lender sends the signed transaction to the Borrower
// 4. Borrower signs the transaction
auto const borrowerSignParams = [&]() {
json::Value params{json::ValueType::Object};
params[jss::passphrase] = borrowerPass;
params[jss::key_type] = "ed25519";
params[jss::signature_target] = "CounterpartySignature";
params[jss::tx_json] = txLenderSignResult;
return params;
}();
auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams));
BEAST_EXPECT(
jSignBorrower.isMember(jss::result) &&
jSignBorrower[jss::result].isMember(jss::tx_json));
auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json];
auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString();
// 5. Borrower submits the signed transaction blob
lowerFee();
auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob);
BEAST_EXPECT(jSubmitBlob.isMember(jss::result));
auto const jSubmitBlobResult = jSubmitBlob[jss::result];
BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json));
auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json];
// To get far enough to return tecNO_ENTRY means that the
// signatures all validated. Of course the transaction won't
// succeed because no Vault or Broker were created.
BEAST_EXPECTS(
jSubmitBlobResult.isMember(jss::engine_result) &&
jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY",
to_string(jSubmitBlobResult));
BEAST_EXPECT(
!jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error));
// 4-alt. Borrower submits the transaction json originally
// received from the Lender. It gets signed, but is now a
// duplicate, so fails. Lender could done this instead of steps
// 4 and 5.
lowerFee();
auto const jSubmitJson = env.rpc("json", "submit", to_string(borrowerSignParams));
BEAST_EXPECT(jSubmitJson.isMember(jss::result));
auto const jSubmitJsonResult = jSubmitJson[jss::result];
BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json));
auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json];
// Since the previous tx claimed a fee, this duplicate is not
// going anywhere
BEAST_EXPECTS(
jSubmitJsonResult.isMember(jss::engine_result) &&
jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ",
to_string(jSubmitJsonResult));
BEAST_EXPECT(
!jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error));
BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx);
}
}
void
testLendingCanTradeDisabledNoImpact()
{
testcase("Lending: CanTrade disabled has no impact");
using namespace jtx;
using namespace loan;
using namespace loan_broker;
Env env(*this, all_);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(1'000'000), issuer, lender, borrower);
env.close();
MPTTester mpt(
{.env = env,
.issuer = issuer,
.holders = {lender, borrower},
.flags = tfMPTCanTransfer | tfMPTCanLock,
.mutableFlags = tmfMPTCanEnableCanTrade});
PrettyAsset const asset = mpt.issuanceID();
env(pay(issuer, lender, asset(10'000'000)));
env(pay(issuer, borrower, asset(100'000)));
env.close();
auto const broker = createVaultAndBroker(env, asset, lender);
// CanTrade is not set
env(offer(lender, XRP(1), asset(10)), Ter{tecNO_PERMISSION});
env.close();
auto const loanSetFee = Fee(env.current()->fees().base * 2);
// New cover deposits still work.
env(coverDeposit(lender, broker.brokerID, asset(100)));
env.close();
// New loan issuance still works.
env(loan::set(borrower, broker.brokerID, 1'000),
Sig(sfCounterpartySignature, lender),
loanSetFee);
env.close();
auto const loanKeylet = keylet::loan(broker.brokerID, 1);
BEAST_EXPECT(env.le(loanKeylet));
// Repayment still works.
env(pay(borrower, loanKeylet.key, asset(1'000)));
env.close();
// Cover withdrawal still works.
env(coverWithdraw(lender, broker.brokerID, asset(100)));
env.close();
// Enable CanTrade and verify the DEX path is restored.
mpt.set({.mutableFlags = tmfMPTSetCanTrade});
env.close();
env(offer(lender, XRP(1), asset(10)));
env.close();
}
void
runAmendmentIndependent()
{
testLendingCanTradeDisabledNoImpact();
}
// Tests run under each entry in amendmentCombinations().
void
runAmendmentSensitive(FeatureBitset features)
{
testRPC(features);
}
public:
void
run() override
{
runAmendmentIndependent();
for (auto const& features : jtx::amendmentCombinations(
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
runAmendmentSensitive(features);
}
};
class LoanBatch_test : public LoanTestBase
{
protected:
beast::xor_shift_engine engine_;
std::uniform_int_distribution<> assetDist_{0, 2};
std::uniform_int_distribution<std::int64_t> principalDist_{100'000, 1'000'000'000};
std::uniform_int_distribution<std::uint32_t> interestRateDist_{0, 10000};
std::uniform_int_distribution<> paymentTotalDist_{12, 10000};
std::uniform_int_distribution<> paymentIntervalDist_{60, 3600 * 24 * 30};
std::uniform_int_distribution<std::uint16_t> managementFeeRateDist_{0, 10'000};
std::uniform_int_distribution<> serviceFeeDist_{0, 20};
/*
# Generate parameters that are more likely to be valid
principal = Decimal(str(rand.randint(100000,
100'000'000))).quantize(ROUND_TARGET)
interest_rate = Decimal(rand.randint(1, 10000)) /
Decimal(100000)
payment_total = rand.randint(12, 10000)
payment_interval = Decimal(str(rand.randint(60, 2629746)))
interest_fee = Decimal(rand.randint(0, 100000)) /
Decimal(100000)
*/
void
testRandomLoan()
{
using namespace jtx;
Account const issuer("issuer");
Account const lender("lender");
Account const borrower("borrower");
// Determine all the random parameters at once
auto const assetType = static_cast<AssetType>(assetDist_(engine_));
auto const principalRequest = principalDist_(engine_);
TenthBips16 const managementFeeRate{managementFeeRateDist_(engine_)};
auto const serviceFee = serviceFeeDist_(engine_);
TenthBips32 interest{interestRateDist_(engine_)};
auto const payTotal = paymentTotalDist_(engine_);
auto const payInterval = paymentIntervalDist_(engine_);
BrokerParameters const brokerParams{
.vaultDeposit = principalRequest * 10,
.debtMax = 0,
.coverRateMin = TenthBips32{0},
.managementFeeRate = managementFeeRate};
LoanParameters const loanParams{
.account = lender,
.counter = borrower,
.principalRequest = principalRequest,
.serviceFee = serviceFee,
.interest = interest,
.payTotal = payTotal,
.payInterval = payInterval,
};
runLoan(assetType, brokerParams, loanParams, all_);
}
public:
void
run() override
{
auto const numIterations = [s = arg()]() -> int {
int const defaultNum = 5;
if (s.empty())
return defaultNum;
try
{
std::size_t pos = 0;
auto const r = stoi(s, &pos);
if (pos != s.size())
return defaultNum;
return r;
}
catch (...)
{
return defaultNum;
}
}();
using namespace jtx;
auto const updateInterval = std::max(std::min(numIterations / 5, 100), 1);
for (int i = 0; i < numIterations; ++i)
{
if (i % updateInterval == 0)
testcase << "Random Loan Test iteration " << (i + 1) << "/" << numIterations;
testRandomLoan();
}
}
};
class LoanArbitrary_test : public LoanBatch_test
{
void
run() override
{
using namespace jtx;
BrokerParameters const brokerParams{
.vaultDeposit = 10000,
.debtMax = 0,
.coverRateMin = TenthBips32{0},
.managementFeeRate = TenthBips16{0},
.coverRateLiquidation = TenthBips32{0}};
LoanParameters const loanParams{
.account = Account("lender"),
.counter = Account("borrower"),
.principalRequest = Number{200000, -6},
.interest = TenthBips32{50000},
.payTotal = 2,
.payInterval = 200};
runLoan(AssetType::XRP, brokerParams, loanParams, all_);
}
};
BEAST_DEFINE_TESTSUITE(LoanMisc, tx, xrpl);
BEAST_DEFINE_TESTSUITE_MANUAL(LoanBatch, tx, xrpl);
BEAST_DEFINE_TESTSUITE_MANUAL(LoanArbitrary, tx, xrpl);
} // namespace xrpl::test

View File

@@ -0,0 +1,760 @@
#include <test/app/lending/LoanTestBase.h>
#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/jtx_json.h>
#include <test/jtx/pay.h>
#include <test/jtx/ter.h>
#include <test/jtx/trust.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/Units.h>
#include <xrpl/tx/transactors/lending/LoanSet.h>
#include <algorithm>
#include <cstdint>
#include <limits>
#include <type_traits>
namespace xrpl::test {
class LoanPay_test : public LoanTestBase
{
private:
#if LOAN_TODO
void
testLoanPayLateFullPaymentBypassesPenalties(FeatureBitset features)
{
testcase("LoanPay full payment skips late penalties");
using namespace jtx;
using namespace loan;
using namespace std::chrono_literals;
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(1'000'000), issuer, lender, borrower);
env.close();
PrettyAsset const asset = issuer[iouCurrency];
env(trust(lender, asset(100'000'000)));
env(trust(borrower, asset(100'000'000)));
env(pay(issuer, lender, asset(50'000'000)));
env(pay(issuer, borrower, asset(5'000'000)));
env.close();
BrokerInfo broker{createVaultAndBroker(env, asset, lender)};
auto const loanSetFee = Fee(env.current()->fees().base * 2);
auto const brokerPreLoan = env.le(keylet::loanBroker(broker.brokerID));
if (BEAST_EXPECT(brokerPreLoan); !brokerPreLoan.has_value())
return;
auto const loanSequence = brokerPreLoan->at(sfLoanSequence);
auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
Number const principal = asset(1'000).value();
Number const serviceFee = asset(2).value();
Number const lateFee = asset(5).value();
Number const closeFee = asset(4).value();
env(set(borrower, broker.brokerID, principal),
Sig(sfCounterpartySignature, lender),
kLoanServiceFee(serviceFee),
kLatePaymentFee(lateFee),
kClosePaymentFee(closeFee),
kInterestRate(percentageToTenthBips(12)),
kLateInterestRate(percentageToTenthBips(24) / 10),
kCloseInterestRate(percentageToTenthBips(5)),
kPaymentTotal(12),
kPaymentInterval(600),
kGracePeriod(0),
Fee(loanSetFee));
env.close();
auto state1 = getCurrentState(env, broker, loanKeylet);
if (!BEAST_EXPECT(state1.paymentRemaining > 1))
return;
using d = NetClock::duration;
using tp = NetClock::time_point;
auto const overdueClose = tp{d{state1.nextPaymentDate + state1.paymentInterval}};
env.close(overdueClose);
auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(brokerSle && loanSle))
return;
auto state = getCurrentState(env, broker, loanKeylet);
TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)};
TenthBips32 const interestRateValue{loanSle->at(sfInterestRate)};
TenthBips32 const lateInterestRateValue{loanSle->at(sfLateInterestRate)};
TenthBips32 const closeInterestRateValue{loanSle->at(sfCloseInterestRate)};
Number const closePaymentFeeRounded =
roundToAsset(broker.asset, loanSle->at(sfClosePaymentFee), state.loanScale);
Number const latePaymentFeeRounded =
roundToAsset(broker.asset, loanSle->at(sfLatePaymentFee), state.loanScale);
auto const roundedLoanState = constructLoanState(
state.totalValue, state.principalOutstanding, state.managementFeeOutstanding);
Number const totalInterestOutstanding = roundedLoanState.interestDue;
auto const periodicRate = loanPeriodicRate(interestRateValue, state.paymentInterval);
auto const rawLoanState = computeTheoreticalLoanState(
env.current()->rules(),
state.periodicPayment,
periodicRate,
state.paymentRemaining,
managementFeeRate);
auto const parentCloseTime = env.current()->parentCloseTime();
auto const startDateSeconds =
static_cast<std::uint32_t>(state.startDate.time_since_epoch().count());
Number const fullPaymentInterest = computeFullPaymentInterest(
rawLoanState.principalOutstanding,
periodicRate,
parentCloseTime,
state.paymentInterval,
state.previousPaymentDate,
startDateSeconds,
closeInterestRateValue);
Number const roundedFullInterestAmount =
roundToAsset(broker.asset, fullPaymentInterest, state.loanScale);
Number const roundedFullManagementFee = computeManagementFee(
broker.asset, roundedFullInterestAmount, managementFeeRate, state.loanScale);
Number const roundedFullInterest = roundedFullInterestAmount - roundedFullManagementFee;
Number const trackedValueDelta =
state.principalOutstanding + totalInterestOutstanding + state.managementFeeOutstanding;
Number const untrackedManagementFee =
closePaymentFeeRounded + roundedFullManagementFee - state.managementFeeOutstanding;
Number const untrackedInterest = roundedFullInterest - totalInterestOutstanding;
Number const baseFullDue = trackedValueDelta + untrackedInterest + untrackedManagementFee;
BEAST_EXPECT(baseFullDue == roundToAsset(broker.asset, baseFullDue, state.loanScale));
auto const overdueSeconds =
parentCloseTime.time_since_epoch().count() - state.nextPaymentDate;
if (!BEAST_EXPECT(overdueSeconds > 0))
return;
Number const overdueRate = loanPeriodicRate(lateInterestRateValue, overdueSeconds);
Number const lateInterestRaw = state.principalOutstanding * overdueRate;
Number const lateInterestRounded =
roundToAsset(broker.asset, lateInterestRaw, state.loanScale);
Number const lateManagementFeeRounded = computeManagementFee(
broker.asset, lateInterestRounded, managementFeeRate, state.loanScale);
Number const penaltyDue =
lateInterestRounded + lateManagementFeeRounded + latePaymentFeeRounded;
BEAST_EXPECT(penaltyDue > Number{});
auto const balanceBefore = env.balance(borrower, broker.asset).number();
STAmount const paymentAmount{broker.asset.raw(), baseFullDue};
env(pay(borrower, loanKeylet.key, paymentAmount, tfLoanFullPayment));
env.close();
if (auto const meta = env.meta(); BEAST_EXPECT(meta))
BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS);
auto const balanceAfter = env.balance(borrower, broker.asset).number();
Number const actualPaid = balanceBefore - balanceAfter;
BEAST_EXPECT(actualPaid == baseFullDue);
Number const expectedWithPenalty = baseFullDue + penaltyDue;
BEAST_EXPECT(expectedWithPenalty > actualPaid);
BEAST_EXPECT(expectedWithPenalty - actualPaid == penaltyDue);
}
#endif
void
testOverpaymentManagementFee(FeatureBitset features)
{
testcase("testOverpaymentManagementFee");
using namespace jtx;
using namespace loan;
Env env{*this, features};
Account const lender{"lender"}, borrower{"borrower"};
env.fund(XRP(10'000'000), lender, borrower);
env.close();
PrettyAsset const asset{xrpIssue(), 1000};
auto const result = createVaultAndBroker(
env,
asset,
lender,
{
.vaultDeposit = asset(100'000).value(),
.managementFeeRate = TenthBips16(10'000),
});
auto const loanSetFee = Fee(env.current()->fees().base * 2);
auto const brokerSle = env.le(result.brokerKeylet());
if (!BEAST_EXPECT(brokerSle))
return;
auto const loanKeylet =
keylet::loan(result.brokerKeylet().key, brokerSle->at(sfLoanSequence));
env(loan::set(
borrower, result.brokerKeylet().key, asset(10'000).value(), tfLoanOverpayment),
Sig(sfCounterpartySignature, lender),
loan::kPaymentInterval(86400 * 30),
loan::kPaymentTotal(3),
loan::kOverpaymentInterestRate(TenthBips32(percentageToTenthBips(20))),
loanSetFee);
// From calculator
auto const expectedOverpaymentManagementFee = Number{33333, 0};
auto const loanBrokerBalanceBefore = env.balance(lender);
auto const loanPayFee = Fee(env.current()->fees().base * 2);
env(pay(borrower, loanKeylet.key, asset(5'000).value(), tfLoanOverpayment), loanPayFee);
env.close();
BEAST_EXPECTS(
env.balance(lender) - loanBrokerBalanceBefore == expectedOverpaymentManagementFee,
"overpayment management fee mismatch; expected:" +
to_string(expectedOverpaymentManagementFee) +
" got: " + to_string(env.balance(lender) - loanBrokerBalanceBefore));
}
void
testDosLoanPay(FeatureBitset features)
{
bool const feeCapped = features[fixCleanup3_1_3];
// From FIND-005
testcase << "DoS LoanPay: fee calculation " << (feeCapped ? "capped" : "uncapped");
using namespace jtx;
using namespace std::chrono_literals;
using namespace lending;
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(1'000'000), issuer, lender, borrower);
env.close();
BEAST_EXPECT(feeCapped == env.current()->rules().enabled(fixCleanup3_1_3));
PrettyAsset const iouAsset = issuer[iouCurrency_];
env(trust(lender, iouAsset(100'000'000)));
env(trust(borrower, iouAsset(100'000'000)));
env(pay(issuer, lender, iouAsset(10'000'000)));
env(pay(issuer, borrower, iouAsset(1'000)));
env.close();
BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)};
using namespace loan;
auto const loanSetFee = Fee(env.current()->fees().base * 2);
Number const principalRequest{3959'37, -2};
auto const baseFee = env.current()->fees().base;
auto const createJson = env.json(
set(borrower, broker.brokerID, principalRequest),
Fee(loanSetFee),
Json(sfCounterpartySignature, json::ValueType::Object),
kClosePaymentFee(0),
kGracePeriod(60),
kInterestRate(TenthBips32(20930)),
kLateInterestRate(TenthBips32(77049)),
kLatePaymentFee(0),
kLoanServiceFee(0),
kOverpaymentFee(TenthBips32(7)),
kOverpaymentInterestRate(TenthBips32(66653)),
kPaymentInterval(60),
kPaymentTotal(3239184));
// There are enough payments due on this loan that it only needs to be
// created once, and can be paid on multiple times. Just don't create a
// gazillion test cases.
auto const keylet = nextLoanKeylet(env, broker);
env(createJson, Sig(sfCounterpartySignature, lender));
env.close();
auto const roundedPayment = [&]() {
auto const stateBefore = getCurrentState(env, broker, keylet);
BEAST_EXPECT(stateBefore.paymentRemaining == 3239184);
BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction);
return roundToAsset(
iouAsset,
stateBefore.periodicPayment,
stateBefore.loanScale,
Number::RoundingMode::Upward);
}();
auto test = [&](int const payFactor,
int const feeFactor,
TER const expectedTer = tesSUCCESS) {
auto const stateBefore = getCurrentState(env, broker, keylet);
BEAST_EXPECT(stateBefore.paymentRemaining <= 3239184);
BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction);
Number const amount = roundedPayment * payFactor;
auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, amount}));
XRPAmount const payFee{baseFee * feeFactor};
env(loanPayTx, Ter(expectedTer), Fee(payFee));
env.close();
auto const expectedChange = isTesSuccess(expectedTer)
? std::min(kLoanMaximumPaymentsPerTransaction, payFactor)
: 0;
auto const stateAfter = getCurrentState(env, broker, keylet);
BEAST_EXPECT(
stateAfter.paymentRemaining == stateBefore.paymentRemaining - expectedChange);
};
static constexpr std::int64_t kMaxFeeIncrements =
kLoanMaximumPaymentsPerTransaction / kLoanPaymentsPerFeeIncrement;
TER const failWithoutFix = feeCapped ? (TER)tesSUCCESS : (TER)telINSUF_FEE_P;
// * Amount well above threshold -> capped fee
// The original test case - way over the limit - more fee is always ok
test(1819878, 363976);
// The capped fee is only sufficient if the amendment is enabled.
test(1819878, kMaxFeeIncrements, failWithoutFix);
// * Amount exactly at threshold -> capped fee
test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements);
// More fee is always ok
test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements + 10);
// * Amount below threshold -> normal calculation
test(1, 1);
test(kLoanPaymentsPerFeeIncrement * 2, 2);
test(0, 0, temBAD_AMOUNT);
test(0, 1, temBAD_AMOUNT);
// Fee difference rounds evenly
test(
kLoanMaximumPaymentsPerTransaction - 10,
((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) - 1,
telINSUF_FEE_P);
test(
kLoanMaximumPaymentsPerTransaction - 10,
((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement));
// More fee is always ok
test(
kLoanMaximumPaymentsPerTransaction - 10,
((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) + 3);
// Fee rounds up
for (int under = 1; under < kLoanPaymentsPerFeeIncrement; ++under)
{
test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements - 1, telINSUF_FEE_P);
test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements);
}
// Only when you get one less fee increment can you pay less
test(
kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement,
kMaxFeeIncrements - 1);
// And again, more fee is always ok.
test(kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, kMaxFeeIncrements);
}
// A LoanSet with InterestRate = 1 (0.001% annualized, the minimum non-zero
// rate). At such a near-zero rate the closed-form payment factor
// (1 + r)^n - 1 cancels catastrophically.
//
// Without fixCleanup3_2_0 the resulting amortization is degenerate and the
// LoanSet is rejected with tecPRECISION_LOSS (no loan created). With the
// amendment, computePowerMinusOneHybrid uses a numerically-stable series
// expansion, so the loan is created and the scheduled payments
// (2 * periodicPayment) cover the principal — no economic underpayment
// (yield theft).
//
// The test runs the same LoanSet under both amendment settings and pins the
// exact outcome for each.
void
testLoanSetNearZeroInterestRateSucceeds()
{
testcase("LoanSet near-zero interest rate covers principal");
using namespace jtx;
using namespace loan;
Number const principalRequested{1000};
struct Result
{
TER ter = tesSUCCESS;
bool created = false;
std::int32_t loanScale = 0;
Number principal;
Number totalValue;
Number managementFee;
Number periodicPayment;
};
auto runScenario = [&](FeatureBitset features, TER expectedTer) -> Result {
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"vaultOwner"};
Account const borrower{"borrower"};
PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower);
auto const broker = createVaultAndBroker(
env,
iouAsset,
lender,
{.vaultDeposit = 100'000, .debtMax = 0, .managementFeeRate = TenthBips16{0}});
auto const brokerSle = env.le(broker.brokerKeylet());
BEAST_EXPECT(brokerSle);
auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
env(set(borrower, broker.brokerID, principalRequested),
Sig(sfCounterpartySignature, lender),
kInterestRate(TenthBips32{1}),
kPaymentTotal(2),
kPaymentInterval(400),
Fee(env.current()->fees().base * 2),
Ter(expectedTer));
env.close();
Result r;
r.ter = env.ter();
if (auto const loanSle = env.le(loanKeylet))
{
r.created = true;
r.loanScale = loanSle->at(sfLoanScale);
r.principal = loanSle->at(sfPrincipalOutstanding);
r.totalValue = loanSle->at(sfTotalValueOutstanding);
r.managementFee = loanSle->at(sfManagementFeeOutstanding);
r.periodicPayment = loanSle->at(sfPeriodicPayment);
}
return r;
};
Result const fixed = runScenario(all_, tesSUCCESS);
Result const legacy = runScenario(all_ - fixCleanup3_2_0, tecPRECISION_LOSS);
// Without the amendment, the catastrophically-cancelling closed-form
// payment factor produces a degenerate amortization that fails
// checkLoanGuards: the LoanSet is rejected with tecPRECISION_LOSS and no
// loan is created.
BEAST_EXPECT(legacy.ter == tecPRECISION_LOSS);
BEAST_EXPECT(!legacy.created);
// With the amendment the stable series expansion produces a valid loan
// at loanScale -10.
BEAST_EXPECT(fixed.ter == tesSUCCESS);
BEAST_EXPECT(fixed.created);
BEAST_EXPECT(fixed.loanScale == -10);
BEAST_EXPECT(fixed.principal == principalRequested);
BEAST_EXPECT((fixed.totalValue == Number{10000000001903, -10}));
BEAST_EXPECT(fixed.managementFee == beast::kZero);
// Periodic payment from the numerically-stable series expansion, and the
// scheduled total (2 * periodicPayment) which exceeds the 1000 principal
// — no economic underpayment / yield theft.
BEAST_EXPECT((fixed.periodicPayment == Number{5000000000951293762, -16}));
BEAST_EXPECT((fixed.periodicPayment * 2 == Number{1000000000190258752, -15}));
BEAST_EXPECT(fixed.periodicPayment * 2 > principalRequested);
}
void
testLoanNextPaymentDueDateOverflow(FeatureBitset features)
{
// For FIND-013
testcase << "Prevent nextPaymentDueDate overflow";
using namespace jtx;
using namespace std::chrono_literals;
using namespace lending;
Env env{*this, features};
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
PrettyAsset const iouAsset =
createFundedIouAsset(env, issuer, lender, borrower, 100'000'000, 10'000'000);
BrokerParameters const brokerParams{.debtMax = Number{0}, .coverRateMin = TenthBips32{1}};
BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)};
using namespace loan;
auto const loanSetFee = Fee(env.current()->fees().base * 2);
using timeType = decltype(sfNextPaymentDueDate)::type::value_type;
static_assert(std::is_same_v<timeType, std::uint32_t>);
constexpr timeType kMaxTime = std::numeric_limits<timeType>::max();
static_assert(kMaxTime == 4'294'967'295);
auto const baseJson = [&]() {
auto createJson = env.json(
set(borrower, broker.brokerID, Number{55524'81, -2}),
Fee(loanSetFee),
kClosePaymentFee(0),
kGracePeriod(LoanSet::kDefaultGracePeriod),
kInterestRate(TenthBips32(12833)),
kLateInterestRate(TenthBips32(77048)),
kLatePaymentFee(0),
kLoanOriginationFee(218),
Json(sfCounterpartySignature, json::ValueType::Object));
createJson.removeMember(sfSequence.getJsonName());
return createJson;
}();
auto const baseFee = env.current()->fees().base;
auto parentCloseTime = [&]() {
return env.current()->parentCloseTime().time_since_epoch().count();
};
auto maxLoanTime = [&]() {
auto const startDate = parentCloseTime();
BEAST_EXPECT(startDate >= 50);
return kMaxTime - startDate;
};
{
// straight-up overflow: interval
auto const interval = maxLoanTime() + 1;
auto const total = 1;
auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total));
env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
env.close();
}
{
// straight-up overflow: total
// min interval is 60
auto const interval = 60;
auto const total = maxLoanTime() + 1;
auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total));
env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
env.close();
}
{
// straight-up overflow: grace period
// min interval is 60
auto const interval = maxLoanTime() + 1;
auto const total = 1;
auto const grace = interval;
auto createJson = env.json(
baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace));
// The grace period can't be larger than the interval.
env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
env.close();
}
{
// Overflow with multiplication of a few large intervals
auto const interval = 1'000'000'000;
auto const total = 10;
auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total));
env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
env.close();
}
{
// Overflow with multiplication of many small payments
// min interval is 60
auto const interval = 60;
auto const total = 1'000'000'000;
auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total));
env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
env.close();
}
{
// Overflow with an absurdly large grace period
// min interval is 60
auto const total = 60;
auto const interval = (maxLoanTime() - total) / total;
auto const grace = interval;
auto createJson = env.json(
baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace));
env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
env.close();
}
{
// Start date when the ledger is closed will be larger
auto const keylet = nextLoanKeylet(env, broker);
auto const grace = 100;
auto const interval = maxLoanTime() - grace;
auto const total = 1;
auto createJson = env.json(
baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace));
env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS));
env.close();
// The transaction is killed in the closed ledger
auto const meta = env.meta();
if (BEAST_EXPECT(meta))
{
BEAST_EXPECT(meta->at(sfTransactionResult) == tecKILLED);
}
// If the transaction had succeeded, the loan would exist
auto const loanSle = env.le(keylet);
// but it doesn't
BEAST_EXPECT(!loanSle);
}
{
// Start date when the ledger is closed will be larger
auto const keylet = nextLoanKeylet(env, broker);
auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10;
auto const grace = 5'000;
auto const interval = kMaxTime - closeStartDate - grace;
auto const total = 1;
auto createJson = env.json(
baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace));
env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS));
env.close();
// The transaction succeeds in the closed ledger
auto const meta = env.meta();
if (BEAST_EXPECT(meta))
{
BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS);
}
// This loan exists
auto const afterState = getCurrentState(env, broker, keylet);
BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace);
BEAST_EXPECT(afterState.previousPaymentDate == 0);
BEAST_EXPECT(afterState.paymentRemaining == 1);
}
{
// Ensure the borrower has funds to pay back the loan
env(pay(issuer, borrower, iouAsset(Number{1'055'524'81, -2})));
// Start date when the ledger is closed will be larger
auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10;
auto const grace = 5'000;
auto const maxLoanTime = kMaxTime - closeStartDate - grace;
auto const total = [&]() {
if (maxLoanTime % 5 == 0)
return 5;
if (maxLoanTime % 3 == 0)
return 3;
if (maxLoanTime % 2 == 0)
return 2;
return 0;
}();
if (!BEAST_EXPECT(total != 0))
return;
auto const brokerState = env.le(keylet::loanBroker(broker.brokerID));
if (!BEAST_EXPECT(brokerState))
return;
// Intentionally shadow the outer values
auto const loanSequence = brokerState->at(sfLoanSequence);
auto const keylet = keylet::loan(broker.brokerID, loanSequence);
auto const interval = maxLoanTime / total;
auto createJson = env.json(
baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace));
env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS));
env.close();
// This loan exists
auto const beforeState = getCurrentState(env, broker, keylet);
BEAST_EXPECT(beforeState.nextPaymentDate == closeStartDate + interval);
BEAST_EXPECT(beforeState.previousPaymentDate == 0);
BEAST_EXPECT(beforeState.paymentRemaining == total);
BEAST_EXPECT(beforeState.periodicPayment > 0);
// pay all but the last payment
{
NumberRoundModeGuard const mg{Number::RoundingMode::Upward};
Number const payment = beforeState.periodicPayment * (total - 1);
XRPAmount const payFee{baseFee * ((total - 1) / kLoanPaymentsPerFeeIncrement + 1)};
STAmount const paymentAmount =
roundToScale(STAmount{broker.asset, payment}, beforeState.loanScale);
auto loanPayTx = env.json(pay(borrower, keylet.key, paymentAmount), Fee(payFee));
env(loanPayTx, Ter(tesSUCCESS));
env.close();
}
// The loan is on the last payment
auto const afterState = getCurrentState(env, broker, keylet);
BEAST_EXPECT(afterState.paymentRemaining == 1);
BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace);
BEAST_EXPECT(afterState.previousPaymentDate == kMaxTime - grace - interval);
}
}
void
runAmendmentIndependent()
{
testLoanSetNearZeroInterestRateSucceeds();
}
// Tests run under each entry in amendmentCombinations().
void
runAmendmentSensitive(FeatureBitset features)
{
#if LOAN_TODO
testLoanPayLateFullPaymentBypassesPenalties(features);
#endif
testOverpaymentManagementFee(features);
testDosLoanPay(features);
testLoanNextPaymentDueDateOverflow(features);
}
public:
void
run() override
{
runAmendmentIndependent();
for (auto const& features : jtx::amendmentCombinations(
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
runAmendmentSensitive(features);
}
};
BEAST_DEFINE_TESTSUITE(LoanPay, tx, xrpl);
} // namespace xrpl::test

View File

@@ -0,0 +1,993 @@
#include <test/app/lending/LoanTestBase.h>
#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/flags.h>
#include <test/jtx/jtx_json.h>
#include <test/jtx/mpt.h>
#include <test/jtx/pay.h>
#include <test/jtx/ter.h>
#include <test/jtx/trust.h>
#include <test/jtx/txflags.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/Units.h>
#include <array>
#include <chrono>
#include <cstdint>
#include <optional>
#include <ostream>
#include <string>
#include <tuple>
namespace xrpl::test {
class LoanRounding_test : public LoanTestBase
{
private:
void
testDustManipulation(FeatureBitset features)
{
testcase("Dust manipulation");
using namespace jtx;
using namespace std::chrono_literals;
Env env{*this, features};
// Setup: Create accounts
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
Account const victim{"victim"};
env.fund(XRP(1'000'000'00), issuer, lender, borrower, victim);
env.close();
// Step 1: Create vault with IOU asset
auto asset = issuer["USD"];
env(trust(lender, asset(100000)));
env(trust(borrower, asset(100000)));
env(trust(victim, asset(100000)));
env(pay(issuer, lender, asset(50000)));
env(pay(issuer, borrower, asset(50000)));
env(pay(issuer, victim, asset(50000)));
env.close();
BrokerParameters const brokerParams{
.vaultDeposit = 10000,
.debtMax = Number{0},
.coverRateMin = TenthBips32{1000},
.coverRateLiquidation = TenthBips32{2500}};
auto broker = createVaultAndBroker(env, asset, lender, brokerParams);
auto const loanKeyletOpt = [&]() -> std::optional<Keylet> {
auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
if (!BEAST_EXPECT(brokerSle))
return std::nullopt;
// Broker has no loans
BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
// The loan keylet is based on the LoanSequence of the
// _LOAN_BROKER_ object.
auto const loanSequence = brokerSle->at(sfLoanSequence);
return keylet::loan(broker.brokerID, loanSequence);
}();
if (!loanKeyletOpt)
return;
auto const& vaultKeylet = broker.vaultKeylet();
{
auto const vaultSle = env.le(vaultKeylet);
Number const assetsTotal = vaultSle->at(sfAssetsTotal);
Number const assetsAvail = vaultSle->at(sfAssetsAvailable);
log << "Before loan creation:" << std::endl;
log << " AssetsTotal: " << assetsTotal << std::endl;
log << " AssetsAvailable: " << assetsAvail << std::endl;
log << " Difference: " << (assetsTotal - assetsAvail) << std::endl;
// before the loan the assets total and available should be equal
BEAST_EXPECT(assetsAvail == assetsTotal);
BEAST_EXPECT(assetsAvail == broker.asset(brokerParams.vaultDeposit).number());
}
Keylet const& loanKeylet = *loanKeyletOpt;
LoanParameters const loanParams{
.account = lender,
.counter = borrower,
.principalRequest = Number{100},
.interest = TenthBips32{1922},
.payTotal = 5816,
.payInterval = 86400 * 6,
.gracePd = 86400 * 5,
};
env(loanParams(env, broker));
env.close();
// Wait for loan to be late enough to default
env.close(std::chrono::seconds(86400 * 40)); // 40 days
{
auto const vaultSle = env.le(vaultKeylet);
Number const assetsTotal = vaultSle->at(sfAssetsTotal);
Number const assetsAvail = vaultSle->at(sfAssetsAvailable);
log << "After loan creation:" << std::endl;
log << " AssetsTotal: " << assetsTotal << std::endl;
log << " AssetsAvailable: " << assetsAvail << std::endl;
log << " Difference: " << (assetsTotal - assetsAvail) << std::endl;
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(loanSle))
return;
auto const state = constructLoanState(loanSle);
log << "Loan state:" << std::endl;
log << " ValueOutstanding: " << state.valueOutstanding << std::endl;
log << " PrincipalOutstanding: " << state.principalOutstanding << std::endl;
log << " InterestOutstanding: " << state.interestOutstanding() << std::endl;
log << " InterestDue: " << state.interestDue << std::endl;
log << " FeeDue: " << state.managementFeeDue << std::endl;
// after loan creation the assets total and available should
// reflect the value of the loan
BEAST_EXPECT(assetsAvail < assetsTotal);
BEAST_EXPECT(
assetsAvail ==
broker.asset(brokerParams.vaultDeposit - loanParams.principalRequest).number());
BEAST_EXPECT(
assetsTotal ==
broker.asset(brokerParams.vaultDeposit + state.interestDue).number());
}
// Step 7: Trigger default (dust adjustment will occur)
env(jtx::loan::manage(lender, loanKeylet.key, tfLoanDefault));
env.close();
// Step 8: Verify phantom assets created
{
auto const vaultSle2 = env.le(vaultKeylet);
Number const assetsTotal2 = vaultSle2->at(sfAssetsTotal);
Number const assetsAvail2 = vaultSle2->at(sfAssetsAvailable);
log << "After default:" << std::endl;
log << " AssetsTotal: " << assetsTotal2 << std::endl;
log << " AssetsAvailable: " << assetsAvail2 << std::endl;
log << " Difference: " << (assetsTotal2 - assetsAvail2) << std::endl;
// after a default the assets total and available should be equal
BEAST_EXPECT(assetsAvail2 == assetsTotal2);
}
}
void
testRoundingAllowsUndercoverage(FeatureBitset features)
{
testcase("Minimum cover rounding allows undercoverage (XRP)");
using namespace jtx;
using namespace loan_broker;
Env env{*this, features};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(200'000), lender, borrower);
env.close();
// Vault with XRP asset
Vault const vault{env};
auto [vaultCreate, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()});
env(vaultCreate);
env.close();
BEAST_EXPECT(env.le(vaultKeylet));
// Seed the vault with XRP so it can fund the loan principal
PrettyAsset const xrpAsset{xrpIssue(), 1};
BrokerParameters const brokerParams{
.vaultDeposit = 1'000,
.debtMax = Number{0},
.coverRateMin = TenthBips32{10'000},
.coverDeposit = 82,
};
auto const brokerInfo = createVaultAndBroker(env, xrpAsset, lender, brokerParams);
// Create a loan with principal 804 XRP and 0% interest (so
// DebtTotal increases by exactly 804)
env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()),
loan::kInterestRate(TenthBips32(0)),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 2));
BEAST_EXPECT(env.ter() == tesSUCCESS);
env.close();
// Verify DebtTotal is exactly 804
if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
BEAST_EXPECT(brokerSle))
{
log << *brokerSle << std::endl;
BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804));
}
// Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable.
// The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails.
env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()),
Ter(tecINSUFFICIENT_FUNDS));
BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS);
env.close();
// Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable.
// because that leaves sufficient cover, this succeeds
env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value()));
BEAST_EXPECT(env.ter() == tesSUCCESS);
env.close();
// Validate CoverAvailable == 81 XRP and DebtTotal remains 804
if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
BEAST_EXPECT(brokerSle))
{
log << *brokerSle << std::endl;
BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == xrpAsset(81).value());
BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804));
// Also demonstrate that the true minimum (804 * 10%) exceeds 80
auto const theoreticalMin = tenthBipsOfValue(Number(804), TenthBips32(10'000));
log << "Theoretical min cover: " << theoreticalMin << std::endl;
BEAST_EXPECT(Number(804, -1) == theoreticalMin);
}
}
void
testYieldTheftRounding(std::uint32_t flags)
{
testcase("Rounding manipulation does not permit yield theft");
using namespace jtx;
using namespace loan;
// 1. Setup Environment
Env env(*this, all_);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(1000), issuer, lender, borrower);
env.close();
// 2. Asset Selection
PrettyAsset const iou = issuer["USD"];
env(trust(lender, iou(100'000'000)));
env(trust(borrower, iou(100'000'000)));
env(pay(issuer, lender, iou(100'000'000)));
env(pay(issuer, borrower, iou(100'000'000)));
env.close();
// 3. Create Vault and Broker with High Debt Limit (100M)
auto const brokerInfo = createVaultAndBroker(
env,
iou,
lender,
{
.vaultDeposit = 5'000'000,
.debtMax = Number{100'000'000},
.coverDeposit = 500'000,
});
auto const [currentSeq, vaultKeylet] = [&]() {
auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
if (!BEAST_EXPECT(brokerSle))
return std::make_tuple(0u, keylet::unchecked(beast::kZero));
auto const currentSeq = brokerSle->at(sfLoanSequence);
auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID));
return std::make_tuple(currentSeq, vaultKeylet);
}();
// 4. Loan Parameters (Attack Vector)
Number const principal = 1'000'000;
TenthBips32 const interestRate = TenthBips32{1}; // 0.001%
std::uint32_t const paymentInterval = 86400;
std::uint32_t const paymentTotal = 3650;
auto const loanSetFee = Fee(env.current()->fees().base * 2);
env(set(borrower, brokerInfo.brokerID, iou(principal).value(), flags),
Sig(sfCounterpartySignature, lender),
loan::kInterestRate(interestRate),
loan::kPaymentInterval(paymentInterval),
loan::kPaymentTotal(paymentTotal),
Fee(loanSetFee));
env.close();
// --- RETRIEVE OBJECTS & SETUP ATTACK ---
auto borrowerBalance = [&]() { return env.balance(borrower, iou); };
auto const borrowerScale = static_cast<STAmount const&>(borrowerBalance()).exponent();
auto const loanKeylet = keylet::loan(brokerInfo.brokerID, currentSeq);
auto const maybePeriodicPayment = [&]() -> std::optional<STAmount> {
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(loanSle))
return std::nullopt;
// Construct Payment
return STAmount{iou, loanSle->at(sfPeriodicPayment)};
}();
if (!maybePeriodicPayment)
return;
auto const periodicPayment = *maybePeriodicPayment;
auto const roundedPayment =
roundToScale(periodicPayment, borrowerScale, Number::RoundingMode::Upward);
// ATTACK: Add dust buffer (1e-9) to force 'excess' logic execution
STAmount const paymentBuffer{iou, Number(1, -9)};
STAmount const attackPayment = periodicPayment + paymentBuffer;
auto const maybeInitialVaultAssets = [&]() -> std::optional<Number> {
auto const vault = env.le(vaultKeylet);
if (!BEAST_EXPECT(vault))
return std::nullopt;
return vault->at(sfAssetsTotal);
}();
if (!maybeInitialVaultAssets)
return;
auto const initialVaultAssets = *maybeInitialVaultAssets;
// 5. Execution Loop
int yieldTheftCount = 0;
auto previousAssetsTotal = initialVaultAssets;
for (int i = 0; i < 100; ++i)
{
auto const balanceBefore = borrowerBalance();
env(pay(borrower, loanKeylet.key, attackPayment, flags));
env.close();
auto const borrowerDelta = balanceBefore - borrowerBalance();
BEAST_EXPECT(borrowerDelta.signum() == roundedPayment.signum());
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(loanSle))
break;
auto const updatedPayment = STAmount{iou, loanSle->at(sfPeriodicPayment)};
BEAST_EXPECT(
(roundToScale(updatedPayment, borrowerScale, Number::RoundingMode::Upward) ==
roundedPayment));
BEAST_EXPECT(
(updatedPayment == periodicPayment) ||
(flags == tfLoanOverpayment && i >= 2 && updatedPayment < periodicPayment));
auto const currentVaultSle = env.le(vaultKeylet);
if (!BEAST_EXPECT(currentVaultSle))
break;
auto const currentAssetsTotal = currentVaultSle->at(sfAssetsTotal);
auto const delta = currentAssetsTotal - previousAssetsTotal;
BEAST_EXPECT(
(delta == beast::kZero && borrowerDelta <= roundedPayment) ||
(delta > beast::kZero && borrowerDelta > roundedPayment));
// If tx succeeded but Assets Total didn't change, interest was
// stolen.
if (delta == beast::kZero && borrowerDelta > roundedPayment)
{
yieldTheftCount++;
}
previousAssetsTotal = currentAssetsTotal;
}
BEAST_EXPECTS(yieldTheftCount == 0, std::to_string(yieldTheftCount));
}
// Regression for the dual-rounding fix at coarse (integer-MPT) scale.
//
// Loan: P=1, r=50% (50000 tenth-bips), n=3, yearly interval. The
// amortization schedule produces a fractional principal
// (~0.47) which under round-to-nearest collapses to 0 in a single
// step, causing `doPayment`'s strict `>` assertion on principal to
// fire mid-loan. With fixCleanup3_2_0 enabled, principal is rounded
// upward (sticks at 1 across the first two periods) and only clears
// in the final payment.
//
// The test pays one period at a time across three LoanPay
// transactions and verifies the loan completes (paymentRemaining=0)
// with totals matching the loan's economics (1 principal + 2 interest).
void
testIntegerScalePrincipalSticks(FeatureBitset features)
{
// Without fixCleanup3_2_0, this behavior will abort the server, so
// don't run without it.
if (!features[fixCleanup3_2_0])
return;
testcase("edge: integer MPT principal stuck mid-loan completes via final");
using namespace jtx;
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(100'000), issuer, lender, borrower);
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer});
PrettyAsset const asset{mptt.issuanceID()};
mptt.authorize({.account = lender});
mptt.authorize({.account = borrower});
env(pay(issuer, lender, asset(10'000)));
env(pay(issuer, borrower, asset(10'000)));
env.close();
Vault const vault{env};
auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
env(vaultTx);
env.close();
env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)}));
env.close();
auto const brokerKeylet = keylet::loanBroker(lender.id(), env.seq(lender));
env(loan_broker::set(lender, vaultKeylet.key),
loan_broker::kDebtMaximum(Number{100}),
Fee(env.current()->fees().base * 2));
env.close();
auto const brokerStateBefore = env.le(brokerKeylet);
if (!BEAST_EXPECT(brokerStateBefore))
return;
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence);
env(loan::set(borrower, brokerKeylet.key, Number{1}),
Sig(sfCounterpartySignature, lender),
loan::kInterestRate(TenthBips32{50'000}),
loan::kPaymentTotal(3),
loan::kPaymentInterval(31'536'000),
Fee(env.current()->fees().base * 2));
env.close();
auto const borrowerStart = env.balance(borrower, asset).value();
// Three separate periodic payments of 1 each. Expected per-period
// evolution at integer MPT scale (TVO = PO + interestDue +
// managementFeeDue):
// start: PO=1, TVO=3, paymentRemaining=3
// after pay #1: PO=1, TVO=2, paymentRemaining=2 (principal sticks)
// after pay #2: PO=1, TVO=1, paymentRemaining=1 (principal sticks)
// after pay #3: PO=0, TVO=0, paymentRemaining=0 (final clears)
std::array<Number, 3> const expectedPO{Number{1}, Number{1}, Number{0}};
std::array<Number, 3> const expectedTVO{Number{2}, Number{1}, Number{0}};
std::array<std::uint32_t, 3> const expectedRemaining{2, 1, 0};
for (int i = 0; i < 3; ++i)
{
env(loan::pay(borrower, loanKeylet.key, asset(1)), Ter(tesSUCCESS));
env.close();
auto const sle = env.le(loanKeylet);
if (!BEAST_EXPECT(sle))
return;
BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]);
BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]);
BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]);
}
// Borrower paid 3 total regardless of fee split (1 principal + 2
// interest+fee, matching loan economics).
auto const borrowerEnd = env.balance(borrower, asset).value();
BEAST_EXPECT(borrowerStart - borrowerEnd == asset(3).value());
}
#if LOAN_TODO
void
testLoanCoverMinimumRoundingExploit(FeatureBitset features)
{
auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) {
testcase << "LoanBrokerCoverClawback drains cover via rounding"
<< " principalRequested=" << to_string(principalRequest);
using namespace jtx;
using namespace loan;
using namespace loan_broker;
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(1'000'000'000), issuer, lender, borrower);
env.close();
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
PrettyAsset const asset = issuer[iouCurrency];
env(trust(lender, asset(2'000'0000)));
env(trust(borrower, asset(2'000'0000)));
env.close();
env(pay(issuer, lender, asset(2'000'0000)));
env.close();
BrokerParameters brokerParams{.debtMax = 0, .coverRateMin = TenthBips32{10'000}};
BrokerInfo broker{createVaultAndBroker(env, asset, lender, brokerParams)};
auto const loanSetFee = Fee(env.current()->fees().base * 2);
auto createTx = env.jt(
set(borrower, broker.brokerID, principalRequest),
Sig(sfCounterpartySignature, lender),
loanSetFee,
kPaymentInterval(600),
kPaymentTotal(1),
kGracePeriod(60));
env(createTx);
env.close();
auto const brokerBefore = env.le(keylet::loanBroker(broker.brokerID));
BEAST_EXPECT(brokerBefore);
if (!brokerBefore)
return;
Number const debtOutstanding = brokerBefore->at(sfDebtTotal);
Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable);
BEAST_EXPECT(debtOutstanding > Number{});
BEAST_EXPECT(coverAvailableBefore > Number{});
log << "debt=" << to_string(debtOutstanding)
<< " cover_available=" << to_string(coverAvailableBefore);
env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID));
env.close();
auto const brokerAfter = env.le(keylet::loanBroker(broker.brokerID));
BEAST_EXPECT(brokerAfter);
if (!brokerAfter)
return;
Number const debtAfter = brokerAfter->at(sfDebtTotal);
// the debt has not changed
BEAST_EXPECT(debtAfter == debtOutstanding);
Number const coverAvailableAfter = brokerAfter->at(sfCoverAvailable);
// since the cover rate min != 0, the cover available should not
// be zero
BEAST_EXPECT(coverAvailableAfter != Number{});
};
// Call the lambda with different principal values
testLoanCoverMinimumRoundingExploit(Number{1, -30}); // 1e-30 units
testLoanCoverMinimumRoundingExploit(Number{1, -20}); // 1e-20 units
testLoanCoverMinimumRoundingExploit(Number{1, -10}); // 1e-10 units
testLoanCoverMinimumRoundingExploit(Number{1, 1}); // 1e-10 units
}
#endif
// A residual overpayment can reduce the stored principal by one scale-unit
// *less* than computeOverpaymentComponents predicts, firing the
// "principal change agrees" XRPL_ASSERT_PARTS in doOverpayment:
//
// trackedPrincipalDelta == principalOutstanding - newPrincipalOutstanding
//
// tryOverpayment re-amortizes the loan at the reduced principal, then
// re-derives the theoretical principal from the new periodic payment via
// (P * paymentFactor) / paymentFactor. That round-trip is not exact in
// Number's 19-digit arithmetic; a positive residual pushes the recomputed
// principal a hair above the exact grid point `oldPrincipal - delta`, and
// the Upward rounding in tryOverpayment then bumps it a full scale-unit
// higher. The principal therefore drops by `delta - 1 unit`, not `delta`.
//
// Concrete case (isolated, at the tryOverpayment level):
// A 100 USD loan at the minimum non-zero rate, 3 payments, loanScale -10.
// After one regular payment (principalOutstanding 66.6666666674) a residual overpayment of
// 0.049999998 yields trackedPrincipalDelta 0.048999998 but only reduces the principal by
// 0.0489999979 (newPrincipal 66.6176666695) — short by 1e-10.
//
// With fixCleanup3_2_0, tryOverpayment pins the new principal to the exact,
// on-grid reduction (oldPrincipal - trackedPrincipalDelta) instead of the
// lossy (P*factor)/factor round-trip, so the assertion holds and the
// overpayment applies cleanly. The three "principal change agrees" /
// "interest paid agrees" / "principal payment matches" assertions are
// gated behind the same amendment, so without it they are disabled (the
// server does not abort) and the loan keeps the pre-amendment computation.
//
// The test runs the same scenario under both amendment settings and checks
// the stored principal against a ground-truth value derived independently of
// the loan-state computation under test.
void
testBugOverpaymentPrincipalChange()
{
testcase("bug: doOverpayment asserts 'principal change agrees'");
using namespace jtx;
using namespace loan;
using namespace xrpl::detail;
struct Params
{
TenthBips32 interestRate;
TenthBips16 managementFeeRate;
std::uint32_t paymentTotal;
std::uint32_t paymentInterval;
std::int64_t principal;
Number overpayment;
TenthBips32 overpaymentInterestRate;
TenthBips32 overpaymentFeeRate;
std::optional<int> vaultScale;
};
struct Result
{
Number principalOutstanding; // stored principal after the LoanPay
Number expectedNewPrincipal; // ground truth, independent of the fix
Number managementFeeChange; // managementFeeOutstanding after - before
Number unit; // one scale-unit at the loan scale
};
auto runScenario = [this](FeatureBitset features, Params const& p) -> Result {
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"vaultOwner"};
Account const borrower{"borrower"};
PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower);
Asset const asset = iouAsset.raw();
auto const broker = createVaultAndBroker(
env,
iouAsset,
lender,
{.vaultDeposit = 900'000,
.debtMax = 0,
.managementFeeRate = p.managementFeeRate,
.vaultScale = p.vaultScale});
auto const brokerSle = env.le(broker.brokerKeylet());
BEAST_EXPECT(brokerSle);
auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
env(set(borrower, broker.brokerID, Number{p.principal}, tfLoanOverpayment),
Sig(sfCounterpartySignature, lender),
kInterestRate(p.interestRate),
kPaymentTotal(p.paymentTotal),
kPaymentInterval(p.paymentInterval),
kGracePeriod(p.paymentInterval),
kOverpaymentFee(p.overpaymentFeeRate),
kOverpaymentInterestRate(p.overpaymentInterestRate),
Fee(env.current()->fees().base * 2),
Ter(tesSUCCESS));
env.close();
// The single LoanPay below makes one regular payment (the overpayment
// is smaller than one period) and leaves the residual as an
// overpayment.
auto const s = getCurrentState(env, broker, loanKeylet);
auto const periodicRate = loanPeriodicRate(s.interestRate, s.paymentInterval);
auto const onePeriod = computePaymentComponents(
env.current()->rules(),
asset,
s.loanScale,
s.totalValue,
s.principalOutstanding,
s.managementFeeOutstanding,
s.periodicPayment,
periodicRate,
s.paymentRemaining,
p.managementFeeRate);
// Ground truth: the stored principal must drop by exactly the regular
// payment's principal portion plus the overpayment's principal
// portion. computeOverpaymentComponents depends only on the
// overpayment amount and rates (not on the loan-state computation
// under test), so it is an independent oracle. Both components are
// computed under the same rules as the env so the payment factor
// matches.
auto const overpaymentComponents = computeOverpaymentComponents(
env.current()->rules(),
asset,
s.loanScale,
p.overpayment,
p.overpaymentInterestRate,
p.overpaymentFeeRate,
p.managementFeeRate);
Number const expectedNewPrincipal = s.principalOutstanding -
onePeriod.trackedPrincipalDelta - overpaymentComponents.trackedPrincipalDelta;
Number const managementFeeBefore = s.managementFeeOutstanding;
STAmount const payAmount{asset, onePeriod.trackedValueDelta + p.overpayment};
env(pay(borrower, loanKeylet.key, payAmount),
Txflags(tfLoanOverpayment),
Ter(tesSUCCESS));
env.close();
auto const loanSle = env.le(loanKeylet);
BEAST_EXPECT(loanSle);
return Result{
.principalOutstanding = loanSle ? Number{loanSle->at(sfPrincipalOutstanding)} : 0,
.expectedNewPrincipal = expectedNewPrincipal,
.managementFeeChange =
(loanSle ? Number{loanSle->at(sfManagementFeeOutstanding)} : Number{0}) -
managementFeeBefore,
.unit = Number{1, s.loanScale}};
};
// Scenario 1: the original near-zero-rate principal reproduction
// (loanScale -10, no management fee). 0.049999998 is smaller than one
// period, so it stays a residual overpayment.
Params const principalCase{
.interestRate = TenthBips32{1},
.managementFeeRate = TenthBips16{0},
.paymentTotal = 3,
.paymentInterval = 60,
.principal = 100,
.overpayment = Number{49999998, -9},
.overpaymentInterestRate = TenthBips32{1000},
.overpaymentFeeRate = TenthBips32{1000},
.vaultScale = 1};
// With fixCleanup3_2_0 the stored principal lands exactly on the
// ground-truth grid point: it is reduced by exactly the overpayment's
// principal portion. This is the key correctness check: if the principal
// pin were removed (even with the assertions still gated off), the lossy
// (P * factor) / factor round-trip would leave the principal one
// scale-unit high and this would fail.
Result const fixed = runScenario(all_, principalCase);
BEAST_EXPECTS(
fixed.principalOutstanding == fixed.expectedNewPrincipal,
"fixed principal " + to_string(fixed.principalOutstanding) + " != expected " +
to_string(fixed.expectedNewPrincipal));
// Without the amendment the loan amortizes with the catastrophically
// cancelling near-zero payment factor, so its schedule (and ground truth)
// differ from the fixed case; the gated assertions keep the server from
// aborting and the overpayment still lands exactly on that schedule.
Result const legacy = runScenario(all_ - fixCleanup3_2_0, principalCase);
BEAST_EXPECTS(
legacy.principalOutstanding == legacy.expectedNewPrincipal,
"legacy principal " + to_string(legacy.principalOutstanding) + " != expected " +
to_string(legacy.expectedNewPrincipal));
// Scenario 2: a normal-rate loan with a 10% management fee. At a normal
// rate the payment factor is identical across the amendment, so toggling
// fixCleanup3_2_0 isolates the fix. This overpayment (found by search)
// lands on a state where both the principal and the management fee differ
// by one scale-unit between the fixed and legacy paths.
Params const feeCase{
.interestRate = TenthBips32{10000},
.managementFeeRate = TenthBips16{10000},
.paymentTotal = 6,
.paymentInterval = 30u * 24 * 60 * 60,
.principal = 1000,
.overpayment = Number{214367363, -10},
.overpaymentInterestRate = TenthBips32{0},
.overpaymentFeeRate = TenthBips32{0},
.vaultScale = std::nullopt};
Result const feeFixed = runScenario(all_, feeCase);
Result const feeLegacy = runScenario(all_ - fixCleanup3_2_0, feeCase);
// With the fix the principal is the exact reduction; without it the lossy
// (P * factor) / factor round-trip leaves it one scale-unit high.
BEAST_EXPECTS(
feeFixed.principalOutstanding == feeFixed.expectedNewPrincipal,
"fee-case fixed principal " + to_string(feeFixed.principalOutstanding) +
" != expected " + to_string(feeFixed.expectedNewPrincipal));
BEAST_EXPECTS(
feeLegacy.principalOutstanding == feeLegacy.expectedNewPrincipal + feeLegacy.unit,
"fee-case legacy principal " + to_string(feeLegacy.principalOutstanding) +
" != expected " + to_string(feeLegacy.expectedNewPrincipal + feeLegacy.unit));
// Management fee: the overpayment re-amortizes a fee-bearing loan, so the management fee
// outstanding drops.
//
// Unlike the principal that is already at the correct precision, the re-amortized
// management fee is tenthBipsOfValue of the new schedule's gross interest, which depends
// on the recomputed periodic payment. So the expected change below is a pinned constant
// captured from a passing run a magic value only because there is nothing simpler to
// compare against.
//
// At the integration level, toggling the amendment also changes the regular payment's
// rounding so a fixed-vs-legacy comparison cannot isolate the overpayment management-fee
// fix.
BEAST_EXPECT(feeFixed.managementFeeChange == feeLegacy.managementFeeChange);
BEAST_EXPECTS(
(feeFixed.managementFeeChange == Number{-8219709543, -10}),
"fee-case mgmt fee change " + to_string(feeFixed.managementFeeChange));
}
// An overpayment whose residual amount has more precision than loanScale
// fires the isRounded(asset, overpayment, loanScale) assertion in
// computeOverpaymentComponents (and a downstream "interest paid agrees"
// assertion in doOverpayment). fixCleanup3_2_0 rounds the residual down
// to loanScale before passing it in. The pre-amendment path can't be
// tested here because the assertion fires in Debug builds and aborts
// the test process — see the PR description for context.
void
testBugOverpayUnroundedAmount()
{
testcase("bug: computeOverpaymentComponents isRounded assertion");
using namespace jtx;
using namespace loan;
Env env(*this, all_);
Account const issuer{"issuer"};
Account const lender{"vaultOwner"};
Account const borrower{"borrower"};
PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower);
auto const broker = createVaultAndBroker(
env,
iouAsset,
lender,
{.vaultDeposit = 100'000,
.debtMax = 5000,
.managementFeeRate = TenthBips16{1000},
.vaultScale = 1});
auto const sleBroker = env.le(broker.brokerKeylet());
if (!BEAST_EXPECT(sleBroker))
return;
auto const loanSequence = sleBroker->at(sfLoanSequence);
auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
using namespace loan;
env(set(borrower, broker.brokerID, Number{1000}, tfLoanOverpayment),
Sig(sfCounterpartySignature, lender),
kInterestRate(TenthBips32{10000}),
kPaymentTotal(12),
kPaymentInterval(60),
kGracePeriod(60),
kOverpaymentFee(TenthBips32{1000}),
kOverpaymentInterestRate(TenthBips32{1000}),
Fee(env.current()->fees().base * 2),
Ter(tesSUCCESS));
env.close();
// periodic * 1.5 at 15-sig-digit precision: 125.000154585042. This
// has too many digits to round cleanly to loanScale=-10, so the
// overpayment residual fails the isRounded check.
STAmount const payAmount{iouAsset.raw(), Number{125'000'154'585'042LL, -12}};
env(pay(borrower, loanKeylet.key, payAmount), Txflags(tfLoanOverpayment), Ter(tesSUCCESS));
env.close();
}
// A near-zero interest rate on a 100 USD loan
// produces total interest of ~6 units at loanScale -9. Numerical error
// in the amortization formula pushes the theoretical principal above
// the theoretical value, producing a negative theoretical interest.
// The payment delta then exceeds the actual outstanding interest,
// violating XRPL_ASSERT_PARTS in computePaymentComponents.
void
testBugInterestDueDeltaCrash()
{
testcase("bug: LoanPay asserts 'interest due delta' on near-zero rate");
using namespace jtx;
using namespace std::chrono_literals;
Env env(*this, all_);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(1'000'000), issuer, lender, borrower);
env.close();
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const iouAsset = issuer["USD"];
env(trust(lender, iouAsset(1'000'000'000)));
env(trust(borrower, iouAsset(1'000'000'000)));
env(pay(issuer, lender, iouAsset(5'000'000)));
env(pay(issuer, borrower, iouAsset(5'000'000)));
env.close();
BrokerParameters const brokerParams{
.vaultDeposit = 1'000'000,
.debtMax = 1'000'000,
.coverRateMin = TenthBips32{0},
.coverDeposit = 0,
.managementFeeRate = TenthBips16{0},
.coverRateLiquidation = TenthBips32{0}};
BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)};
using namespace loan;
auto const loanSetFee = Fee(env.current()->fees().base * 2);
Number const principalRequest{100};
auto createJson = env.json(
set(borrower, broker.brokerID, principalRequest),
Fee(loanSetFee),
Json(sfCounterpartySignature, json::ValueType::Object));
createJson["InterestRate"] = 1; // minimum non-zero rate
createJson["PaymentTotal"] = 3;
createJson["PaymentInterval"] = 600;
auto const keylet = nextLoanKeylet(env, broker);
createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
env(createJson, Ter(tesSUCCESS));
env.close();
// For principal=100, n=3 the amortization schedule produces a
// periodic payment ≈ 33.33 USD. We pay 35 USD, which is more than
// one period's worth — enough for the LoanPay path to enter
// computePaymentComponents and reach the assertion that fires
// when the bug is present. With the fix, the tx applies cleanly.
env(pay(borrower, keylet.key, iouAsset(35)), Ter(tesSUCCESS));
env.close();
}
void
runAmendmentIndependent()
{
for (auto const flags : {0u, tfLoanOverpayment})
testYieldTheftRounding(flags);
testBugOverpaymentPrincipalChange();
testBugOverpayUnroundedAmount();
testBugInterestDueDeltaCrash();
}
// Tests run under each entry in amendmentCombinations().
void
runAmendmentSensitive(FeatureBitset features)
{
testDustManipulation(features);
testRoundingAllowsUndercoverage(features);
testIntegerScalePrincipalSticks(features);
#if LOAN_TODO
testLoanCoverMinimumRoundingExploit(features);
#endif
}
public:
void
run() override
{
runAmendmentIndependent();
for (auto const& features : jtx::amendmentCombinations(
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
runAmendmentSensitive(features);
}
};
BEAST_DEFINE_TESTSUITE(LoanRounding, tx, xrpl);
} // namespace xrpl::test

View File

@@ -0,0 +1,538 @@
#include <test/app/lending/LoanTestBase.h>
#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/noop.h>
#include <test/jtx/txflags.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/json/to_string.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/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/jss.h>
#include <algorithm>
#include <cstdint>
#include <ostream>
namespace xrpl::test {
class LoanSecurity_test : public LoanTestBase
{
private:
void
testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(FeatureBitset features)
{
// --- PoC Summary ----------------------------------------------------
// Scenario: Borrower makes one periodic payment early (before next due)
// so doPayment sets sfPreviousPaymentDueDate to the (future)
// sfNextPaymentDueDate and advances sfNextPaymentDueDate by one
// interval. Borrower then immediately performs a full-payment
// (tfLoanFullPayment). Why it matters: Full-payment interest accrual
// uses
// delta = now - max(prevPaymentDate, startDate)
// with an unsigned clock representation (uint32). If prevPaymentDate is
// in the future, the subtraction underflows to a very large positive
// number. This inflates roundedFullInterest and total full-close due,
// and LoanPay applies the inflated valueChange to the vault
// (sfAssetsTotal), increasing NAV.
// --------------------------------------------------------------------
testcase("PoC: Unsigned-underflow full-pay accrual after early periodic");
using namespace jtx;
using namespace loan;
using namespace std::chrono_literals;
Env env{*this, features};
Account const lender{"poc_lender4"};
Account const borrower{"poc_borrower4"};
env.fund(XRP(3'000'000), lender, borrower);
env.close();
PrettyAsset const asset{xrpIssue(), 1'000'000};
BrokerParameters const brokerParams{};
auto const broker = createVaultAndBroker(env, asset, lender, brokerParams);
// Create a 3-payment loan so full-payment path is enabled after 1
// periodic payment.
auto const loanSetFee = Fee(env.current()->fees().base * 2);
Number const principalRequest = asset(1000).value();
auto const originationFee = asset(0).value();
auto const serviceFee = asset(1).value();
auto const serviceFeePA = asset(1);
auto const lateFee = asset(0).value();
auto const closeFee = asset(0).value();
auto const interest = percentageToTenthBips(12);
auto const lateInterest = percentageToTenthBips(12) / 10;
auto const closeInterest = percentageToTenthBips(12) / 10;
auto const overpaymentInterest = percentageToTenthBips(12) / 10;
auto const total = 3u;
auto const interval = 600u;
auto const grace = 60u;
auto createJtx = env.jt(
set(borrower, broker.brokerID, principalRequest, 0),
Sig(sfCounterpartySignature, lender),
kLoanOriginationFee(originationFee),
kLoanServiceFee(serviceFee),
kLatePaymentFee(lateFee),
kClosePaymentFee(closeFee),
kOverpaymentFee(percentageToTenthBips(5) / 10),
kInterestRate(interest),
kLateInterestRate(lateInterest),
kCloseInterestRate(closeInterest),
kOverpaymentInterestRate(overpaymentInterest),
kPaymentTotal(total),
kPaymentInterval(interval),
kGracePeriod(grace),
Fee(loanSetFee));
auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
BEAST_EXPECT(brokerSle);
auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
env(createJtx);
env.close();
// Compute a regular periodic due and pay it early (before next due).
auto state = getCurrentState(env, broker, loanKeylet);
Number const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval);
auto const components = xrpl::detail::computePaymentComponents(
env.current()->rules(),
asset.raw(),
state.loanScale,
state.totalValue,
state.principalOutstanding,
state.managementFeeOutstanding,
state.periodicPayment,
periodicRate,
state.paymentRemaining,
brokerParams.managementFeeRate);
STAmount const regularDue{asset, components.trackedValueDelta + serviceFeePA.number()};
// now < nextDue immediately after creation, so this is an early pay.
env(pay(borrower, loanKeylet.key, regularDue));
env.close();
// Immediately attempt a full payoff. Compute the exact full-payment
// due to ensure the tx applies.
auto after = getCurrentState(env, broker, loanKeylet);
auto const loanSle = env.le(loanKeylet);
BEAST_EXPECT(loanSle);
auto const brokerSle2 = env.le(keylet::loanBroker(broker.brokerID));
BEAST_EXPECT(brokerSle2);
auto const closePaymentFee = loanSle ? loanSle->at(sfClosePaymentFee) : Number{};
auto const closeInterestRate =
loanSle ? TenthBips32{loanSle->at(sfCloseInterestRate)} : TenthBips32{};
auto const managementFeeRate =
brokerSle2 ? TenthBips16{brokerSle2->at(sfManagementFeeRate)} : TenthBips16{};
Number const periodicRate2 = loanPeriodicRate(after.interestRate, after.paymentInterval);
// Accrued + prepayment-penalty interest based on current periodic
// schedule
auto const fullPaymentInterest = computeFullPaymentInterest(
xrpl::detail::loanPrincipalFromPeriodicPayment(
env.current()->rules(),
after.periodicPayment,
periodicRate2,
after.paymentRemaining),
periodicRate2,
env.current()->parentCloseTime(),
after.paymentInterval,
after.previousPaymentDate,
static_cast<std::uint32_t>(after.startDate.time_since_epoch().count()),
closeInterestRate);
// Round to asset scale and split interest/fee parts
auto const roundedInterest =
roundToAsset(asset.raw(), fullPaymentInterest, after.loanScale);
Number const roundedFullMgmtFee =
computeManagementFee(asset.raw(), roundedInterest, managementFeeRate, after.loanScale);
Number const roundedFullInterest = roundedInterest - roundedFullMgmtFee;
// Show both signed and unsigned deltas to highlight the underflow.
auto const nowSecs =
static_cast<std::uint32_t>(env.current()->parentCloseTime().time_since_epoch().count());
auto const startSecs =
static_cast<std::uint32_t>(after.startDate.time_since_epoch().count());
auto const lastPaymentDate = std::max(after.previousPaymentDate, startSecs);
auto const signedDelta =
static_cast<std::int64_t>(nowSecs) - static_cast<std::int64_t>(lastPaymentDate);
auto const unsignedDelta = static_cast<std::uint32_t>(nowSecs - lastPaymentDate);
log << "PoC window: prev=" << after.previousPaymentDate << " start=" << startSecs
<< " now=" << nowSecs << " signedDelta=" << signedDelta
<< " unsignedDelta=" << unsignedDelta << std::endl;
// Reference (clamped) computation: emulate a non-negative accrual
// window by clamping prevPaymentDate to 'now' for the full-pay path.
auto const prevClamped = std::min(after.previousPaymentDate, nowSecs);
auto const fullPaymentInterestClamped = computeFullPaymentInterest(
xrpl::detail::loanPrincipalFromPeriodicPayment(
env.current()->rules(),
after.periodicPayment,
periodicRate2,
after.paymentRemaining),
periodicRate2,
env.current()->parentCloseTime(),
after.paymentInterval,
prevClamped,
startSecs,
closeInterestRate);
auto const roundedInterestClamped =
roundToAsset(asset.raw(), fullPaymentInterestClamped, after.loanScale);
Number const roundedFullMgmtFeeClamped = computeManagementFee(
asset.raw(), roundedInterestClamped, managementFeeRate, after.loanScale);
Number const roundedFullInterestClamped =
roundedInterestClamped - roundedFullMgmtFeeClamped;
STAmount const fullDueClamped{
asset,
after.principalOutstanding + roundedFullInterestClamped + roundedFullMgmtFeeClamped +
closePaymentFee};
// Collect vault NAV before closing payment
auto const vaultId2 = brokerSle2 ? brokerSle2->at(sfVaultID) : uint256{};
auto const vaultKey2 = keylet::vault(vaultId2);
auto const vaultBefore = env.le(vaultKey2);
BEAST_EXPECT(vaultBefore);
Number const assetsTotalBefore = vaultBefore ? vaultBefore->at(sfAssetsTotal) : Number{};
STAmount const fullDue{
asset,
after.principalOutstanding + roundedFullInterest + roundedFullMgmtFee +
closePaymentFee};
log << "PoC payoff: principalOutstanding=" << after.principalOutstanding
<< " roundedFullInterest=" << roundedFullInterest
<< " roundedFullMgmtFee=" << roundedFullMgmtFee << " closeFee=" << closePaymentFee
<< " fullDue=" << to_string(fullDue.getJson()) << std::endl;
log << "PoC reference (clamped): roundedFullInterestClamped=" << roundedFullInterestClamped
<< " roundedFullMgmtFeeClamped=" << roundedFullMgmtFeeClamped
<< " fullDueClamped=" << to_string(fullDueClamped.getJson()) << std::endl;
env(pay(borrower, loanKeylet.key, fullDue), Txflags(tfLoanFullPayment));
env.close();
// Sanity: underflow present (unsigned delta very large relative to
// interval)
BEAST_EXPECT(unsignedDelta > after.paymentInterval);
// Compare vault NAV before/after the full close
auto const vaultAfter = env.le(vaultKey2);
BEAST_EXPECT(vaultAfter);
if (vaultAfter)
{
auto const assetsTotalAfter = vaultAfter->at(sfAssetsTotal);
log << "PoC NAV: assetsTotalBefore=" << assetsTotalBefore
<< " assetsTotalAfter=" << assetsTotalAfter
<< " delta=" << (assetsTotalAfter - assetsTotalBefore) << std::endl;
// Regression check: the underflowed window must be clamped so the
// payoff matches the non-underflow reference, i.e. no overcharge.
BEAST_EXPECT(fullDue == fullDueClamped);
if (fullDue != fullDueClamped)
log << "PoC delta: overcharge (fullDue > clamped)" << std::endl;
}
// Loan should be paid off
auto const finalLoan = env.le(loanKeylet);
BEAST_EXPECT(finalLoan);
if (finalLoan)
{
BEAST_EXPECT(finalLoan->at(sfPaymentRemaining) == 0);
BEAST_EXPECT(finalLoan->at(sfPrincipalOutstanding) == 0);
}
}
void
testRIPD3831(FeatureBitset features)
{
using namespace jtx;
testcase("RIPD-3831");
Account const issuer("issuer");
Account const lender("lender");
Account const borrower("borrower");
BrokerParameters const brokerParams{
.vaultDeposit = 100000,
.debtMax = 0,
.coverRateMin = TenthBips32{0},
// .managementFeeRate = TenthBips16{5919},
.coverRateLiquidation = TenthBips32{0}};
LoanParameters const loanParams{
.account = lender,
.counter = borrower,
.principalRequest = Number{200'000, -6},
.lateFee = Number{200, -6},
.interest = TenthBips32{50'000},
.payTotal = 10,
.payInterval = 150};
auto const assetType = AssetType::XRP;
Env env{*this, features};
auto loanResult =
createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
if (BEAST_EXPECT(loanResult); !loanResult.has_value())
return;
auto broker = std::get<BrokerInfo>(*loanResult);
auto loanKeylet = std::get<Keylet>(*loanResult);
using tp = NetClock::time_point;
using d = NetClock::duration;
auto state = getCurrentState(env, broker, loanKeylet);
if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan))
{
env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}});
}
topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee);
using namespace jtx::loan;
auto jv = pay(borrower, loanKeylet.key, drops(XRPAmount(state.totalValue)));
{
auto const submitParam = to_string(jv);
auto const jr = env.rpc("submit", borrower.name(), submitParam);
BEAST_EXPECT(jr.isMember(jss::result));
}
env.close();
// Make sure the system keeps responding
env(noop(borrower));
env.close();
env(noop(issuer));
env.close();
env(noop(lender));
env.close();
}
void
testRIPD3459(FeatureBitset features)
{
testcase("RIPD-3459 - LoanBroker incorrect debt total");
using namespace jtx;
Account const issuer("issuer");
Account const lender("lender");
Account const borrower("borrower");
BrokerParameters const brokerParams{
.vaultDeposit = 200'000,
.debtMax = 0,
.coverRateMin = TenthBips32{0},
.managementFeeRate = TenthBips16{500},
.coverRateLiquidation = TenthBips32{0}};
LoanParameters const loanParams{
.account = lender,
.counter = borrower,
.principalRequest = Number{100'000, -4},
.interest = TenthBips32{100'000},
.payTotal = 10};
auto const assetType = AssetType::MPT;
Env env{*this, features};
auto loanResult =
createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
if (BEAST_EXPECT(loanResult); !loanResult.has_value())
return;
auto broker = std::get<BrokerInfo>(*loanResult);
auto loanKeylet = std::get<Keylet>(*loanResult);
auto pseudoAcct = std::get<Account>(*loanResult);
VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle))
{
if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle))
{
BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding));
}
}
makeLoanPayments(
env,
broker,
loanParams,
loanKeylet,
verifyLoanStatus,
issuer,
lender,
borrower,
PaymentParameters{.showStepBalances = true});
if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle))
{
if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle))
{
BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding));
BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::kZero);
}
}
}
void
testRIPD3901()
{
testcase("Crash with tfLoanOverpayment");
using namespace jtx;
using namespace loan;
Account const lender{"lender"};
Account const issuer{"issuer"};
Account const borrower{"borrower"};
Account const depositor{"depositor"};
auto const txFee = Fee(XRP(100));
Env env(*this);
Vault const vault(env);
env.fund(XRP(10'000), lender, issuer, borrower, depositor);
env.close();
auto [tx, vaultKeyLet] = vault.create({.owner = lender, .asset = xrpIssue()});
env(tx, txFee);
env.close();
env(vault.deposit({.depositor = depositor, .id = vaultKeyLet.key, .amount = XRP(1'000)}),
txFee);
env.close();
auto const brokerKeyLet = keylet::loanBroker(lender.id(), env.seq(lender));
env(loan_broker::set(lender, vaultKeyLet.key), txFee);
env.close();
STAmount const debtMaximumRequest = XRPAmount(200'000);
env(set(borrower, brokerKeyLet.key, debtMaximumRequest),
Sig(sfCounterpartySignature, lender),
kInterestRate(TenthBips32(50'000)),
kPaymentTotal(2),
kPaymentInterval(150),
Txflags(tfLoanOverpayment),
txFee);
env.close();
std::uint32_t const loanSequence = 1;
auto const loanKeylet = keylet::loan(brokerKeyLet.key, loanSequence);
if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan))
{
env(loan::pay(borrower, loanKeylet.key, XRPAmount(150'001)),
Txflags(tfLoanOverpayment),
txFee);
env.close();
}
}
void
testRIPD3902(FeatureBitset features)
{
testcase("RIPD-3902 - 1 IOU loan payments");
using namespace jtx;
Account const issuer("issuer");
Account const lender("lender");
Account const borrower("borrower");
BrokerParameters const brokerParams{
.vaultDeposit = 10,
.debtMax = 0,
.coverRateMin = TenthBips32{0},
.managementFeeRate = TenthBips16{0},
.coverRateLiquidation = TenthBips32{0}};
LoanParameters const loanParams{
.account = lender,
.counter = borrower,
.principalRequest = Number{1, 0},
.interest = TenthBips32{100'000},
.payTotal = 5,
.payInterval = 150,
.gracePd = 60};
auto const assetType = AssetType::IOU;
Env env{*this, features};
auto loanResult =
createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
if (BEAST_EXPECT(loanResult); !loanResult.has_value())
return;
auto broker = std::get<BrokerInfo>(*loanResult);
auto loanKeylet = std::get<Keylet>(*loanResult);
auto pseudoAcct = std::get<Account>(*loanResult);
VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
makeLoanPayments(
env,
broker,
loanParams,
loanKeylet,
verifyLoanStatus,
issuer,
lender,
borrower,
PaymentParameters{.showStepBalances = true});
}
void
runAmendmentIndependent()
{
testRIPD3901();
}
// Tests run under each entry in amendmentCombinations().
void
runAmendmentSensitive(FeatureBitset features)
{
testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(features);
testRIPD3831(features);
testRIPD3459(features);
testRIPD3902(features);
}
public:
void
run() override
{
runAmendmentIndependent();
for (auto const& features : jtx::amendmentCombinations(
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
runAmendmentSensitive(features);
}
};
BEAST_DEFINE_TESTSUITE(LoanSecurity, tx, xrpl);
} // namespace xrpl::test

View File

@@ -0,0 +1,607 @@
#include <test/app/lending/LoanTestBase.h>
#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/flags.h>
#include <test/jtx/mpt.h>
#include <test/jtx/multisign.h>
#include <test/jtx/noop.h>
#include <test/jtx/pay.h>
#include <test/jtx/trust.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/Units.h>
#include <xrpl/protocol/XRPAmount.h>
#include <array>
#include <functional>
#include <utility>
#include <vector>
namespace xrpl::test {
class LoanSet_test : public LoanTestBase
{
private:
void
testLoanSet(FeatureBitset features)
{
using namespace jtx;
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
struct CaseArgs
{
bool requireAuth = false;
bool authorizeBorrower = false;
int initialXRP = 1'000'000;
};
auto const testCase = [&, this](
std::function<void(Env&, BrokerInfo const&, MPTTester&)> mptTest,
std::function<void(Env&, BrokerInfo const&)> iouTest,
CaseArgs args = {}) {
Env env(*this, features);
env.fund(XRP(args.initialXRP), issuer, lender, borrower);
env.close();
if (args.requireAuth)
{
env(fset(issuer, asfRequireAuth));
env.close();
}
// We need two different asset types, MPT and IOU. Prepare MPT
// first
MPTTester mptt{env, issuer, kMptInitNoFund};
auto const kNone = LedgerSpecificFlags(0);
mptt.create(
{.flags = tfMPTCanTransfer | tfMPTCanLock |
(args.requireAuth ? tfMPTRequireAuth : kNone)});
env.close();
PrettyAsset const mptAsset = mptt.issuanceID();
mptt.authorize({.account = lender});
mptt.authorize({.account = borrower});
env.close();
if (args.requireAuth)
{
mptt.authorize({.account = issuer, .holder = lender});
if (args.authorizeBorrower)
mptt.authorize({.account = issuer, .holder = borrower});
env.close();
}
env(pay(issuer, lender, mptAsset(10'000'000)));
env.close();
// Prepare IOU
PrettyAsset const iouAsset = issuer[iouCurrency_];
env(trust(lender, iouAsset(10'000'000)));
env(trust(borrower, iouAsset(10'000'000)));
env.close();
if (args.requireAuth)
{
env(trust(issuer, iouAsset(0), lender, tfSetfAuth));
env(pay(issuer, lender, iouAsset(10'000'000)));
if (args.authorizeBorrower)
{
env(trust(issuer, iouAsset(0), borrower, tfSetfAuth));
env(pay(issuer, borrower, iouAsset(10'000)));
}
}
else
{
env(pay(issuer, lender, iouAsset(10'000'000)));
env(pay(issuer, borrower, iouAsset(10'000)));
}
env.close();
// Create vaults and loan brokers
std::array const assets{mptAsset, iouAsset};
std::vector<BrokerInfo> brokers;
brokers.reserve(assets.size());
for (auto const& asset : assets)
{
brokers.emplace_back(createVaultAndBroker(env, asset, lender));
}
if (mptTest)
mptTest(env, brokers[0], mptt);
if (iouTest)
iouTest(env, brokers[1]);
};
testCase(
[&, this](Env& env, BrokerInfo const& broker, auto&) {
using namespace loan;
Number const principalRequest = broker.asset(1'000).value();
testcase("MPT issuer is borrower, issuer submits");
env(set(issuer, broker.brokerID, principalRequest),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5));
testcase("MPT issuer is borrower, lender submits");
env(set(lender, broker.brokerID, principalRequest),
kCounterparty(issuer),
Sig(sfCounterpartySignature, issuer),
Fee(env.current()->fees().base * 5));
},
[&, this](Env& env, BrokerInfo const& broker) {
using namespace loan;
Number const principalRequest = broker.asset(1'000).value();
testcase("IOU issuer is borrower, issuer submits");
env(set(issuer, broker.brokerID, principalRequest),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5));
testcase("IOU issuer is borrower, lender submits");
env(set(lender, broker.brokerID, principalRequest),
kCounterparty(issuer),
Sig(sfCounterpartySignature, issuer),
Fee(env.current()->fees().base * 5));
},
CaseArgs{.requireAuth = true});
testCase(
[&, this](Env& env, BrokerInfo const& broker, auto&) {
using namespace loan;
Number const principalRequest = broker.asset(1'000).value();
testcase("MPT unauthorized borrower, borrower submits");
env(set(borrower, broker.brokerID, principalRequest),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5),
Ter{tecNO_AUTH});
testcase("MPT unauthorized borrower, lender submits");
env(set(lender, broker.brokerID, principalRequest),
kCounterparty(borrower),
Sig(sfCounterpartySignature, borrower),
Fee(env.current()->fees().base * 5),
Ter{tecNO_AUTH});
},
[&, this](Env& env, BrokerInfo const& broker) {
using namespace loan;
Number const principalRequest = broker.asset(1'000).value();
testcase("IOU unauthorized borrower, borrower submits");
env(set(borrower, broker.brokerID, principalRequest),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5),
Ter{tecNO_AUTH});
testcase("IOU unauthorized borrower, lender submits");
env(set(lender, broker.brokerID, principalRequest),
kCounterparty(borrower),
Sig(sfCounterpartySignature, borrower),
Fee(env.current()->fees().base * 5),
Ter{tecNO_AUTH});
},
CaseArgs{.requireAuth = true});
auto const [acctReserve, incReserve] = [this]() -> std::pair<int, int> {
Env const env{*this, testableAmendments()};
return {
env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(),
env.current()->fees().increment.drops() / kDropsPerXrp.drops()};
}();
testCase(
[&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) {
using namespace loan;
Number const principalRequest = broker.asset(1'000).value();
testcase(
"MPT authorized borrower, borrower submits, borrower has "
"no reserve");
mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize});
env.close();
auto const mptoken = keylet::mptoken(mptt.issuanceID(), borrower);
auto const sleMPT1 = env.le(mptoken);
BEAST_EXPECT(sleMPT1 == nullptr);
// Burn some XRP
env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2))));
env.close();
// Cannot create loan, not enough reserve to create MPToken
env(set(borrower, broker.brokerID, principalRequest),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5),
Ter{tecINSUFFICIENT_RESERVE});
env.close();
// Can create loan now, will implicitly create MPToken
env(pay(issuer, borrower, XRP(incReserve)));
env.close();
env(set(borrower, broker.brokerID, principalRequest),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5));
env.close();
auto const sleMPT2 = env.le(mptoken);
BEAST_EXPECT(sleMPT2 != nullptr);
},
{},
CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1});
testCase(
{},
[&, this](Env& env, BrokerInfo const& broker) {
using namespace loan;
Number const principalRequest = broker.asset(1'000).value();
testcase(
"IOU authorized borrower, borrower submits, borrower has "
"no reserve");
// Remove trust line from borrower to issuer
env.trust(broker.asset(0), borrower);
env.close();
env(pay(borrower, issuer, broker.asset(10'000)));
env.close();
auto const trustline = keylet::trustLine(borrower, broker.asset.raw().get<Issue>());
auto const sleLine1 = env.le(trustline);
BEAST_EXPECT(sleLine1 == nullptr);
// Burn some XRP
env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2))));
env.close();
// Cannot create loan, not enough reserve to create trust line
env(set(borrower, broker.brokerID, principalRequest),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5),
Ter{tecNO_LINE_INSUF_RESERVE});
env.close();
// Can create loan now, will implicitly create trust line
env(pay(issuer, borrower, XRP(incReserve)));
env.close();
env(set(borrower, broker.brokerID, principalRequest),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5));
env.close();
auto const sleLine2 = env.le(trustline);
BEAST_EXPECT(sleLine2 != nullptr);
},
CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1});
testCase(
[&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) {
using namespace loan;
Number const principalRequest = broker.asset(1'000).value();
testcase(
"MPT authorized borrower, borrower submits, lender has "
"no reserve");
auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender);
auto const sleMPT1 = env.le(mptoken);
BEAST_EXPECT(sleMPT1 != nullptr);
env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount))));
env.close();
mptt.authorize({.account = lender, .flags = tfMPTUnauthorize});
env.close();
auto const sleMPT2 = env.le(mptoken);
BEAST_EXPECT(sleMPT2 == nullptr);
// Burn some XRP
env(noop(lender), Fee(XRP(incReserve)));
env.close();
// Cannot create loan, not enough reserve to create MPToken
env(set(borrower, broker.brokerID, principalRequest),
kLoanOriginationFee(broker.asset(1).value()),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5),
Ter{tecINSUFFICIENT_RESERVE});
env.close();
// Can create loan now, will implicitly create MPToken
env(pay(issuer, lender, XRP(incReserve)));
env.close();
env(set(borrower, broker.brokerID, principalRequest),
kLoanOriginationFee(broker.asset(1).value()),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5));
env.close();
auto const sleMPT3 = env.le(mptoken);
BEAST_EXPECT(sleMPT3 != nullptr);
},
{},
CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1});
testCase(
{},
[&, this](Env& env, BrokerInfo const& broker) {
using namespace loan;
Number const principalRequest = broker.asset(1'000).value();
testcase(
"IOU authorized borrower, borrower submits, lender has no "
"reserve");
// Remove trust line from lender to issuer
env.trust(broker.asset(0), lender);
env.close();
auto const trustline = keylet::trustLine(lender, broker.asset.raw().get<Issue>());
auto const sleLine1 = env.le(trustline);
BEAST_EXPECT(sleLine1 != nullptr);
env(pay(lender, issuer, broker.asset(abs(sleLine1->at(sfBalance).value()))));
env.close();
auto const sleLine2 = env.le(trustline);
BEAST_EXPECT(sleLine2 == nullptr);
// Burn some XRP
env(noop(lender), Fee(XRP(incReserve)));
env.close();
// Cannot create loan, not enough reserve to create trust line
env(set(borrower, broker.brokerID, principalRequest),
kLoanOriginationFee(broker.asset(1).value()),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5),
Ter{tecNO_LINE_INSUF_RESERVE});
env.close();
// Can create loan now, will implicitly create trust line
env(pay(issuer, lender, XRP(incReserve)));
env.close();
env(set(borrower, broker.brokerID, principalRequest),
kLoanOriginationFee(broker.asset(1).value()),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5));
env.close();
auto const sleLine3 = env.le(trustline);
BEAST_EXPECT(sleLine3 != nullptr);
},
CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1});
testCase(
[&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) {
using namespace loan;
Number const principalRequest = broker.asset(1'000).value();
testcase("MPT authorized borrower, unauthorized lender");
auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender);
auto const sleMPT1 = env.le(mptoken);
BEAST_EXPECT(sleMPT1 != nullptr);
env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount))));
env.close();
mptt.authorize({.account = lender, .flags = tfMPTUnauthorize});
env.close();
auto const sleMPT2 = env.le(mptoken);
BEAST_EXPECT(sleMPT2 == nullptr);
// Cannot create loan, lender not authorized to receive fee
env(set(borrower, broker.brokerID, principalRequest),
kLoanOriginationFee(broker.asset(1).value()),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5),
Ter{tecNO_AUTH});
env.close();
// Cannot create loan, even without an origination fee
env(set(borrower, broker.brokerID, principalRequest),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5),
Ter{tecNO_AUTH});
env.close();
// No MPToken for lender - no authorization and no payment
auto const sleMPT3 = env.le(mptoken);
BEAST_EXPECT(sleMPT3 == nullptr);
},
{},
CaseArgs{.requireAuth = true, .authorizeBorrower = true});
testCase(
[&, this](Env& env, BrokerInfo const& broker, auto&) {
using namespace loan;
Number const principalRequest = broker.asset(1'000).value();
testcase("MPT authorized borrower, borrower submits");
env(set(borrower, broker.brokerID, principalRequest),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5));
},
[&, this](Env& env, BrokerInfo const& broker) {
using namespace loan;
Number const principalRequest = broker.asset(1'000).value();
testcase("IOU authorized borrower, borrower submits");
env(set(borrower, broker.brokerID, principalRequest),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5));
},
CaseArgs{.requireAuth = true, .authorizeBorrower = true});
testCase(
[&, this](Env& env, BrokerInfo const& broker, auto&) {
using namespace loan;
Number const principalRequest = broker.asset(1'000).value();
testcase("MPT authorized borrower, lender submits");
env(set(lender, broker.brokerID, principalRequest),
kCounterparty(borrower),
Sig(sfCounterpartySignature, borrower),
Fee(env.current()->fees().base * 5));
},
[&, this](Env& env, BrokerInfo const& broker) {
using namespace loan;
Number const principalRequest = broker.asset(1'000).value();
testcase("IOU authorized borrower, lender submits");
env(set(lender, broker.brokerID, principalRequest),
kCounterparty(borrower),
Sig(sfCounterpartySignature, borrower),
Fee(env.current()->fees().base * 5));
},
CaseArgs{.requireAuth = true, .authorizeBorrower = true});
jtx::Account const alice{"alice"};
jtx::Account const bella{"bella"};
auto const msigSetup = [&](Env& env, Account const& account) {
json::Value const tx1 = signers(account, 2, {{alice, 1}, {bella, 1}});
env(tx1);
env.close();
};
testCase(
[&, this](Env& env, BrokerInfo const& broker, auto&) {
using namespace loan;
msigSetup(env, lender);
Number const principalRequest = broker.asset(1'000).value();
testcase(
"MPT authorized borrower, borrower submits, lender "
"multisign");
env(set(borrower, broker.brokerID, principalRequest),
kCounterparty(lender),
Msig(sfCounterpartySignature, alice, bella),
Fee(env.current()->fees().base * 5));
},
[&, this](Env& env, BrokerInfo const& broker) {
using namespace loan;
msigSetup(env, lender);
Number const principalRequest = broker.asset(1'000).value();
testcase(
"IOU authorized borrower, borrower submits, lender "
"multisign");
env(set(borrower, broker.brokerID, principalRequest),
kCounterparty(lender),
Msig(sfCounterpartySignature, alice, bella),
Fee(env.current()->fees().base * 5));
},
CaseArgs{.requireAuth = true, .authorizeBorrower = true});
testCase(
[&, this](Env& env, BrokerInfo const& broker, auto&) {
using namespace loan;
msigSetup(env, borrower);
Number const principalRequest = broker.asset(1'000).value();
testcase(
"MPT authorized borrower, lender submits, borrower "
"multisign");
env(set(lender, broker.brokerID, principalRequest),
kCounterparty(borrower),
Msig(sfCounterpartySignature, alice, bella),
Fee(env.current()->fees().base * 5));
},
[&, this](Env& env, BrokerInfo const& broker) {
using namespace loan;
msigSetup(env, borrower);
Number const principalRequest = broker.asset(1'000).value();
testcase(
"IOU authorized borrower, lender submits, borrower "
"multisign");
env(set(lender, broker.brokerID, principalRequest),
kCounterparty(borrower),
Msig(sfCounterpartySignature, alice, bella),
Fee(env.current()->fees().base * 5));
},
CaseArgs{.requireAuth = true, .authorizeBorrower = true});
testCase(
[&, this](Env& env, BrokerInfo const& broker, auto&) {
using namespace loan;
Number const principalRequest = broker.asset(1'000).value();
Vault const vault{env};
auto tx = vault.set({.owner = lender, .id = broker.vaultID});
tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit;
env(tx);
env.close();
testcase("Vault at maximum value");
env(set(issuer, broker.brokerID, principalRequest),
kCounterparty(lender),
kInterestRate(TenthBips32(10'000)),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5),
Ter(tecLIMIT_EXCEEDED));
},
nullptr);
testCase(
[&, this](Env& env, BrokerInfo const& broker, auto&) {
using namespace loan;
Number const principalRequest = broker.asset(1'000).value();
Vault const vault{env};
auto tx = vault.set({.owner = lender, .id = broker.vaultID});
tx[sfAssetsMaximum] =
BrokerParameters::defaults().vaultDeposit + broker.asset(1).number();
env(tx);
env.close();
testcase("Vault maximum value exceeded");
env(set(issuer, broker.brokerID, principalRequest),
kCounterparty(lender),
kInterestRate(TenthBips32(100'000)),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 5),
kPaymentTotal(2),
kPaymentInterval(3600 * 24),
Ter(tecLIMIT_EXCEEDED));
},
nullptr);
}
public:
void
run() override
{
for (auto const& features : jtx::amendmentCombinations(
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
testLoanSet(features);
}
};
BEAST_DEFINE_TESTSUITE(LoanSet, tx, xrpl);
} // namespace xrpl::test

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,558 @@
#include <test/app/lending/LoanTestBase.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/envconfig.h>
#include <test/jtx/fee.h>
#include <test/jtx/flags.h>
#include <test/jtx/jtx_json.h>
#include <test/jtx/mpt.h>
#include <test/jtx/pay.h>
#include <test/jtx/sponsor.h>
#include <test/jtx/ter.h>
#include <test/jtx/trust.h>
#include <test/jtx/txflags.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/json/json_value.h>
#include <xrpl/json/to_string.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/tx/transactors/lending/LoanSet.h>
#include <cstdint>
#include <optional>
namespace xrpl::test {
class LoanValidation_test : public LoanTestBase
{
private:
void
testDisabled()
{
testcase("Disabled");
// Lending Protocol depends on Single Asset Vault (SAV). Test
// combinations of the two amendments.
// Single Asset Vault depends on MPTokensV1, but don't test every combo
// of that.
using namespace jtx;
auto failAll = [this](FeatureBitset features) {
Env env(*this, features);
Account const alice{"alice"};
Account const bob{"bob"};
env.fund(XRP(10000), alice, bob);
auto const keylet = keylet::loanBroker(alice, env.seq(alice));
using namespace std::chrono_literals;
using namespace loan;
// counter party signature is optional on LoanSet. Confirm that by
// sending transaction without one.
auto setTx = env.jt(set(alice, keylet.key, Number(10000)), Ter(temDISABLED));
env(setTx);
// All loan transactions are disabled.
// 1. LoanSet
setTx = env.jt(setTx, Sig(sfCounterpartySignature, bob), Ter(temDISABLED));
env(setTx);
// Actual sequence will be based off the loan broker, but we
// obviously don't have one of those if the amendment is disabled
auto const loanKeylet = keylet::loan(keylet.key, env.seq(alice));
// Other Loan transactions are disabled, too.
// 2. LoanDelete
env(del(alice, loanKeylet.key), Ter(temDISABLED));
// 3. LoanManage
env(manage(alice, loanKeylet.key, tfLoanImpair), Ter(temDISABLED));
// 4. LoanPay
env(pay(alice, loanKeylet.key, XRP(500)), Ter(temDISABLED));
};
failAll(all_ - featureMPTokensV1);
failAll(all_ - featureSingleAssetVault - featureLendingProtocol);
failAll(all_ - featureSingleAssetVault);
failAll(all_ - featureLendingProtocol);
}
void
testInvalidLoanSet()
{
testcase("Invalid LoanSet");
using namespace jtx;
using namespace loan;
Account const lender{"lender"};
Account const issuer{"issuer"};
Account const borrower{"borrower"};
Account const sponsor{"sponsor"};
auto const iou = issuer["IOU"];
auto testWrapper = [&](auto&& test) {
Env env(*this);
env.fund(XRP(1'000), lender, issuer, borrower, sponsor);
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);
Number const debtMaximumRequest = brokerInfo.asset(1'000).value();
test(env, brokerInfo, loanSetFee, debtMaximumRequest);
};
// preflight:
testWrapper([&](Env& env,
BrokerInfo const& brokerInfo,
jtx::Fee const& loanSetFee,
Number const& debtMaximumRequest) {
for (auto const sponsorFlags : {spfSponsorReserve, spfSponsorReserve | spfSponsorFee})
{
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
sponsor::As(sponsor, sponsorFlags),
Sig(sfCounterpartySignature, lender),
loanSetFee,
Ter(temINVALID_FLAG));
}
// first temBAD_SIGNER: TODO
// invalid grace period
{
// zero grace period
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
Sig(sfCounterpartySignature, lender),
kGracePeriod(0),
loanSetFee,
Ter(temINVALID));
// grace period less than default minimum
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
Sig(sfCounterpartySignature, lender),
kGracePeriod(LoanSet::kDefaultGracePeriod - 1),
loanSetFee,
Ter(temINVALID));
// grace period greater than payment interval
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
Sig(sfCounterpartySignature, lender),
kPaymentInterval(120),
kGracePeriod(121),
loanSetFee,
Ter(temINVALID));
}
// empty/zero broker ID
{
auto jv = set(borrower, uint256{}, debtMaximumRequest);
auto testZeroBrokerID = [&](std::string const& id, std::uint32_t flags = 0) {
// empty broker ID
jv[sfLoanBrokerID] = id;
env(jv,
Sig(sfCounterpartySignature, lender),
loanSetFee,
Txflags(flags),
Ter(temINVALID));
};
// empty broker ID
testZeroBrokerID(std::string(""));
// zero broker ID
// needs a flag to distinguish the parsed STTx from the prior
// test
testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig);
}
// preflightCheckSigningKey() failure:
// can it happen? the signature is checked before transactor
// executes
JTx const tx = env.jt(
set(borrower, brokerInfo.brokerID, debtMaximumRequest),
Sig(sfCounterpartySignature, lender),
loanSetFee);
STTx local = *(tx.stx);
auto counterpartySig = local.getFieldObject(sfCounterpartySignature);
auto badPubKey = counterpartySig.getFieldVL(sfSigningPubKey);
badPubKey[20] ^= 0xAA;
counterpartySig.setFieldVL(sfSigningPubKey, badPubKey);
local.setFieldObject(sfCounterpartySignature, counterpartySig);
json::Value jvResult;
jvResult[jss::tx_blob] = strHex(local.getSerializer().slice());
auto res = env.rpc("json", "submit", to_string(jvResult))["result"];
BEAST_EXPECT(
res[jss::error] == "invalidTransaction" &&
res[jss::error_exception] ==
"fails local checks: Counterparty: Invalid signature.");
});
// preclaim:
testWrapper([&](Env& env,
BrokerInfo const& brokerInfo,
jtx::Fee const& loanSetFee,
Number const& debtMaximumRequest) {
// canAddHoldingFailure (IOU only, if MPT doesn't have
// MPTCanTransfer set, then can't create Vault/LoanBroker,
// and LoanSet will fail with different error
env(fclear(issuer, asfDefaultRipple));
env.close();
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
Sig(sfCounterpartySignature, lender),
loanSetFee,
Ter(terNO_RIPPLE));
});
// doApply:
testWrapper([&](Env& env,
BrokerInfo const& brokerInfo,
jtx::Fee const& loanSetFee,
Number const& debtMaximumRequest) {
auto const amt =
env.balance(borrower) - accountReserve(*env.current(), borrower.id(), env.journal);
env(pay(borrower, issuer, amt));
// tecINSUFFICIENT_RESERVE
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
Sig(sfCounterpartySignature, lender),
loanSetFee,
Ter(tecINSUFFICIENT_RESERVE));
// addEmptyHolding failure
env(pay(issuer, borrower, amt));
env(fset(issuer, asfGlobalFreeze));
env.close();
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
Sig(sfCounterpartySignature, lender),
loanSetFee,
Ter(tecFROZEN));
});
}
void
testInvalidLoanDelete()
{
testcase("Invalid LoanDelete");
using namespace jtx;
using namespace loan;
// preflight: temINVALID, LoanID == zero
{
Account const alice{"alice"};
Env env(*this);
env.fund(XRP(1'000), alice);
env.close();
env(del(alice, beast::kZero), Ter(temINVALID));
}
}
void
testInvalidLoanManage()
{
testcase("Invalid LoanManage");
using namespace jtx;
using namespace loan;
// preflight: temINVALID, LoanID == zero
{
Account const alice{"alice"};
Env env(*this);
env.fund(XRP(1'000), alice);
env.close();
env(manage(alice, beast::kZero, tfLoanDefault), Ter(temINVALID));
}
}
void
testInvalidLoanPay()
{
testcase("Invalid LoanPay");
using namespace jtx;
using namespace loan;
Account const lender{"lender"};
Account const issuer{"issuer"};
Account const borrower{"borrower"};
auto const iou = issuer["IOU"];
// preclaim
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 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();
std::uint32_t const loanSequence = 1;
auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence);
env(fset(issuer, asfGlobalFreeze));
env.close();
// preclaim: tecFROZEN
env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN));
env.close();
env(fclear(issuer, asfGlobalFreeze));
env.close();
auto const pseudoBroker = [&]() -> std::optional<Account> {
if (auto brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
BEAST_EXPECT(brokerSle))
{
return Account{"pseudo", brokerSle->at(sfAccount)};
}
return std::nullopt;
}();
if (!pseudoBroker)
return;
// Lender and pseudoaccount must both be frozen
env(trust(issuer, lender["IOU"](1'000), lender, tfSetFreeze | tfSetDeepFreeze));
env(trust(
issuer, (*pseudoBroker)["IOU"](1'000), *pseudoBroker, tfSetFreeze | tfSetDeepFreeze));
env.close();
// preclaim: tecFROZEN due to deep frozen
env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN));
env.close();
// Only one needs to be unfrozen
env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze));
env.close();
// The payment is late by this point
env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecEXPIRED));
env.close();
env(pay(borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment));
env.close();
// preclaim: tecKILLED
// note that tecKILLED in loanMakePayment()
// doesn't happen because of the preclaim check.
env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecKILLED));
}
void
testRequireAuth()
{
testcase("Require Auth - Implicit Pseudo-account authorization");
using namespace jtx;
using namespace loan;
Account const lender{"lender"};
Account const issuer{"issuer"};
Account const borrower{"borrower"};
Env env(*this);
env.fund(XRP(100'000), issuer, lender, borrower);
env.close();
auto asset = MPTTester({
.env = env,
.issuer = issuer,
.holders = {lender, borrower},
.flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock,
.authHolder = true,
});
env(pay(issuer, lender, asset(5'000'000)));
BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)};
auto const loanSetFee = Fee(env.current()->fees().base * 2);
STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value();
auto forUnauthAuth = [&](auto&& doTx) {
for (auto const flag : {tfMPTUnauthorize, 0u})
{
asset.authorize({.account = issuer, .holder = borrower, .flags = flag});
env.close();
doTx(flag == 0);
env.close();
}
};
// Can't create a loan if the borrower is not authorized
forUnauthAuth([&](bool authorized) {
auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS);
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
Sig(sfCounterpartySignature, lender),
loanSetFee,
err);
});
static constexpr std::uint32_t kLoanSequence = 1;
auto const loanKeylet = keylet::loan(brokerInfo.brokerID, kLoanSequence);
// Can't loan pay if the borrower is not authorized
forUnauthAuth([&](bool authorized) {
auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS);
env(pay(borrower, loanKeylet.key, debtMaximumRequest), err);
});
}
void
testLimitExceeded()
{
testcase("RIPD-4125 - overpayment");
using namespace jtx;
Account const issuer("issuer");
Account const lender("lender");
Account const borrower("borrower");
BrokerParameters const brokerParams{
.vaultDeposit = 100'000,
.debtMax = 0,
.coverRateMin = TenthBips32{0},
.managementFeeRate = TenthBips16{0},
.coverRateLiquidation = TenthBips32{0}};
LoanParameters const loanParams{
.account = lender,
.counter = borrower,
.principalRequest = Number{200000, -6},
.interest = TenthBips32{50000},
.payTotal = 3,
.payInterval = 200,
.gracePd = 60,
.flags = tfLoanOverpayment,
};
auto const assetType = AssetType::XRP;
Env env(*this, makeConfig(), all_, nullptr, beast::Severity::Warning);
auto loanResult =
createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
if (BEAST_EXPECT(loanResult); !loanResult.has_value())
return;
auto broker = std::get<BrokerInfo>(*loanResult);
auto loanKeylet = std::get<Keylet>(*loanResult);
auto pseudoAcct = std::get<Account>(*loanResult);
VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
auto const state = getCurrentState(env, broker, loanKeylet);
env(loan::pay(
borrower,
loanKeylet.key,
STAmount{broker.asset, state.periodicPayment * 3 / 2 + 1},
tfLoanOverpayment));
env.close();
PaymentParameters const paymentParams{
.showStepBalances = false,
.validateBalances = true,
};
makeLoanPayments(
env,
broker,
loanParams,
loanKeylet,
verifyLoanStatus,
issuer,
lender,
borrower,
paymentParams);
}
void
testWrongMaxDebtBehavior(FeatureBitset features)
{
// From FIND-003
testcase << "Wrong Max Debt Behavior";
using namespace jtx;
using namespace std::chrono_literals;
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
BrokerParameters const brokerParams{.debtMax = 0};
env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, noripple(lender));
env.close();
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
BEAST_EXPECT(brokerSle))
{
BEAST_EXPECT(brokerSle->at(sfDebtMaximum) == 0);
}
using namespace loan;
auto const loanSetFee = Fee(env.current()->fees().base * 2);
Number const principalRequest{1, 3};
auto createJson = env.json(set(lender, broker.brokerID, principalRequest), Fee(loanSetFee));
json::Value counterpartyJson{json::ValueType::Object};
counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature];
counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey];
if (!BEAST_EXPECT(!createJson.isMember(jss::Signers)))
counterpartyJson[sfSigners] = createJson[sfSigners];
createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson));
env(createJson);
env.close();
}
void
runAmendmentIndependent()
{
testDisabled();
testInvalidLoanSet();
testInvalidLoanDelete();
testInvalidLoanManage();
testInvalidLoanPay();
testRequireAuth();
testLimitExceeded();
}
// Tests run under each entry in amendmentCombinations().
void
runAmendmentSensitive(FeatureBitset features)
{
testWrongMaxDebtBehavior(features);
}
public:
void
run() override
{
runAmendmentIndependent();
for (auto const& features : jtx::amendmentCombinations(
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
runAmendmentSensitive(features);
}
};
BEAST_DEFINE_TESTSUITE(LoanValidation, tx, xrpl);
} // namespace xrpl::test

View File

@@ -0,0 +1,46 @@
#include <xrpl/beast/unit_test/global_suites.h>
#include <xrpl/beast/unit_test/suite.h>
#include <algorithm>
#include <array>
#include <string_view>
namespace xrpl::test {
/**
* Aggregator: running this suite ("Loan") reruns every topical Loan/Lending
* suite in one invocation. Each member suite below remains independently
* runnable under its own name. Declared manual so an unfiltered full test
* run doesn't execute every case twice.
*/
class Loan_test : public beast::unit_test::Suite
{
void
run() override
{
static constexpr std::array<std::string_view, 12> kMembers{
"LendingHelpers",
"LoanBroker",
"LoanCashBasis",
"LoanCoverFreezeAuth",
"LoanInvariants",
"LoanLifecycle",
"LoanMisc",
"LoanPay",
"LoanRounding",
"LoanSecurity",
"LoanSet",
"LoanValidation",
};
for (auto const& info : beast::unit_test::globalSuites())
{
if (std::ranges::find(kMembers, info.name()) != kMembers.end())
info.run(runner());
}
}
};
BEAST_DEFINE_TESTSUITE_MANUAL(Loan, tx, xrpl);
} // namespace xrpl::test