mirror of
https://github.com/XRPLF/rippled.git
synced 2026-09-27 15:28:03 +00:00
1120 lines
47 KiB
C++
1120 lines
47 KiB
C++
#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/basics/base_uint.h>
|
|
#include <xrpl/basics/chrono.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/Protocol.h>
|
|
#include <xrpl/protocol/SField.h>
|
|
#include <xrpl/protocol/SeqProxy.h>
|
|
#include <xrpl/protocol/TER.h>
|
|
#include <xrpl/protocol/TxFlags.h>
|
|
#include <xrpl/protocol/Units.h>
|
|
#include <xrpl/protocol/XRPAmount.h>
|
|
#include <xrpl/tx/transactors/lending/LoanSet.h>
|
|
|
|
#include <array>
|
|
#include <cstdint>
|
|
#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);
|
|
}
|
|
|
|
// Two-step LoanSet scenarios where preclaim / doApply semantics diverge
|
|
// from the one-step (immediate) flow. Cases where both flows behave
|
|
// identically are already covered by testLoanSet -- this method only
|
|
// exercises the divergences:
|
|
//
|
|
// * proposal is submitted by the broker owner, naming the borrower
|
|
// via kBorrower / kStartDate (no CounterpartySignature);
|
|
// * the pending loan reserves an owner slot on the broker owner, so
|
|
// the lender's reserve is checked at LoanSet time rather than at
|
|
// disbursement;
|
|
// * the borrower's holding reserve (MPToken / trust line) is deferred
|
|
// to LoanAccept, yielding tecINSUFFICIENT_RESERVE /
|
|
// tecNO_LINE_INSUF_RESERVE on accept rather than on set.
|
|
//
|
|
// Requires featureLendingProtocolV1_1.
|
|
void
|
|
testTwoStepLoanSet()
|
|
{
|
|
using namespace jtx;
|
|
using namespace jtx::loan;
|
|
using namespace std::chrono_literals;
|
|
|
|
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;
|
|
};
|
|
|
|
// Same shape as testLoanSet's harness, duplicated here so the two
|
|
// methods stay independent.
|
|
auto const testCase = [&, this](
|
|
std::function<void(Env&, BrokerInfo const&, MPTTester&)> mptTest,
|
|
std::function<void(Env&, BrokerInfo const&)> iouTest,
|
|
CaseArgs args = {}) {
|
|
Env env(*this);
|
|
BEAST_EXPECT(env.enabled(featureLendingProtocolV1_1));
|
|
env.fund(XRP(args.initialXRP), issuer, lender, borrower);
|
|
env.close();
|
|
if (args.requireAuth)
|
|
{
|
|
env(fset(issuer, asfRequireAuth));
|
|
env.close();
|
|
}
|
|
|
|
// MPT
|
|
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();
|
|
|
|
// 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();
|
|
|
|
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]);
|
|
};
|
|
|
|
// Submit a two-step LoanSet proposal: the LoanBroker owner (lender)
|
|
// proposes the loan naming `theBorrower`. Returns the keylet of the
|
|
// pending Loan so the caller can drive a follow-up LoanAccept.
|
|
auto const submitSet = [&](Env& env,
|
|
BrokerInfo const& broker,
|
|
Account const& theBorrower,
|
|
Number const& principalRequest,
|
|
auto const&... extras) -> uint256 {
|
|
auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
|
|
auto const loanKey =
|
|
keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)))
|
|
.key;
|
|
std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count();
|
|
env(set(lender, broker.brokerID, principalRequest),
|
|
kBorrower(theBorrower),
|
|
kStartDate(startDate),
|
|
Fee(env.current()->fees().base * 5),
|
|
extras...);
|
|
return loanKey;
|
|
};
|
|
|
|
// Issuer is the borrower: the broker owner (lender) proposes on
|
|
// behalf of the issuer. Only the "lender submits" shape exists in
|
|
// two-step; the one-step "issuer submits" and "issuer=borrower is
|
|
// the same signer" cases don't apply.
|
|
testCase(
|
|
[&](Env& env, BrokerInfo const& broker, auto&) {
|
|
Number const principalRequest = broker.asset(1'000).value();
|
|
testcase("Two-step: MPT issuer is borrower, lender submits");
|
|
submitSet(env, broker, issuer, principalRequest);
|
|
},
|
|
[&](Env& env, BrokerInfo const& broker) {
|
|
Number const principalRequest = broker.asset(1'000).value();
|
|
testcase("Two-step: IOU issuer is borrower, lender submits");
|
|
submitSet(env, broker, issuer, principalRequest);
|
|
},
|
|
CaseArgs{.requireAuth = true});
|
|
|
|
// Unauthorized borrower is rejected at LoanSet preclaim (WeakAuth
|
|
// requireAuth on the named Borrower).
|
|
testCase(
|
|
[&](Env& env, BrokerInfo const& broker, auto&) {
|
|
Number const principalRequest = broker.asset(1'000).value();
|
|
testcase("Two-step: MPT unauthorized borrower, lender submits");
|
|
submitSet(env, broker, borrower, principalRequest, Ter{tecNO_AUTH});
|
|
},
|
|
[&](Env& env, BrokerInfo const& broker) {
|
|
Number const principalRequest = broker.asset(1'000).value();
|
|
testcase("Two-step: IOU unauthorized borrower, lender submits");
|
|
submitSet(env, broker, borrower, principalRequest, 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()};
|
|
}();
|
|
|
|
// Borrower has no reserve: LoanSet succeeds (the broker owner
|
|
// carries the pending-loan reserve), the borrower's holding-reserve
|
|
// check is deferred to LoanAccept.
|
|
testCase(
|
|
[&](Env& env, BrokerInfo const& broker, MPTTester& mptt) {
|
|
Number const principalRequest = broker.asset(1'000).value();
|
|
|
|
testcase("Two-step: MPT authorized borrower, borrower has no reserve");
|
|
mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize});
|
|
env.close();
|
|
|
|
auto const mptoken = keylet::mptoken(mptt.issuanceID(), borrower);
|
|
BEAST_EXPECT(env.le(mptoken) == nullptr);
|
|
|
|
// Burn borrower XRP so it cannot afford the LoanAccept
|
|
// MPToken reserve.
|
|
env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2))));
|
|
env.close();
|
|
|
|
// Top up the lender so the proposal's owner slot fits.
|
|
env(pay(issuer, lender, XRP(incReserve)));
|
|
env.close();
|
|
|
|
auto const loanKey = submitSet(env, broker, borrower, principalRequest);
|
|
env.close();
|
|
|
|
env(accept(borrower, loanKey), Ter{tecINSUFFICIENT_RESERVE});
|
|
env.close();
|
|
|
|
env(pay(issuer, borrower, XRP(incReserve)));
|
|
env.close();
|
|
env(accept(borrower, loanKey));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.le(mptoken) != nullptr);
|
|
},
|
|
{},
|
|
CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1});
|
|
|
|
testCase(
|
|
{},
|
|
[&](Env& env, BrokerInfo const& broker) {
|
|
Number const principalRequest = broker.asset(1'000).value();
|
|
|
|
testcase("Two-step: IOU authorized borrower, borrower has no reserve");
|
|
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>());
|
|
BEAST_EXPECT(env.le(trustline) == nullptr);
|
|
|
|
env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2))));
|
|
env.close();
|
|
|
|
// Top up the lender so the proposal's owner slot fits.
|
|
env(pay(issuer, lender, XRP(incReserve)));
|
|
env.close();
|
|
|
|
auto const loanKey = submitSet(env, broker, borrower, principalRequest);
|
|
env.close();
|
|
|
|
env(accept(borrower, loanKey), Ter{tecNO_LINE_INSUF_RESERVE});
|
|
env.close();
|
|
|
|
env(pay(issuer, borrower, XRP(incReserve)));
|
|
env.close();
|
|
env(accept(borrower, loanKey));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.le(trustline) != nullptr);
|
|
},
|
|
CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1});
|
|
|
|
// Lender has no reserve: the pending-loan owner slot on the broker
|
|
// owner cannot be reserved, so LoanSet itself fails with the
|
|
// generic tecINSUFFICIENT_RESERVE (regardless of asset type).
|
|
testCase(
|
|
[&](Env& env, BrokerInfo const& broker, MPTTester& mptt) {
|
|
Number const principalRequest = broker.asset(1'000).value();
|
|
|
|
testcase("Two-step: MPT authorized borrower, 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();
|
|
BEAST_EXPECT(env.le(mptoken) == nullptr);
|
|
|
|
env(noop(lender), Fee(XRP(incReserve)));
|
|
env.close();
|
|
|
|
submitSet(
|
|
env,
|
|
broker,
|
|
borrower,
|
|
principalRequest,
|
|
kLoanOriginationFee(broker.asset(1).value()),
|
|
Ter{tecINSUFFICIENT_RESERVE});
|
|
env.close();
|
|
|
|
env(pay(issuer, lender, XRP(incReserve)));
|
|
env.close();
|
|
auto const loanKey = submitSet(
|
|
env,
|
|
broker,
|
|
borrower,
|
|
principalRequest,
|
|
kLoanOriginationFee(broker.asset(1).value()));
|
|
env.close();
|
|
env(accept(borrower, loanKey));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.le(mptoken) != nullptr);
|
|
},
|
|
{},
|
|
CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1});
|
|
|
|
testCase(
|
|
{},
|
|
[&](Env& env, BrokerInfo const& broker) {
|
|
Number const principalRequest = broker.asset(1'000).value();
|
|
|
|
testcase("Two-step: IOU authorized borrower, lender has no reserve");
|
|
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();
|
|
BEAST_EXPECT(env.le(trustline) == nullptr);
|
|
|
|
env(noop(lender), Fee(XRP(incReserve)));
|
|
env.close();
|
|
|
|
// Note: one-step returns tecNO_LINE_INSUF_RESERVE here;
|
|
// two-step's reserveLoanOwner on the pending loan trips
|
|
// first with the generic tecINSUFFICIENT_RESERVE.
|
|
submitSet(
|
|
env,
|
|
broker,
|
|
borrower,
|
|
principalRequest,
|
|
kLoanOriginationFee(broker.asset(1).value()),
|
|
Ter{tecINSUFFICIENT_RESERVE});
|
|
env.close();
|
|
|
|
env(pay(issuer, lender, XRP(incReserve)));
|
|
env.close();
|
|
auto const loanKey = submitSet(
|
|
env,
|
|
broker,
|
|
borrower,
|
|
principalRequest,
|
|
kLoanOriginationFee(broker.asset(1).value()));
|
|
env.close();
|
|
env(accept(borrower, loanKey));
|
|
env.close();
|
|
|
|
BEAST_EXPECT(env.le(trustline) != nullptr);
|
|
},
|
|
CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1});
|
|
}
|
|
|
|
// LoanSet in a closed-ended vault — phase gating and maturity bound.
|
|
void
|
|
testLoanSetClosedEnded()
|
|
{
|
|
testcase("LoanSet closed-ended: phase and maturity bound");
|
|
using namespace jtx;
|
|
using namespace loan;
|
|
using d = NetClock::duration;
|
|
using tp = NetClock::time_point;
|
|
|
|
Account const issuer{"issuer"};
|
|
Account const lender{"lender"};
|
|
Account const borrower{"borrower"};
|
|
|
|
// Common loan schedule used by the phase-rejection cases below.
|
|
constexpr std::uint32_t kInterval = 3600u * 24u; // 1 day
|
|
constexpr std::uint32_t kTotal = 2u;
|
|
|
|
// featureLendingProtocolV1_1 is excluded from `all_` by convention (see the comment on
|
|
// `all_`), so callers must opt in. Closed-ended vaults are gated on this amendment; without
|
|
// it VaultCreate returns temDISABLED and every follow-on txn sees tecNO_ENTRY.
|
|
auto const withEnv = [&, this](auto&& body) {
|
|
Env env(*this, testableAmendments() | featureLendingProtocolV1_1);
|
|
env.fund(XRP(1'000'000'000), issuer, lender, borrower);
|
|
env.close();
|
|
PrettyAsset const asset{xrpIssue(), 1'000'000};
|
|
body(env, asset);
|
|
};
|
|
|
|
auto const setLoan = [&](Env& env, BrokerInfo const& broker, TER expected) {
|
|
env(set(lender, broker.brokerID, broker.asset(100).value()),
|
|
kCounterparty(borrower),
|
|
Sig(sfCounterpartySignature, borrower),
|
|
Fee(env.current()->fees().base * 5),
|
|
kPaymentTotal(kTotal),
|
|
kPaymentInterval(kInterval),
|
|
Ter(expected));
|
|
env.close();
|
|
};
|
|
|
|
// 1. Rejected during Subscription: the broker is created in Subscription (skipPhaseAdvance
|
|
// = true), then LoanSet is attempted before advancing past SubscriptionDate.
|
|
withEnv([&](Env& env, PrettyAsset const& asset) {
|
|
auto const broker = createVaultAndBroker(
|
|
env,
|
|
asset,
|
|
lender,
|
|
BrokerParameters{.vaultKind = VaultKind::ClosedEnded, .skipPhaseAdvance = true});
|
|
setLoan(env, broker, tecTOO_SOON);
|
|
});
|
|
|
|
// 2. Rejected during Redemption: broker is set up normally (which lands the vault in
|
|
// Investment), then advance the clock past RedemptionDate before attempting LoanSet.
|
|
withEnv([&](Env& env, PrettyAsset const& asset) {
|
|
auto const broker = createVaultAndBroker(
|
|
env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded});
|
|
BEAST_EXPECT(broker.redemptionDate.has_value());
|
|
using d = NetClock::duration;
|
|
using tp = NetClock::time_point;
|
|
env.close(tp{d{*broker.redemptionDate + 1}});
|
|
setLoan(env, broker, tecEXPIRED);
|
|
});
|
|
|
|
// 3. Accepted during Investment when the schedule comfortably fits before RedemptionDate.
|
|
withEnv([&](Env& env, PrettyAsset const& asset) {
|
|
auto const broker = createVaultAndBroker(
|
|
env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded});
|
|
setLoan(env, broker, tesSUCCESS);
|
|
});
|
|
|
|
// 4. Rejected during Investment when the loan's final payment would land fewer than
|
|
// kLoanRedemptionBuffer seconds before RedemptionDate. Use a tight redemptionOffset and a
|
|
// schedule whose final payment is well past that boundary.
|
|
withEnv([&](Env& env, PrettyAsset const& asset) {
|
|
constexpr std::uint32_t kRedemptionOffset = 3u * 24u * 3600u;
|
|
auto const broker = createVaultAndBroker(
|
|
env,
|
|
asset,
|
|
lender,
|
|
BrokerParameters{
|
|
.vaultKind = VaultKind::ClosedEnded, .redemptionOffset = kRedemptionOffset});
|
|
env(set(lender, broker.brokerID, broker.asset(100).value()),
|
|
kCounterparty(borrower),
|
|
Sig(sfCounterpartySignature, borrower),
|
|
Fee(env.current()->fees().base * 5),
|
|
kPaymentTotal(10u),
|
|
kPaymentInterval(kInterval),
|
|
Ter(tecNO_PERMISSION));
|
|
env.close();
|
|
});
|
|
|
|
// 5. Boundary: a finalPayment exactly kLoanRedemptionBuffer seconds before
|
|
// RedemptionDate is accepted; one second later is rejected. Uses payTotal = 1 so
|
|
// finalPayment = startDate + interval.
|
|
withEnv([&](Env& env, PrettyAsset const& asset) {
|
|
auto const broker = createVaultAndBroker(
|
|
env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded});
|
|
BEAST_EXPECT(broker.redemptionDate.has_value());
|
|
|
|
auto const startDate = env.now().time_since_epoch().count();
|
|
auto const acceptInterval = *broker.redemptionDate - kLoanRedemptionBuffer - startDate;
|
|
env(set(lender, broker.brokerID, broker.asset(100).value()),
|
|
kCounterparty(borrower),
|
|
Sig(sfCounterpartySignature, borrower),
|
|
Fee(env.current()->fees().base * 5),
|
|
kPaymentTotal(1u),
|
|
kPaymentInterval(acceptInterval),
|
|
Ter(tesSUCCESS));
|
|
env.close();
|
|
|
|
auto const rejectInterval = *broker.redemptionDate - (kLoanRedemptionBuffer - 1) -
|
|
env.now().time_since_epoch().count();
|
|
env(set(lender, broker.brokerID, broker.asset(100).value()),
|
|
kCounterparty(borrower),
|
|
Sig(sfCounterpartySignature, borrower),
|
|
Fee(env.current()->fees().base * 5),
|
|
kPaymentTotal(1u),
|
|
kPaymentInterval(rejectInterval),
|
|
Ter(tecNO_PERMISSION));
|
|
env.close();
|
|
});
|
|
|
|
// 6. A vault whose Investment window is exactly kMinInvestmentPeriod can originate a
|
|
// minimum-interval, single-payment loan at the start of Investment, and rejects the same
|
|
// schedule once StartDate no longer leaves kLoanRedemptionBuffer before RedemptionDate.
|
|
// Do not pin an unrounded wall-clock instant: Env::close rounds to the close-time
|
|
// resolution. Read env.now() (the same clock LoanSet::preclaim uses) and assert the
|
|
// buffer relationship before each LoanSet.
|
|
withEnv([&](Env& env, PrettyAsset const& asset) {
|
|
auto const broker = createVaultAndBroker(
|
|
env,
|
|
asset,
|
|
lender,
|
|
BrokerParameters{
|
|
.vaultKind = VaultKind::ClosedEnded,
|
|
.subscriptionOffset = 300u,
|
|
.redemptionOffset = kMinInvestmentPeriod,
|
|
.skipPhaseAdvance = true});
|
|
BEAST_EXPECT(broker.subscriptionDate.has_value());
|
|
BEAST_EXPECT(broker.redemptionDate.has_value());
|
|
|
|
auto const red = *broker.redemptionDate;
|
|
auto const startDate = [&]() { return env.now().time_since_epoch().count(); };
|
|
auto const minLoan = [&](TER expected) {
|
|
env(set(lender, broker.brokerID, broker.asset(100).value()),
|
|
kCounterparty(borrower),
|
|
Sig(sfCounterpartySignature, borrower),
|
|
Fee(env.current()->fees().base * 5),
|
|
kPaymentTotal(1u),
|
|
kPaymentInterval(LoanSet::kMinPaymentInterval),
|
|
Ter(expected));
|
|
env.close();
|
|
};
|
|
|
|
// First Investment ledger: the minimum schedule still clears the buffer.
|
|
env.close(tp{d{*broker.subscriptionDate + 1}});
|
|
BEAST_EXPECT(startDate() > *broker.subscriptionDate);
|
|
BEAST_EXPECT(startDate() + LoanSet::kMinPaymentInterval + kLoanRedemptionBuffer <= red);
|
|
minLoan(tesSUCCESS);
|
|
|
|
// Still Investment, but the minimum schedule no longer clears the buffer.
|
|
while (startDate() + LoanSet::kMinPaymentInterval + kLoanRedemptionBuffer <= red)
|
|
env.close();
|
|
BEAST_EXPECT(startDate() < red);
|
|
minLoan(tecNO_PERMISSION);
|
|
});
|
|
}
|
|
|
|
public:
|
|
void
|
|
run() override
|
|
{
|
|
for (auto const& features : jtx::amendmentCombinations(
|
|
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
|
|
testLoanSet(features);
|
|
|
|
testTwoStepLoanSet();
|
|
testLoanSetClosedEnded();
|
|
}
|
|
};
|
|
|
|
BEAST_DEFINE_TESTSUITE(LoanSet, tx, xrpl);
|
|
|
|
} // namespace xrpl::test
|