mirror of
https://github.com/XRPLF/rippled.git
synced 2026-04-29 15:37:57 +00:00
7303 lines
289 KiB
C++
7303 lines
289 KiB
C++
#include <xrpl/beast/unit_test/suite.h>
|
||
//
|
||
#include <test/jtx.h>
|
||
#include <test/jtx/mpt.h>
|
||
|
||
#include <xrpl/beast/xor_shift_engine.h>
|
||
#include <xrpl/protocol/SField.h>
|
||
#include <xrpl/server/LoadFeeTrack.h>
|
||
#include <xrpl/tx/transactors/lending/LendingHelpers.h>
|
||
#include <xrpl/tx/transactors/lending/LoanSet.h>
|
||
#include <xrpl/tx/transactors/system/Batch.h>
|
||
|
||
#include <chrono>
|
||
|
||
namespace xrpl {
|
||
namespace test {
|
||
|
||
class Loan_test : public beast::unit_test::suite
|
||
{
|
||
protected:
|
||
// Ensure that all the features needed for Lending Protocol are included,
|
||
// even if they are set to unsupported.
|
||
FeatureBitset const all{
|
||
jtx::testable_amendments() | featureMPTokensV1 | featureSingleAssetVault |
|
||
featureLendingProtocol};
|
||
|
||
std::string const iouCurrency{"IOU"};
|
||
|
||
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);
|
||
}
|
||
|
||
struct BrokerParameters
|
||
{
|
||
Number vaultDeposit = 1'000'000;
|
||
Number debtMax = 25'000;
|
||
TenthBips32 coverRateMin = percentageToTenthBips(10);
|
||
int coverDeposit = 1000;
|
||
TenthBips16 managementFeeRate{100};
|
||
TenthBips32 coverRateLiquidation = percentageToTenthBips(25);
|
||
std::string data = {}; // NOLINT(readability-redundant-member-init)
|
||
std::uint32_t flags = 0;
|
||
|
||
Number
|
||
maxCoveredLoanValue(Number const& currentDebt) const
|
||
{
|
||
NumberRoundModeGuard mg(Number::downward);
|
||
auto debtLimit = coverDeposit * tenthBipsPerUnity.value() / coverRateMin.value();
|
||
|
||
return debtLimit - currentDebt;
|
||
}
|
||
|
||
static BrokerParameters const&
|
||
defaults()
|
||
{
|
||
static BrokerParameters const result{};
|
||
return result;
|
||
}
|
||
|
||
// TODO: create an operator() which returns a transaction similar to
|
||
// LoanParameters
|
||
};
|
||
|
||
struct BrokerInfo
|
||
{
|
||
jtx::PrettyAsset asset;
|
||
uint256 brokerID;
|
||
uint256 vaultID;
|
||
BrokerParameters params;
|
||
BrokerInfo(
|
||
jtx::PrettyAsset const& asset_,
|
||
Keylet const& brokerKeylet_,
|
||
Keylet const& vaultKeylet_,
|
||
BrokerParameters const& p)
|
||
: asset(asset_), brokerID(brokerKeylet_.key), vaultID(vaultKeylet_.key), params(p)
|
||
{
|
||
}
|
||
|
||
Keylet
|
||
brokerKeylet() const
|
||
{
|
||
return keylet::loanbroker(brokerID);
|
||
}
|
||
Keylet
|
||
vaultKeylet() const
|
||
{
|
||
return keylet::vault(vaultID);
|
||
}
|
||
|
||
int
|
||
vaultScale(jtx::Env const& env) const
|
||
{
|
||
using namespace jtx;
|
||
|
||
auto const vaultSle = env.le(keylet::vault(vaultID));
|
||
return getAssetsTotalScale(vaultSle);
|
||
}
|
||
};
|
||
|
||
struct LoanParameters
|
||
{
|
||
// The account submitting the transaction. May be borrower or broker.
|
||
jtx::Account account;
|
||
// The counterparty. Should be the other of borrower or broker.
|
||
jtx::Account counter;
|
||
// Whether the counterparty is specified in the `counterparty` field, or
|
||
// only signs.
|
||
bool counterpartyExplicit = true;
|
||
Number principalRequest;
|
||
// NOLINTBEGIN(readability-redundant-member-init)
|
||
std::optional<STAmount> setFee = std::nullopt;
|
||
std::optional<Number> originationFee = std::nullopt;
|
||
std::optional<Number> serviceFee = std::nullopt;
|
||
std::optional<Number> lateFee = std::nullopt;
|
||
std::optional<Number> closeFee = std::nullopt;
|
||
std::optional<TenthBips32> overFee = std::nullopt;
|
||
std::optional<TenthBips32> interest = std::nullopt;
|
||
std::optional<TenthBips32> lateInterest = std::nullopt;
|
||
std::optional<TenthBips32> closeInterest = std::nullopt;
|
||
std::optional<TenthBips32> overpaymentInterest = std::nullopt;
|
||
std::optional<std::uint32_t> payTotal = std::nullopt;
|
||
std::optional<std::uint32_t> payInterval = std::nullopt;
|
||
std::optional<std::uint32_t> gracePd = std::nullopt;
|
||
std::optional<std::uint32_t> flags = std::nullopt;
|
||
// NOLINTEND(readability-redundant-member-init)
|
||
|
||
template <class... FN>
|
||
jtx::JTx
|
||
operator()(jtx::Env& env, BrokerInfo const& broker, FN const&... fN) const
|
||
{
|
||
using namespace jtx;
|
||
using namespace jtx::loan;
|
||
|
||
JTx jt{loan::set(
|
||
account,
|
||
broker.brokerID,
|
||
broker.asset(principalRequest).number(),
|
||
flags.value_or(0))};
|
||
|
||
sig(sfCounterpartySignature, counter)(env, jt);
|
||
|
||
fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt);
|
||
|
||
if (counterpartyExplicit)
|
||
counterparty(counter)(env, jt);
|
||
if (originationFee)
|
||
loanOriginationFee(broker.asset(*originationFee).number())(env, jt);
|
||
if (serviceFee)
|
||
loanServiceFee(broker.asset(*serviceFee).number())(env, jt);
|
||
if (lateFee)
|
||
latePaymentFee(broker.asset(*lateFee).number())(env, jt);
|
||
if (closeFee)
|
||
closePaymentFee(broker.asset(*closeFee).number())(env, jt);
|
||
if (overFee)
|
||
overpaymentFee (*overFee)(env, jt);
|
||
if (interest)
|
||
interestRate (*interest)(env, jt);
|
||
if (lateInterest)
|
||
lateInterestRate (*lateInterest)(env, jt);
|
||
if (closeInterest)
|
||
closeInterestRate (*closeInterest)(env, jt);
|
||
if (overpaymentInterest)
|
||
overpaymentInterestRate (*overpaymentInterest)(env, jt);
|
||
if (payTotal)
|
||
paymentTotal (*payTotal)(env, jt);
|
||
if (payInterval)
|
||
paymentInterval (*payInterval)(env, jt);
|
||
if (gracePd)
|
||
gracePeriod (*gracePd)(env, jt);
|
||
|
||
return env.jt(jt, fN...);
|
||
}
|
||
};
|
||
|
||
struct PaymentParameters
|
||
{
|
||
Number overpaymentFactor = Number{1};
|
||
std::optional<Number> overpaymentExtra = std::nullopt;
|
||
std::uint32_t flags = 0;
|
||
bool showStepBalances = false;
|
||
bool validateBalances = true;
|
||
|
||
static PaymentParameters const&
|
||
defaults()
|
||
{
|
||
static PaymentParameters const result{};
|
||
return result;
|
||
}
|
||
};
|
||
|
||
struct LoanState
|
||
{
|
||
std::uint32_t previousPaymentDate = 0;
|
||
NetClock::time_point startDate;
|
||
std::uint32_t nextPaymentDate = 0;
|
||
std::uint32_t paymentRemaining = 0;
|
||
std::int32_t const loanScale = 0;
|
||
Number totalValue = 0;
|
||
Number principalOutstanding = 0;
|
||
Number managementFeeOutstanding = 0;
|
||
Number periodicPayment = 0;
|
||
std::uint32_t flags = 0;
|
||
std::uint32_t const paymentInterval = 0;
|
||
TenthBips32 const interestRate{};
|
||
};
|
||
|
||
/** Helper class to compare the expected state of a loan and loan broker
|
||
* against the data in the ledger.
|
||
*/
|
||
struct VerifyLoanStatus
|
||
{
|
||
public:
|
||
jtx::Env const& env;
|
||
BrokerInfo const& broker;
|
||
jtx::Account const& pseudoAccount;
|
||
Keylet const& loanKeylet;
|
||
|
||
VerifyLoanStatus(
|
||
jtx::Env const& env_,
|
||
BrokerInfo const& broker_,
|
||
jtx::Account const& pseudo_,
|
||
Keylet const& keylet_)
|
||
: env(env_), broker(broker_), pseudoAccount(pseudo_), loanKeylet(keylet_)
|
||
{
|
||
}
|
||
|
||
/** Checks the expected broker state against the ledger
|
||
*/
|
||
void
|
||
checkBroker(
|
||
Number const& principalOutstanding,
|
||
Number const& interestOwed,
|
||
TenthBips32 interestRate,
|
||
std::uint32_t paymentInterval,
|
||
std::uint32_t paymentsRemaining,
|
||
std::uint32_t ownerCount) const
|
||
{
|
||
using namespace jtx;
|
||
if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID));
|
||
env.test.BEAST_EXPECT(brokerSle))
|
||
{
|
||
TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)};
|
||
auto const brokerDebt = brokerSle->at(sfDebtTotal);
|
||
auto const expectedDebt = principalOutstanding + interestOwed;
|
||
env.test.BEAST_EXPECT(brokerDebt == expectedDebt);
|
||
env.test.BEAST_EXPECT(
|
||
env.balance(pseudoAccount, broker.asset).number() ==
|
||
brokerSle->at(sfCoverAvailable));
|
||
env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount);
|
||
|
||
if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID)));
|
||
env.test.BEAST_EXPECT(vaultSle))
|
||
{
|
||
Account const vaultPseudo{"vaultPseudoAccount", vaultSle->at(sfAccount)};
|
||
env.test.BEAST_EXPECT(
|
||
vaultSle->at(sfAssetsAvailable) ==
|
||
env.balance(vaultPseudo, broker.asset).number());
|
||
if (ownerCount == 0)
|
||
{
|
||
// Allow some slop for rounding IOUs
|
||
|
||
// TODO: This needs to be an exact match once all the
|
||
// other rounding issues are worked out.
|
||
auto const total = vaultSle->at(sfAssetsTotal);
|
||
auto const available = vaultSle->at(sfAssetsAvailable);
|
||
env.test.BEAST_EXPECT(
|
||
total == available ||
|
||
(!broker.asset.integral() && available != 0 &&
|
||
((total - available) / available < Number(1, -6))));
|
||
env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void
|
||
checkPayment(
|
||
std::int32_t loanScale,
|
||
jtx::Account const& account,
|
||
jtx::PrettyAmount const& balanceBefore,
|
||
STAmount const& expectedPayment,
|
||
jtx::PrettyAmount const& adjustment) const
|
||
{
|
||
auto const borrowerScale = std::max(loanScale, balanceBefore.number().exponent());
|
||
|
||
STAmount const balanceChangeAmount{
|
||
broker.asset,
|
||
roundToAsset(broker.asset, expectedPayment + adjustment, borrowerScale)};
|
||
{
|
||
auto const difference = roundToScale(
|
||
env.balance(account, broker.asset) - (balanceBefore - balanceChangeAmount),
|
||
borrowerScale);
|
||
env.test.expect(
|
||
roundToScale(difference, loanScale) >= beast::zero,
|
||
"Balance before: " + to_string(balanceBefore.value()) +
|
||
", expected change: " + to_string(balanceChangeAmount) +
|
||
", difference (balance after - expected): " + to_string(difference),
|
||
__FILE__,
|
||
__LINE__);
|
||
}
|
||
}
|
||
|
||
/** Checks both the loan and broker expect states against the ledger */
|
||
void
|
||
operator()(
|
||
std::uint32_t previousPaymentDate,
|
||
std::uint32_t nextPaymentDate,
|
||
std::uint32_t paymentRemaining,
|
||
Number const& loanScale,
|
||
Number const& totalValue,
|
||
Number const& principalOutstanding,
|
||
Number const& managementFeeOutstanding,
|
||
Number const& periodicPayment,
|
||
std::uint32_t flags) const
|
||
{
|
||
using namespace jtx;
|
||
if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan))
|
||
{
|
||
env.test.BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == previousPaymentDate);
|
||
env.test.BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentRemaining);
|
||
env.test.BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == nextPaymentDate);
|
||
env.test.BEAST_EXPECT(loan->at(sfLoanScale) == loanScale);
|
||
env.test.BEAST_EXPECT(loan->at(sfTotalValueOutstanding) == totalValue);
|
||
env.test.BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalOutstanding);
|
||
env.test.BEAST_EXPECT(
|
||
loan->at(sfManagementFeeOutstanding) == managementFeeOutstanding);
|
||
env.test.BEAST_EXPECT(loan->at(sfPeriodicPayment) == periodicPayment);
|
||
env.test.BEAST_EXPECT(loan->at(sfFlags) == flags);
|
||
|
||
auto const ls = constructRoundedLoanState(loan);
|
||
|
||
auto const interestRate = TenthBips32{loan->at(sfInterestRate)};
|
||
auto const paymentInterval = loan->at(sfPaymentInterval);
|
||
checkBroker(
|
||
principalOutstanding,
|
||
ls.interestDue,
|
||
interestRate,
|
||
paymentInterval,
|
||
paymentRemaining,
|
||
1);
|
||
|
||
if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID));
|
||
env.test.BEAST_EXPECT(brokerSle))
|
||
{
|
||
if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID)));
|
||
env.test.BEAST_EXPECT(vaultSle))
|
||
{
|
||
if (((flags & lsfLoanImpaired) != 0u) && ((flags & lsfLoanDefault) == 0u))
|
||
{
|
||
env.test.BEAST_EXPECT(
|
||
vaultSle->at(sfLossUnrealized) ==
|
||
totalValue - managementFeeOutstanding);
|
||
}
|
||
else
|
||
{
|
||
env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Checks both the loan and broker expect states against the ledger */
|
||
void
|
||
operator()(LoanState const& state) const
|
||
{
|
||
operator()(
|
||
state.previousPaymentDate,
|
||
state.nextPaymentDate,
|
||
state.paymentRemaining,
|
||
state.loanScale,
|
||
state.totalValue,
|
||
state.principalOutstanding,
|
||
state.managementFeeOutstanding,
|
||
state.periodicPayment,
|
||
state.flags);
|
||
};
|
||
};
|
||
|
||
BrokerInfo
|
||
createVaultAndBroker(
|
||
jtx::Env& env,
|
||
jtx::PrettyAsset const& asset,
|
||
jtx::Account const& lender,
|
||
BrokerParameters const& params = BrokerParameters::defaults())
|
||
{
|
||
using namespace jtx;
|
||
|
||
Vault vault{env};
|
||
|
||
auto const deposit = asset(params.vaultDeposit);
|
||
auto const debtMaximumValue = asset(params.debtMax).value();
|
||
auto const coverDepositValue = asset(params.coverDeposit).value();
|
||
|
||
auto const coverRateMinValue = params.coverRateMin;
|
||
|
||
auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
|
||
env(tx);
|
||
env.close();
|
||
BEAST_EXPECT(env.le(vaultKeylet));
|
||
|
||
env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit}));
|
||
env.close();
|
||
if (auto const vault = env.le(keylet::vault(vaultKeylet.key)); BEAST_EXPECT(vault))
|
||
{
|
||
BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value());
|
||
}
|
||
|
||
auto const keylet = keylet::loanbroker(lender.id(), env.seq(lender));
|
||
|
||
using namespace loanBroker;
|
||
env(set(lender, vaultKeylet.key, params.flags),
|
||
data(params.data),
|
||
managementFeeRate(params.managementFeeRate),
|
||
debtMaximum(debtMaximumValue),
|
||
coverRateMinimum(coverRateMinValue),
|
||
coverRateLiquidation(TenthBips32(params.coverRateLiquidation)));
|
||
|
||
if (coverDepositValue != beast::zero)
|
||
env(coverDeposit(lender, keylet.key, coverDepositValue));
|
||
|
||
env.close();
|
||
|
||
return {asset, keylet, vaultKeylet, params};
|
||
}
|
||
|
||
/// Get the state without checking anything
|
||
LoanState
|
||
getCurrentState(jtx::Env const& env, BrokerInfo const& broker, Keylet const& loanKeylet)
|
||
{
|
||
using d = NetClock::duration;
|
||
using tp = NetClock::time_point;
|
||
|
||
// Lookup the current loan state
|
||
if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan))
|
||
{
|
||
return LoanState{
|
||
.previousPaymentDate = loan->at(sfPreviousPaymentDueDate),
|
||
.startDate = tp{d{loan->at(sfStartDate)}},
|
||
.nextPaymentDate = loan->at(sfNextPaymentDueDate),
|
||
.paymentRemaining = loan->at(sfPaymentRemaining),
|
||
.loanScale = loan->at(sfLoanScale),
|
||
.totalValue = loan->at(sfTotalValueOutstanding),
|
||
.principalOutstanding = loan->at(sfPrincipalOutstanding),
|
||
.managementFeeOutstanding = loan->at(sfManagementFeeOutstanding),
|
||
.periodicPayment = loan->at(sfPeriodicPayment),
|
||
.flags = loan->at(sfFlags),
|
||
.paymentInterval = loan->at(sfPaymentInterval),
|
||
.interestRate = TenthBips32{loan->at(sfInterestRate)},
|
||
};
|
||
}
|
||
return LoanState{};
|
||
}
|
||
|
||
/// Get the state and check the values against the parameters used in
|
||
/// `lifecycle`
|
||
LoanState
|
||
getCurrentState(
|
||
jtx::Env const& env,
|
||
BrokerInfo const& broker,
|
||
Keylet const& loanKeylet,
|
||
VerifyLoanStatus const& verifyLoanStatus)
|
||
{
|
||
using namespace std::chrono_literals;
|
||
using d = NetClock::duration;
|
||
using tp = NetClock::time_point;
|
||
|
||
auto const state = getCurrentState(env, broker, loanKeylet);
|
||
BEAST_EXPECT(state.previousPaymentDate == 0);
|
||
BEAST_EXPECT(tp{d{state.nextPaymentDate}} == state.startDate + 600s);
|
||
BEAST_EXPECT(state.paymentRemaining == 12);
|
||
BEAST_EXPECT(state.principalOutstanding == broker.asset(1000).value());
|
||
BEAST_EXPECT(
|
||
state.loanScale >=
|
||
(broker.asset.integral()
|
||
? 0
|
||
: std::max(broker.vaultScale(env), state.principalOutstanding.exponent())));
|
||
BEAST_EXPECT(state.paymentInterval == 600);
|
||
{
|
||
NumberRoundModeGuard mg(Number::upward);
|
||
BEAST_EXPECT(
|
||
state.totalValue ==
|
||
roundToAsset(
|
||
broker.asset, state.periodicPayment * state.paymentRemaining, state.loanScale));
|
||
}
|
||
BEAST_EXPECT(
|
||
state.managementFeeOutstanding ==
|
||
computeManagementFee(
|
||
broker.asset,
|
||
state.totalValue - state.principalOutstanding,
|
||
broker.params.managementFeeRate,
|
||
state.loanScale));
|
||
|
||
verifyLoanStatus(state);
|
||
|
||
return state;
|
||
}
|
||
|
||
bool
|
||
canImpairLoan(jtx::Env const& env, BrokerInfo const& broker, LoanState const& state)
|
||
{
|
||
if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
|
||
BEAST_EXPECT(brokerSle))
|
||
{
|
||
if (auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID)));
|
||
BEAST_EXPECT(vaultSle))
|
||
{
|
||
// log << vaultSle->getJson() << std::endl;
|
||
auto const assetsUnavailable =
|
||
vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable);
|
||
auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + state.totalValue -
|
||
state.managementFeeOutstanding;
|
||
|
||
if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable))
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
enum class AssetType { XRP = 0, IOU = 1, MPT = 2 };
|
||
|
||
// Specify the accounts as params to allow other accounts to be used
|
||
jtx::PrettyAsset
|
||
createAsset(
|
||
jtx::Env& env,
|
||
AssetType assetType,
|
||
BrokerParameters const& brokerParams,
|
||
jtx::Account const& issuer,
|
||
jtx::Account const& lender,
|
||
jtx::Account const& borrower)
|
||
{
|
||
using namespace jtx;
|
||
|
||
switch (assetType)
|
||
{
|
||
case AssetType::XRP:
|
||
// TODO: remove the factor, and set up loans in drops
|
||
return PrettyAsset{xrpIssue(), 1'000'000};
|
||
|
||
case AssetType::IOU: {
|
||
PrettyAsset const asset{issuer[iouCurrency]};
|
||
|
||
auto const limit =
|
||
asset(100 * (brokerParams.vaultDeposit + brokerParams.coverDeposit));
|
||
if (lender != issuer)
|
||
env(trust(lender, limit));
|
||
if (borrower != issuer)
|
||
env(trust(borrower, limit));
|
||
|
||
return asset;
|
||
}
|
||
|
||
case AssetType::MPT: {
|
||
// Enough to cover initial fees
|
||
if (!env.le(keylet::account(issuer)))
|
||
env.fund(env.current()->fees().accountReserve(10) * 10, issuer);
|
||
if (!env.le(keylet::account(lender)))
|
||
env.fund(env.current()->fees().accountReserve(10) * 10, noripple(lender));
|
||
if (!env.le(keylet::account(borrower)))
|
||
env.fund(env.current()->fees().accountReserve(10) * 10, noripple(borrower));
|
||
|
||
MPTTester mptt{env, issuer, mptInitNoFund};
|
||
mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
|
||
// Scale the MPT asset so interest is interesting
|
||
PrettyAsset const asset{mptt.issuanceID(), 10'000};
|
||
// Need to do the authorization here because mptt isn't
|
||
// accessible outside
|
||
if (lender != issuer)
|
||
mptt.authorize({.account = lender});
|
||
if (borrower != issuer)
|
||
mptt.authorize({.account = borrower});
|
||
|
||
env.close();
|
||
|
||
return asset;
|
||
}
|
||
|
||
default:
|
||
throw std::runtime_error("Unknown asset type");
|
||
}
|
||
}
|
||
|
||
void
|
||
describeLoan(
|
||
jtx::Env& env,
|
||
BrokerParameters const& brokerParams,
|
||
LoanParameters const& loanParams,
|
||
AssetType assetType,
|
||
jtx::Account const& issuer,
|
||
jtx::Account const& lender,
|
||
jtx::Account const& borrower)
|
||
{
|
||
using namespace jtx;
|
||
|
||
auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower);
|
||
auto const principal = asset(loanParams.principalRequest).number();
|
||
auto const interest = loanParams.interest.value_or(TenthBips32{});
|
||
auto const interval = loanParams.payInterval.value_or(LoanSet::defaultPaymentInterval);
|
||
auto const total = loanParams.payTotal.value_or(LoanSet::defaultPaymentTotal);
|
||
auto const feeRate = brokerParams.managementFeeRate;
|
||
auto const props = computeLoanProperties(
|
||
asset,
|
||
principal,
|
||
interest,
|
||
interval,
|
||
total,
|
||
feeRate,
|
||
asset(brokerParams.vaultDeposit).number().exponent());
|
||
log << "Loan properties:\n"
|
||
<< "\tPrincipal: " << principal << std::endl
|
||
<< "\tInterest rate: " << interest << std::endl
|
||
<< "\tPayment interval: " << interval << std::endl
|
||
<< "\tManagement Fee Rate: " << feeRate << std::endl
|
||
<< "\tTotal Payments: " << total << std::endl
|
||
<< "\tPeriodic Payment: " << props.periodicPayment << std::endl
|
||
<< "\tTotal Value: " << props.loanState.valueOutstanding << std::endl
|
||
<< "\tManagement Fee: " << props.loanState.managementFeeDue << std::endl
|
||
<< "\tLoan Scale: " << props.loanScale << std::endl
|
||
<< "\tFirst payment principal: " << props.firstPaymentPrincipal << std::endl;
|
||
|
||
// checkGuards returns a TER, so success is 0
|
||
BEAST_EXPECT(!checkLoanGuards(
|
||
asset,
|
||
asset(loanParams.principalRequest).number(),
|
||
loanParams.interest.value_or(TenthBips32{}) != beast::zero,
|
||
loanParams.payTotal.value_or(LoanSet::defaultPaymentTotal),
|
||
props,
|
||
env.journal));
|
||
}
|
||
|
||
std::optional<std::tuple<BrokerInfo, Keylet, jtx::Account>>
|
||
createLoan(
|
||
jtx::Env& env,
|
||
AssetType assetType,
|
||
BrokerParameters const& brokerParams,
|
||
LoanParameters const& loanParams,
|
||
jtx::Account const& issuer,
|
||
jtx::Account const& lender,
|
||
jtx::Account const& borrower)
|
||
{
|
||
using namespace jtx;
|
||
|
||
// Enough to cover initial fees
|
||
env.fund(env.current()->fees().accountReserve(10) * 10, issuer);
|
||
if (lender != issuer)
|
||
env.fund(env.current()->fees().accountReserve(10) * 10, noripple(lender));
|
||
if (borrower != issuer && borrower != lender)
|
||
env.fund(env.current()->fees().accountReserve(10) * 10, noripple(borrower));
|
||
|
||
describeLoan(env, brokerParams, loanParams, assetType, issuer, lender, borrower);
|
||
|
||
// Make the asset
|
||
auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower);
|
||
|
||
env.close();
|
||
if (asset.native() || lender != issuer)
|
||
{
|
||
env(
|
||
pay((asset.native() ? env.master : issuer),
|
||
lender,
|
||
asset(brokerParams.vaultDeposit + brokerParams.coverDeposit)));
|
||
}
|
||
// Fund the borrower later once we know the total loan
|
||
// size
|
||
|
||
BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams);
|
||
|
||
auto const pseudoAcctOpt = [&]() -> std::optional<Account> {
|
||
auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
|
||
if (!BEAST_EXPECT(brokerSle))
|
||
return std::nullopt;
|
||
auto const brokerPseudo = brokerSle->at(sfAccount);
|
||
return Account("Broker pseudo-account", brokerPseudo);
|
||
}();
|
||
if (!pseudoAcctOpt)
|
||
return std::nullopt;
|
||
Account const& pseudoAcct = *pseudoAcctOpt;
|
||
|
||
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 std::nullopt;
|
||
Keylet const& loanKeylet = *loanKeyletOpt;
|
||
|
||
env(loanParams(env, broker));
|
||
|
||
env.close();
|
||
|
||
return std::make_tuple(broker, loanKeylet, pseudoAcct);
|
||
}
|
||
|
||
static void
|
||
topUpBorrower(
|
||
jtx::Env& env,
|
||
BrokerInfo const& broker,
|
||
jtx::Account const& issuer,
|
||
jtx::Account const& borrower,
|
||
LoanState const& state,
|
||
std::optional<Number> const& servFee)
|
||
{
|
||
using namespace jtx;
|
||
|
||
STAmount const serviceFee = broker.asset(servFee.value_or(0));
|
||
|
||
// Ensure the borrower has enough funds to make the payments
|
||
// (including tx fees, if necessary)
|
||
auto const borrowerBalance = env.balance(borrower, broker.asset);
|
||
|
||
auto const baseFee = env.current()->fees().base;
|
||
|
||
// Add extra for transaction fees and reserves, if appropriate, or a
|
||
// tiny amount for the extra paid in each transaction
|
||
auto const totalNeeded = state.totalValue + (serviceFee * state.paymentRemaining) +
|
||
(broker.asset.native()
|
||
? Number(
|
||
baseFee * state.paymentRemaining +
|
||
env.current()->fees().accountReserve(env.ownerCount(borrower)))
|
||
: broker.asset(15).number());
|
||
|
||
auto const shortage = totalNeeded - borrowerBalance.number();
|
||
|
||
if (shortage > beast::zero && (broker.asset.native() || issuer != borrower))
|
||
{
|
||
env(
|
||
pay((broker.asset.native() ? env.master : issuer),
|
||
borrower,
|
||
STAmount{broker.asset, shortage}));
|
||
}
|
||
}
|
||
|
||
void
|
||
makeLoanPayments(
|
||
jtx::Env& env,
|
||
BrokerInfo const& broker,
|
||
LoanParameters const& loanParams,
|
||
Keylet const& loanKeylet,
|
||
VerifyLoanStatus const& verifyLoanStatus,
|
||
jtx::Account const& issuer,
|
||
jtx::Account const& lender,
|
||
jtx::Account const& borrower,
|
||
PaymentParameters const& paymentParams = PaymentParameters::defaults())
|
||
{
|
||
// Make all the individual payments
|
||
using namespace jtx;
|
||
using namespace jtx::loan;
|
||
using namespace std::chrono_literals;
|
||
using d = NetClock::duration;
|
||
|
||
bool const showStepBalances = paymentParams.showStepBalances;
|
||
|
||
auto const currencyLabel = getCurrencyLabel(broker.asset);
|
||
|
||
auto const baseFee = env.current()->fees().base;
|
||
|
||
env.close();
|
||
auto state = getCurrentState(env, broker, loanKeylet);
|
||
|
||
verifyLoanStatus(state);
|
||
|
||
STAmount const serviceFee = broker.asset(loanParams.serviceFee.value_or(0));
|
||
|
||
topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee);
|
||
|
||
// Periodic payment amount will consist of
|
||
// 1. principal outstanding (1000)
|
||
// 2. interest interest rate (at 12%)
|
||
// 3. payment interval (600s)
|
||
// 4. loan service fee (2)
|
||
// Calculate these values without the helper functions
|
||
// to verify they're working correctly The numbers in
|
||
// the below BEAST_EXPECTs may not hold across assets.
|
||
auto const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval);
|
||
STAmount const roundedPeriodicPayment{
|
||
broker.asset,
|
||
roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)};
|
||
|
||
if (!showStepBalances)
|
||
{
|
||
log << currencyLabel << " Payment components: "
|
||
<< "Payments remaining, "
|
||
<< "rawInterest, rawPrincipal, "
|
||
"rawMFee, "
|
||
<< "trackedValueDelta, trackedPrincipalDelta, "
|
||
"trackedInterestDelta, trackedMgmtFeeDelta, special"
|
||
<< std::endl;
|
||
}
|
||
|
||
// Include the service fee
|
||
STAmount const totalDue =
|
||
roundToScale(roundedPeriodicPayment + serviceFee, state.loanScale, Number::upward);
|
||
|
||
auto currentRoundedState = constructLoanState(
|
||
state.totalValue, state.principalOutstanding, state.managementFeeOutstanding);
|
||
{
|
||
auto const raw = computeTheoreticalLoanState(
|
||
state.periodicPayment,
|
||
periodicRate,
|
||
state.paymentRemaining,
|
||
broker.params.managementFeeRate);
|
||
|
||
if (showStepBalances)
|
||
{
|
||
log << currencyLabel << " Starting loan balances: "
|
||
<< "\n\tTotal value: " << currentRoundedState.valueOutstanding
|
||
<< "\n\tPrincipal: " << currentRoundedState.principalOutstanding
|
||
<< "\n\tInterest: " << currentRoundedState.interestDue
|
||
<< "\n\tMgmt fee: " << currentRoundedState.managementFeeDue
|
||
<< "\n\tPayments remaining " << state.paymentRemaining << std::endl;
|
||
}
|
||
else
|
||
{
|
||
log << currencyLabel << " Loan starting state: " << state.paymentRemaining << ", "
|
||
<< raw.interestDue << ", " << raw.principalOutstanding << ", "
|
||
<< raw.managementFeeDue << ", " << currentRoundedState.valueOutstanding << ", "
|
||
<< currentRoundedState.principalOutstanding << ", "
|
||
<< currentRoundedState.interestDue << ", "
|
||
<< currentRoundedState.managementFeeDue << std::endl;
|
||
}
|
||
}
|
||
|
||
// Try to pay a little extra to show that it's _not_
|
||
// taken
|
||
auto const extraAmount = paymentParams.overpaymentExtra
|
||
? broker.asset(*paymentParams.overpaymentExtra).value()
|
||
: std::min(broker.asset(10).value(), STAmount{broker.asset, totalDue / 20});
|
||
|
||
STAmount const transactionAmount =
|
||
STAmount{broker.asset, totalDue * paymentParams.overpaymentFactor} + extraAmount;
|
||
|
||
auto const borrowerInitialBalance = env.balance(borrower, broker.asset).number();
|
||
auto const initialState = state;
|
||
detail::PaymentComponents totalPaid{
|
||
.trackedValueDelta = 0, .trackedPrincipalDelta = 0, .trackedManagementFeeDelta = 0};
|
||
Number totalInterestPaid = 0;
|
||
Number totalFeesPaid = 0;
|
||
std::size_t totalPaymentsMade = 0;
|
||
|
||
xrpl::LoanState currentTrueState = computeTheoreticalLoanState(
|
||
state.periodicPayment,
|
||
periodicRate,
|
||
state.paymentRemaining,
|
||
broker.params.managementFeeRate);
|
||
|
||
auto validateBorrowerBalance = [&]() {
|
||
if (borrower == issuer || !paymentParams.validateBalances)
|
||
return;
|
||
auto const totalSpent =
|
||
(totalPaid.trackedValueDelta + totalFeesPaid +
|
||
(broker.asset.native() ? Number(baseFee) * totalPaymentsMade : numZero));
|
||
BEAST_EXPECT(
|
||
env.balance(borrower, broker.asset).number() ==
|
||
borrowerInitialBalance - totalSpent);
|
||
};
|
||
|
||
auto const defaultRound = broker.asset.integral() ? 3 : 0;
|
||
auto truncate = [defaultRound](Number const& n, std::optional<int> places = std::nullopt) {
|
||
auto const p = places.value_or(defaultRound);
|
||
if (p == 0)
|
||
return n;
|
||
auto const factor = Number{1, p};
|
||
return (n * factor).truncate() / factor;
|
||
};
|
||
while (state.paymentRemaining > 0)
|
||
{
|
||
validateBorrowerBalance();
|
||
// Compute the expected principal amount
|
||
auto const paymentComponents = detail::computePaymentComponents(
|
||
broker.asset.raw(),
|
||
state.loanScale,
|
||
state.totalValue,
|
||
state.principalOutstanding,
|
||
state.managementFeeOutstanding,
|
||
state.periodicPayment,
|
||
periodicRate,
|
||
state.paymentRemaining,
|
||
broker.params.managementFeeRate);
|
||
|
||
BEAST_EXPECT(
|
||
paymentComponents.trackedValueDelta <= roundedPeriodicPayment ||
|
||
(paymentComponents.specialCase == detail::PaymentSpecialCase::final &&
|
||
paymentComponents.trackedValueDelta >= roundedPeriodicPayment));
|
||
BEAST_EXPECT(
|
||
paymentComponents.trackedValueDelta ==
|
||
paymentComponents.trackedPrincipalDelta + paymentComponents.trackedInterestPart() +
|
||
paymentComponents.trackedManagementFeeDelta);
|
||
|
||
xrpl::LoanState const nextTrueState = computeTheoreticalLoanState(
|
||
state.periodicPayment,
|
||
periodicRate,
|
||
state.paymentRemaining - 1,
|
||
broker.params.managementFeeRate);
|
||
detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState;
|
||
BEAST_EXPECT(
|
||
deltas.total() == deltas.principal + deltas.interest + deltas.managementFee);
|
||
BEAST_EXPECT(
|
||
paymentComponents.specialCase == detail::PaymentSpecialCase::final ||
|
||
deltas.total() == state.periodicPayment ||
|
||
(state.loanScale - (deltas.total() - state.periodicPayment).exponent()) > 14);
|
||
|
||
if (!showStepBalances)
|
||
{
|
||
log << currencyLabel << " Payment components: " << state.paymentRemaining << ", "
|
||
|
||
<< deltas.interest << ", " << deltas.principal << ", " << deltas.managementFee
|
||
<< ", " << paymentComponents.trackedValueDelta << ", "
|
||
<< paymentComponents.trackedPrincipalDelta << ", "
|
||
<< paymentComponents.trackedInterestPart() << ", "
|
||
<< paymentComponents.trackedManagementFeeDelta << ", " << [&]() -> char const* {
|
||
if (paymentComponents.specialCase == detail::PaymentSpecialCase::final)
|
||
return "final";
|
||
if (paymentComponents.specialCase == detail::PaymentSpecialCase::extra)
|
||
return "extra";
|
||
return "none";
|
||
}() << std::endl;
|
||
}
|
||
|
||
auto const totalDueAmount =
|
||
STAmount{broker.asset, paymentComponents.trackedValueDelta + serviceFee};
|
||
|
||
if (paymentParams.validateBalances)
|
||
{
|
||
// Due to the rounding algorithms to keep the interest and
|
||
// principal in sync with "true" values, the computed amount
|
||
// may be a little less than the rounded fixed payment
|
||
// amount. For integral types, the difference should be < 3
|
||
// (1 unit for each of the interest and management fee). For
|
||
// IOUs, the difference should be dust.
|
||
Number const diff = totalDue - totalDueAmount;
|
||
BEAST_EXPECT(
|
||
paymentComponents.specialCase == detail::PaymentSpecialCase::final ||
|
||
diff == beast::zero ||
|
||
(diff > beast::zero &&
|
||
((broker.asset.integral() && (static_cast<Number>(diff) < 3)) ||
|
||
(state.loanScale - diff.exponent() > 13))));
|
||
|
||
BEAST_EXPECT(
|
||
paymentComponents.trackedPrincipalDelta >= beast::zero &&
|
||
paymentComponents.trackedPrincipalDelta <= state.principalOutstanding);
|
||
BEAST_EXPECT(
|
||
paymentComponents.specialCase != detail::PaymentSpecialCase::final ||
|
||
paymentComponents.trackedPrincipalDelta == state.principalOutstanding);
|
||
}
|
||
|
||
auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset);
|
||
|
||
// Make the payment
|
||
env(pay(borrower, loanKeylet.key, transactionAmount, paymentParams.flags));
|
||
|
||
env.close(d{state.paymentInterval / 2});
|
||
|
||
if (paymentParams.validateBalances)
|
||
{
|
||
// Need to account for fees if the loan is in XRP
|
||
PrettyAmount adjustment = broker.asset(0);
|
||
if (broker.asset.native())
|
||
{
|
||
adjustment = env.current()->fees().base;
|
||
}
|
||
|
||
// Check the result
|
||
verifyLoanStatus.checkPayment(
|
||
state.loanScale,
|
||
borrower,
|
||
borrowerBalanceBeforePayment,
|
||
totalDueAmount,
|
||
adjustment);
|
||
}
|
||
|
||
if (showStepBalances)
|
||
{
|
||
auto const loanSle = env.le(loanKeylet);
|
||
if (!BEAST_EXPECT(loanSle))
|
||
{
|
||
// No reason for this not to exist
|
||
return;
|
||
}
|
||
auto const current = constructRoundedLoanState(loanSle);
|
||
auto const errors = nextTrueState - current;
|
||
log << currencyLabel << " Loan balances: "
|
||
<< "\n\tAmount taken: " << paymentComponents.trackedValueDelta
|
||
<< "\n\tTotal value: " << current.valueOutstanding
|
||
<< " (true: " << truncate(nextTrueState.valueOutstanding)
|
||
<< ", error: " << truncate(errors.total())
|
||
<< ")\n\tPrincipal: " << current.principalOutstanding
|
||
<< " (true: " << truncate(nextTrueState.principalOutstanding)
|
||
<< ", error: " << truncate(errors.principal)
|
||
<< ")\n\tInterest: " << current.interestDue
|
||
<< " (true: " << truncate(nextTrueState.interestDue)
|
||
<< ", error: " << truncate(errors.interest)
|
||
<< ")\n\tMgmt fee: " << current.managementFeeDue
|
||
<< " (true: " << truncate(nextTrueState.managementFeeDue)
|
||
<< ", error: " << truncate(errors.managementFee) << ")\n\tPayments remaining "
|
||
<< loanSle->at(sfPaymentRemaining) << std::endl;
|
||
|
||
currentRoundedState = current;
|
||
}
|
||
|
||
--state.paymentRemaining;
|
||
state.previousPaymentDate = state.nextPaymentDate;
|
||
if (paymentComponents.specialCase == detail::PaymentSpecialCase::final)
|
||
{
|
||
state.paymentRemaining = 0;
|
||
state.nextPaymentDate = 0;
|
||
}
|
||
else
|
||
{
|
||
state.nextPaymentDate += state.paymentInterval;
|
||
}
|
||
state.principalOutstanding -= paymentComponents.trackedPrincipalDelta;
|
||
state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta;
|
||
state.totalValue -= paymentComponents.trackedValueDelta;
|
||
|
||
if (paymentParams.validateBalances)
|
||
verifyLoanStatus(state);
|
||
|
||
totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta;
|
||
totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta;
|
||
totalPaid.trackedManagementFeeDelta += paymentComponents.trackedManagementFeeDelta;
|
||
totalInterestPaid += paymentComponents.trackedInterestPart();
|
||
totalFeesPaid += serviceFee;
|
||
++totalPaymentsMade;
|
||
|
||
currentTrueState = nextTrueState;
|
||
}
|
||
validateBorrowerBalance();
|
||
|
||
// Loan is paid off
|
||
BEAST_EXPECT(state.paymentRemaining == 0);
|
||
BEAST_EXPECT(state.principalOutstanding == 0);
|
||
|
||
auto const initialInterestDue = initialState.totalValue -
|
||
(initialState.principalOutstanding + initialState.managementFeeOutstanding);
|
||
if (paymentParams.validateBalances)
|
||
{
|
||
// Make sure all the payments add up
|
||
BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue);
|
||
BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding);
|
||
BEAST_EXPECT(
|
||
totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding);
|
||
// This is almost a tautology given the previous checks, but
|
||
// check it anyway for completeness.
|
||
BEAST_EXPECT(totalInterestPaid == initialInterestDue);
|
||
BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining);
|
||
}
|
||
|
||
if (showStepBalances)
|
||
{
|
||
auto const loanSle = env.le(loanKeylet);
|
||
if (!BEAST_EXPECT(loanSle))
|
||
{
|
||
// No reason for this not to exist
|
||
return;
|
||
}
|
||
log << currencyLabel << " Total amounts paid: "
|
||
<< "\n\tTotal value: " << totalPaid.trackedValueDelta
|
||
<< " (initial: " << truncate(initialState.totalValue)
|
||
<< ", error: " << truncate(initialState.totalValue - totalPaid.trackedValueDelta)
|
||
<< ")\n\tPrincipal: " << totalPaid.trackedPrincipalDelta
|
||
<< " (initial: " << truncate(initialState.principalOutstanding) << ", error: "
|
||
<< truncate(initialState.principalOutstanding - totalPaid.trackedPrincipalDelta)
|
||
<< ")\n\tInterest: " << totalInterestPaid
|
||
<< " (initial: " << truncate(initialInterestDue)
|
||
<< ", error: " << truncate(initialInterestDue - totalInterestPaid)
|
||
<< ")\n\tMgmt fee: " << totalPaid.trackedManagementFeeDelta
|
||
<< " (initial: " << truncate(initialState.managementFeeOutstanding) << ", error: "
|
||
<< truncate(
|
||
initialState.managementFeeOutstanding - totalPaid.trackedManagementFeeDelta)
|
||
<< ")\n\tTotal payments made: " << totalPaymentsMade << std::endl;
|
||
}
|
||
}
|
||
|
||
void
|
||
runLoan(
|
||
AssetType assetType,
|
||
BrokerParameters const& brokerParams,
|
||
LoanParameters const& loanParams)
|
||
{
|
||
using namespace jtx;
|
||
|
||
Account const issuer("issuer");
|
||
Account const lender("lender");
|
||
Account const borrower("borrower");
|
||
|
||
Env env(*this, all);
|
||
|
||
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 verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
|
||
|
||
makeLoanPayments(
|
||
env,
|
||
broker,
|
||
loanParams,
|
||
loanKeylet,
|
||
verifyLoanStatus,
|
||
issuer,
|
||
lender,
|
||
borrower,
|
||
PaymentParameters{.showStepBalances = true});
|
||
}
|
||
|
||
/** Runs through the complete lifecycle of a loan
|
||
*
|
||
* 1. Create a loan.
|
||
* 2. Test a bunch of transaction failure conditions.
|
||
* 3. Use the `toEndOfLife` callback to take the loan to 0. How that is done
|
||
* depends on the callback. e.g. Default, Early payoff, make all the
|
||
* normal payments, etc.
|
||
* 4. Delete the loan. The loan will alternate between being deleted by the
|
||
* lender and the borrower.
|
||
*/
|
||
void
|
||
lifecycle(
|
||
std::string const& caseLabel,
|
||
char const* label,
|
||
jtx::Env& env,
|
||
Number const& loanAmount,
|
||
int interestExponent,
|
||
jtx::Account const& lender,
|
||
jtx::Account const& borrower,
|
||
jtx::Account const& evan,
|
||
BrokerInfo const& broker,
|
||
jtx::Account const& pseudoAcct,
|
||
std::uint32_t flags,
|
||
// The end of life callback is expected to take the loan to 0 payments
|
||
// remaining, one way or another
|
||
std::function<void(Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus)>
|
||
toEndOfLife)
|
||
{
|
||
auto const [keylet, loanSequence] = [&]() {
|
||
auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
|
||
if (!BEAST_EXPECT(brokerSle))
|
||
{
|
||
// will be invalid
|
||
return std::make_pair(keylet::loan(broker.brokerID), std::uint32_t(0));
|
||
}
|
||
|
||
// 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 std::make_pair(keylet::loan(broker.brokerID, loanSequence), loanSequence);
|
||
}();
|
||
|
||
VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet);
|
||
|
||
// No loans yet
|
||
verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 1, 0, 0);
|
||
|
||
if (!BEAST_EXPECT(loanSequence != 0))
|
||
return;
|
||
|
||
testcase << caseLabel << " " << label;
|
||
|
||
using namespace jtx;
|
||
using namespace loan;
|
||
using namespace std::chrono_literals;
|
||
|
||
auto applyExponent = [interestExponent, this](TenthBips32 value) mutable {
|
||
BEAST_EXPECT(value > TenthBips32(0));
|
||
while (interestExponent > 0)
|
||
{
|
||
auto const oldValue = value;
|
||
value *= 10;
|
||
--interestExponent;
|
||
BEAST_EXPECT(value / 10 == oldValue);
|
||
}
|
||
while (interestExponent < 0)
|
||
{
|
||
auto const oldValue = value;
|
||
value /= 10;
|
||
++interestExponent;
|
||
BEAST_EXPECT(value * 10 == oldValue);
|
||
}
|
||
return value;
|
||
};
|
||
|
||
auto const borrowerOwnerCount = env.ownerCount(borrower);
|
||
|
||
auto const loanSetFee = env.current()->fees().base * 2;
|
||
LoanParameters const loanParams{
|
||
.account = borrower,
|
||
.counter = lender,
|
||
.counterpartyExplicit = false,
|
||
.principalRequest = loanAmount,
|
||
.setFee = loanSetFee,
|
||
.originationFee = 1,
|
||
.serviceFee = 2,
|
||
.lateFee = 3,
|
||
.closeFee = 4,
|
||
.overFee = applyExponent(percentageToTenthBips(5) / 10),
|
||
.interest = applyExponent(percentageToTenthBips(12)),
|
||
// 2.4%
|
||
.lateInterest = applyExponent(percentageToTenthBips(24) / 10),
|
||
.closeInterest = applyExponent(percentageToTenthBips(36) / 10),
|
||
.overpaymentInterest = applyExponent(percentageToTenthBips(48) / 10),
|
||
.payTotal = 12,
|
||
.payInterval = 600,
|
||
.gracePd = 60,
|
||
.flags = flags,
|
||
};
|
||
Number const principalRequestAmount = broker.asset(loanParams.principalRequest).value();
|
||
auto const originationFeeAmount = broker.asset(*loanParams.originationFee).value();
|
||
auto const serviceFeeAmount = broker.asset(*loanParams.serviceFee).value();
|
||
auto const lateFeeAmount = broker.asset(*loanParams.lateFee).value();
|
||
auto const closeFeeAmount = broker.asset(*loanParams.closeFee).value();
|
||
|
||
auto const borrowerStartbalance = env.balance(borrower, broker.asset);
|
||
|
||
auto createJtx = loanParams(env, broker);
|
||
// Successfully create a Loan
|
||
env(createJtx);
|
||
|
||
env.close();
|
||
|
||
auto const startDate = env.current()->header().parentCloseTime.time_since_epoch().count();
|
||
|
||
if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
|
||
BEAST_EXPECT(brokerSle))
|
||
{
|
||
BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 1);
|
||
}
|
||
|
||
{
|
||
// Need to account for fees if the loan is in XRP
|
||
PrettyAmount adjustment = broker.asset(0);
|
||
if (broker.asset.native())
|
||
{
|
||
adjustment = 2 * env.current()->fees().base;
|
||
}
|
||
|
||
BEAST_EXPECT(
|
||
env.balance(borrower, broker.asset).value() ==
|
||
borrowerStartbalance.value() + principalRequestAmount - originationFeeAmount -
|
||
adjustment.value());
|
||
}
|
||
|
||
auto const loanFlags =
|
||
createJtx.stx->isFlag(tfLoanOverpayment) ? lsfLoanOverpayment : LedgerSpecificFlags(0);
|
||
|
||
if (auto loan = env.le(keylet); BEAST_EXPECT(loan))
|
||
{
|
||
// log << "loan after create: " << to_string(loan->getJson())
|
||
// << std::endl;
|
||
BEAST_EXPECT(
|
||
loan->isFlag(lsfLoanOverpayment) == createJtx.stx->isFlag(tfLoanOverpayment));
|
||
BEAST_EXPECT(loan->at(sfLoanSequence) == loanSequence);
|
||
BEAST_EXPECT(loan->at(sfBorrower) == borrower.id());
|
||
BEAST_EXPECT(loan->at(sfLoanBrokerID) == broker.brokerID);
|
||
BEAST_EXPECT(loan->at(sfLoanOriginationFee) == originationFeeAmount);
|
||
BEAST_EXPECT(loan->at(sfLoanServiceFee) == serviceFeeAmount);
|
||
BEAST_EXPECT(loan->at(sfLatePaymentFee) == lateFeeAmount);
|
||
BEAST_EXPECT(loan->at(sfClosePaymentFee) == closeFeeAmount);
|
||
BEAST_EXPECT(loan->at(sfOverpaymentFee) == *loanParams.overFee);
|
||
BEAST_EXPECT(loan->at(sfInterestRate) == *loanParams.interest);
|
||
BEAST_EXPECT(loan->at(sfLateInterestRate) == *loanParams.lateInterest);
|
||
BEAST_EXPECT(loan->at(sfCloseInterestRate) == *loanParams.closeInterest);
|
||
BEAST_EXPECT(loan->at(sfOverpaymentInterestRate) == *loanParams.overpaymentInterest);
|
||
BEAST_EXPECT(loan->at(sfStartDate) == startDate);
|
||
BEAST_EXPECT(loan->at(sfPaymentInterval) == *loanParams.payInterval);
|
||
BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd);
|
||
BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == 0);
|
||
BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == startDate + *loanParams.payInterval);
|
||
BEAST_EXPECT(loan->at(sfPaymentRemaining) == *loanParams.payTotal);
|
||
BEAST_EXPECT(
|
||
loan->at(sfLoanScale) >=
|
||
(broker.asset.integral()
|
||
? 0
|
||
: std::max(broker.vaultScale(env), principalRequestAmount.exponent())));
|
||
BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalRequestAmount);
|
||
}
|
||
|
||
auto state = getCurrentState(env, broker, keylet, verifyLoanStatus);
|
||
|
||
auto const loanProperties = computeLoanProperties(
|
||
broker.asset.raw(),
|
||
state.principalOutstanding,
|
||
state.interestRate,
|
||
state.paymentInterval,
|
||
state.paymentRemaining,
|
||
broker.params.managementFeeRate,
|
||
state.loanScale);
|
||
|
||
verifyLoanStatus(
|
||
0,
|
||
startDate + *loanParams.payInterval,
|
||
*loanParams.payTotal,
|
||
state.loanScale,
|
||
loanProperties.loanState.valueOutstanding,
|
||
principalRequestAmount,
|
||
loanProperties.loanState.managementFeeDue,
|
||
loanProperties.periodicPayment,
|
||
loanFlags | 0);
|
||
|
||
// Manage the loan
|
||
// no-op
|
||
env(manage(lender, keylet.key, 0));
|
||
{
|
||
// no flags
|
||
auto jt = manage(lender, keylet.key, 0);
|
||
jt.removeMember(sfFlags.getName());
|
||
env(jt);
|
||
}
|
||
// Only the lender can manage
|
||
env(manage(evan, keylet.key, 0), ter(tecNO_PERMISSION));
|
||
// unknown flags
|
||
env(manage(lender, keylet.key, tfLoanManageMask), ter(temINVALID_FLAG));
|
||
// combinations of flags are not allowed
|
||
env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair), ter(temINVALID_FLAG));
|
||
env(manage(lender, keylet.key, tfLoanImpair | tfLoanDefault), ter(temINVALID_FLAG));
|
||
env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanDefault), ter(temINVALID_FLAG));
|
||
env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair | tfLoanDefault),
|
||
ter(temINVALID_FLAG));
|
||
// invalid loan ID
|
||
env(manage(lender, broker.brokerID, tfLoanImpair), ter(tecNO_ENTRY));
|
||
// Loan is unimpaired, can't unimpair it again
|
||
env(manage(lender, keylet.key, tfLoanUnimpair), ter(tecNO_PERMISSION));
|
||
// Loan is unimpaired, it can go into default, but only after it's past
|
||
// due
|
||
env(manage(lender, keylet.key, tfLoanDefault), ter(tecTOO_SOON));
|
||
|
||
// Check the vault
|
||
bool const canImpair = canImpairLoan(env, broker, state);
|
||
// Impair the loan, if possible
|
||
env(manage(lender, keylet.key, tfLoanImpair),
|
||
canImpair ? ter(tesSUCCESS) : ter(tecLIMIT_EXCEEDED));
|
||
// Unimpair the loan
|
||
env(manage(lender, keylet.key, tfLoanUnimpair),
|
||
canImpair ? ter(tesSUCCESS) : ter(tecNO_PERMISSION));
|
||
|
||
auto const nextDueDate = startDate + *loanParams.payInterval;
|
||
|
||
env.close();
|
||
|
||
verifyLoanStatus(
|
||
0,
|
||
nextDueDate,
|
||
*loanParams.payTotal,
|
||
loanProperties.loanScale,
|
||
loanProperties.loanState.valueOutstanding,
|
||
principalRequestAmount,
|
||
loanProperties.loanState.managementFeeDue,
|
||
loanProperties.periodicPayment,
|
||
loanFlags | 0);
|
||
|
||
// Can't delete the loan yet. It has payments remaining.
|
||
env(del(lender, keylet.key), ter(tecHAS_OBLIGATIONS));
|
||
|
||
if (BEAST_EXPECT(toEndOfLife))
|
||
toEndOfLife(keylet, verifyLoanStatus);
|
||
env.close();
|
||
|
||
// Verify the loan is at EOL
|
||
if (auto loan = env.le(keylet); BEAST_EXPECT(loan))
|
||
{
|
||
BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0);
|
||
BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == 0);
|
||
}
|
||
auto const borrowerStartingBalance = env.balance(borrower, broker.asset);
|
||
|
||
// Try to delete the loan broker with an active loan
|
||
env(loanBroker::del(lender, broker.brokerID), ter(tecHAS_OBLIGATIONS));
|
||
// Ensure the above tx doesn't get ordered after the LoanDelete and
|
||
// delete our broker!
|
||
env.close();
|
||
|
||
// Test failure cases
|
||
env(del(lender, keylet.key, tfLoanOverpayment), ter(temINVALID_FLAG));
|
||
env(del(evan, keylet.key), ter(tecNO_PERMISSION));
|
||
env(del(lender, broker.brokerID), ter(tecNO_ENTRY));
|
||
|
||
// Delete the loan
|
||
// Either the borrower or the lender can delete the loan. Alternate
|
||
// between who does it across tests.
|
||
static unsigned deleteCounter = 0;
|
||
auto const deleter = ((++deleteCounter % 2) != 0u) ? lender : borrower;
|
||
env(del(deleter, keylet.key));
|
||
env.close();
|
||
|
||
PrettyAmount adjustment = broker.asset(0);
|
||
if (deleter == borrower)
|
||
{
|
||
// Need to account for fees if the loan is in XRP
|
||
if (broker.asset.native())
|
||
{
|
||
adjustment = env.current()->fees().base;
|
||
}
|
||
}
|
||
|
||
// No loans left
|
||
verifyLoanStatus.checkBroker(0, 0, *loanParams.interest, 1, 0, 0);
|
||
|
||
BEAST_EXPECT(
|
||
env.balance(borrower, broker.asset).value() ==
|
||
borrowerStartingBalance.value() - adjustment);
|
||
BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount);
|
||
|
||
if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
|
||
BEAST_EXPECT(brokerSle))
|
||
{
|
||
BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
|
||
}
|
||
}
|
||
|
||
static std::string
|
||
getCurrencyLabel(Asset const& asset)
|
||
{
|
||
if (asset.native())
|
||
return "XRP";
|
||
if (asset.holds<Issue>())
|
||
return "IOU";
|
||
if (asset.holds<MPTIssue>())
|
||
return "MPT";
|
||
return "Unknown";
|
||
}
|
||
|
||
/** Wrapper to run a series of lifecycle tests for a given asset and loan
|
||
* amount
|
||
*
|
||
* Will be used in the future to vary the loan parameters. For now, it is
|
||
* only called once.
|
||
*
|
||
* Tests a bunch of LoanSet failure conditions before lifecycle.
|
||
*/
|
||
template <class TAsset, std::size_t NAsset>
|
||
void
|
||
testCaseWrapper(
|
||
jtx::Env& env,
|
||
jtx::MPTTester& mptt,
|
||
std::array<TAsset, NAsset> const& assets,
|
||
BrokerInfo const& broker,
|
||
Number const& loanAmount,
|
||
int interestExponent)
|
||
{
|
||
using namespace jtx;
|
||
using namespace Lending;
|
||
|
||
auto const& asset = broker.asset.raw();
|
||
auto const currencyLabel = getCurrencyLabel(asset);
|
||
auto const caseLabel = [&]() {
|
||
std::stringstream ss;
|
||
ss << "Lifecycle: " << loanAmount << " " << currencyLabel
|
||
<< " Scale interest to: " << interestExponent << " ";
|
||
return ss.str();
|
||
}();
|
||
testcase << caseLabel;
|
||
|
||
using namespace loan;
|
||
using namespace std::chrono_literals;
|
||
using d = NetClock::duration;
|
||
using tp = NetClock::time_point;
|
||
|
||
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"};
|
||
|
||
Number const principalRequest = broker.asset(loanAmount).value();
|
||
Number const maxCoveredLoanValue = broker.params.maxCoveredLoanValue(0);
|
||
BEAST_EXPECT(maxCoveredLoanValue == 1000 * 100 / 10);
|
||
Number const maxCoveredLoanRequest = broker.asset(maxCoveredLoanValue).value();
|
||
Number const totalVaultRequest = broker.asset(broker.params.vaultDeposit).value();
|
||
Number const debtMaximumRequest = broker.asset(broker.params.debtMax).value();
|
||
|
||
auto const loanSetFee = fee(env.current()->fees().base * 2);
|
||
|
||
auto const pseudoAcct = [&]() {
|
||
auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
|
||
if (!BEAST_EXPECT(brokerSle))
|
||
return Account{lender};
|
||
auto const brokerPseudo = brokerSle->at(sfAccount);
|
||
return Account("Broker pseudo-account", brokerPseudo);
|
||
}();
|
||
|
||
auto const baseFee = env.current()->fees().base;
|
||
|
||
auto badKeylet = keylet::vault(lender.id(), env.seq(lender));
|
||
// Try some failure cases
|
||
// flags are checked first
|
||
env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask),
|
||
sig(sfCounterpartySignature, lender),
|
||
loanSetFee,
|
||
ter(temINVALID_FLAG));
|
||
|
||
// field length validation
|
||
// sfData: good length, bad account
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, borrower),
|
||
data(std::string(maxDataPayloadLength, 'X')),
|
||
loanSetFee,
|
||
ter(tefBAD_AUTH));
|
||
// sfData: too long
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
data(std::string(maxDataPayloadLength + 1, 'Y')),
|
||
loanSetFee,
|
||
ter(temINVALID));
|
||
|
||
// field range validation
|
||
// sfOverpaymentFee: good value, bad account
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, borrower),
|
||
overpaymentFee(maxOverpaymentFee),
|
||
loanSetFee,
|
||
ter(tefBAD_AUTH));
|
||
// sfOverpaymentFee: too big
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
overpaymentFee(maxOverpaymentFee + 1),
|
||
loanSetFee,
|
||
ter(temINVALID));
|
||
|
||
// sfInterestRate: good value, bad account
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, borrower),
|
||
interestRate(maxInterestRate),
|
||
loanSetFee,
|
||
ter(tefBAD_AUTH));
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, borrower),
|
||
interestRate(TenthBips32(0)),
|
||
loanSetFee,
|
||
ter(tefBAD_AUTH));
|
||
// sfInterestRate: too big
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
interestRate(maxInterestRate + 1),
|
||
loanSetFee,
|
||
ter(temINVALID));
|
||
// sfInterestRate: too small
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
interestRate(TenthBips32(-1)),
|
||
loanSetFee,
|
||
ter(temINVALID));
|
||
|
||
// sfLateInterestRate: good value, bad account
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, borrower),
|
||
lateInterestRate(maxLateInterestRate),
|
||
loanSetFee,
|
||
ter(tefBAD_AUTH));
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, borrower),
|
||
lateInterestRate(TenthBips32(0)),
|
||
loanSetFee,
|
||
ter(tefBAD_AUTH));
|
||
// sfLateInterestRate: too big
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
lateInterestRate(maxLateInterestRate + 1),
|
||
loanSetFee,
|
||
ter(temINVALID));
|
||
// sfLateInterestRate: too small
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
lateInterestRate(TenthBips32(-1)),
|
||
loanSetFee,
|
||
ter(temINVALID));
|
||
|
||
// sfCloseInterestRate: good value, bad account
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, borrower),
|
||
closeInterestRate(maxCloseInterestRate),
|
||
loanSetFee,
|
||
ter(tefBAD_AUTH));
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, borrower),
|
||
closeInterestRate(TenthBips32(0)),
|
||
loanSetFee,
|
||
ter(tefBAD_AUTH));
|
||
// sfCloseInterestRate: too big
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
closeInterestRate(maxCloseInterestRate + 1),
|
||
loanSetFee,
|
||
ter(temINVALID));
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
closeInterestRate(TenthBips32(-1)),
|
||
loanSetFee,
|
||
ter(temINVALID));
|
||
|
||
// sfOverpaymentInterestRate: good value, bad account
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, borrower),
|
||
overpaymentInterestRate(maxOverpaymentInterestRate),
|
||
loanSetFee,
|
||
ter(tefBAD_AUTH));
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, borrower),
|
||
overpaymentInterestRate(TenthBips32(0)),
|
||
loanSetFee,
|
||
ter(tefBAD_AUTH));
|
||
// sfOverpaymentInterestRate: too big
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
overpaymentInterestRate(maxOverpaymentInterestRate + 1),
|
||
loanSetFee,
|
||
ter(temINVALID));
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
overpaymentInterestRate(TenthBips32(-1)),
|
||
loanSetFee,
|
||
ter(temINVALID));
|
||
|
||
// sfPaymentTotal: good value, bad account
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, borrower),
|
||
paymentTotal(LoanSet::minPaymentTotal),
|
||
loanSetFee,
|
||
ter(tefBAD_AUTH));
|
||
// sfPaymentTotal: too small (there is no max)
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
paymentTotal(LoanSet::minPaymentTotal - 1),
|
||
loanSetFee,
|
||
ter(temINVALID));
|
||
|
||
// sfPaymentInterval: good value, bad account
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, borrower),
|
||
paymentInterval(LoanSet::minPaymentInterval),
|
||
loanSetFee,
|
||
ter(tefBAD_AUTH));
|
||
// sfPaymentInterval: too small (there is no max)
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
paymentInterval(LoanSet::minPaymentInterval - 1),
|
||
loanSetFee,
|
||
ter(temINVALID));
|
||
|
||
// sfGracePeriod: good value, bad account
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, borrower),
|
||
paymentInterval(LoanSet::minPaymentInterval * 2),
|
||
gracePeriod(LoanSet::minPaymentInterval * 2),
|
||
loanSetFee,
|
||
ter(tefBAD_AUTH));
|
||
// sfGracePeriod: larger than paymentInterval
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
paymentInterval(LoanSet::minPaymentInterval * 2),
|
||
gracePeriod(LoanSet::minPaymentInterval * 3),
|
||
loanSetFee,
|
||
ter(temINVALID));
|
||
|
||
// insufficient fee - single sign
|
||
env(set(borrower, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
ter(telINSUF_FEE_P));
|
||
// insufficient fee - multisign
|
||
env(signers(lender, 2, {{evan, 1}, {borrower, 1}}));
|
||
env(signers(borrower, 2, {{evan, 1}, {lender, 1}}));
|
||
env(set(borrower, broker.brokerID, principalRequest),
|
||
counterparty(lender),
|
||
msig(evan, lender),
|
||
msig(sfCounterpartySignature, evan, borrower),
|
||
fee(env.current()->fees().base * 5 - 1),
|
||
ter(telINSUF_FEE_P));
|
||
// Bad multisign signatures for borrower (Account)
|
||
env(set(borrower, broker.brokerID, principalRequest),
|
||
counterparty(lender),
|
||
msig(alice, issuer),
|
||
msig(sfCounterpartySignature, evan, borrower),
|
||
fee(env.current()->fees().base * 5),
|
||
ter(tefBAD_SIGNATURE));
|
||
// Bad multisign signatures for issuer (Counterparty)
|
||
env(set(borrower, broker.brokerID, principalRequest),
|
||
counterparty(lender),
|
||
msig(evan, lender),
|
||
msig(sfCounterpartySignature, alice, issuer),
|
||
fee(env.current()->fees().base * 5 - 1),
|
||
ter(tefBAD_SIGNATURE));
|
||
env(signers(lender, none));
|
||
env(signers(borrower, none));
|
||
// multisign sufficient fee, but no signers set up
|
||
env(set(borrower, broker.brokerID, principalRequest),
|
||
counterparty(lender),
|
||
msig(evan, lender),
|
||
msig(sfCounterpartySignature, evan, borrower),
|
||
fee(env.current()->fees().base * 5),
|
||
ter(tefNOT_MULTI_SIGNING));
|
||
// not the broker owner, no counterparty, not signed by broker
|
||
// owner
|
||
env(set(borrower, broker.brokerID, principalRequest),
|
||
sig(sfCounterpartySignature, evan),
|
||
loanSetFee,
|
||
ter(tefBAD_AUTH));
|
||
// not the broker owner, counterparty is borrower
|
||
env(set(evan, broker.brokerID, principalRequest),
|
||
counterparty(borrower),
|
||
sig(sfCounterpartySignature, borrower),
|
||
loanSetFee,
|
||
ter(tecNO_PERMISSION));
|
||
// not a LoanBroker object, no counterparty
|
||
env(set(lender, badKeylet.key, principalRequest),
|
||
sig(sfCounterpartySignature, evan),
|
||
loanSetFee,
|
||
ter(temBAD_SIGNER));
|
||
// not a LoanBroker object, counterparty is valid
|
||
env(set(lender, badKeylet.key, principalRequest),
|
||
counterparty(borrower),
|
||
sig(sfCounterpartySignature, borrower),
|
||
loanSetFee,
|
||
ter(tecNO_ENTRY));
|
||
// borrower doesn't exist
|
||
env(set(lender, broker.brokerID, principalRequest),
|
||
counterparty(alice),
|
||
sig(sfCounterpartySignature, alice),
|
||
loanSetFee,
|
||
ter(terNO_ACCOUNT));
|
||
|
||
// Request more funds than the vault has available
|
||
env(set(evan, broker.brokerID, totalVaultRequest + 1),
|
||
sig(sfCounterpartySignature, lender),
|
||
loanSetFee,
|
||
ter(tecINSUFFICIENT_FUNDS));
|
||
|
||
// Request more funds than the broker's first-loss capital can
|
||
// cover.
|
||
env(set(evan, broker.brokerID, maxCoveredLoanRequest + 1),
|
||
sig(sfCounterpartySignature, lender),
|
||
loanSetFee,
|
||
ter(tecINSUFFICIENT_FUNDS));
|
||
|
||
// Frozen trust line / locked MPT issuance
|
||
// XRP can not be frozen, but run through the loop anyway to test
|
||
// the tecLIMIT_EXCEEDED case
|
||
{
|
||
auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
|
||
if (!BEAST_EXPECT(brokerSle))
|
||
return;
|
||
|
||
auto const vaultPseudo = [&]() {
|
||
auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID)));
|
||
if (!BEAST_EXPECT(vaultSle))
|
||
{
|
||
// This will be wrong, but the test has failed anyway.
|
||
return Account{lender};
|
||
}
|
||
auto vaultPseudo = Account("Vault pseudo-account", vaultSle->at(sfAccount));
|
||
return vaultPseudo;
|
||
}();
|
||
|
||
auto const [freeze, deepfreeze, unfreeze, expectedResult] =
|
||
[&]() -> std::tuple<
|
||
std::function<void(Account const& holder)>,
|
||
std::function<void(Account const& holder)>,
|
||
std::function<void(Account const& holder)>,
|
||
TER> {
|
||
// Freeze / lock the asset
|
||
std::function<void(Account const& holder)> empty;
|
||
if (broker.asset.native())
|
||
{
|
||
// XRP can't be frozen
|
||
return std::make_tuple(empty, empty, empty, tesSUCCESS);
|
||
}
|
||
if (broker.asset.holds<Issue>())
|
||
{
|
||
auto freeze = [&](Account const& holder) {
|
||
env(trust(issuer, holder[iouCurrency](0), tfSetFreeze));
|
||
};
|
||
auto deepfreeze = [&](Account const& holder) {
|
||
env(trust(issuer, holder[iouCurrency](0), tfSetFreeze | tfSetDeepFreeze));
|
||
};
|
||
auto unfreeze = [&](Account const& holder) {
|
||
env(trust(
|
||
issuer, holder[iouCurrency](0), tfClearFreeze | tfClearDeepFreeze));
|
||
};
|
||
return std::make_tuple(freeze, deepfreeze, unfreeze, tecFROZEN);
|
||
}
|
||
|
||
auto freeze = [&](Account const& holder) {
|
||
mptt.set({.account = issuer, .holder = holder, .flags = tfMPTLock});
|
||
};
|
||
auto unfreeze = [&](Account const& holder) {
|
||
mptt.set({.account = issuer, .holder = holder, .flags = tfMPTUnlock});
|
||
};
|
||
return std::make_tuple(freeze, empty, unfreeze, tecLOCKED);
|
||
}();
|
||
|
||
// Try freezing the accounts that can't be frozen
|
||
if (freeze)
|
||
{
|
||
for (auto const& account : {vaultPseudo, evan})
|
||
{
|
||
// Freeze the account
|
||
freeze(account);
|
||
|
||
// Try to create a loan with a frozen line
|
||
env(set(evan, broker.brokerID, debtMaximumRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
loanSetFee,
|
||
ter(expectedResult));
|
||
|
||
// Unfreeze the account
|
||
BEAST_EXPECT(unfreeze);
|
||
unfreeze(account);
|
||
|
||
// Ensure the line is unfrozen with a request that is fine
|
||
// except too it requests more principal than the broker can
|
||
// carry
|
||
env(set(evan, broker.brokerID, debtMaximumRequest + 1),
|
||
sig(sfCounterpartySignature, lender),
|
||
loanSetFee,
|
||
ter(tecLIMIT_EXCEEDED));
|
||
}
|
||
}
|
||
|
||
// Deep freeze the borrower, which prevents them from receiving
|
||
// funds
|
||
if (deepfreeze)
|
||
{
|
||
// Make sure evan has a trust line that so the issuer can
|
||
// freeze it. (Don't need to do this for the borrower,
|
||
// because LoanSet will create a line to the borrower
|
||
// automatically.)
|
||
env(trust(evan, issuer[iouCurrency](100'000)));
|
||
|
||
for (auto const& account : {// these accounts can't be frozen, which deep freeze
|
||
// implies
|
||
vaultPseudo,
|
||
evan,
|
||
// these accounts can't be deep frozen
|
||
lender})
|
||
{
|
||
// Freeze evan
|
||
deepfreeze(account);
|
||
|
||
// Try to create a loan with a deep frozen line
|
||
env(set(evan, broker.brokerID, debtMaximumRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
loanSetFee,
|
||
ter(expectedResult));
|
||
|
||
// Unfreeze evan
|
||
BEAST_EXPECT(unfreeze);
|
||
unfreeze(account);
|
||
|
||
// Ensure the line is unfrozen with a request that is fine
|
||
// except too it requests more principal than the broker can
|
||
// carry
|
||
env(set(evan, broker.brokerID, debtMaximumRequest + 1),
|
||
sig(sfCounterpartySignature, lender),
|
||
loanSetFee,
|
||
ter(tecLIMIT_EXCEEDED));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Finally! Create a loan
|
||
|
||
auto coverAvailable = [&env, this](uint256 const& brokerID, Number const& expected) {
|
||
if (auto const brokerSle = env.le(keylet::loanbroker(brokerID));
|
||
BEAST_EXPECT(brokerSle))
|
||
{
|
||
auto const available = brokerSle->at(sfCoverAvailable);
|
||
BEAST_EXPECT(available == expected);
|
||
return available;
|
||
}
|
||
return Number{};
|
||
};
|
||
auto getDefaultInfo = [&env, this](LoanState const& state, BrokerInfo const& broker) {
|
||
if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
|
||
BEAST_EXPECT(brokerSle))
|
||
{
|
||
BEAST_EXPECT(
|
||
state.loanScale >=
|
||
(broker.asset.integral()
|
||
? 0
|
||
: std::max(
|
||
broker.vaultScale(env), state.principalOutstanding.exponent())));
|
||
NumberRoundModeGuard mg(Number::upward);
|
||
auto const defaultAmount = roundToAsset(
|
||
broker.asset,
|
||
std::min(
|
||
tenthBipsOfValue(
|
||
tenthBipsOfValue(
|
||
brokerSle->at(sfDebtTotal), broker.params.coverRateMin),
|
||
broker.params.coverRateLiquidation),
|
||
state.totalValue - state.managementFeeOutstanding),
|
||
state.loanScale);
|
||
return std::make_pair(defaultAmount, brokerSle->at(sfOwner));
|
||
}
|
||
return std::make_pair(Number{}, AccountID{});
|
||
};
|
||
auto replenishCover = [&env, &coverAvailable](
|
||
BrokerInfo const& broker,
|
||
AccountID const& brokerAcct,
|
||
Number const& startingCoverAvailable,
|
||
Number const& amountToBeCovered) {
|
||
coverAvailable(broker.brokerID, startingCoverAvailable - amountToBeCovered);
|
||
env(loanBroker::coverDeposit(
|
||
brokerAcct, broker.brokerID, STAmount{broker.asset, amountToBeCovered}));
|
||
coverAvailable(broker.brokerID, startingCoverAvailable);
|
||
env.close();
|
||
};
|
||
|
||
auto defaultImmediately = [&](std::uint32_t baseFlag, bool impair = true) {
|
||
return [&, impair, baseFlag](
|
||
Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) {
|
||
// toEndOfLife
|
||
//
|
||
// Default the loan
|
||
|
||
// Initialize values with the current state
|
||
auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus);
|
||
BEAST_EXPECT(state.flags == baseFlag);
|
||
|
||
auto const& broker = verifyLoanStatus.broker;
|
||
auto const startingCoverAvailable = coverAvailable(
|
||
broker.brokerID, broker.asset(broker.params.coverDeposit).number());
|
||
|
||
if (impair)
|
||
{
|
||
// Check the vault
|
||
bool const canImpair = canImpairLoan(env, broker, state);
|
||
// Impair the loan, if possible
|
||
env(manage(lender, loanKeylet.key, tfLoanImpair),
|
||
canImpair ? ter(tesSUCCESS) : ter(tecLIMIT_EXCEEDED));
|
||
|
||
if (canImpair)
|
||
{
|
||
state.flags |= tfLoanImpair;
|
||
state.nextPaymentDate = env.now().time_since_epoch().count();
|
||
|
||
// Once the loan is impaired, it can't be impaired again
|
||
env(manage(lender, loanKeylet.key, tfLoanImpair), ter(tecNO_PERMISSION));
|
||
}
|
||
verifyLoanStatus(state);
|
||
}
|
||
|
||
auto const nextDueDate = tp{d{state.nextPaymentDate}};
|
||
|
||
// Can't default the loan yet. The grace period hasn't
|
||
// expired
|
||
env(manage(lender, loanKeylet.key, tfLoanDefault), ter(tecTOO_SOON));
|
||
|
||
// Let some time pass so that the loan can be
|
||
// defaulted
|
||
env.close(nextDueDate + 60s);
|
||
|
||
auto const [amountToBeCovered, brokerAcct] = getDefaultInfo(state, broker);
|
||
|
||
// Default the loan
|
||
env(manage(lender, loanKeylet.key, tfLoanDefault));
|
||
env.close();
|
||
|
||
// The LoanBroker just lost some of it's first-loss capital.
|
||
// Replenish it.
|
||
replenishCover(broker, brokerAcct, startingCoverAvailable, amountToBeCovered);
|
||
|
||
state.flags |= tfLoanDefault;
|
||
state.paymentRemaining = 0;
|
||
state.totalValue = 0;
|
||
state.principalOutstanding = 0;
|
||
state.managementFeeOutstanding = 0;
|
||
state.nextPaymentDate = 0;
|
||
verifyLoanStatus(state);
|
||
|
||
// Once a loan is defaulted, it can't be managed
|
||
env(manage(lender, loanKeylet.key, tfLoanUnimpair), ter(tecNO_PERMISSION));
|
||
env(manage(lender, loanKeylet.key, tfLoanImpair), ter(tecNO_PERMISSION));
|
||
// Can't make a payment on it either
|
||
env(pay(borrower, loanKeylet.key, broker.asset(300)), ter(tecKILLED));
|
||
};
|
||
};
|
||
|
||
auto singlePayment = [&](Keylet const& loanKeylet,
|
||
VerifyLoanStatus const& verifyLoanStatus,
|
||
LoanState& state,
|
||
STAmount const& payoffAmount,
|
||
std::uint32_t numPayments,
|
||
std::uint32_t baseFlag,
|
||
std::uint32_t txFlags) {
|
||
// toEndOfLife
|
||
//
|
||
verifyLoanStatus(state);
|
||
|
||
// Send some bogus pay transactions
|
||
env(pay(borrower, keylet::loan(uint256(0)).key, broker.asset(10), txFlags),
|
||
ter(temINVALID));
|
||
// broker.asset(80) is less than a single payment, but all these
|
||
// checks fail before that matters
|
||
env(pay(borrower, loanKeylet.key, broker.asset(-80), txFlags), ter(temBAD_AMOUNT));
|
||
env(pay(borrower, broker.brokerID, broker.asset(80), txFlags), ter(tecNO_ENTRY));
|
||
env(pay(evan, loanKeylet.key, broker.asset(80), txFlags), ter(tecNO_PERMISSION));
|
||
|
||
// TODO: Write a general "isFlag" function? See STObject::isFlag.
|
||
// Maybe add a static overloaded member?
|
||
if (!(state.flags & lsfLoanOverpayment))
|
||
{
|
||
// If the loan does not allow overpayments, send a payment that
|
||
// tries to make an overpayment. Do not include `txFlags`, so we
|
||
// don't end up duplicating the next test transaction.
|
||
env(pay(borrower,
|
||
loanKeylet.key,
|
||
STAmount{broker.asset, state.periodicPayment * Number{15, -1}},
|
||
tfLoanOverpayment),
|
||
fee(XRPAmount{baseFee * (Number{15, -1} / loanPaymentsPerFeeIncrement + 1)}),
|
||
ter(temINVALID_FLAG));
|
||
}
|
||
// Try to send a payment marked as multiple mutually exclusive
|
||
// payment types. Do not include `txFlags`, so we don't duplicate
|
||
// the prior test transaction.
|
||
env(pay(borrower,
|
||
loanKeylet.key,
|
||
broker.asset(state.periodicPayment * 2),
|
||
tfLoanLatePayment | tfLoanFullPayment),
|
||
ter(temINVALID_FLAG));
|
||
env(pay(borrower,
|
||
loanKeylet.key,
|
||
broker.asset(state.periodicPayment * 2),
|
||
tfLoanLatePayment | tfLoanOverpayment),
|
||
ter(temINVALID_FLAG));
|
||
env(pay(borrower,
|
||
loanKeylet.key,
|
||
broker.asset(state.periodicPayment * 2),
|
||
tfLoanOverpayment | tfLoanFullPayment),
|
||
ter(temINVALID_FLAG));
|
||
env(pay(borrower,
|
||
loanKeylet.key,
|
||
broker.asset(state.periodicPayment * 2),
|
||
tfLoanLatePayment | tfLoanOverpayment | tfLoanFullPayment),
|
||
ter(temINVALID_FLAG));
|
||
|
||
{
|
||
auto const otherAsset =
|
||
broker.asset.raw() == assets[0].raw() ? assets[1] : assets[0];
|
||
env(pay(borrower, loanKeylet.key, otherAsset(100), txFlags), ter(tecWRONG_ASSET));
|
||
}
|
||
|
||
// Amount doesn't cover a single payment
|
||
env(pay(borrower, loanKeylet.key, STAmount{broker.asset, 1}, txFlags),
|
||
ter(tecINSUFFICIENT_PAYMENT));
|
||
|
||
// Get the balance after these failed transactions take
|
||
// fees
|
||
auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset);
|
||
|
||
BEAST_EXPECT(payoffAmount > state.principalOutstanding);
|
||
// Try to pay a little extra to show that it's _not_
|
||
// taken
|
||
auto const transactionAmount = payoffAmount + broker.asset(10);
|
||
|
||
// Send a transaction that tries to pay more than the borrowers's
|
||
// balance
|
||
XRPAmount const badFee{
|
||
baseFee *
|
||
(borrowerBalanceBeforePayment.number() * 2 / state.periodicPayment /
|
||
loanPaymentsPerFeeIncrement +
|
||
1)};
|
||
env(pay(borrower,
|
||
loanKeylet.key,
|
||
STAmount{broker.asset, borrowerBalanceBeforePayment.number() * 2},
|
||
txFlags),
|
||
fee(badFee),
|
||
ter(tecINSUFFICIENT_FUNDS));
|
||
|
||
XRPAmount const goodFee{baseFee * (numPayments / loanPaymentsPerFeeIncrement + 1)};
|
||
env(pay(borrower, loanKeylet.key, transactionAmount, txFlags), fee(goodFee));
|
||
|
||
env.close();
|
||
|
||
// log << env.meta()->getJson() << std::endl;
|
||
|
||
// Need to account for fees if the loan is in XRP
|
||
PrettyAmount adjustment = broker.asset(0);
|
||
if (broker.asset.native())
|
||
{
|
||
adjustment = badFee + goodFee;
|
||
}
|
||
|
||
state.paymentRemaining = 0;
|
||
state.principalOutstanding = 0;
|
||
state.totalValue = 0;
|
||
state.managementFeeOutstanding = 0;
|
||
state.previousPaymentDate =
|
||
state.nextPaymentDate + (state.paymentInterval * (numPayments - 1));
|
||
state.nextPaymentDate = 0;
|
||
verifyLoanStatus(state);
|
||
|
||
verifyLoanStatus.checkPayment(
|
||
state.loanScale, borrower, borrowerBalanceBeforePayment, payoffAmount, adjustment);
|
||
|
||
// Can't impair or default a paid off loan
|
||
env(manage(lender, loanKeylet.key, tfLoanImpair), ter(tecNO_PERMISSION));
|
||
env(manage(lender, loanKeylet.key, tfLoanDefault), ter(tecNO_PERMISSION));
|
||
};
|
||
|
||
auto fullPayment = [&](std::uint32_t baseFlag) {
|
||
return [&, baseFlag](
|
||
Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) {
|
||
// toEndOfLife
|
||
//
|
||
auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus);
|
||
env.close(state.startDate + 20s);
|
||
auto const loanAge = (env.now() - state.startDate).count();
|
||
BEAST_EXPECT(loanAge == 30);
|
||
|
||
// Full payoff amount will consist of
|
||
// 1. principal outstanding (1000)
|
||
// 2. accrued interest (at 12%)
|
||
// 3. prepayment penalty (closeInterest at 3.6%)
|
||
// 4. close payment fee (4)
|
||
// Calculate these values without the helper functions
|
||
// to verify they're working correctly The numbers in
|
||
// the below BEAST_EXPECTs may not hold across assets.
|
||
Number const interval = state.paymentInterval;
|
||
auto const periodicRate = interval * Number(12, -2) / secondsInYear;
|
||
BEAST_EXPECT(
|
||
periodicRate == Number(2283105022831050228ULL, -24, Number::normalized{}));
|
||
STAmount const principalOutstanding{broker.asset, state.principalOutstanding};
|
||
STAmount const accruedInterest{
|
||
broker.asset, state.principalOutstanding * periodicRate * loanAge / interval};
|
||
BEAST_EXPECT(accruedInterest == broker.asset(Number(1141552511415525, -19)));
|
||
STAmount const prepaymentPenalty{
|
||
broker.asset, state.principalOutstanding * Number(36, -3)};
|
||
BEAST_EXPECT(prepaymentPenalty == broker.asset(36));
|
||
STAmount const closePaymentFee = broker.asset(4);
|
||
auto const payoffAmount = roundToScale(
|
||
principalOutstanding + accruedInterest + prepaymentPenalty + closePaymentFee,
|
||
state.loanScale);
|
||
BEAST_EXPECT(
|
||
payoffAmount ==
|
||
roundToAsset(
|
||
broker.asset,
|
||
broker.asset(Number(1040000114155251, -12)).number(),
|
||
state.loanScale));
|
||
|
||
// The terms of this loan actually make the early payoff
|
||
// more expensive than just making payments
|
||
BEAST_EXPECT(
|
||
payoffAmount >
|
||
state.paymentRemaining * (state.periodicPayment + broker.asset(2).value()));
|
||
|
||
singlePayment(
|
||
loanKeylet,
|
||
verifyLoanStatus,
|
||
state,
|
||
payoffAmount,
|
||
1,
|
||
baseFlag,
|
||
tfLoanFullPayment);
|
||
};
|
||
};
|
||
|
||
auto combineAllPayments = [&](std::uint32_t baseFlag) {
|
||
return
|
||
[&, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) {
|
||
// toEndOfLife
|
||
//
|
||
|
||
auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus);
|
||
env.close();
|
||
|
||
BEAST_EXPECT(
|
||
STAmount(broker.asset, state.periodicPayment) ==
|
||
broker.asset(Number(8333457002039338267, -17)));
|
||
|
||
// Make all the payments in one transaction
|
||
// service fee is 2
|
||
auto const startingPayments = state.paymentRemaining;
|
||
STAmount const payoffAmount = [&]() {
|
||
NumberRoundModeGuard mg(Number::upward);
|
||
auto const rawPayoff =
|
||
startingPayments * (state.periodicPayment + broker.asset(2).value());
|
||
STAmount payoffAmount{broker.asset, rawPayoff};
|
||
BEAST_EXPECTS(
|
||
payoffAmount == broker.asset(Number(1024014840244721, -12)),
|
||
to_string(payoffAmount));
|
||
BEAST_EXPECT(payoffAmount > state.principalOutstanding);
|
||
|
||
payoffAmount = roundToScale(payoffAmount, state.loanScale);
|
||
|
||
return payoffAmount;
|
||
}();
|
||
|
||
auto const totalPayoffValue =
|
||
state.totalValue + startingPayments * broker.asset(2).value();
|
||
STAmount const totalPayoffAmount{broker.asset, totalPayoffValue};
|
||
|
||
BEAST_EXPECTS(
|
||
totalPayoffAmount == payoffAmount,
|
||
"Payoff amount: " + to_string(payoffAmount) +
|
||
". Total Value: " + to_string(totalPayoffAmount));
|
||
|
||
singlePayment(
|
||
loanKeylet,
|
||
verifyLoanStatus,
|
||
state,
|
||
payoffAmount,
|
||
state.paymentRemaining,
|
||
baseFlag,
|
||
0);
|
||
};
|
||
};
|
||
|
||
// There are a lot of fields that can be set on a loan, but most
|
||
// of them only affect the "math" when a payment is made. The
|
||
// only one that really affects behavior is the
|
||
// `tfLoanOverpayment` flag.
|
||
lifecycle(
|
||
caseLabel,
|
||
"Loan overpayment allowed - Impair and Default",
|
||
env,
|
||
loanAmount,
|
||
interestExponent,
|
||
lender,
|
||
borrower,
|
||
evan,
|
||
broker,
|
||
pseudoAcct,
|
||
tfLoanOverpayment,
|
||
defaultImmediately(lsfLoanOverpayment));
|
||
|
||
lifecycle(
|
||
caseLabel,
|
||
"Loan overpayment prohibited - Impair and Default",
|
||
env,
|
||
loanAmount,
|
||
interestExponent,
|
||
lender,
|
||
borrower,
|
||
evan,
|
||
broker,
|
||
pseudoAcct,
|
||
0,
|
||
defaultImmediately(0));
|
||
|
||
lifecycle(
|
||
caseLabel,
|
||
"Loan overpayment allowed - Default without Impair",
|
||
env,
|
||
loanAmount,
|
||
interestExponent,
|
||
lender,
|
||
borrower,
|
||
evan,
|
||
broker,
|
||
pseudoAcct,
|
||
tfLoanOverpayment,
|
||
defaultImmediately(lsfLoanOverpayment, false));
|
||
|
||
lifecycle(
|
||
caseLabel,
|
||
"Loan overpayment prohibited - Default without Impair",
|
||
env,
|
||
loanAmount,
|
||
interestExponent,
|
||
lender,
|
||
borrower,
|
||
evan,
|
||
broker,
|
||
pseudoAcct,
|
||
0,
|
||
defaultImmediately(0, false));
|
||
|
||
lifecycle(
|
||
caseLabel,
|
||
"Loan overpayment prohibited - Pay off immediately",
|
||
env,
|
||
loanAmount,
|
||
interestExponent,
|
||
lender,
|
||
borrower,
|
||
evan,
|
||
broker,
|
||
pseudoAcct,
|
||
0,
|
||
fullPayment(0));
|
||
|
||
lifecycle(
|
||
caseLabel,
|
||
"Loan overpayment allowed - Pay off immediately",
|
||
env,
|
||
loanAmount,
|
||
interestExponent,
|
||
lender,
|
||
borrower,
|
||
evan,
|
||
broker,
|
||
pseudoAcct,
|
||
tfLoanOverpayment,
|
||
fullPayment(lsfLoanOverpayment));
|
||
|
||
lifecycle(
|
||
caseLabel,
|
||
"Loan overpayment prohibited - Combine all payments",
|
||
env,
|
||
loanAmount,
|
||
interestExponent,
|
||
lender,
|
||
borrower,
|
||
evan,
|
||
broker,
|
||
pseudoAcct,
|
||
0,
|
||
combineAllPayments(0));
|
||
|
||
lifecycle(
|
||
caseLabel,
|
||
"Loan overpayment allowed - Combine all payments",
|
||
env,
|
||
loanAmount,
|
||
interestExponent,
|
||
lender,
|
||
borrower,
|
||
evan,
|
||
broker,
|
||
pseudoAcct,
|
||
tfLoanOverpayment,
|
||
combineAllPayments(lsfLoanOverpayment));
|
||
|
||
lifecycle(
|
||
caseLabel,
|
||
"Loan overpayment prohibited - Make payments",
|
||
env,
|
||
loanAmount,
|
||
interestExponent,
|
||
lender,
|
||
borrower,
|
||
evan,
|
||
broker,
|
||
pseudoAcct,
|
||
0,
|
||
[&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) {
|
||
// toEndOfLife
|
||
//
|
||
// Draw and make multiple payments
|
||
auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus);
|
||
BEAST_EXPECT(state.flags == 0);
|
||
env.close();
|
||
|
||
verifyLoanStatus(state);
|
||
|
||
env.close(state.startDate + 20s);
|
||
auto const loanAge = (env.now() - state.startDate).count();
|
||
BEAST_EXPECT(loanAge == 30);
|
||
|
||
// Periodic payment amount will consist of
|
||
// 1. principal outstanding (1000)
|
||
// 2. interest interest rate (at 12%)
|
||
// 3. payment interval (600s)
|
||
// 4. loan service fee (2)
|
||
// Calculate these values without the helper functions
|
||
// to verify they're working correctly The numbers in
|
||
// the below BEAST_EXPECTs may not hold across assets.
|
||
Number const interval = state.paymentInterval;
|
||
auto const periodicRate = interval * Number(12, -2) / secondsInYear;
|
||
BEAST_EXPECT(
|
||
periodicRate == Number(2283105022831050228, -24, Number::normalized{}));
|
||
STAmount const roundedPeriodicPayment{
|
||
broker.asset,
|
||
roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)};
|
||
|
||
testcase << currencyLabel << " Payment components: "
|
||
<< "Payments remaining, rawInterest, rawPrincipal, "
|
||
"rawMFee, trackedValueDelta, trackedPrincipalDelta, "
|
||
"trackedInterestDelta, trackedMgmtFeeDelta, special";
|
||
|
||
auto const serviceFee = broker.asset(2);
|
||
|
||
BEAST_EXPECT(
|
||
roundedPeriodicPayment ==
|
||
roundToScale(
|
||
broker.asset(Number(8333457002039338267, -17), Number::upward),
|
||
state.loanScale,
|
||
Number::upward));
|
||
// 83334570.01162141
|
||
// Include the service fee
|
||
STAmount const totalDue = roundToScale(
|
||
roundedPeriodicPayment + serviceFee, state.loanScale, Number::upward);
|
||
// Only check the first payment since the rounding
|
||
// may drift as payments are made
|
||
BEAST_EXPECT(
|
||
totalDue ==
|
||
roundToScale(
|
||
broker.asset(Number(8533457002039338267, -17), Number::upward),
|
||
state.loanScale,
|
||
Number::upward));
|
||
|
||
{
|
||
auto const raw = computeTheoreticalLoanState(
|
||
state.periodicPayment,
|
||
periodicRate,
|
||
state.paymentRemaining,
|
||
broker.params.managementFeeRate);
|
||
auto const rounded = constructLoanState(
|
||
state.totalValue,
|
||
state.principalOutstanding,
|
||
state.managementFeeOutstanding);
|
||
testcase << currencyLabel << " Loan starting state: " << state.paymentRemaining
|
||
<< ", " << raw.interestDue << ", " << raw.principalOutstanding << ", "
|
||
<< raw.managementFeeDue << ", " << rounded.valueOutstanding << ", "
|
||
<< rounded.principalOutstanding << ", " << rounded.interestDue << ", "
|
||
<< rounded.managementFeeDue;
|
||
}
|
||
|
||
// Try to pay a little extra to show that it's _not_
|
||
// taken
|
||
STAmount const transactionAmount =
|
||
STAmount{broker.asset, totalDue} + broker.asset(10);
|
||
// Only check the first payment since the rounding
|
||
// may drift as payments are made
|
||
BEAST_EXPECT(
|
||
transactionAmount ==
|
||
roundToScale(
|
||
broker.asset(Number(9533457002039400, -14), Number::upward),
|
||
state.loanScale,
|
||
Number::upward));
|
||
|
||
auto const initialState = state;
|
||
detail::PaymentComponents totalPaid{
|
||
.trackedValueDelta = 0,
|
||
.trackedPrincipalDelta = 0,
|
||
.trackedManagementFeeDelta = 0};
|
||
Number totalInterestPaid = 0;
|
||
std::size_t totalPaymentsMade = 0;
|
||
|
||
xrpl::LoanState currentTrueState = computeTheoreticalLoanState(
|
||
state.periodicPayment,
|
||
periodicRate,
|
||
state.paymentRemaining,
|
||
broker.params.managementFeeRate);
|
||
|
||
while (state.paymentRemaining > 0)
|
||
{
|
||
// Compute the expected principal amount
|
||
auto const paymentComponents = detail::computePaymentComponents(
|
||
broker.asset.raw(),
|
||
state.loanScale,
|
||
state.totalValue,
|
||
state.principalOutstanding,
|
||
state.managementFeeOutstanding,
|
||
state.periodicPayment,
|
||
periodicRate,
|
||
state.paymentRemaining,
|
||
broker.params.managementFeeRate);
|
||
|
||
BEAST_EXPECTS(
|
||
paymentComponents.specialCase == detail::PaymentSpecialCase::final ||
|
||
paymentComponents.trackedValueDelta <= roundedPeriodicPayment,
|
||
"Delta: " + to_string(paymentComponents.trackedValueDelta) +
|
||
", periodic payment: " + to_string(roundedPeriodicPayment));
|
||
|
||
xrpl::LoanState const nextTrueState = computeTheoreticalLoanState(
|
||
state.periodicPayment,
|
||
periodicRate,
|
||
state.paymentRemaining - 1,
|
||
broker.params.managementFeeRate);
|
||
detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState;
|
||
|
||
testcase << currencyLabel << " Payment components: " << state.paymentRemaining
|
||
<< ", " << deltas.interest << ", " << deltas.principal << ", "
|
||
<< deltas.managementFee << ", " << paymentComponents.trackedValueDelta
|
||
<< ", " << paymentComponents.trackedPrincipalDelta << ", "
|
||
<< paymentComponents.trackedInterestPart() << ", "
|
||
<< paymentComponents.trackedManagementFeeDelta << ", "
|
||
<< [&]() -> char const* {
|
||
if (paymentComponents.specialCase == detail::PaymentSpecialCase::final)
|
||
return "final";
|
||
if (paymentComponents.specialCase == detail::PaymentSpecialCase::extra)
|
||
return "extra";
|
||
return "none";
|
||
}();
|
||
|
||
auto const totalDueAmount = STAmount{
|
||
broker.asset, paymentComponents.trackedValueDelta + serviceFee.number()};
|
||
|
||
// Due to the rounding algorithms to keep the interest and
|
||
// principal in sync with "true" values, the computed amount
|
||
// may be a little less than the rounded fixed payment
|
||
// amount. For integral types, the difference should be < 3
|
||
// (1 unit for each of the interest and management fee). For
|
||
// IOUs, the difference should be after the 8th digit.
|
||
Number const diff = totalDue - totalDueAmount;
|
||
BEAST_EXPECT(
|
||
paymentComponents.specialCase == detail::PaymentSpecialCase::final ||
|
||
diff == beast::zero ||
|
||
(diff > beast::zero &&
|
||
((broker.asset.integral() && (static_cast<Number>(diff) < 3)) ||
|
||
(state.loanScale - diff.exponent() > 13))));
|
||
|
||
BEAST_EXPECT(
|
||
paymentComponents.trackedValueDelta ==
|
||
paymentComponents.trackedPrincipalDelta +
|
||
paymentComponents.trackedInterestPart() +
|
||
paymentComponents.trackedManagementFeeDelta);
|
||
BEAST_EXPECT(
|
||
paymentComponents.specialCase == detail::PaymentSpecialCase::final ||
|
||
paymentComponents.trackedValueDelta <= roundedPeriodicPayment);
|
||
|
||
BEAST_EXPECT(
|
||
state.paymentRemaining < 12 ||
|
||
roundToAsset(
|
||
broker.asset, deltas.principal, state.loanScale, Number::upward) ==
|
||
roundToScale(
|
||
broker.asset(Number(8333228691531218890, -17), Number::upward),
|
||
state.loanScale,
|
||
Number::upward));
|
||
BEAST_EXPECT(
|
||
paymentComponents.trackedPrincipalDelta >= beast::zero &&
|
||
paymentComponents.trackedPrincipalDelta <= state.principalOutstanding);
|
||
BEAST_EXPECT(
|
||
paymentComponents.specialCase != detail::PaymentSpecialCase::final ||
|
||
paymentComponents.trackedPrincipalDelta == state.principalOutstanding);
|
||
BEAST_EXPECT(
|
||
paymentComponents.specialCase == detail::PaymentSpecialCase::final ||
|
||
(state.periodicPayment.exponent() -
|
||
(deltas.principal + deltas.interest + deltas.managementFee -
|
||
state.periodicPayment)
|
||
.exponent()) > 14);
|
||
|
||
auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset);
|
||
|
||
if (canImpairLoan(env, broker, state))
|
||
{
|
||
// Making a payment will unimpair the loan
|
||
env(manage(lender, loanKeylet.key, tfLoanImpair));
|
||
}
|
||
|
||
env.close();
|
||
|
||
// Make the payment
|
||
env(pay(borrower, loanKeylet.key, transactionAmount));
|
||
|
||
env.close();
|
||
|
||
// Need to account for fees if the loan is in XRP
|
||
PrettyAmount adjustment = broker.asset(0);
|
||
if (broker.asset.native())
|
||
{
|
||
adjustment = env.current()->fees().base;
|
||
}
|
||
|
||
// Check the result
|
||
verifyLoanStatus.checkPayment(
|
||
state.loanScale,
|
||
borrower,
|
||
borrowerBalanceBeforePayment,
|
||
totalDueAmount,
|
||
adjustment);
|
||
|
||
--state.paymentRemaining;
|
||
state.previousPaymentDate = state.nextPaymentDate;
|
||
if (paymentComponents.specialCase == detail::PaymentSpecialCase::final)
|
||
{
|
||
state.paymentRemaining = 0;
|
||
state.nextPaymentDate = 0;
|
||
}
|
||
else
|
||
{
|
||
state.nextPaymentDate += state.paymentInterval;
|
||
}
|
||
state.principalOutstanding -= paymentComponents.trackedPrincipalDelta;
|
||
state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta;
|
||
state.totalValue -= paymentComponents.trackedValueDelta;
|
||
|
||
verifyLoanStatus(state);
|
||
|
||
totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta;
|
||
totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta;
|
||
totalPaid.trackedManagementFeeDelta +=
|
||
paymentComponents.trackedManagementFeeDelta;
|
||
totalInterestPaid += paymentComponents.trackedInterestPart();
|
||
++totalPaymentsMade;
|
||
|
||
currentTrueState = nextTrueState;
|
||
}
|
||
|
||
// Loan is paid off
|
||
BEAST_EXPECT(state.paymentRemaining == 0);
|
||
BEAST_EXPECT(state.principalOutstanding == 0);
|
||
|
||
// Make sure all the payments add up
|
||
BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue);
|
||
BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding);
|
||
BEAST_EXPECT(
|
||
totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding);
|
||
// This is almost a tautology given the previous checks, but
|
||
// check it anyway for completeness.
|
||
BEAST_EXPECT(
|
||
totalInterestPaid ==
|
||
initialState.totalValue -
|
||
(initialState.principalOutstanding +
|
||
initialState.managementFeeOutstanding));
|
||
BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining);
|
||
|
||
// Can't impair or default a paid off loan
|
||
env(manage(lender, loanKeylet.key, tfLoanImpair), ter(tecNO_PERMISSION));
|
||
env(manage(lender, loanKeylet.key, tfLoanDefault), ter(tecNO_PERMISSION));
|
||
});
|
||
|
||
#if LOAN_TODO
|
||
// TODO
|
||
|
||
/*
|
||
LoanPay fails with tecINVARIANT_FAILED error when loan_broker(also
|
||
borrower) tries to do the payment. Here's the scenario: Create a XRP
|
||
loan with loan broker as borrower, loan origination fee and loan service
|
||
fee. Loan broker makes the first payment with periodic payment and loan
|
||
service fee.
|
||
*/
|
||
|
||
auto time = [&](std::string label, std::function<void()> timed) {
|
||
if (!BEAST_EXPECT(timed))
|
||
return;
|
||
|
||
using clock_type = std::chrono::steady_clock;
|
||
using duration_type = std::chrono::milliseconds;
|
||
|
||
auto const start = clock_type::now();
|
||
timed();
|
||
auto const duration =
|
||
std::chrono::duration_cast<duration_type>(clock_type::now() - start);
|
||
|
||
log << label << " took " << duration.count() << "ms" << std::endl;
|
||
|
||
return duration;
|
||
};
|
||
|
||
lifecycle(
|
||
caseLabel,
|
||
"timing",
|
||
env,
|
||
loanAmount,
|
||
interestExponent,
|
||
lender,
|
||
borrower,
|
||
evan,
|
||
broker,
|
||
pseudoAcct,
|
||
tfLoanOverpayment,
|
||
[&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) {
|
||
// Estimate optimal values for loanPaymentsPerFeeIncrement and
|
||
// loanMaximumPaymentsPerTransaction.
|
||
using namespace loan;
|
||
|
||
auto const state = getCurrentState(env, broker, verifyLoanStatus.keylet);
|
||
auto const serviceFee = broker.asset(2).value();
|
||
|
||
STAmount const totalDue{
|
||
broker.asset,
|
||
roundPeriodicPayment(
|
||
broker.asset, state.periodicPayment + serviceFee, state.loanScale)};
|
||
|
||
// Make a single payment
|
||
time("single payment", [&]() { env(pay(borrower, loanKeylet.key, totalDue)); });
|
||
env.close();
|
||
|
||
// Make all but the final payment
|
||
auto const numPayments = (state.paymentRemaining - 2);
|
||
STAmount const bigPayment{broker.asset, totalDue * numPayments};
|
||
XRPAmount const bigFee{baseFee * (numPayments / loanPaymentsPerFeeIncrement + 1)};
|
||
time("ten payments", [&]() {
|
||
env(pay(borrower, loanKeylet.key, bigPayment), fee(bigFee));
|
||
});
|
||
env.close();
|
||
|
||
time("final payment", [&]() {
|
||
// Make the final payment
|
||
env(pay(borrower, loanKeylet.key, totalDue + STAmount{broker.asset, 1}));
|
||
});
|
||
env.close();
|
||
});
|
||
|
||
lifecycle(
|
||
caseLabel,
|
||
"Loan overpayment allowed - Explicit overpayment",
|
||
env,
|
||
loanAmount,
|
||
interestExponent,
|
||
lender,
|
||
borrower,
|
||
evan,
|
||
broker,
|
||
pseudoAcct,
|
||
tfLoanOverpayment,
|
||
[&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; });
|
||
|
||
lifecycle(
|
||
caseLabel,
|
||
"Loan overpayment prohibited - Late payment",
|
||
env,
|
||
loanAmount,
|
||
interestExponent,
|
||
lender,
|
||
borrower,
|
||
evan,
|
||
broker,
|
||
pseudoAcct,
|
||
tfLoanOverpayment,
|
||
[&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; });
|
||
|
||
lifecycle(
|
||
caseLabel,
|
||
"Loan overpayment allowed - Late payment",
|
||
env,
|
||
loanAmount,
|
||
interestExponent,
|
||
lender,
|
||
borrower,
|
||
evan,
|
||
broker,
|
||
pseudoAcct,
|
||
tfLoanOverpayment,
|
||
[&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; });
|
||
|
||
lifecycle(
|
||
caseLabel,
|
||
"Loan overpayment allowed - Late payment and overpayment",
|
||
env,
|
||
loanAmount,
|
||
interestExponent,
|
||
lender,
|
||
borrower,
|
||
evan,
|
||
broker,
|
||
pseudoAcct,
|
||
tfLoanOverpayment,
|
||
[&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; });
|
||
|
||
#endif
|
||
}
|
||
|
||
void
|
||
testLoanSet()
|
||
{
|
||
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, all);
|
||
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, mptInitNoFund};
|
||
|
||
auto const none = LedgerSpecificFlags(0);
|
||
mptt.create(
|
||
{.flags = tfMPTCanTransfer | tfMPTCanLock |
|
||
(args.requireAuth ? tfMPTRequireAuth : none)});
|
||
env.close();
|
||
PrettyAsset 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),
|
||
counterparty(lender),
|
||
sig(sfCounterpartySignature, lender),
|
||
fee(env.current()->fees().base * 5));
|
||
|
||
testcase("MPT issuer is borrower, lender submits");
|
||
env(set(lender, broker.brokerID, principalRequest),
|
||
counterparty(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),
|
||
counterparty(lender),
|
||
sig(sfCounterpartySignature, lender),
|
||
fee(env.current()->fees().base * 5));
|
||
|
||
testcase("IOU issuer is borrower, lender submits");
|
||
env(set(lender, broker.brokerID, principalRequest),
|
||
counterparty(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),
|
||
counterparty(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),
|
||
counterparty(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),
|
||
counterparty(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),
|
||
counterparty(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 env{*this, testable_amendments()};
|
||
return {
|
||
env.current()->fees().accountReserve(0).drops() / DROPS_PER_XRP.drops(),
|
||
env.current()->fees().increment.drops() / DROPS_PER_XRP.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),
|
||
counterparty(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),
|
||
counterparty(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::line(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),
|
||
counterparty(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),
|
||
counterparty(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),
|
||
loanOriginationFee(broker.asset(1).value()),
|
||
counterparty(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),
|
||
loanOriginationFee(broker.asset(1).value()),
|
||
counterparty(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::line(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),
|
||
loanOriginationFee(broker.asset(1).value()),
|
||
counterparty(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),
|
||
loanOriginationFee(broker.asset(1).value()),
|
||
counterparty(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),
|
||
loanOriginationFee(broker.asset(1).value()),
|
||
counterparty(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),
|
||
counterparty(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),
|
||
counterparty(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),
|
||
counterparty(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),
|
||
counterparty(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),
|
||
counterparty(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 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),
|
||
counterparty(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),
|
||
counterparty(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),
|
||
counterparty(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),
|
||
counterparty(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 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),
|
||
counterparty(lender),
|
||
interestRate(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 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),
|
||
counterparty(lender),
|
||
interestRate(TenthBips32(100'000)),
|
||
sig(sfCounterpartySignature, lender),
|
||
fee(env.current()->fees().base * 5),
|
||
paymentTotal(2),
|
||
paymentInterval(3600 * 24),
|
||
ter(tecLIMIT_EXCEEDED));
|
||
},
|
||
nullptr);
|
||
}
|
||
|
||
void
|
||
testLifecycle()
|
||
{
|
||
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, all);
|
||
|
||
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, mptInitNoFund};
|
||
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(loanBroker::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(loanBroker::del(lender, broker.brokerID));
|
||
env.close();
|
||
}
|
||
}
|
||
|
||
void
|
||
testSelfLoan()
|
||
{
|
||
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, all);
|
||
|
||
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::objectValue));
|
||
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::objectValue};
|
||
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::objectValue);
|
||
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
|
||
testBatchBypassCounterparty()
|
||
{
|
||
// From FIND-001
|
||
testcase << "Batch Bypass Counterparty";
|
||
|
||
bool const lendingBatchEnabled = !std::any_of(
|
||
Batch::disabledTxTypes.begin(), Batch::disabledTxTypes.end(), [](auto const& disabled) {
|
||
return disabled == ttLOAN_BROKER_SET;
|
||
});
|
||
|
||
using namespace jtx;
|
||
using namespace std::chrono_literals;
|
||
Env env(*this, all);
|
||
|
||
Account const lender{"lender"};
|
||
Account const borrower{"borrower"};
|
||
|
||
BrokerParameters brokerParams;
|
||
env.fund(XRP(brokerParams.vaultDeposit * 100), lender, borrower);
|
||
env.close();
|
||
|
||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||
|
||
BrokerInfo 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::objectValue};
|
||
randomData[jss::SigningPubKey] = Json::StaticString{"2600"};
|
||
Json::Value sigObject{Json::objectValue};
|
||
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::objectValue);
|
||
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);
|
||
}
|
||
}
|
||
|
||
void
|
||
testWrongMaxDebtBehavior()
|
||
{
|
||
// From FIND-003
|
||
testcase << "Wrong Max Debt Behavior";
|
||
|
||
using namespace jtx;
|
||
using namespace std::chrono_literals;
|
||
Env env(*this, all);
|
||
|
||
Account const issuer{"issuer"};
|
||
Account const lender{"lender"};
|
||
|
||
BrokerParameters brokerParams{.debtMax = 0};
|
||
env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, noripple(lender));
|
||
env.close();
|
||
|
||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||
|
||
BrokerInfo 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::objectValue};
|
||
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
|
||
testLoanPayComputePeriodicPaymentValidRateInvariant()
|
||
{
|
||
// From FIND-012
|
||
testcase << "LoanPay xrpl::detail::computePeriodicPayment : "
|
||
"valid 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"};
|
||
|
||
BrokerParameters brokerParams;
|
||
env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, lender, borrower);
|
||
env.close();
|
||
|
||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||
BrokerInfo 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),
|
||
loanServiceFee(serviceFee),
|
||
paymentTotal(numPayments),
|
||
json(sfCounterpartySignature, Json::objectValue));
|
||
|
||
createJson["CloseInterestRate"] = 55374;
|
||
createJson["ClosePaymentFee"] = "3825205248";
|
||
createJson["LatePaymentFee"] = "237";
|
||
createJson["LoanOriginationFee"] = "0";
|
||
createJson["OverpaymentFee"] = 35167;
|
||
createJson["OverpaymentInterestRate"] = 1360;
|
||
createJson["PaymentInterval"] = 727;
|
||
|
||
auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
|
||
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
|
||
auto const keylet = keylet::loan(broker.brokerID, loanSequence);
|
||
|
||
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();
|
||
}
|
||
|
||
void
|
||
testRPC()
|
||
{
|
||
// 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, all);
|
||
|
||
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 borrower{borrowerPass, KeyType::ed25519};
|
||
auto const lenderPass = "lender";
|
||
Account 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::objectValue};
|
||
txJson[sfTransactionType] = "AccountSet";
|
||
txJson[sfAccount] = borrower.human();
|
||
|
||
auto const signParams = [&]() {
|
||
Json::Value signParams{Json::objectValue};
|
||
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(none), seq(none), fee(none), ter(tefPAST_SEQ));
|
||
}
|
||
|
||
{
|
||
testcase("RPC LoanSet - illegal signature_target");
|
||
|
||
Json::Value txJson{Json::objectValue};
|
||
txJson[sfTransactionType] = "AccountSet";
|
||
txJson[sfAccount] = borrower.human();
|
||
|
||
auto const borrowerSignParams = [&]() {
|
||
Json::Value params{Json::objectValue};
|
||
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::objectValue};
|
||
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::objectValue};
|
||
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::objectValue};
|
||
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::objectValue};
|
||
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::objectValue};
|
||
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::objectValue};
|
||
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
|
||
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::line(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),
|
||
loanServiceFee(IOU(100).value()),
|
||
paymentInterval(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
|
||
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);
|
||
|
||
std::int64_t constexpr issuerBalance = 10'000'000;
|
||
MPTTester asset({.env = env, .issuer = issuer, .holders = {lender}, .pay = issuerBalance});
|
||
|
||
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(-issuerBalance + 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(-issuerBalance));
|
||
}
|
||
|
||
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::zero), 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::zero, 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
|
||
testInvalidLoanSet()
|
||
{
|
||
testcase("Invalid LoanSet");
|
||
using namespace jtx;
|
||
using namespace loan;
|
||
Account const lender{"lender"};
|
||
Account const issuer{"issuer"};
|
||
Account const borrower{"borrower"};
|
||
auto const IOU = issuer["IOU"];
|
||
|
||
auto testWrapper = [&](auto&& test) {
|
||
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);
|
||
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) {
|
||
// first temBAD_SIGNER: TODO
|
||
// invalid grace period
|
||
{
|
||
// zero grace period
|
||
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
gracePeriod(0),
|
||
loanSetFee,
|
||
ter(temINVALID));
|
||
|
||
// grace period less than default minimum
|
||
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
gracePeriod(LoanSet::defaultGracePeriod - 1),
|
||
loanSetFee,
|
||
ter(temINVALID));
|
||
|
||
// grace period greater than payment interval
|
||
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
paymentInterval(120),
|
||
gracePeriod(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 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) -
|
||
env.current()->fees().accountReserve(env.ownerCount(borrower));
|
||
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
|
||
testAccountSendMptMinAmountInvariant()
|
||
{
|
||
// (From FIND-006)
|
||
testcase << "LoanSet trigger xrpl::accountSendMPT : minimum amount "
|
||
"and MPT";
|
||
|
||
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();
|
||
|
||
MPTTester mptt{env, issuer, mptInitNoFund};
|
||
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 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::objectValue));
|
||
|
||
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";
|
||
|
||
auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
|
||
|
||
createJson = env.json(createJson, sig(sfCounterpartySignature, lender));
|
||
env(createJson, ter(temINVALID));
|
||
env.close();
|
||
}
|
||
|
||
void
|
||
testLoanPayDebtDecreaseInvariant()
|
||
{
|
||
// 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, all);
|
||
|
||
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 iouAsset = issuer[iouCurrency];
|
||
auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000)));
|
||
env(trustLenderTx);
|
||
auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000)));
|
||
env(trustBorrowerTx);
|
||
auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000));
|
||
env(payLenderTx);
|
||
auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000));
|
||
env(payIssuerTx);
|
||
env.close();
|
||
|
||
BrokerInfo 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::objectValue));
|
||
|
||
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 brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
|
||
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
|
||
auto const keylet = keylet::loan(broker.brokerID, loanSequence);
|
||
|
||
createJson = env.json(createJson, sig(sfCounterpartySignature, lender));
|
||
env(createJson, ter(tesSUCCESS));
|
||
env.close();
|
||
|
||
auto const pseudoAcct = [&]() {
|
||
auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
|
||
if (!BEAST_EXPECT(brokerSle))
|
||
return Account{lender};
|
||
auto const brokerPseudo = brokerSle->at(sfAccount);
|
||
return Account("Broker pseudo-account", brokerPseudo);
|
||
}();
|
||
|
||
VerifyLoanStatus 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) / loanPaymentsPerFeeIncrement + 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
|
||
testLoanPayComputePeriodicPaymentValidTotalInterestInvariant()
|
||
{
|
||
// From FIND-010
|
||
testcase << "xrpl::loanComputePaymentParts : valid total interest";
|
||
|
||
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();
|
||
|
||
PrettyAsset const iouAsset = issuer[iouCurrency];
|
||
auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000)));
|
||
env(trustLenderTx);
|
||
auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000)));
|
||
env(trustBorrowerTx);
|
||
auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000));
|
||
env(payLenderTx);
|
||
auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000));
|
||
env(payIssuerTx);
|
||
env.close();
|
||
|
||
BrokerInfo 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::objectValue));
|
||
|
||
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 brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
|
||
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
|
||
auto const keylet = keylet::loan(broker.brokerID, loanSequence);
|
||
|
||
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();
|
||
}
|
||
|
||
void
|
||
testDosLoanPay()
|
||
{
|
||
// From FIND-005
|
||
testcase << "DoS LoanPay";
|
||
|
||
using namespace jtx;
|
||
using namespace std::chrono_literals;
|
||
using namespace Lending;
|
||
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();
|
||
|
||
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 broker{createVaultAndBroker(env, iouAsset, lender)};
|
||
|
||
using namespace loan;
|
||
|
||
auto const loanSetFee = fee(env.current()->fees().base * 2);
|
||
Number const principalRequest{1, 3};
|
||
auto const baseFee = env.current()->fees().base;
|
||
|
||
auto createJson = env.json(
|
||
set(borrower, broker.brokerID, principalRequest),
|
||
fee(loanSetFee),
|
||
json(sfCounterpartySignature, Json::objectValue));
|
||
|
||
createJson["ClosePaymentFee"] = "0";
|
||
createJson["GracePeriod"] = 60;
|
||
createJson["InterestRate"] = 20930;
|
||
createJson["LateInterestRate"] = 77049;
|
||
createJson["LatePaymentFee"] = "0";
|
||
createJson["LoanServiceFee"] = "0";
|
||
createJson["OverpaymentFee"] = 7;
|
||
createJson["OverpaymentInterestRate"] = 66653;
|
||
createJson["PaymentInterval"] = 60;
|
||
createJson["PaymentTotal"] = 3239184;
|
||
createJson["PrincipalRequested"] = "3959.37";
|
||
|
||
auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
|
||
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
|
||
auto const keylet = keylet::loan(broker.brokerID, loanSequence);
|
||
|
||
createJson = env.json(createJson, sig(sfCounterpartySignature, lender));
|
||
env(createJson, ter(tesSUCCESS));
|
||
env.close();
|
||
|
||
auto const stateBefore = getCurrentState(env, broker, keylet);
|
||
BEAST_EXPECT(stateBefore.paymentRemaining == 3239184);
|
||
BEAST_EXPECT(stateBefore.paymentRemaining > loanMaximumPaymentsPerTransaction);
|
||
|
||
auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}}));
|
||
Number const amount{395937, -2};
|
||
loanPayTx["Amount"]["value"] = to_string(amount);
|
||
XRPAmount const payFee{
|
||
baseFee *
|
||
std::int64_t(amount / stateBefore.periodicPayment / loanPaymentsPerFeeIncrement + 1)};
|
||
env(loanPayTx, ter(tesSUCCESS), fee(payFee));
|
||
env.close();
|
||
|
||
auto const stateAfter = getCurrentState(env, broker, keylet);
|
||
BEAST_EXPECT(
|
||
stateAfter.paymentRemaining ==
|
||
stateBefore.paymentRemaining - loanMaximumPaymentsPerTransaction);
|
||
}
|
||
|
||
void
|
||
testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant()
|
||
{
|
||
// From FIND-009
|
||
testcase << "xrpl::loanComputePaymentParts : totalPrincipalPaid "
|
||
"rounded";
|
||
|
||
using namespace jtx;
|
||
using namespace std::chrono_literals;
|
||
using namespace Lending;
|
||
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();
|
||
|
||
PrettyAsset const iouAsset = issuer[iouCurrency];
|
||
auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000)));
|
||
env(trustLenderTx);
|
||
auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000)));
|
||
env(trustBorrowerTx);
|
||
auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000));
|
||
env(payLenderTx);
|
||
auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000));
|
||
env(payIssuerTx);
|
||
env.close();
|
||
|
||
BrokerInfo 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::objectValue));
|
||
|
||
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 brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
|
||
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
|
||
auto const keylet = keylet::loan(broker.brokerID, loanSequence);
|
||
|
||
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 / loanPaymentsPerFeeIncrement + 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 / loanPaymentsPerFeeIncrement + 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));
|
||
}
|
||
|
||
void
|
||
testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant()
|
||
{
|
||
// From FIND-008
|
||
testcase << "xrpl::loanComputePaymentParts : loanValueChange rounded";
|
||
|
||
using namespace jtx;
|
||
using namespace std::chrono_literals;
|
||
using namespace Lending;
|
||
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();
|
||
|
||
PrettyAsset const iouAsset = issuer[iouCurrency];
|
||
auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000)));
|
||
env(trustLenderTx);
|
||
auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000)));
|
||
env(trustBorrowerTx);
|
||
auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000));
|
||
env(payLenderTx);
|
||
auto payIssuerTx = pay(issuer, borrower, iouAsset(10'000'000));
|
||
env(payIssuerTx);
|
||
env.close();
|
||
|
||
BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)};
|
||
{
|
||
auto const coverDepositValue = broker.asset(broker.params.coverDeposit * 10).value();
|
||
env(loanBroker::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::objectValue));
|
||
|
||
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 brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
|
||
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
|
||
auto const keylet = keylet::loan(broker.brokerID, loanSequence);
|
||
|
||
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 > loanMaximumPaymentsPerTransaction);
|
||
|
||
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 / loanPaymentsPerFeeIncrement + 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 - loanMaximumPaymentsPerTransaction);
|
||
}
|
||
|
||
void
|
||
testLoanNextPaymentDueDateOverflow()
|
||
{
|
||
// For FIND-013
|
||
testcase << "Prevent nextPaymentDueDate overflow";
|
||
|
||
using namespace jtx;
|
||
using namespace std::chrono_literals;
|
||
using namespace Lending;
|
||
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();
|
||
|
||
PrettyAsset const iouAsset = issuer[iouCurrency];
|
||
auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000)));
|
||
env(trustLenderTx);
|
||
auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000)));
|
||
env(trustBorrowerTx);
|
||
auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000));
|
||
env(payLenderTx);
|
||
auto payIssuerTx = pay(issuer, borrower, iouAsset(10'000'000));
|
||
env(payIssuerTx);
|
||
env.close();
|
||
|
||
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>);
|
||
timeType constexpr maxTime = std::numeric_limits<timeType>::max();
|
||
static_assert(maxTime == 4'294'967'295);
|
||
|
||
auto const baseJson = [&]() {
|
||
auto createJson = env.json(
|
||
set(borrower, broker.brokerID, Number{55524'81, -2}),
|
||
fee(loanSetFee),
|
||
closePaymentFee(0),
|
||
gracePeriod(LoanSet::defaultGracePeriod),
|
||
interestRate(TenthBips32(12833)),
|
||
lateInterestRate(TenthBips32(77048)),
|
||
latePaymentFee(0),
|
||
loanOriginationFee(218),
|
||
json(sfCounterpartySignature, Json::objectValue));
|
||
|
||
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 maxTime - startDate;
|
||
};
|
||
|
||
{
|
||
// straight-up overflow: interval
|
||
auto const interval = maxLoanTime() + 1;
|
||
auto const total = 1;
|
||
auto createJson = env.json(baseJson, paymentInterval(interval), paymentTotal(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, paymentInterval(interval), paymentTotal(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, paymentInterval(interval), paymentTotal(total), gracePeriod(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, paymentInterval(interval), paymentTotal(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, paymentInterval(interval), paymentTotal(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, paymentInterval(interval), paymentTotal(total), gracePeriod(grace));
|
||
|
||
env(createJson, sig(sfCounterpartySignature, lender), ter(tecKILLED));
|
||
env.close();
|
||
}
|
||
{
|
||
// Start date when the ledger is closed will be larger
|
||
auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
|
||
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
|
||
auto const keylet = keylet::loan(broker.brokerID, loanSequence);
|
||
|
||
auto const grace = 100;
|
||
auto const interval = maxLoanTime() - grace;
|
||
auto const total = 1;
|
||
auto createJson = env.json(
|
||
baseJson, paymentInterval(interval), paymentTotal(total), gracePeriod(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 brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
|
||
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
|
||
auto const keylet = keylet::loan(broker.brokerID, loanSequence);
|
||
|
||
auto const closeStartDate = (parentCloseTime() / 10 + 1) * 10;
|
||
auto const grace = 5'000;
|
||
auto const interval = maxTime - closeStartDate - grace;
|
||
auto const total = 1;
|
||
auto createJson = env.json(
|
||
baseJson, paymentInterval(interval), paymentTotal(total), gracePeriod(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 == maxTime - 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 = maxTime - 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));
|
||
// 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, paymentInterval(interval), paymentTotal(total), gracePeriod(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 mg{Number::upward};
|
||
Number const payment = beforeState.periodicPayment * (total - 1);
|
||
XRPAmount const payFee{baseFee * ((total - 1) / loanPaymentsPerFeeIncrement + 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 == maxTime - grace);
|
||
BEAST_EXPECT(afterState.previousPaymentDate == maxTime - grace - interval);
|
||
}
|
||
}
|
||
|
||
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 = MPTDEXFlags | 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);
|
||
});
|
||
|
||
std::uint32_t constexpr loanSequence = 1;
|
||
auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence);
|
||
|
||
// 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
|
||
testCoverDepositWithdrawNonTransferableMPT()
|
||
{
|
||
testcase("CoverDeposit and CoverWithdraw reject MPT without CanTransfer");
|
||
using namespace jtx;
|
||
using namespace loanBroker;
|
||
|
||
Env env(*this, all);
|
||
|
||
Account const issuer{"issuer"};
|
||
Account const alice{"alice"};
|
||
|
||
env.fund(XRP(100'000), issuer, alice);
|
||
env.close();
|
||
|
||
MPTTester mpt{env, issuer, mptInitNoFund};
|
||
|
||
mpt.create({.flags = tfMPTCanTransfer, .mutableFlags = tmfMPTCanMutateCanTransfer});
|
||
|
||
env.close();
|
||
|
||
PrettyAsset const asset = mpt["MPT"];
|
||
mpt.authorize({.account = alice});
|
||
env.close();
|
||
|
||
// Issuer can fund the holder even if CanTransfer is not set.
|
||
env(pay(issuer, alice, asset(100)));
|
||
env.close();
|
||
|
||
Vault vault{env};
|
||
auto const [createTx, vaultKeylet] = vault.create({.owner = alice, .asset = asset});
|
||
env(createTx);
|
||
env.close();
|
||
|
||
auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
|
||
env(set(alice, vaultKeylet.key));
|
||
env.close();
|
||
|
||
auto const brokerSle = env.le(brokerKeylet);
|
||
if (!BEAST_EXPECT(brokerSle))
|
||
return;
|
||
|
||
Account const pseudoAccount{"Loan Broker pseudo-account", brokerSle->at(sfAccount)};
|
||
|
||
// Remove CanTransfer after the broker is set up.
|
||
mpt.set({.mutableFlags = tmfMPTClearCanTransfer});
|
||
env.close();
|
||
|
||
// Standard Payment path should forbid third-party transfers.
|
||
env(pay(alice, pseudoAccount, asset(1)), ter(tecNO_AUTH));
|
||
env.close();
|
||
|
||
// Cover cannot be transferred to broker account
|
||
auto const depositAmount = asset(1);
|
||
env(coverDeposit(alice, brokerKeylet.key, depositAmount), ter{tecNO_AUTH});
|
||
env.close();
|
||
|
||
if (auto const refreshed = env.le(brokerKeylet); BEAST_EXPECT(refreshed))
|
||
{
|
||
BEAST_EXPECT(refreshed->at(sfCoverAvailable) == 0);
|
||
env.require(balance(pseudoAccount, asset(0)));
|
||
}
|
||
|
||
// Set CanTransfer again and transfer some deposit
|
||
mpt.set({.mutableFlags = tmfMPTSetCanTransfer});
|
||
env.close();
|
||
|
||
env(coverDeposit(alice, brokerKeylet.key, depositAmount));
|
||
env.close();
|
||
|
||
if (auto const refreshed = env.le(brokerKeylet); BEAST_EXPECT(refreshed))
|
||
{
|
||
BEAST_EXPECT(refreshed->at(sfCoverAvailable) == 1);
|
||
env.require(balance(pseudoAccount, depositAmount));
|
||
}
|
||
|
||
// Remove CanTransfer after the deposit
|
||
mpt.set({.mutableFlags = tmfMPTClearCanTransfer});
|
||
env.close();
|
||
|
||
// Cover cannot be transferred from broker account
|
||
env(coverWithdraw(alice, brokerKeylet.key, depositAmount), ter{tecNO_AUTH});
|
||
env.close();
|
||
|
||
// Set CanTransfer again and withdraw
|
||
mpt.set({.mutableFlags = tmfMPTSetCanTransfer});
|
||
env.close();
|
||
|
||
env(coverWithdraw(alice, brokerKeylet.key, depositAmount));
|
||
env.close();
|
||
|
||
if (auto const refreshed = env.le(brokerKeylet); BEAST_EXPECT(refreshed))
|
||
{
|
||
BEAST_EXPECT(refreshed->at(sfCoverAvailable) == 0);
|
||
env.require(balance(pseudoAccount, asset(0)));
|
||
}
|
||
}
|
||
|
||
#if LOAN_TODO
|
||
void
|
||
testLoanPayLateFullPaymentBypassesPenalties()
|
||
{
|
||
testcase("LoanPay full payment skips late penalties");
|
||
using namespace jtx;
|
||
using namespace 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();
|
||
|
||
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),
|
||
loanServiceFee(serviceFee),
|
||
latePaymentFee(lateFee),
|
||
closePaymentFee(closeFee),
|
||
interestRate(percentageToTenthBips(12)),
|
||
lateInterestRate(percentageToTenthBips(24) / 10),
|
||
closeInterestRate(percentageToTenthBips(5)),
|
||
paymentTotal(12),
|
||
paymentInterval(600),
|
||
gracePeriod(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(
|
||
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);
|
||
}
|
||
|
||
void
|
||
testLoanCoverMinimumRoundingExploit()
|
||
{
|
||
auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) {
|
||
testcase << "LoanBrokerCoverClawback drains cover via rounding"
|
||
<< " principalRequested=" << to_string(principalRequest);
|
||
|
||
using namespace jtx;
|
||
using namespace loan;
|
||
using namespace loanBroker;
|
||
|
||
Env env(*this, all);
|
||
|
||
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,
|
||
paymentInterval(600),
|
||
paymentTotal(1),
|
||
gracePeriod(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
|
||
|
||
void
|
||
testPoC_UnsignedUnderflowOnFullPayAfterEarlyPeriodic()
|
||
{
|
||
// --- 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, all);
|
||
|
||
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 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),
|
||
loanOriginationFee(originationFee),
|
||
loanServiceFee(serviceFee),
|
||
latePaymentFee(lateFee),
|
||
closePaymentFee(closeFee),
|
||
overpaymentFee(percentageToTenthBips(5) / 10),
|
||
interestRate(interest),
|
||
lateInterestRate(lateInterest),
|
||
closeInterestRate(closeInterest),
|
||
overpaymentInterestRate(overpaymentInterest),
|
||
paymentTotal(total),
|
||
paymentInterval(interval),
|
||
gracePeriod(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 = detail::computePaymentComponents(
|
||
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(
|
||
detail::loanPrincipalFromPeriodicPayment(
|
||
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(
|
||
detail::loanPrincipalFromPeriodicPayment(
|
||
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;
|
||
|
||
// Value-based proof: underflowed window yields a payoff larger than
|
||
// the clamped (non-underflow) reference.
|
||
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
|
||
testDustManipulation()
|
||
{
|
||
testcase("Dust manipulation");
|
||
|
||
using namespace jtx;
|
||
using namespace std::chrono_literals;
|
||
Env env(*this, all);
|
||
|
||
// Setup: Create accounts
|
||
Account issuer{"issuer"};
|
||
Account lender{"lender"};
|
||
Account borrower{"borrower"};
|
||
Account 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 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 assetsTotal = vaultSle->at(sfAssetsTotal);
|
||
Number 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 assetsTotal = vaultSle->at(sfAssetsTotal);
|
||
Number 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 = constructRoundedLoanState(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 assetsTotal2 = vaultSle2->at(sfAssetsTotal);
|
||
Number 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
|
||
testRIPD3831()
|
||
{
|
||
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, all);
|
||
|
||
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));
|
||
auto const jResult = jr[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()
|
||
{
|
||
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, all);
|
||
|
||
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 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::zero);
|
||
}
|
||
}
|
||
}
|
||
|
||
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 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(loanBroker::set(lender, vaultKeyLet.key), txfee);
|
||
env.close();
|
||
|
||
// BrokerInfo brokerInfo{xrpIssue(), keylet, vaultKeyLet, {}};
|
||
|
||
STAmount const debtMaximumRequest = XRPAmount(200'000);
|
||
|
||
env(set(borrower, brokerKeyLet.key, debtMaximumRequest),
|
||
sig(sfCounterpartySignature, lender),
|
||
interestRate(TenthBips32(50'000)),
|
||
paymentTotal(2),
|
||
paymentInterval(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
|
||
testRoundingAllowsUndercoverage()
|
||
{
|
||
testcase("Minimum cover rounding allows undercoverage (XRP)");
|
||
|
||
using namespace jtx;
|
||
using namespace loanBroker;
|
||
|
||
Env env(*this, all);
|
||
|
||
Account const lender{"lender"};
|
||
Account const borrower{"borrower"};
|
||
|
||
env.fund(XRP(200'000), lender, borrower);
|
||
env.close();
|
||
|
||
// Vault with XRP asset
|
||
Vault 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::interestRate(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 == 80 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
|
||
testRIPD3902()
|
||
{
|
||
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, all);
|
||
|
||
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 verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
|
||
|
||
makeLoanPayments(
|
||
env,
|
||
broker,
|
||
loanParams,
|
||
loanKeylet,
|
||
verifyLoanStatus,
|
||
issuer,
|
||
lender,
|
||
borrower,
|
||
PaymentParameters{.showStepBalances = true});
|
||
}
|
||
|
||
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 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(loanBroker::set(broker, vaultKeylet.key), txFee);
|
||
env.close();
|
||
|
||
auto const serviceFee = 101;
|
||
|
||
env(set(broker, brokerKeylet.key, debtMaximumRequest),
|
||
counterparty(borrower),
|
||
sig(sfCounterpartySignature, borrower),
|
||
loanServiceFee(serviceFee),
|
||
paymentTotal(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 mpt(
|
||
{.env = env,
|
||
.issuer = issuer,
|
||
.holders = {broker, depositor},
|
||
.pay = 100'000'000});
|
||
return mpt(200'000);
|
||
},
|
||
borrowerAcct);
|
||
}
|
||
}
|
||
|
||
void
|
||
testIssuerIsBorrower()
|
||
{
|
||
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, all);
|
||
|
||
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 verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
|
||
|
||
makeLoanPayments(
|
||
env,
|
||
broker,
|
||
loanParams,
|
||
loanKeylet,
|
||
verifyLoanStatus,
|
||
issuer,
|
||
lender,
|
||
issuer,
|
||
PaymentParameters{.showStepBalances = true});
|
||
}
|
||
|
||
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::severities::Severity::kWarning);
|
||
|
||
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 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 paymentParams{
|
||
.showStepBalances = false,
|
||
.validateBalances = true,
|
||
};
|
||
|
||
makeLoanPayments(
|
||
env,
|
||
broker,
|
||
loanParams,
|
||
loanKeylet,
|
||
verifyLoanStatus,
|
||
issuer,
|
||
lender,
|
||
borrower,
|
||
paymentParams);
|
||
}
|
||
|
||
void
|
||
testOverpaymentManagementFee()
|
||
{
|
||
testcase("testOverpaymentManagementFee");
|
||
|
||
using namespace jtx;
|
||
using namespace loan;
|
||
|
||
Env env(*this, all);
|
||
|
||
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 loanKeylet = keylet::loan(
|
||
result.brokerKeylet().key, (env.le(result.brokerKeylet()))->at(sfLoanSequence));
|
||
env(loan::set(
|
||
borrower, result.brokerKeylet().key, asset(10'000).value(), tfLoanOverpayment),
|
||
sig(sfCounterpartySignature, lender),
|
||
loan::paymentInterval(86400 * 30),
|
||
loan::paymentTotal(3),
|
||
loan::overpaymentInterestRate(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 missmatch; expected:" +
|
||
to_string(expectedOverpaymentManagementFee) +
|
||
" got: " + to_string(env.balance(lender) - loanBrokerBalanceBefore));
|
||
}
|
||
|
||
void
|
||
testLoanPayBrokerOwnerMissingTrustline()
|
||
{
|
||
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, all);
|
||
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),
|
||
loanServiceFee(IOU(100).value()),
|
||
paymentInterval(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(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{IOU, additionalCover}));
|
||
env.close();
|
||
// Verify broker owner has a trustline
|
||
auto const brokerTrustline = keylet::line(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()
|
||
{
|
||
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, all);
|
||
env.fund(XRP(20'000), issuer, broker, borrower);
|
||
env.close();
|
||
|
||
MPTTester mptt{env, issuer, mptInitNoFund};
|
||
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),
|
||
loanServiceFee(MPT(100).value()),
|
||
paymentInterval(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(loanBroker::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.
|
||
auto const borrowerBalance = env.balance(borrower, MPT);
|
||
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()
|
||
{
|
||
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, all);
|
||
env.fund(XRP(20'000), issuer, broker, borrower);
|
||
env.close();
|
||
|
||
auto credType = "credential1";
|
||
|
||
pdomain::Credentials const credentials1{{issuer, 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, mptInitNoFund};
|
||
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),
|
||
loanServiceFee(MPT(100).value()),
|
||
paymentInterval(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(loanBroker::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.
|
||
auto const borrowerBalance = env.balance(borrower, MPT);
|
||
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()
|
||
{
|
||
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, all);
|
||
env.fund(XRP(20'000), issuer, broker, borrower);
|
||
env.close();
|
||
|
||
auto credType = "credential1";
|
||
|
||
pdomain::Credentials const credentials1{{issuer, 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, mptInitNoFund};
|
||
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),
|
||
loanServiceFee(MPT(100).value()),
|
||
paymentInterval(100),
|
||
fee(XRP(100)),
|
||
ter(tecNO_AUTH));
|
||
env.close();
|
||
}
|
||
|
||
void
|
||
testSequentialFLCDepletion()
|
||
{
|
||
testcase << "First-Loss Capital Depletion on Sequential Defaults";
|
||
|
||
using namespace jtx;
|
||
using namespace loan;
|
||
using namespace loanBroker;
|
||
|
||
Env env(*this, all);
|
||
|
||
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),
|
||
interestRate(TenthBips32(500)), // 5%
|
||
paymentTotal(12),
|
||
loan::paymentInterval(loanPaymentInterval),
|
||
loan::gracePeriod(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),
|
||
interestRate(TenthBips32(500)), // 5%
|
||
paymentTotal(12),
|
||
loan::paymentInterval(loanPaymentInterval),
|
||
loan::gracePeriod(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()
|
||
{
|
||
using namespace jtx;
|
||
using namespace loan;
|
||
using namespace std::chrono_literals;
|
||
|
||
testcase("Vault withdraw reflects sfLossUnrealized");
|
||
|
||
// Test constants
|
||
static constexpr std::int64_t INITIAL_FUNDING = 1'000'000;
|
||
static constexpr std::int64_t LENDER_INITIAL_IOU = 5'000'000;
|
||
static constexpr std::int64_t DEPOSITOR_INITIAL_IOU = 1'000'000;
|
||
static constexpr std::int64_t BORROWER_INITIAL_IOU = 100'000;
|
||
static constexpr std::int64_t DEPOSIT_AMOUNT = 5'000;
|
||
static constexpr std::int64_t PRINCIPAL_AMOUNT = 99;
|
||
static constexpr std::uint64_t EXPECTED_SHARES_PER_DEPOSITOR = 5'000'000'000;
|
||
static constexpr std::uint32_t PAYMENT_INTERVAL = 600;
|
||
static constexpr std::uint32_t PAYMENT_TOTAL = 2;
|
||
|
||
Env env(*this, all);
|
||
|
||
// 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(INITIAL_FUNDING), 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(LENDER_INITIAL_IOU)));
|
||
env(pay(issuer, depositorA, iouAsset(DEPOSITOR_INITIAL_IOU)));
|
||
env(pay(issuer, depositorB, iouAsset(DEPOSITOR_INITIAL_IOU)));
|
||
env(pay(issuer, borrower, iouAsset(BORROWER_INITIAL_IOU)));
|
||
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(DEPOSIT_AMOUNT),
|
||
}),
|
||
ter(tesSUCCESS));
|
||
env(v.deposit({
|
||
.depositor = depositorB,
|
||
.id = broker.vaultKeylet().key,
|
||
.amount = iouAsset(DEPOSIT_AMOUNT),
|
||
}),
|
||
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, PRINCIPAL_AMOUNT),
|
||
sig(sfCounterpartySignature, lender),
|
||
paymentTotal(PAYMENT_TOTAL),
|
||
paymentInterval(PAYMENT_INTERVAL),
|
||
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(PRINCIPAL_AMOUNT).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 == EXPECTED_SHARES_PER_DEPOSITOR);
|
||
BEAST_EXPECT(sharesLpB == EXPECTED_SHARES_PER_DEPOSITOR);
|
||
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);
|
||
}
|
||
|
||
public:
|
||
void
|
||
run() override
|
||
{
|
||
#if LOAN_TODO
|
||
testLoanPayLateFullPaymentBypassesPenalties();
|
||
testLoanCoverMinimumRoundingExploit();
|
||
#endif
|
||
testWithdrawReflectsUnrealizedLoss();
|
||
testInvalidLoanSet();
|
||
|
||
testCoverDepositWithdrawNonTransferableMPT();
|
||
testPoC_UnsignedUnderflowOnFullPayAfterEarlyPeriodic();
|
||
|
||
testDisabled();
|
||
testSelfLoan();
|
||
testIssuerLoan();
|
||
testLoanSet();
|
||
testLifecycle();
|
||
testServiceFeeOnBrokerDeepFreeze();
|
||
|
||
testRPC();
|
||
testInvalidLoanDelete();
|
||
testInvalidLoanManage();
|
||
testInvalidLoanPay();
|
||
|
||
testBatchBypassCounterparty();
|
||
testLoanPayComputePeriodicPaymentValidRateInvariant();
|
||
testAccountSendMptMinAmountInvariant();
|
||
testLoanPayDebtDecreaseInvariant();
|
||
testWrongMaxDebtBehavior();
|
||
testLoanPayComputePeriodicPaymentValidTotalInterestInvariant();
|
||
testDosLoanPay();
|
||
testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant();
|
||
testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant();
|
||
testLoanNextPaymentDueDateOverflow();
|
||
|
||
testRequireAuth();
|
||
testDustManipulation();
|
||
|
||
testRIPD3831();
|
||
testRIPD3459();
|
||
testRIPD3901();
|
||
testRIPD3902();
|
||
testRoundingAllowsUndercoverage();
|
||
testBorrowerIsBroker();
|
||
testIssuerIsBorrower();
|
||
testLimitExceeded();
|
||
testOverpaymentManagementFee();
|
||
testLoanPayBrokerOwnerMissingTrustline();
|
||
testLoanPayBrokerOwnerUnauthorizedMPT();
|
||
testLoanPayBrokerOwnerNoPermissionedDomainMPT();
|
||
testLoanSetBrokerOwnerNoPermissionedDomainMPT();
|
||
testSequentialFLCDepletion();
|
||
}
|
||
};
|
||
|
||
class LoanBatch_test : public Loan_test
|
||
{
|
||
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
|
||
AssetType assetType = static_cast<AssetType>(assetDist(engine_));
|
||
auto const principalRequest = principalDist(engine_);
|
||
TenthBips16 managementFeeRate{managementFeeRateDist(engine_)};
|
||
auto const serviceFee = serviceFeeDist(engine_);
|
||
TenthBips32 interest{interestRateDist(engine_)};
|
||
auto const payTotal = paymentTotalDist(engine_);
|
||
auto const payInterval = paymentIntervalDist(engine_);
|
||
|
||
BrokerParameters brokerParams{
|
||
.vaultDeposit = principalRequest * 10,
|
||
.debtMax = 0,
|
||
.coverRateMin = TenthBips32{0},
|
||
.managementFeeRate = managementFeeRate};
|
||
LoanParameters loanParams{
|
||
.account = lender,
|
||
.counter = borrower,
|
||
.principalRequest = principalRequest,
|
||
.serviceFee = serviceFee,
|
||
.interest = interest,
|
||
.payTotal = payTotal,
|
||
.payInterval = payInterval,
|
||
};
|
||
|
||
runLoan(assetType, brokerParams, loanParams);
|
||
}
|
||
|
||
public:
|
||
void
|
||
run() override
|
||
{
|
||
auto const numIterations = [s = arg()]() -> int {
|
||
int 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::min(numIterations / 5, 100);
|
||
|
||
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);
|
||
}
|
||
};
|
||
|
||
BEAST_DEFINE_TESTSUITE(Loan, tx, xrpl);
|
||
BEAST_DEFINE_TESTSUITE_MANUAL(LoanBatch, tx, xrpl);
|
||
BEAST_DEFINE_TESTSUITE_MANUAL(LoanArbitrary, tx, xrpl);
|
||
|
||
} // namespace test
|
||
} // namespace xrpl
|