Files
rippled/src/test/app/Loan_test.cpp
Ed Hennis 5da586bffc Add support for explicit overpayment flag in LoanPay
- Uses the same name and value as for LoanSet: tfLoanOverpayment.
- Untested.
- Also create several placeholders for missing test cases.
2025-10-21 23:55:46 -04:00

2813 lines
109 KiB
C++

//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2025 Ripple Labs Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <test/jtx.h>
#include <xrpld/app/misc/LendingHelpers.h>
#include <xrpld/app/misc/LoadFeeTrack.h>
#include <xrpld/app/tx/detail/LoanSet.h>
#include <xrpl/beast/unit_test/suite.h>
namespace ripple {
namespace test {
class Loan_test : public beast::unit_test::suite
{
// 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};
static constexpr auto const coverDepositParameter = 1000;
static constexpr auto const coverRateMinParameter =
percentageToTenthBips(10);
static constexpr auto const coverRateLiquidationParameter =
percentageToTenthBips(25);
static constexpr auto const maxCoveredLoanValue = 1000 * 100 / 10;
static constexpr auto const vaultDeposit = 1'000'000;
static constexpr auto const debtMaximumParameter = 25'000;
static constexpr TenthBips16 const managementFeeRateParameter{100};
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 BrokerInfo
{
jtx::PrettyAsset asset;
uint256 brokerID;
BrokerInfo(jtx::PrettyAsset const& asset_, uint256 const& brokerID_)
: asset(asset_), brokerID(brokerID_)
{
}
};
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;
Number const& loanAmount;
jtx::Account const& pseudoAccount;
Keylet const& keylet;
VerifyLoanStatus(
jtx::Env const& env_,
BrokerInfo const& broker_,
Number const& loanAmount_,
jtx::Account const& pseudo_,
Keylet const& keylet_)
: env(env_)
, broker(broker_)
, loanAmount(loanAmount_)
, pseudoAccount(pseudo_)
, keylet(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.BEAST_EXPECT(
roundToScale(difference, loanScale) >= beast::zero);
}
}
/** 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(keylet); env.test.BEAST_EXPECT(loan))
{
env.test.BEAST_EXPECT(
loan->at(sfPreviousPaymentDate) == previousPaymentDate);
env.test.BEAST_EXPECT(
loan->at(sfPaymentRemaining) == paymentRemaining);
if (paymentRemaining == 0)
env.test.BEAST_EXPECT(!loan->at(~sfNextPaymentDueDate));
else
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 = calculateRoundedLoanState(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) &&
!(flags & lsfLoanDefault))
{
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,
std::optional<Number> debtMax = std::nullopt)
{
using namespace jtx;
Vault vault{env};
auto const deposit = asset(vaultDeposit);
auto const debtMaximumValue = debtMax
? STAmount{asset.raw(), *debtMax}
: asset(debtMaximumParameter).value();
auto const coverDepositValue = asset(coverDepositParameter).value();
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));
auto const testData = "spam spam spam spam";
using namespace loanBroker;
env(set(lender, vaultKeylet.key),
data(testData),
managementFeeRate(managementFeeRateParameter),
debtMaximum(debtMaximumValue),
coverRateMinimum(TenthBips32(coverRateMinParameter)),
coverRateLiquidation(TenthBips32(coverRateLiquidationParameter)));
env(coverDeposit(lender, keylet.key, coverDepositValue));
env.close();
return {asset, keylet.key};
}
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;
// Lookup the current loan state
if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan))
{
LoanState state{
.previousPaymentDate = loan->at(sfPreviousPaymentDate),
.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)},
};
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.principalOutstanding.exponent() == state.loanScale);
BEAST_EXPECT(state.paymentInterval == 600);
BEAST_EXPECT(
state.totalValue ==
roundToAsset(
broker.asset,
state.periodicPayment * state.paymentRemaining,
state.loanScale));
BEAST_EXPECT(
state.managementFeeOutstanding ==
computeFee(
broker.asset,
state.totalValue - state.principalOutstanding,
managementFeeRateParameter,
state.loanScale));
verifyLoanStatus(state);
return state;
}
return LoanState{};
}
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 (unrealizedLoss > assetsUnavailable)
{
return false;
}
}
}
return 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, loanAmount, 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 const borrowerOwnerCount = env.ownerCount(borrower);
auto const loanSetFee = fee(env.current()->fees().base * 2);
Number const principalRequest = broker.asset(loanAmount).value();
auto const originationFee = broker.asset(1).value();
auto const serviceFee = broker.asset(2).value();
auto const lateFee = broker.asset(3).value();
auto const closeFee = broker.asset(4).value();
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 overFee = applyExponent(percentageToTenthBips(5) / 10);
auto const interest = applyExponent(percentageToTenthBips(12));
// 2.4%
auto const lateInterest = applyExponent(percentageToTenthBips(24) / 10);
auto const closeInterest =
applyExponent(percentageToTenthBips(36) / 10);
auto const overpaymentInterest =
applyExponent(percentageToTenthBips(48) / 10);
auto const total = 12;
auto const interval = 600;
auto const grace = 60;
auto const borrowerStartbalance = env.balance(borrower, broker.asset);
// Use the defined values
auto createJtx = env.jt(
set(borrower, broker.brokerID, principalRequest, flags),
sig(sfCounterpartySignature, lender),
loanOriginationFee(originationFee),
loanServiceFee(serviceFee),
latePaymentFee(lateFee),
closePaymentFee(closeFee),
overpaymentFee(overFee),
interestRate(interest),
lateInterestRate(lateInterest),
closeInterestRate(closeInterest),
overpaymentInterestRate(overpaymentInterest),
paymentTotal(total),
paymentInterval(interval),
gracePeriod(grace),
fee(loanSetFee));
// Successfully create a Loan
env(createJtx);
env.close();
auto const startDate =
env.current()->info().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.raw().native())
{
adjustment = 2 * env.current()->fees().base;
}
BEAST_EXPECT(
env.balance(borrower, broker.asset).value() ==
borrowerStartbalance.value() + principalRequest -
originationFee - 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) == originationFee);
BEAST_EXPECT(loan->at(sfLoanServiceFee) == serviceFee);
BEAST_EXPECT(loan->at(sfLatePaymentFee) == lateFee);
BEAST_EXPECT(loan->at(sfClosePaymentFee) == closeFee);
BEAST_EXPECT(loan->at(sfOverpaymentFee) == overFee);
BEAST_EXPECT(loan->at(sfInterestRate) == interest);
BEAST_EXPECT(loan->at(sfLateInterestRate) == lateInterest);
BEAST_EXPECT(loan->at(sfCloseInterestRate) == closeInterest);
BEAST_EXPECT(
loan->at(sfOverpaymentInterestRate) == overpaymentInterest);
BEAST_EXPECT(loan->at(sfStartDate) == startDate);
BEAST_EXPECT(loan->at(sfPaymentInterval) == interval);
BEAST_EXPECT(loan->at(sfGracePeriod) == grace);
BEAST_EXPECT(loan->at(sfPreviousPaymentDate) == 0);
BEAST_EXPECT(
loan->at(sfNextPaymentDueDate) == startDate + interval);
BEAST_EXPECT(loan->at(sfPaymentRemaining) == total);
BEAST_EXPECT(loan->at(sfLoanScale) == principalRequest.exponent());
BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalRequest);
}
auto state = getCurrentState(env, broker, keylet, verifyLoanStatus);
auto const loanProperties = computeLoanProperties(
broker.asset.raw(),
state.principalOutstanding,
state.interestRate,
state.paymentInterval,
state.paymentRemaining,
managementFeeRateParameter);
verifyLoanStatus(
0,
startDate + interval,
total,
principalRequest.exponent(),
loanProperties.totalValueOutstanding,
principalRequest,
loanProperties.managementFeeOwedToBroker,
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 + interval;
env.close();
verifyLoanStatus(
0,
nextDueDate,
total,
principalRequest.exponent(),
loanProperties.totalValueOutstanding,
principalRequest,
loanProperties.managementFeeOwedToBroker,
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 ? 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.raw().native())
{
adjustment = env.current()->fees().base;
}
}
// No loans left
verifyLoanStatus.checkBroker(0, 0, 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);
}
}
/** 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;
auto const& asset = broker.asset.raw();
auto const caseLabel = [&]() {
std::stringstream ss;
ss << "Lifecycle: " << loanAmount << " "
<< (asset.native() ? "XRP"
: asset.holds<Issue>() ? "IOU"
: asset.holds<MPTIssue>() ? "MPT"
: "Unknown")
<< " 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 maxCoveredLoanRequest =
broker.asset(maxCoveredLoanValue).value();
Number const totalVaultRequest = broker.asset(vaultDeposit).value();
Number const debtMaximumRequest =
broker.asset(debtMaximumParameter).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 lender;
auto const brokerPseudo = brokerSle->at(sfAccount);
return Account("Broker pseudo-account", brokerPseudo);
}();
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));
// sfInterestRate: too big
env(set(evan, broker.brokerID, principalRequest),
sig(sfCounterpartySignature, lender),
interestRate(maxInterestRate + 1),
loanSetFee,
ter(temINVALID));
// sfLateInterestRate: good value, bad account
env(set(evan, broker.brokerID, principalRequest),
sig(sfCounterpartySignature, borrower),
lateInterestRate(maxLateInterestRate),
loanSetFee,
ter(tefBAD_AUTH));
// sfLateInterestRate: too big
env(set(evan, broker.brokerID, principalRequest),
sig(sfCounterpartySignature, lender),
lateInterestRate(maxLateInterestRate + 1),
loanSetFee,
ter(temINVALID));
// sfCloseInterestRate: good value, bad account
env(set(evan, broker.brokerID, principalRequest),
sig(sfCounterpartySignature, borrower),
closeInterestRate(maxCloseInterestRate),
loanSetFee,
ter(tefBAD_AUTH));
// sfCloseInterestRate: too big
env(set(evan, broker.brokerID, principalRequest),
sig(sfCounterpartySignature, lender),
closeInterestRate(maxCloseInterestRate + 1),
loanSetFee,
ter(temINVALID));
// sfOverpaymentInterestRate: good value, bad account
env(set(evan, broker.brokerID, principalRequest),
sig(sfCounterpartySignature, borrower),
overpaymentInterestRate(maxOverpaymentInterestRate),
loanSetFee,
ter(tefBAD_AUTH));
// sfOverpaymentInterestRate: too big
env(set(evan, broker.brokerID, principalRequest),
sig(sfCounterpartySignature, lender),
overpaymentInterestRate(maxOverpaymentInterestRate + 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(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));
// 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 lender;
auto const 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.raw().native())
{
// XRP can't be frozen
return std::make_tuple(empty, empty, empty, tesSUCCESS);
}
else if (broker.asset.raw().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);
}
else
{
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
std::string testData;
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 == state.principalOutstanding.exponent());
auto const defaultAmount = roundToAsset(
broker.asset,
std::min(
tenthBipsOfValue(
tenthBipsOfValue(
brokerSle->at(sfDebtTotal),
coverRateMinParameter),
coverRateLiquidationParameter),
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(coverDepositParameter).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;
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) {
// toEndOfLife
//
verifyLoanStatus(state);
// Send some bogus pay transactions
env(pay(borrower, keylet::loan(uint256(0)).key, broker.asset(10)),
ter(temINVALID));
env(pay(borrower, loanKeylet.key, broker.asset(-100)),
ter(temBAD_AMOUNT));
env(pay(borrower, broker.brokerID, broker.asset(100)),
ter(tecNO_ENTRY));
env(pay(evan, loanKeylet.key, broker.asset(500)),
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
env(pay(borrower,
loanKeylet.key,
broker.asset(state.periodicPayment * 2),
tfLoanOverpayment),
ter(temINVALID_FLAG));
}
{
auto const otherAsset = broker.asset.raw() == assets[0].raw()
? assets[1]
: assets[0];
env(pay(borrower, loanKeylet.key, otherAsset(100)),
ter(tecWRONG_ASSET));
}
// Amount doesn't cover a single payment
env(pay(borrower, loanKeylet.key, STAmount{broker.asset, 1}),
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);
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.raw().native())
{
adjustment = env.current()->fees().base;
}
state.paymentRemaining = 0;
state.principalOutstanding = 0;
state.totalValue = 0;
state.managementFeeOutstanding = 0;
state.previousPaymentDate = state.nextPaymentDate +
state.paymentInterval * (numPayments - 1);
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) / (365 * 24 * 60 * 60);
BEAST_EXPECT(
periodicRate ==
Number(2283105022831050, -21, Number::unchecked{}));
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 ==
broker.asset(Number(1040000114155251, -12)));
// 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);
};
};
auto combineAllPayments = [&](std::uint32_t baseFlag) {
return [&, baseFlag](
Keylet const& loanKeylet,
VerifyLoanStatus const& verifyLoanStatus) {
// toEndOfLife
//
auto state =
getCurrentState(env, broker, loanKeylet, verifyLoanStatus);
env.close();
// Make all the payments in one transaction
// service fee is 2
auto const startingPayments = state.paymentRemaining;
auto const rawPayoff = startingPayments *
(state.periodicPayment + broker.asset(2).value());
STAmount const payoffAmount{broker.asset, rawPayoff};
BEAST_EXPECT(
payoffAmount ==
broker.asset(Number(1024014840139457, -12)));
BEAST_EXPECT(payoffAmount > state.principalOutstanding);
singlePayment(
loanKeylet,
verifyLoanStatus,
state,
payoffAmount,
state.paymentRemaining,
baseFlag);
};
};
// 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) / (365 * 24 * 60 * 60);
BEAST_EXPECT(
periodicRate ==
Number(2283105022831050, -21, Number::unchecked{}));
STAmount const roundedPeriodicPayment{
broker.asset,
roundPeriodicPayment(
broker.asset, state.periodicPayment, state.loanScale)};
testcase << "\tPayment components: "
<< "Payments remaining, rawInterest, rawPrincipal, "
"rawMFee, roundedInterest, roundedPrincipal, "
"roundedMFee, final, extra";
auto const serviceFee = broker.asset(2);
BEAST_EXPECT(
roundedPeriodicPayment ==
roundToScale(
broker.asset(
Number(8333457001162141, -14), 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(8533457001162141, -14), Number::upward),
state.loanScale,
Number::upward));
{
auto const raw = calculateRawLoanState(
state.periodicPayment,
periodicRate,
state.paymentRemaining,
managementFeeRateParameter);
auto const rounded = calculateRoundedLoanState(
state.totalValue,
state.principalOutstanding,
state.managementFeeOutstanding);
testcase
<< "\tLoan starting state: " << state.paymentRemaining
<< ", " << raw.interestDue << ", "
<< raw.principalOutstanding << ", "
<< raw.managementFeeDue << ", " << rounded.interestDue
<< ", " << rounded.principalOutstanding << ", "
<< rounded.managementFeeDue;
}
while (state.paymentRemaining > 0)
{
// 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(9533457001162141, -14), Number::upward),
state.loanScale,
Number::upward));
// Compute the expected principal amount
auto const paymentComponents = computePaymentComponents(
broker.asset.raw(),
state.loanScale,
state.totalValue,
state.principalOutstanding,
state.managementFeeOutstanding,
state.periodicPayment,
periodicRate,
state.paymentRemaining,
managementFeeRateParameter);
testcase
<< "\tPayment components: " << state.paymentRemaining
<< ", " << paymentComponents.rawInterest << ", "
<< paymentComponents.rawPrincipal << ", "
<< paymentComponents.rawManagementFee << ", "
<< paymentComponents.roundedInterest << ", "
<< paymentComponents.roundedPrincipal << ", "
<< paymentComponents.roundedManagementFee << ", "
<< (paymentComponents.final ? "true" : "false") << ", "
<< (paymentComponents.extra ? "true" : "false");
auto const totalDueAmount = STAmount{
broker.asset,
paymentComponents.roundedPrincipal +
paymentComponents.roundedInterest +
paymentComponents.roundedManagementFee +
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.final || diff == beast::zero ||
(diff > beast::zero &&
((broker.asset.integral() &&
(static_cast<Number>(diff) < 3)) ||
(totalDue.exponent() - diff.exponent() > 8))));
BEAST_EXPECT(
paymentComponents.roundedInterest >= Number(0));
BEAST_EXPECT(
state.paymentRemaining < 12 ||
roundToAsset(
broker.asset,
paymentComponents.rawPrincipal,
state.loanScale,
Number::upward) ==
roundToScale(
broker.asset(
Number(8333228690659858, -14),
Number::upward),
state.loanScale,
Number::upward));
BEAST_EXPECT(
paymentComponents.roundedPrincipal >= Number(0) &&
paymentComponents.roundedPrincipal <=
state.principalOutstanding);
BEAST_EXPECT(
!paymentComponents.final ||
paymentComponents.roundedPrincipal ==
state.principalOutstanding);
BEAST_EXPECT(
paymentComponents.final ||
(state.periodicPayment.exponent() -
(paymentComponents.rawPrincipal +
paymentComponents.rawInterest +
paymentComponents.rawManagementFee -
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.raw().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.final)
{
state.paymentRemaining = 0;
}
else
{
state.nextPaymentDate += state.paymentInterval;
}
state.principalOutstanding -=
paymentComponents.roundedPrincipal;
state.managementFeeOutstanding -=
paymentComponents.roundedManagementFee;
state.totalValue -= paymentComponents.roundedPrincipal +
paymentComponents.roundedInterest +
paymentComponents.roundedManagementFee;
verifyLoanStatus(state);
}
// Loan is paid off
BEAST_EXPECT(state.paymentRemaining == 0);
BEAST_EXPECT(state.principalOutstanding == 0);
// 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 LOANCOMPLETE
// TODO
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
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{xrpAsset, mptAsset, iouAsset};
// Create vaults and loan brokers
std::vector<BrokerInfo> brokers;
for (auto const& asset : assets)
{
brokers.emplace_back(createVaultAndBroker(env, asset, lender));
}
// 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()->info().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(sfPreviousPaymentDate));
BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000");
BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000");
BEAST_EXPECT(loan[sfLoanScale] == -6);
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()
{
testcase << "Batch Bypass Counterparty";
using namespace jtx;
using namespace std::chrono_literals;
Env env(*this, all);
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(vaultDeposit * 100), lender, borrower);
env.close();
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)};
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 = ripple::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(temBAD_SIGNATURE));
env.close();
// ? Check that the loan was 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);
}
}
BrokerInfo
createVaultAndBrokerNoMaxDebt(
jtx::Env& env,
jtx::PrettyAsset const& asset,
jtx::Account const& lender)
{
return createVaultAndBroker(env, asset, lender, Number(0));
}
void
testWrongMaxDebtBehavior()
{
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"};
env.fund(XRP(vaultDeposit * 100), issuer, noripple(lender));
env.close();
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
BrokerInfo broker{createVaultAndBrokerNoMaxDebt(env, xrpAsset, lender)};
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()
{
testcase << "LoanPay ripple::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"};
env.fund(XRP(vaultDeposit * 100), issuer, lender, borrower);
env.close();
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)};
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};
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["GracePeriod"] = 0;
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";
std::string const borrowerSeed = "ssBRAsLpH4778sLNYC4ik1JBJsBVf";
Account borrower{borrowerPass, KeyType::ed25519};
auto const lenderPass = "lender";
std::string const lenderSeed = "shPTCZGwTEhJrYT8NbcNkeaa8pzPM";
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] =
"FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FECF83F"
"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] =
"FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FECF83F"
"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(trust(borrower, IOU(20'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();
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
testBasicMath()
{
// Test the functions defined in LendingHelpers.h
testcase("Basic Math");
pass();
}
public:
void
run() override
{
testDisabled();
testSelfLoan();
testLifecycle();
testBatchBypassCounterparty();
testWrongMaxDebtBehavior();
testLoanPayComputePeriodicPaymentValidRateInvariant();
testServiceFeeOnBrokerDeepFreeze();
testRPC();
testBasicMath();
}
};
BEAST_DEFINE_TESTSUITE(Loan, tx, ripple);
} // namespace test
} // namespace ripple