mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-21 22:30:57 +00:00
690 lines
26 KiB
C++
690 lines
26 KiB
C++
#include <test/app/lending/LoanTestBase.h>
|
|
#include <test/jtx/Account.h>
|
|
#include <test/jtx/Env.h>
|
|
#include <test/jtx/TestHelpers.h>
|
|
#include <test/jtx/amount.h>
|
|
#include <test/jtx/batch.h>
|
|
#include <test/jtx/fee.h>
|
|
#include <test/jtx/flags.h>
|
|
#include <test/jtx/jtx_json.h>
|
|
#include <test/jtx/mpt.h>
|
|
#include <test/jtx/pay.h>
|
|
#include <test/jtx/ter.h>
|
|
#include <test/jtx/trust.h>
|
|
#include <test/jtx/utility.h>
|
|
#include <test/jtx/vault.h>
|
|
|
|
#include <xrpl/basics/Number.h>
|
|
#include <xrpl/basics/base_uint.h>
|
|
#include <xrpl/basics/strHex.h>
|
|
#include <xrpl/beast/unit_test/suite.h>
|
|
#include <xrpl/json/json_value.h>
|
|
#include <xrpl/json/to_string.h>
|
|
#include <xrpl/protocol/Feature.h>
|
|
#include <xrpl/protocol/HashPrefix.h>
|
|
#include <xrpl/protocol/Indexes.h>
|
|
#include <xrpl/protocol/Issue.h>
|
|
#include <xrpl/protocol/SField.h>
|
|
#include <xrpl/protocol/SecretKey.h>
|
|
#include <xrpl/protocol/Serializer.h>
|
|
#include <xrpl/protocol/TER.h>
|
|
#include <xrpl/protocol/TxFlags.h>
|
|
#include <xrpl/protocol/TxFormats.h>
|
|
#include <xrpl/protocol/jss.h>
|
|
#include <xrpl/tx/transactors/system/Batch.h>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <map>
|
|
#include <string_view>
|
|
#include <vector>
|
|
|
|
namespace xrpl::test {
|
|
|
|
class LoanLifecycle_test : public LoanTestBase
|
|
{
|
|
private:
|
|
void
|
|
testLifecycle(FeatureBitset features)
|
|
{
|
|
testcase("Lifecycle");
|
|
using namespace jtx;
|
|
|
|
// Create 3 loan brokers: one for XRP, one for an IOU, and one for
|
|
// an MPT. That'll require three corresponding SAVs.
|
|
Env env(*this, features);
|
|
|
|
Account const issuer{"issuer"};
|
|
// For simplicity, lender will be the sole actor for the vault &
|
|
// brokers.
|
|
Account const lender{"lender"};
|
|
// Borrower only wants to borrow
|
|
Account const borrower{"borrower"};
|
|
// Evan will attempt to be naughty
|
|
Account const evan{"evan"};
|
|
// Do not fund alice
|
|
Account const alice{"alice"};
|
|
|
|
// Fund the accounts and trust lines with the same amount so that
|
|
// tests can use the same values regardless of the asset.
|
|
env.fund(XRP(100'000'000), issuer, noripple(lender, borrower, evan));
|
|
env.close();
|
|
|
|
// Create assets
|
|
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
|
PrettyAsset const iouAsset = issuer[iouCurrency_];
|
|
env(trust(lender, iouAsset(10'000'000)));
|
|
env(trust(borrower, iouAsset(10'000'000)));
|
|
env(trust(evan, iouAsset(10'000'000)));
|
|
env(pay(issuer, evan, iouAsset(1'000'000)));
|
|
env(pay(issuer, lender, iouAsset(10'000'000)));
|
|
// Fund the borrower with enough to cover interest and fees
|
|
env(pay(issuer, borrower, iouAsset(10'000)));
|
|
env.close();
|
|
|
|
MPTTester mptt{env, issuer, kMptInitNoFund};
|
|
mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
|
|
// Scale the MPT asset a little bit so we can get some interest
|
|
PrettyAsset const mptAsset{mptt.issuanceID(), 100};
|
|
mptt.authorize({.account = lender});
|
|
mptt.authorize({.account = borrower});
|
|
mptt.authorize({.account = evan});
|
|
env(pay(issuer, lender, mptAsset(10'000'000)));
|
|
env(pay(issuer, evan, mptAsset(1'000'000)));
|
|
// Fund the borrower with enough to cover interest and fees
|
|
env(pay(issuer, borrower, mptAsset(10'000)));
|
|
env.close();
|
|
|
|
std::array const assets{iouAsset, xrpAsset, mptAsset};
|
|
|
|
// Create vaults and loan brokers
|
|
std::vector<BrokerInfo> brokers;
|
|
brokers.reserve(assets.size());
|
|
for (auto const& asset : assets)
|
|
{
|
|
brokers.emplace_back(createVaultAndBroker(
|
|
env, asset, lender, BrokerParameters{.data = "spam spam spam spam"}));
|
|
}
|
|
|
|
// Create and update Loans
|
|
for (auto const& broker : brokers)
|
|
{
|
|
for (int amountExponent = 3; amountExponent >= 3; --amountExponent)
|
|
{
|
|
Number const loanAmount{1, amountExponent};
|
|
for (int interestExponent = 0; interestExponent >= 0; --interestExponent)
|
|
{
|
|
testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent);
|
|
}
|
|
}
|
|
|
|
if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID));
|
|
BEAST_EXPECT(brokerSle))
|
|
{
|
|
BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
|
|
BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0);
|
|
|
|
auto const coverAvailable = brokerSle->at(sfCoverAvailable);
|
|
env(loan_broker::coverWithdraw(
|
|
lender, broker.brokerID, STAmount(broker.asset, coverAvailable)));
|
|
env.close();
|
|
|
|
brokerSle = env.le(keylet::loanBroker(broker.brokerID));
|
|
BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0);
|
|
}
|
|
// Verify we can delete the loan broker
|
|
env(loan_broker::del(lender, broker.brokerID));
|
|
env.close();
|
|
}
|
|
}
|
|
|
|
void
|
|
testSelfLoan(FeatureBitset features)
|
|
{
|
|
testcase << "Self Loan";
|
|
|
|
using namespace jtx;
|
|
using namespace std::chrono_literals;
|
|
// Create 3 loan brokers: one for XRP, one for an IOU, and one for
|
|
// an MPT. That'll require three corresponding SAVs.
|
|
Env env(*this, features);
|
|
|
|
Account const issuer{"issuer"};
|
|
// For simplicity, lender will be the sole actor for the vault &
|
|
// brokers.
|
|
Account const lender{"lender"};
|
|
|
|
// Fund the accounts and trust lines with the same amount so that
|
|
// tests can use the same values regardless of the asset.
|
|
env.fund(XRP(100'000'000), issuer, noripple(lender));
|
|
env.close();
|
|
|
|
// Use an XRP asset for simplicity
|
|
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
|
|
|
// Create vaults and loan brokers
|
|
BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)};
|
|
|
|
using namespace loan;
|
|
|
|
auto const loanSetFee = Fee(env.current()->fees().base * 2);
|
|
Number const principalRequest{1, 3};
|
|
|
|
// The LoanSet json can be created without a counterparty signature,
|
|
// but it will not pass preflight
|
|
auto createJson = env.json(
|
|
set(lender, broker.brokerID, broker.asset(principalRequest).value()), Fee(loanSetFee));
|
|
env(createJson, Ter(temBAD_SIGNER));
|
|
|
|
// Adding an empty counterparty signature object also fails, but
|
|
// at the RPC level.
|
|
createJson = env.json(createJson, Json(sfCounterpartySignature, json::ValueType::Object));
|
|
env(createJson, Ter(telENV_RPC_FAILED));
|
|
|
|
if (auto const jt = env.jt(createJson); BEAST_EXPECT(jt.stx))
|
|
{
|
|
Serializer s;
|
|
jt.stx->add(s);
|
|
auto const jr = env.rpc("submit", strHex(s.slice()));
|
|
|
|
BEAST_EXPECT(jr.isMember(jss::result));
|
|
auto const jResult = jr[jss::result];
|
|
BEAST_EXPECT(jResult[jss::error] == "invalidTransaction");
|
|
BEAST_EXPECT(
|
|
jResult[jss::error_exception] ==
|
|
"fails local checks: Transaction has bad signature.");
|
|
}
|
|
|
|
// Copy the transaction signature into the counterparty signature.
|
|
json::Value counterpartyJson{json::ValueType::Object};
|
|
counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature];
|
|
counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey];
|
|
if (!BEAST_EXPECT(!createJson.isMember(jss::Signers)))
|
|
counterpartyJson[sfSigners] = createJson[sfSigners];
|
|
|
|
// The duplicated signature works
|
|
createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson));
|
|
env(createJson);
|
|
|
|
env.close();
|
|
|
|
auto const startDate = env.current()->header().parentCloseTime;
|
|
|
|
// Loan is successfully created
|
|
{
|
|
auto const res = env.rpc("account_objects", lender.human());
|
|
auto const objects = res[jss::result][jss::account_objects];
|
|
|
|
std::map<std::string, std::size_t> types;
|
|
BEAST_EXPECT(objects.size() == 4);
|
|
for (auto const& object : objects)
|
|
{
|
|
++types[object[sfLedgerEntryType].asString()];
|
|
}
|
|
BEAST_EXPECT(types.size() == 4);
|
|
for (std::string const type : {"MPToken", "Vault", "LoanBroker", "Loan"})
|
|
{
|
|
BEAST_EXPECT(types[type] == 1);
|
|
}
|
|
}
|
|
auto const loanID = [&]() {
|
|
json::Value params(json::ValueType::Object);
|
|
params[jss::account] = lender.human();
|
|
params[jss::type] = "Loan";
|
|
auto const res = env.rpc("json", "account_objects", to_string(params));
|
|
auto const objects = res[jss::result][jss::account_objects];
|
|
|
|
BEAST_EXPECT(objects.size() == 1);
|
|
|
|
auto const loan = objects[0u];
|
|
BEAST_EXPECT(loan[sfBorrower] == lender.human());
|
|
// soeDEFAULT fields are not returned if they're in the default
|
|
// state
|
|
BEAST_EXPECT(!loan.isMember(sfCloseInterestRate));
|
|
BEAST_EXPECT(!loan.isMember(sfClosePaymentFee));
|
|
BEAST_EXPECT(loan[sfFlags] == 0);
|
|
BEAST_EXPECT(loan[sfGracePeriod] == 60);
|
|
BEAST_EXPECT(!loan.isMember(sfInterestRate));
|
|
BEAST_EXPECT(!loan.isMember(sfLateInterestRate));
|
|
BEAST_EXPECT(!loan.isMember(sfLatePaymentFee));
|
|
BEAST_EXPECT(loan[sfLoanBrokerID] == to_string(broker.brokerID));
|
|
BEAST_EXPECT(!loan.isMember(sfLoanOriginationFee));
|
|
BEAST_EXPECT(loan[sfLoanSequence] == 1);
|
|
BEAST_EXPECT(!loan.isMember(sfLoanServiceFee));
|
|
BEAST_EXPECT(loan[sfNextPaymentDueDate] == loan[sfStartDate].asUInt() + 60);
|
|
BEAST_EXPECT(!loan.isMember(sfOverpaymentFee));
|
|
BEAST_EXPECT(!loan.isMember(sfOverpaymentInterestRate));
|
|
BEAST_EXPECT(loan[sfPaymentInterval] == 60);
|
|
BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000");
|
|
BEAST_EXPECT(loan[sfPaymentRemaining] == 1);
|
|
BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDueDate));
|
|
BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000");
|
|
BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000");
|
|
BEAST_EXPECT(!loan.isMember(sfLoanScale));
|
|
BEAST_EXPECT(loan[sfStartDate].asUInt() == startDate.time_since_epoch().count());
|
|
|
|
return loan["index"].asString();
|
|
}();
|
|
auto const loanKeylet{keylet::loan(uint256{std::string_view(loanID)})};
|
|
|
|
env.close(startDate);
|
|
|
|
// Make a payment
|
|
env(pay(lender, loanKeylet.key, broker.asset(1000)));
|
|
}
|
|
|
|
void
|
|
testIssuerLoan()
|
|
{
|
|
testcase << "Issuer Loan";
|
|
|
|
using namespace jtx;
|
|
using namespace loan;
|
|
Account const issuer("issuer");
|
|
Account const borrower = issuer;
|
|
Account const lender("lender");
|
|
Env env(*this);
|
|
|
|
env.fund(XRP(1'000), issuer, lender);
|
|
|
|
static constexpr std::int64_t kIssuerBalance = 10'000'000;
|
|
MPTTester const asset(
|
|
{.env = env, .issuer = issuer, .holders = {lender}, .pay = kIssuerBalance});
|
|
|
|
BrokerParameters const brokerParams{
|
|
.debtMax = 200,
|
|
};
|
|
auto const broker = createVaultAndBroker(env, asset, lender, brokerParams);
|
|
auto const loanSetFee = Fee(env.current()->fees().base * 2);
|
|
// Create Loan
|
|
env(set(borrower, broker.brokerID, 200), Sig(sfCounterpartySignature, lender), loanSetFee);
|
|
env.close();
|
|
// Issuer should not create MPToken
|
|
BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer)));
|
|
// Issuer "borrowed" 200, OutstandingAmount decreased by 200
|
|
BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200));
|
|
// Pay Loan
|
|
auto const loanKeylet = keylet::loan(broker.brokerID, 1);
|
|
env(pay(borrower, loanKeylet.key, asset(200)));
|
|
env.close();
|
|
// Issuer "re-payed" 200, OutstandingAmount increased by 200
|
|
BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance));
|
|
}
|
|
|
|
void
|
|
testBorrowerIsBroker()
|
|
{
|
|
testcase("Test Borrower is Broker");
|
|
using namespace jtx;
|
|
using namespace loan;
|
|
Account const broker{"broker"};
|
|
Account const issuer{"issuer"};
|
|
Account const borrower{"borrower"};
|
|
Account const depositor{"depositor"};
|
|
|
|
auto testLoanAsset = [&](auto&& getMaxDebt, auto const& borrower) {
|
|
Env env(*this);
|
|
Vault const vault(env);
|
|
|
|
if (borrower == broker)
|
|
{
|
|
env.fund(XRP(10'000), broker, issuer, depositor);
|
|
}
|
|
else
|
|
{
|
|
env.fund(XRP(10'000), broker, borrower, issuer, depositor);
|
|
}
|
|
env.close();
|
|
|
|
auto const xrpFee = XRP(100);
|
|
auto const txFee = Fee(xrpFee);
|
|
|
|
STAmount const debtMaximumRequest = getMaxDebt(env);
|
|
|
|
auto const& asset = debtMaximumRequest.asset();
|
|
auto const initialVault = asset(debtMaximumRequest * 100);
|
|
|
|
auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset});
|
|
env(tx, txFee);
|
|
env.close();
|
|
|
|
env(vault.deposit(
|
|
{.depositor = depositor, .id = vaultKeylet.key, .amount = initialVault}),
|
|
txFee);
|
|
env.close();
|
|
|
|
auto const brokerKeylet = keylet::loanBroker(broker.id(), env.seq(broker));
|
|
|
|
env(loan_broker::set(broker, vaultKeylet.key), txFee);
|
|
env.close();
|
|
|
|
auto const serviceFee = 101;
|
|
|
|
env(set(broker, brokerKeylet.key, debtMaximumRequest),
|
|
kCounterparty(borrower),
|
|
Sig(sfCounterpartySignature, borrower),
|
|
kLoanServiceFee(serviceFee),
|
|
kPaymentTotal(10),
|
|
txFee);
|
|
env.close();
|
|
|
|
std::uint32_t const loanSequence = 1;
|
|
auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence);
|
|
|
|
auto const brokerBalanceBefore = env.balance(broker, asset);
|
|
|
|
if (auto const loanSle = env.le(loanKeylet); env.test.BEAST_EXPECT(loanSle))
|
|
{
|
|
auto const payment = loanSle->at(sfPeriodicPayment);
|
|
auto const totalPayment = payment + serviceFee;
|
|
env(loan::pay(borrower, loanKeylet.key, asset(totalPayment)), txFee);
|
|
env.close();
|
|
if (auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle))
|
|
{
|
|
auto const expected = [&]() {
|
|
// The service fee is transferred to the broker if
|
|
// a borrower is not the broker
|
|
if (borrower != broker)
|
|
return brokerBalanceBefore.number() + serviceFee;
|
|
// Since a borrower is the broker, the payment is
|
|
// transferred to the Vault from the broker but not
|
|
// the service fee.
|
|
// If the asset is XRP then the broker pays the txFee.
|
|
if (asset.native())
|
|
return brokerBalanceBefore.number() - payment - xrpFee.number();
|
|
return brokerBalanceBefore.number() - payment;
|
|
}();
|
|
BEAST_EXPECT(env.balance(broker, asset).value() == asset(expected).value());
|
|
}
|
|
}
|
|
};
|
|
// Test when a borrower is the broker and is not to verify correct
|
|
// service fee transfer in both cases.
|
|
for (auto const& borrowerAcct : {broker, borrower})
|
|
{
|
|
testLoanAsset(
|
|
[&](Env&) -> STAmount { return STAmount{XRPAmount{200'000}}; }, borrowerAcct);
|
|
testLoanAsset(
|
|
[&](Env& env) -> STAmount {
|
|
auto const iou = issuer["USD"];
|
|
env(trust(broker, iou(1'000'000'000)));
|
|
env(trust(depositor, iou(1'000'000'000)));
|
|
env(pay(issuer, broker, iou(100'000'000)));
|
|
env(pay(issuer, depositor, iou(100'000'000)));
|
|
env.close();
|
|
return iou(200'000);
|
|
},
|
|
borrowerAcct);
|
|
testLoanAsset(
|
|
[&](Env& env) -> STAmount {
|
|
MPTTester const mpt(
|
|
{.env = env,
|
|
.issuer = issuer,
|
|
.holders = {broker, depositor},
|
|
.pay = 100'000'000});
|
|
return mpt(200'000);
|
|
},
|
|
borrowerAcct);
|
|
}
|
|
}
|
|
|
|
void
|
|
testIssuerIsBorrower(FeatureBitset features)
|
|
{
|
|
testcase("RIPD-4096 - Issuer as borrower");
|
|
|
|
using namespace jtx;
|
|
|
|
Account const issuer("issuer");
|
|
Account const lender("lender");
|
|
|
|
BrokerParameters const brokerParams{
|
|
.vaultDeposit = 100'000,
|
|
.debtMax = 0,
|
|
.coverRateMin = TenthBips32{0},
|
|
.managementFeeRate = TenthBips16{0},
|
|
.coverRateLiquidation = TenthBips32{0}};
|
|
LoanParameters const loanParams{
|
|
.account = lender, .counter = issuer, .principalRequest = Number{10000}};
|
|
|
|
auto const assetType = AssetType::IOU;
|
|
|
|
Env env{*this, features};
|
|
|
|
auto loanResult =
|
|
createLoan(env, assetType, brokerParams, loanParams, issuer, lender, issuer);
|
|
|
|
if (BEAST_EXPECT(loanResult); !loanResult.has_value())
|
|
return;
|
|
|
|
auto broker = std::get<BrokerInfo>(*loanResult);
|
|
auto loanKeylet = std::get<Keylet>(*loanResult);
|
|
auto pseudoAcct = std::get<Account>(*loanResult);
|
|
|
|
VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
|
|
|
|
makeLoanPayments(
|
|
env,
|
|
broker,
|
|
loanParams,
|
|
loanKeylet,
|
|
verifyLoanStatus,
|
|
issuer,
|
|
lender,
|
|
issuer,
|
|
PaymentParameters{.showStepBalances = true});
|
|
}
|
|
|
|
void
|
|
testBatchBypassCounterparty(FeatureBitset features)
|
|
{
|
|
// From FIND-001
|
|
testcase << "Batch Bypass Counterparty";
|
|
|
|
bool const lendingBatchEnabled = !std::ranges::any_of(
|
|
Batch::kDisabledTxTypes, [](auto const& disabled) { return disabled == ttLOAN_SET; });
|
|
|
|
using namespace jtx;
|
|
using namespace std::chrono_literals;
|
|
Env env(*this, features);
|
|
|
|
Account const lender{"lender"};
|
|
Account const borrower{"borrower"};
|
|
|
|
BrokerParameters const brokerParams;
|
|
env.fund(XRP(brokerParams.vaultDeposit * 100), lender, borrower);
|
|
env.close();
|
|
|
|
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
|
|
|
BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
|
|
|
|
using namespace loan;
|
|
|
|
auto const loanSetFee = Fee(env.current()->fees().base * 2);
|
|
Number const principalRequest{1, 3};
|
|
|
|
auto forgedLoanSet = set(borrower, broker.brokerID, principalRequest, 0);
|
|
|
|
json::Value randomData{json::ValueType::Object};
|
|
randomData[jss::SigningPubKey] = json::StaticString{"2600"};
|
|
json::Value sigObject{json::ValueType::Object};
|
|
sigObject[jss::SigningPubKey] = strHex(lender.pk().slice());
|
|
Serializer ss;
|
|
ss.add32(HashPrefix::TxSign);
|
|
parse(randomData).addWithoutSigningFields(ss);
|
|
auto const sig = xrpl::sign(borrower.pk(), borrower.sk(), ss.slice());
|
|
sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()});
|
|
|
|
forgedLoanSet[json::StaticString{"CounterpartySignature"}] = sigObject;
|
|
|
|
// ? Fails because the lender hasn't signed the tx
|
|
env(env.json(forgedLoanSet, Fee(loanSetFee)), Ter(telENV_RPC_FAILED));
|
|
|
|
auto const seq = env.seq(borrower);
|
|
auto const batchFee = batch::calcBatchFee(env, 1, 2);
|
|
// ! Should fail because the lender hasn't signed the tx
|
|
env(batch::outer(borrower, seq, batchFee, tfAllOrNothing),
|
|
batch::Inner(forgedLoanSet, seq + 1),
|
|
batch::Inner(pay(borrower, lender, XRP(1)), seq + 2),
|
|
Ter(lendingBatchEnabled ? temBAD_SIGNATURE : temINVALID_INNER_BATCH));
|
|
env.close();
|
|
|
|
// ? Check that the loan was NOT created
|
|
{
|
|
json::Value params(json::ValueType::Object);
|
|
params[jss::account] = borrower.human();
|
|
params[jss::type] = "Loan";
|
|
auto const res = env.rpc("json", "account_objects", to_string(params));
|
|
auto const objects = res[jss::result][jss::account_objects];
|
|
BEAST_EXPECT(objects.size() == 0);
|
|
}
|
|
}
|
|
|
|
// Integration test: full lifecycle of a $1B loan in the bug regime.
|
|
// Verifies that the vault collects the economically-correct interest
|
|
// income and that conservation holds at the trust-line level.
|
|
//
|
|
// Pre-fix (closed-form `power(1+r, n) - 1`): vault collected only
|
|
// ~$0.058 per $1B due to cancellation of `(1+r)^n - 1` at r*n ~ 5.7e-10.
|
|
// Post-fix (hybrid binomial path): vault collects ~$0.38 per $1B,
|
|
// matching the value computed independently with arbitrary-precision
|
|
// Decimal arithmetic.
|
|
void
|
|
testFullLifecycleVaultPnLNearZeroRate()
|
|
{
|
|
testcase("integration: full loan lifecycle, vault interest at near-zero rate");
|
|
|
|
using namespace jtx;
|
|
using namespace jtx::loan;
|
|
using namespace std::chrono_literals;
|
|
Env env(*this, all_);
|
|
|
|
Account const issuer{"issuer"};
|
|
Account const lender{"lender"};
|
|
Account const borrower{"borrower"};
|
|
|
|
env.fund(XRP(1'000'000), issuer, lender, borrower);
|
|
env.close();
|
|
env(fset(issuer, asfDefaultRipple));
|
|
env.close();
|
|
|
|
PrettyAsset const iouAsset = issuer["USD"];
|
|
STAmount const trustLimit{iouAsset.raw(), Number{1, 17}};
|
|
env(trust(lender, trustLimit));
|
|
env(trust(borrower, trustLimit));
|
|
env.close();
|
|
env(pay(issuer, lender, iouAsset(5'000'000'000LL)));
|
|
env(pay(issuer, borrower, iouAsset(5'000'000'000LL)));
|
|
env.close();
|
|
|
|
auto usdBalance = [&](Account const& a) {
|
|
return env.balance(a, iouAsset.raw().get<Issue>()).value();
|
|
};
|
|
STAmount const borrowerStartBal = usdBalance(borrower);
|
|
|
|
BrokerParameters const brokerParams{
|
|
.vaultDeposit = Number{2, 9},
|
|
.debtMax = Number{0},
|
|
.coverRateMin = TenthBips32{0},
|
|
.coverDeposit = 0,
|
|
.managementFeeRate = TenthBips16{0},
|
|
.coverRateLiquidation = TenthBips32{0}};
|
|
BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)};
|
|
|
|
auto const vaultBefore = env.le(broker.vaultKeylet());
|
|
if (!BEAST_EXPECT(vaultBefore))
|
|
return;
|
|
Number const vaultAvailableBefore = vaultBefore->at(sfAssetsAvailable);
|
|
|
|
// Loan: $1B principal, 3 payments, 600s interval, rate=1 TenthBips32.
|
|
auto const loanSetFee = Fee(env.current()->fees().base * 2);
|
|
Number const principalRequest{1, 9};
|
|
auto createJson = env.json(
|
|
set(borrower, broker.brokerID, principalRequest),
|
|
Fee(loanSetFee),
|
|
Json(sfCounterpartySignature, json::ValueType::Object));
|
|
createJson["InterestRate"] = 1;
|
|
createJson["PaymentTotal"] = 3;
|
|
createJson["PaymentInterval"] = 600;
|
|
|
|
auto const loanKeylet = nextLoanKeylet(env, broker);
|
|
createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
|
|
env(createJson, Ter(tesSUCCESS));
|
|
env.close();
|
|
|
|
auto const loanSle = env.le(loanKeylet);
|
|
if (!BEAST_EXPECT(loanSle))
|
|
return;
|
|
Number const expectedTotalInterest =
|
|
loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfPrincipalOutstanding);
|
|
|
|
env(pay(borrower, loanKeylet.key, iouAsset(1'500'000'000LL)), Ter(tesSUCCESS));
|
|
env.close();
|
|
|
|
auto const vaultAfter = env.le(broker.vaultKeylet());
|
|
if (!BEAST_EXPECT(vaultAfter))
|
|
return;
|
|
Number const vaultAvailableAfter = vaultAfter->at(sfAssetsAvailable);
|
|
Number const vaultGain = vaultAvailableAfter - vaultAvailableBefore;
|
|
|
|
STAmount const borrowerEndBal = usdBalance(borrower);
|
|
STAmount const borrowerNetOut = borrowerStartBal - borrowerEndBal;
|
|
|
|
// Self-consistency: vault gained exactly the expected interest
|
|
// computed at LoanSet, and the borrower's outflow matches.
|
|
BEAST_EXPECT(vaultGain == expectedTotalInterest);
|
|
BEAST_EXPECT(Number(borrowerNetOut) == expectedTotalInterest);
|
|
|
|
// Mathematical correctness: the total interest for this loan
|
|
// configuration is 0.38051750382930729983, calculated
|
|
// independently using 50-digit Decimal arithmetic (no
|
|
// cancellation possible at that precision). At Number's 19-digit
|
|
// mantissa this rounds to 0.38051750382930729 — the literal
|
|
// below. The vault's actual gain must agree to within
|
|
// sub-microcent precision.
|
|
Number const decimalReference{38051750382930729LL, -17};
|
|
Number const tolerance{1, -6}; // 1e-6 USD = sub-microcent
|
|
Number const error = abs(vaultGain - decimalReference);
|
|
BEAST_EXPECTS(
|
|
error < tolerance,
|
|
"vault gain " + to_string(vaultGain) + " differs from Decimal reference " +
|
|
to_string(decimalReference) + " by " + to_string(error) + " — exceeds tolerance " +
|
|
to_string(tolerance));
|
|
}
|
|
|
|
void
|
|
runAmendmentIndependent()
|
|
{
|
|
testIssuerLoan();
|
|
testBorrowerIsBroker();
|
|
testFullLifecycleVaultPnLNearZeroRate();
|
|
}
|
|
|
|
// Tests run under each entry in amendmentCombinations().
|
|
void
|
|
runAmendmentSensitive(FeatureBitset features)
|
|
{
|
|
testLifecycle(features);
|
|
testSelfLoan(features);
|
|
testIssuerIsBorrower(features);
|
|
testBatchBypassCounterparty(features);
|
|
}
|
|
|
|
public:
|
|
void
|
|
run() override
|
|
{
|
|
runAmendmentIndependent();
|
|
for (auto const& features : jtx::amendmentCombinations(
|
|
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
|
|
runAmendmentSensitive(features);
|
|
}
|
|
};
|
|
|
|
BEAST_DEFINE_TESTSUITE(LoanLifecycle, tx, xrpl);
|
|
|
|
} // namespace xrpl::test
|