mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-21 14:20:56 +00:00
test: Split Loan_test.cpp into topical suites (#7864)
Co-authored-by: Ayaz Salikhov <mathbunnyru@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
1013
src/test/app/lending/LoanCashBasis_test.cpp
Normal file
1013
src/test/app/lending/LoanCashBasis_test.cpp
Normal file
File diff suppressed because it is too large
Load Diff
722
src/test/app/lending/LoanCoverFreezeAuth_test.cpp
Normal file
722
src/test/app/lending/LoanCoverFreezeAuth_test.cpp
Normal 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
|
||||
873
src/test/app/lending/LoanInvariants_test.cpp
Normal file
873
src/test/app/lending/LoanInvariants_test.cpp
Normal 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
|
||||
689
src/test/app/lending/LoanLifecycle_test.cpp
Normal file
689
src/test/app/lending/LoanLifecycle_test.cpp
Normal 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
|
||||
561
src/test/app/lending/LoanMisc_test.cpp
Normal file
561
src/test/app/lending/LoanMisc_test.cpp
Normal 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
|
||||
760
src/test/app/lending/LoanPay_test.cpp
Normal file
760
src/test/app/lending/LoanPay_test.cpp
Normal 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
|
||||
993
src/test/app/lending/LoanRounding_test.cpp
Normal file
993
src/test/app/lending/LoanRounding_test.cpp
Normal 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
|
||||
538
src/test/app/lending/LoanSecurity_test.cpp
Normal file
538
src/test/app/lending/LoanSecurity_test.cpp
Normal 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
|
||||
607
src/test/app/lending/LoanSet_test.cpp
Normal file
607
src/test/app/lending/LoanSet_test.cpp
Normal 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
|
||||
2949
src/test/app/lending/LoanTestBase.h
Normal file
2949
src/test/app/lending/LoanTestBase.h
Normal file
File diff suppressed because it is too large
Load Diff
558
src/test/app/lending/LoanValidation_test.cpp
Normal file
558
src/test/app/lending/LoanValidation_test.cpp
Normal 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
|
||||
46
src/test/app/lending/Loan_test.cpp
Normal file
46
src/test/app/lending/Loan_test.cpp
Normal 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
|
||||
Reference in New Issue
Block a user