Files
rippled/src/test/app/Loan_test.cpp

7303 lines
289 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#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