mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-22 23:00:55 +00:00
test: Split Loan_test.cpp into topical suites (#7864)
Co-authored-by: Ayaz Salikhov <mathbunnyru@users.noreply.github.com>
This commit is contained in:
689
src/test/app/lending/LoanLifecycle_test.cpp
Normal file
689
src/test/app/lending/LoanLifecycle_test.cpp
Normal file
@@ -0,0 +1,689 @@
|
||||
#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
|
||||
Reference in New Issue
Block a user