mirror of
https://github.com/XRPLF/rippled.git
synced 2026-09-27 15:28:03 +00:00
test: Cover Vault and Lending transactions as Batch inners
Add the LendingBatch suite: vault lifecycle, loan lifecycle on open-ended and closed-ended vaults, arbitrage with rollback, default with cover withdraw, and tfIndependent chains, each run with LendingProtocolV1_1 on and off.
This commit is contained in:
755
src/test/app/lending/LendingBatch_test.cpp
Normal file
755
src/test/app/lending/LendingBatch_test.cpp
Normal file
@@ -0,0 +1,755 @@
|
||||
#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/offer.h>
|
||||
#include <test/jtx/owners.h>
|
||||
#include <test/jtx/pay.h>
|
||||
#include <test/jtx/seq.h>
|
||||
#include <test/jtx/sig.h>
|
||||
#include <test/jtx/tags.h>
|
||||
#include <test/jtx/ter.h>
|
||||
#include <test/jtx/trust.h>
|
||||
#include <test/jtx/vault.h>
|
||||
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/basics/chrono.h>
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
#include <xrpl/json/json_value.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/Issue.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/SeqProxy.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/TxFlags.h>
|
||||
#include <xrpl/protocol/Units.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// Vault and Lending transactions used as Batch inners under LendingProtocolV1_2.
|
||||
class LendingBatch_test : public LoanTestBase
|
||||
{
|
||||
private:
|
||||
// Asserts a loan has been paid off in full: no principal, value, or
|
||||
// remaining payments left on the loan; no debt left on the broker; and the
|
||||
// vault's available assets back to `depositValue`.
|
||||
void
|
||||
assertLoanFullyRepaid(
|
||||
jtx::Env const& env,
|
||||
Keylet const& brokerKeylet,
|
||||
Keylet const& vaultKeylet,
|
||||
Keylet const& loanKeylet,
|
||||
STAmount const& depositValue)
|
||||
{
|
||||
if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle))
|
||||
{
|
||||
BEAST_EXPECT(loanSle->at(sfPrincipalOutstanding) == 0);
|
||||
BEAST_EXPECT(loanSle->at(sfTotalValueOutstanding) == 0);
|
||||
BEAST_EXPECT(loanSle->at(sfPaymentRemaining) == 0);
|
||||
}
|
||||
if (auto const brokerSle = env.le(brokerKeylet); BEAST_EXPECT(brokerSle))
|
||||
BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0);
|
||||
if (auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle))
|
||||
BEAST_EXPECT(vaultSle->at(sfAssetsAvailable) == depositValue);
|
||||
}
|
||||
|
||||
// LoanSet as a batch inner: the counterparty signs the outer batch, so
|
||||
// this inner carries no signature of its own. Pass paymentTotal >= 2
|
||||
// wherever the same batch also submits an early tfLoanFullPayment:
|
||||
// computeFullPayment refuses it with tecKILLED when only the final
|
||||
// scheduled payment is left.
|
||||
static json::Value
|
||||
loanSetInner(
|
||||
jtx::Env& env,
|
||||
jtx::Account const& account,
|
||||
uint256 const& brokerID,
|
||||
jtx::Account const& counterparty,
|
||||
STAmount const& principal,
|
||||
std::uint32_t paymentTotal)
|
||||
{
|
||||
using namespace jtx;
|
||||
using namespace jtx::loan;
|
||||
|
||||
return env.json(
|
||||
set(account, brokerID, principal.value()),
|
||||
kCounterparty(counterparty.id()),
|
||||
kPaymentTotal(paymentTotal),
|
||||
Sig(kNone),
|
||||
Fee(kNone),
|
||||
Seq(kNone));
|
||||
}
|
||||
|
||||
// Runs VaultCreate -> Deposit -> Withdraw -> Delete in one tfAllOrNothing
|
||||
// batch and checks the vault is gone, the owner paid only the batch fee,
|
||||
// and both owner count and sequence are back where they belong.
|
||||
void
|
||||
runVaultLifecycleBatch(jtx::Env& env, jtx::Account const& owner, json::Value const& createTx)
|
||||
{
|
||||
using namespace jtx;
|
||||
|
||||
Vault const vault{env};
|
||||
|
||||
auto const seq = env.seq(owner);
|
||||
auto const balanceBefore = env.balance(owner);
|
||||
auto const ownerCountBefore = env.ownerCount(owner);
|
||||
|
||||
// The outer Batch consumes owner's current sequence, so the first
|
||||
// inner (VaultCreate) lands on seq + 1.
|
||||
auto const vaultKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(seq + 1));
|
||||
auto const depositAmount = XRP(1'000);
|
||||
|
||||
auto const batchFee = batch::calcBatchFee(env, 0, 4);
|
||||
env(batch::outer(owner, seq, batchFee, tfAllOrNothing),
|
||||
batch::Inner(createTx, seq + 1),
|
||||
batch::Inner(
|
||||
vault.deposit({.depositor = owner, .id = vaultKeylet.key, .amount = depositAmount}),
|
||||
seq + 2),
|
||||
batch::Inner(
|
||||
vault.withdraw(
|
||||
{.depositor = owner, .id = vaultKeylet.key, .amount = depositAmount}),
|
||||
seq + 3),
|
||||
batch::Inner(vault.del({.owner = owner, .id = vaultKeylet.key}), seq + 4),
|
||||
Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
BEAST_EXPECT(!env.le(vaultKeylet));
|
||||
BEAST_EXPECT(env.balance(owner) == balanceBefore - batchFee);
|
||||
BEAST_EXPECT(env.ownerCount(owner) == ownerCountBefore);
|
||||
BEAST_EXPECT(env.seq(owner) == seq + 5);
|
||||
}
|
||||
|
||||
void
|
||||
testVaultLifecycle(FeatureBitset features)
|
||||
{
|
||||
// A single tfAllOrNothing batch runs an open-ended XRP vault through
|
||||
// its whole lifecycle: create, deposit, withdraw everything back out,
|
||||
// and delete.
|
||||
testcase("vault lifecycle in a batch");
|
||||
|
||||
using namespace jtx;
|
||||
|
||||
Env env(*this, features);
|
||||
|
||||
Account const owner{"owner"};
|
||||
env.fund(XRP(100'000), owner);
|
||||
env.close();
|
||||
|
||||
auto const createTx = std::get<0>(Vault{env}.create({.owner = owner, .asset = xrpIssue()}));
|
||||
runVaultLifecycleBatch(env, owner, createTx);
|
||||
}
|
||||
|
||||
void
|
||||
testClosedEndedVaultLifecycle(FeatureBitset features)
|
||||
{
|
||||
// Same lifecycle as testVaultLifecycle, but on a closed-ended vault
|
||||
// submitted while it is still in the Subscription phase, where
|
||||
// LendingProtocolV1_1 allows both deposit and withdraw.
|
||||
testcase("vault lifecycle in a batch, closed-ended vault, subscription phase");
|
||||
|
||||
using namespace jtx;
|
||||
|
||||
Env env(*this, features);
|
||||
|
||||
Account const owner{"owner"};
|
||||
env.fund(XRP(100'000), owner);
|
||||
env.close();
|
||||
|
||||
auto const createTx = std::get<0>(Vault{env}.createClosedEnded(
|
||||
{.owner = owner, .asset = xrpIssue(), .subscriptionOffset = std::chrono::seconds{60}}));
|
||||
runVaultLifecycleBatch(env, owner, createTx);
|
||||
}
|
||||
|
||||
void
|
||||
testLoanLifecycleOpenEndedVault(FeatureBitset features)
|
||||
{
|
||||
// With LendingProtocolV1_1 off, an open-ended vault can host a broker,
|
||||
// so vault creation, deposit, broker setup, loan origination, and an
|
||||
// early full payoff all fit in one atomic batch. The borrower signs the
|
||||
// outer as counterparty to the LoanSet inner and as the account behind
|
||||
// the LoanPay inner.
|
||||
testcase("loan lifecycle in a batch, open-ended vault");
|
||||
|
||||
using namespace jtx;
|
||||
|
||||
Env env(*this, features);
|
||||
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
env.fund(XRP(1'000'000), lender, borrower);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const asset{xrpIssue(), 1'000'000};
|
||||
Vault const vault{env};
|
||||
|
||||
auto const lenderSeq = env.seq(lender);
|
||||
auto const borrowerSeq = env.seq(borrower);
|
||||
auto const lenderBalanceBefore = env.balance(lender);
|
||||
auto const borrowerBalanceBefore = env.balance(borrower);
|
||||
|
||||
auto const depositAmount = asset(50'000);
|
||||
auto const principal = asset(1'000);
|
||||
|
||||
auto const vaultKeylet = keylet::vault(lender.id(), SeqProxy::rawSequence(lenderSeq + 1));
|
||||
auto const brokerKeylet =
|
||||
keylet::loanBroker(lender.id(), SeqProxy::rawSequence(lenderSeq + 3));
|
||||
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
|
||||
|
||||
auto const createTx = std::get<0>(vault.create({.owner = lender, .asset = xrpIssue()}));
|
||||
|
||||
// CoverRateMinimum/CoverRateLiquidation are left unset on the
|
||||
// LoanBrokerSet inner below, which defaults both to zero, so this
|
||||
// loan needs no first-loss cover deposit from the broker.
|
||||
auto const loanSetTx = loanSetInner(env, lender, brokerKeylet.key, borrower, principal, 2);
|
||||
auto const loanPayTx =
|
||||
loan::pay(borrower, loanKeylet.key, principal.value(), tfLoanFullPayment);
|
||||
|
||||
auto const batchFee = batch::calcBatchFee(env, 1, 5);
|
||||
env(batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing),
|
||||
batch::Inner(createTx, lenderSeq + 1),
|
||||
batch::Inner(
|
||||
vault.deposit(
|
||||
{.depositor = lender, .id = vaultKeylet.key, .amount = depositAmount.value()}),
|
||||
lenderSeq + 2),
|
||||
batch::Inner(loan_broker::set(lender, vaultKeylet.key), lenderSeq + 3),
|
||||
batch::Inner(loanSetTx, lenderSeq + 4),
|
||||
batch::Inner(loanPayTx, borrowerSeq),
|
||||
batch::Sig(borrower),
|
||||
Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
assertLoanFullyRepaid(env, brokerKeylet, vaultKeylet, loanKeylet, depositAmount.value());
|
||||
|
||||
// The borrower borrowed the principal and repaid it in full at zero
|
||||
// interest and fees, so their balance nets to zero; the batch fee is
|
||||
// paid entirely by the lender, who also funded the vault deposit.
|
||||
BEAST_EXPECT(env.balance(borrower) == borrowerBalanceBefore);
|
||||
BEAST_EXPECT(env.balance(lender) == lenderBalanceBefore - batchFee - depositAmount.value());
|
||||
}
|
||||
|
||||
void
|
||||
testLoanLifecycleClosedEndedVault(FeatureBitset features)
|
||||
{
|
||||
// Under LendingProtocolV1_1 a broker can attach only to a closed-ended
|
||||
// vault, and LoanSet is refused outside the Investment phase, so the
|
||||
// vault/broker setup and the loan/payoff run as two batches split at
|
||||
// the Subscription -> Investment boundary.
|
||||
testcase("loan lifecycle in a batch, closed-ended vault");
|
||||
|
||||
using namespace jtx;
|
||||
|
||||
Env env(*this, features);
|
||||
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
env.fund(XRP(1'000'000), lender, borrower);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const asset{xrpIssue(), 1'000'000};
|
||||
Vault const vault{env};
|
||||
|
||||
auto const lenderSeq1 = env.seq(lender);
|
||||
auto const depositAmount = asset(50'000);
|
||||
|
||||
auto const vaultKeylet = keylet::vault(lender.id(), SeqProxy::rawSequence(lenderSeq1 + 1));
|
||||
auto const brokerKeylet =
|
||||
keylet::loanBroker(lender.id(), SeqProxy::rawSequence(lenderSeq1 + 3));
|
||||
|
||||
auto const created = vault.createClosedEnded(
|
||||
{.owner = lender, .asset = xrpIssue(), .subscriptionOffset = std::chrono::seconds{60}});
|
||||
auto const createTx = std::get<0>(created);
|
||||
auto const subscriptionDate = std::get<2>(created);
|
||||
|
||||
// LoanBrokerSet checks only the vault's kind, not its phase, so setting
|
||||
// up the broker is allowed here in the same batch, while the vault is
|
||||
// still in the Subscription phase.
|
||||
auto const batch1Fee = batch::calcBatchFee(env, 0, 3);
|
||||
env(batch::outer(lender, lenderSeq1, batch1Fee, tfAllOrNothing),
|
||||
batch::Inner(createTx, lenderSeq1 + 1),
|
||||
batch::Inner(
|
||||
vault.deposit(
|
||||
{.depositor = lender, .id = vaultKeylet.key, .amount = depositAmount.value()}),
|
||||
lenderSeq1 + 2),
|
||||
batch::Inner(loan_broker::set(lender, vaultKeylet.key), lenderSeq1 + 3),
|
||||
Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
if (auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle))
|
||||
BEAST_EXPECT(vaultSle->at(sfAssetsAvailable) == depositAmount.value());
|
||||
BEAST_EXPECT(env.le(brokerKeylet));
|
||||
|
||||
vault.closePastSubscription(subscriptionDate);
|
||||
|
||||
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
|
||||
auto const principal = asset(1'000);
|
||||
|
||||
auto const lenderSeq2 = env.seq(lender);
|
||||
auto const borrowerSeq = env.seq(borrower);
|
||||
auto const lenderBalanceBefore = env.balance(lender);
|
||||
auto const borrowerBalanceBefore = env.balance(borrower);
|
||||
|
||||
auto const loanSetTx = loanSetInner(env, lender, brokerKeylet.key, borrower, principal, 2);
|
||||
auto const loanPayTx =
|
||||
loan::pay(borrower, loanKeylet.key, principal.value(), tfLoanFullPayment);
|
||||
|
||||
auto const batch2Fee = batch::calcBatchFee(env, 1, 2);
|
||||
env(batch::outer(lender, lenderSeq2, batch2Fee, tfAllOrNothing),
|
||||
batch::Inner(loanSetTx, lenderSeq2 + 1),
|
||||
batch::Inner(loanPayTx, borrowerSeq),
|
||||
batch::Sig(borrower),
|
||||
Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
assertLoanFullyRepaid(env, brokerKeylet, vaultKeylet, loanKeylet, depositAmount.value());
|
||||
|
||||
BEAST_EXPECT(env.balance(borrower) == borrowerBalanceBefore);
|
||||
// The deposit already left the lender's balance in batch 1; batch 2
|
||||
// only costs the batch fee, since LoanSet/LoanPay net to zero XRP.
|
||||
BEAST_EXPECT(env.balance(lender) == lenderBalanceBefore - batch2Fee);
|
||||
}
|
||||
|
||||
// Handles shared by testArbitrage and testArbitrageRollback.
|
||||
struct ArbitrageSetup
|
||||
{
|
||||
jtx::PrettyAsset iou;
|
||||
jtx::Account issuer;
|
||||
jtx::Account lender;
|
||||
jtx::Account borrower;
|
||||
jtx::Account mm1;
|
||||
jtx::Account mm2;
|
||||
BrokerInfo broker;
|
||||
};
|
||||
|
||||
// Broker with zero cover rates and no debt cap, so LoanSet needs no cover
|
||||
// deposit. The borrower holds a trust line but no IOU, so the profit is
|
||||
// exact. mm1 rests an offer selling 400 XRP for 1000 IOU; mm2 is funded
|
||||
// but places its offer only inside testArbitrage's batch.
|
||||
ArbitrageSetup
|
||||
setupArbitrage(jtx::Env& env)
|
||||
{
|
||||
using namespace jtx;
|
||||
|
||||
Account const issuer{"issuer"};
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
Account const mm1{"mm1"};
|
||||
Account const mm2{"mm2"};
|
||||
env.fund(XRP(1'000'000), issuer, lender, borrower, mm1, mm2);
|
||||
env(fset(issuer, asfDefaultRipple));
|
||||
env.close();
|
||||
|
||||
PrettyAsset const iou = issuer[iouCurrency_];
|
||||
STAmount const iouLimit = iou(1'000'000'000);
|
||||
env(trust(lender, iouLimit));
|
||||
env(trust(borrower, iouLimit));
|
||||
env(trust(mm1, iouLimit));
|
||||
env(trust(mm2, iouLimit));
|
||||
env.close();
|
||||
|
||||
BrokerParameters params = BrokerParameters::defaults();
|
||||
params.vaultDeposit = 100'000;
|
||||
params.coverRateMin = TenthBips32{0};
|
||||
params.coverDeposit = 0;
|
||||
params.debtMax = 0;
|
||||
params.coverRateLiquidation = TenthBips32{0};
|
||||
|
||||
env(pay(issuer, lender, iou(params.vaultDeposit)));
|
||||
env(pay(issuer, mm2, iou(20'000)));
|
||||
env.close();
|
||||
|
||||
BrokerInfo const broker{createVaultAndBroker(env, iou, lender, params)};
|
||||
|
||||
env(offer(mm1, iou(1'000), XRP(400)));
|
||||
env.close();
|
||||
|
||||
return {
|
||||
.iou = iou,
|
||||
.issuer = issuer,
|
||||
.lender = lender,
|
||||
.borrower = borrower,
|
||||
.mm1 = mm1,
|
||||
.mm2 = mm2,
|
||||
.broker = broker};
|
||||
}
|
||||
|
||||
void
|
||||
testArbitrage(FeatureBitset features)
|
||||
{
|
||||
// A borrower profits risk-free from a loan and two crossing DEX
|
||||
// offers in one atomic batch. Crossed offers execute the moment both
|
||||
// rest on the book, so mm2's offer is itself a batch inner, placed
|
||||
// right after the borrower's buy leg consumes mm1.
|
||||
testcase("arbitrage across a loan and two crossing offers in a batch");
|
||||
|
||||
using namespace jtx;
|
||||
|
||||
Env env(*this, features);
|
||||
auto const setup = setupArbitrage(env);
|
||||
auto const& iou = setup.iou;
|
||||
auto const& lender = setup.lender;
|
||||
auto const& borrower = setup.borrower;
|
||||
auto const& broker = setup.broker;
|
||||
auto const& mm2 = setup.mm2;
|
||||
|
||||
auto const loanKeylet = nextLoanKeylet(env, broker);
|
||||
auto const principal = iou(1'000);
|
||||
|
||||
BEAST_EXPECT(env.balance(borrower, iou) == iou(0));
|
||||
|
||||
auto const borrowerSeq = env.seq(borrower);
|
||||
auto const mm2Seq = env.seq(mm2);
|
||||
|
||||
auto const loanSetTx = loanSetInner(env, borrower, broker.brokerID, lender, principal, 2);
|
||||
auto const loanPayTx =
|
||||
loan::pay(borrower, loanKeylet.key, principal.value(), tfLoanFullPayment);
|
||||
|
||||
auto const batchFee = batch::calcBatchFee(env, 2, 5);
|
||||
env(batch::outer(borrower, borrowerSeq, batchFee, tfAllOrNothing),
|
||||
batch::Inner(loanSetTx, borrowerSeq + 1),
|
||||
batch::Inner(
|
||||
offer(borrower, XRP(400), principal.value(), tfFillOrKill), borrowerSeq + 2),
|
||||
batch::Inner(offer(mm2, XRP(400), iou(1'008)), mm2Seq),
|
||||
batch::Inner(
|
||||
offer(borrower, iou(1'008).value(), XRP(400), tfFillOrKill), borrowerSeq + 3),
|
||||
batch::Inner(loanPayTx, borrowerSeq + 4),
|
||||
batch::Sig(lender, mm2),
|
||||
Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle))
|
||||
BEAST_EXPECT(loanSle->at(sfPrincipalOutstanding) == 0);
|
||||
BEAST_EXPECT(env.balance(borrower, iou) == iou(8));
|
||||
env.require(offers(setup.mm1, 0));
|
||||
env.require(offers(mm2, 0));
|
||||
if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle))
|
||||
BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0);
|
||||
if (auto const vaultSle = env.le(broker.vaultKeylet()); BEAST_EXPECT(vaultSle))
|
||||
{
|
||||
BEAST_EXPECT(
|
||||
vaultSle->at(sfAssetsAvailable) == iou(broker.params.vaultDeposit).value());
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testArbitrageRollback(FeatureBitset features)
|
||||
{
|
||||
// Without a second market maker willing to buy XRP back at the
|
||||
// higher price, the sell leg's tfFillOrKill offer cannot fill and
|
||||
// returns tecKILLED. tfAllOrNothing then rolls back every inner,
|
||||
// including the loan origination; the outer batch itself still
|
||||
// returns tesSUCCESS.
|
||||
testcase("arbitrage batch rolls back when the sell offer cannot fill");
|
||||
|
||||
using namespace jtx;
|
||||
|
||||
Env env(*this, features);
|
||||
auto const setup = setupArbitrage(env);
|
||||
auto const& iou = setup.iou;
|
||||
auto const& lender = setup.lender;
|
||||
auto const& borrower = setup.borrower;
|
||||
auto const& broker = setup.broker;
|
||||
|
||||
auto const brokerSleBefore = env.le(broker.brokerKeylet());
|
||||
auto const vaultSleBefore = env.le(broker.vaultKeylet());
|
||||
if (!BEAST_EXPECT(brokerSleBefore) || !BEAST_EXPECT(vaultSleBefore))
|
||||
return;
|
||||
auto const loanSequenceBefore = brokerSleBefore->at(sfLoanSequence);
|
||||
auto const assetsAvailableBefore = vaultSleBefore->at(sfAssetsAvailable);
|
||||
|
||||
auto const loanKeylet = nextLoanKeylet(env, broker);
|
||||
auto const principal = iou(1'000);
|
||||
|
||||
auto const borrowerSeq = env.seq(borrower);
|
||||
auto const borrowerBalanceBefore = env.balance(borrower);
|
||||
|
||||
auto const loanSetTx = loanSetInner(env, borrower, broker.brokerID, lender, principal, 2);
|
||||
auto const loanPayTx =
|
||||
loan::pay(borrower, loanKeylet.key, principal.value(), tfLoanFullPayment);
|
||||
|
||||
auto const batchFee = batch::calcBatchFee(env, 1, 4);
|
||||
env(batch::outer(borrower, borrowerSeq, batchFee, tfAllOrNothing),
|
||||
batch::Inner(loanSetTx, borrowerSeq + 1),
|
||||
batch::Inner(
|
||||
offer(borrower, XRP(400), principal.value(), tfFillOrKill), borrowerSeq + 2),
|
||||
batch::Inner(
|
||||
offer(borrower, iou(1'008).value(), XRP(400), tfFillOrKill), borrowerSeq + 3),
|
||||
batch::Inner(loanPayTx, borrowerSeq + 4),
|
||||
batch::Sig(lender),
|
||||
Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
BEAST_EXPECT(!env.le(loanKeylet));
|
||||
if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle))
|
||||
{
|
||||
BEAST_EXPECT(brokerSle->at(sfLoanSequence) == loanSequenceBefore);
|
||||
BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0);
|
||||
}
|
||||
BEAST_EXPECT(env.balance(borrower, iou) == iou(0));
|
||||
BEAST_EXPECT(env.balance(borrower) == borrowerBalanceBefore - batchFee);
|
||||
env.require(offers(setup.mm1, 1));
|
||||
if (auto const vaultSle = env.le(broker.vaultKeylet()); BEAST_EXPECT(vaultSle))
|
||||
BEAST_EXPECT(vaultSle->at(sfAssetsAvailable) == assetsAvailableBefore);
|
||||
}
|
||||
|
||||
void
|
||||
testDefaultAndCoverWithdraw(FeatureBitset features)
|
||||
{
|
||||
// A defaulted loan burns first-loss cover per CoverRateLiquidation;
|
||||
// the remainder can be withdrawn once DebtTotal drops to zero. The
|
||||
// default runs in its own batch: LoanManage refuses tfLoanDefault
|
||||
// with tecTOO_SOON until the grace period elapses, so it cannot share
|
||||
// a batch with the LoanSet that creates the loan.
|
||||
testcase("loan default and cover withdraw in a batch");
|
||||
|
||||
using namespace jtx;
|
||||
|
||||
Env env(*this, features);
|
||||
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
env.fund(XRP(100'000'000), lender, borrower);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const asset{xrpIssue(), 1'000'000};
|
||||
auto const broker = createVaultAndBroker(env, asset, lender);
|
||||
|
||||
auto const loanKeylet = nextLoanKeylet(env, broker);
|
||||
auto const principal = asset(1'000);
|
||||
|
||||
{
|
||||
using namespace loan;
|
||||
// PaymentTotal 1 is fine: the loan is only ever defaulted here,
|
||||
// never paid off early, so the tfLoanFullPayment restriction on
|
||||
// the last remaining payment does not apply.
|
||||
env(set(lender, broker.brokerID, principal.value()),
|
||||
kCounterparty(borrower.id()),
|
||||
kPaymentTotal(1),
|
||||
kPaymentInterval(86400),
|
||||
kGracePeriod(86400),
|
||||
Sig(sfCounterpartySignature, borrower),
|
||||
Fee(env.current()->fees().base * 2));
|
||||
}
|
||||
env.close();
|
||||
|
||||
auto const loanSleBefore = env.le(loanKeylet);
|
||||
if (!BEAST_EXPECT(loanSleBefore))
|
||||
return;
|
||||
|
||||
// Advance past NextPaymentDueDate + GracePeriod so the default is no
|
||||
// longer refused with tecTOO_SOON.
|
||||
auto const dueDate = loanSleBefore->at(sfNextPaymentDueDate);
|
||||
auto const gracePeriod = loanSleBefore->at(sfGracePeriod);
|
||||
using d = NetClock::duration;
|
||||
using tp = NetClock::time_point;
|
||||
env.close(tp{d{dueDate + gracePeriod + 1}});
|
||||
|
||||
auto const brokerSleBefore = env.le(broker.brokerKeylet());
|
||||
auto const vaultSleBefore = env.le(broker.vaultKeylet());
|
||||
if (!BEAST_EXPECT(brokerSleBefore) || !BEAST_EXPECT(vaultSleBefore))
|
||||
return;
|
||||
|
||||
auto const coverAvailableBefore = brokerSleBefore->at(sfCoverAvailable);
|
||||
auto const assetsAvailableBefore = vaultSleBefore->at(sfAssetsAvailable);
|
||||
auto const assetsTotalBefore = vaultSleBefore->at(sfAssetsTotal);
|
||||
|
||||
TenthBips32 const coverRateMin{brokerSleBefore->at(sfCoverRateMinimum)};
|
||||
TenthBips32 const coverRateLiquidation{brokerSleBefore->at(sfCoverRateLiquidation)};
|
||||
Number const totalDefaultAmount{loanSleBefore->at(sfPrincipalOutstanding)};
|
||||
|
||||
// DefaultCovered = min(MinimumCover x CoverRateLiquidation,
|
||||
// DefaultAmount, CoverAvailable), matching LoanManage::defaultLoan.
|
||||
// Zero interest and fees make DefaultAmount equal the outstanding
|
||||
// principal under both accounting modes, so no
|
||||
// featureLendingProtocolV1_1 branch is needed here.
|
||||
Number defaultCovered;
|
||||
{
|
||||
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
|
||||
auto const minimumCover =
|
||||
tenthBipsOfValue(Number{brokerSleBefore->at(sfDebtTotal)}, coverRateMin);
|
||||
defaultCovered = std::min(
|
||||
roundToAsset(
|
||||
asset.raw(),
|
||||
std::min(
|
||||
tenthBipsOfValue(minimumCover, coverRateLiquidation), totalDefaultAmount),
|
||||
loanSleBefore->at(sfLoanScale)),
|
||||
Number{coverAvailableBefore});
|
||||
}
|
||||
auto const vaultDefaultAmount = totalDefaultAmount - defaultCovered;
|
||||
|
||||
auto const lenderSeq = env.seq(lender);
|
||||
auto const lenderBalanceBefore = env.balance(lender);
|
||||
auto const withdrawAmount = STAmount{asset.raw(), coverAvailableBefore - defaultCovered};
|
||||
|
||||
auto const batchFee = batch::calcBatchFee(env, 0, 2);
|
||||
env(batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing),
|
||||
batch::Inner(loan::manage(lender, loanKeylet.key, tfLoanDefault), lenderSeq + 1),
|
||||
batch::Inner(
|
||||
loan_broker::coverWithdraw(lender, broker.brokerID, withdrawAmount), lenderSeq + 2),
|
||||
Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle))
|
||||
{
|
||||
BEAST_EXPECT(loanSle->isFlag(lsfLoanDefault));
|
||||
BEAST_EXPECT(loanSle->at(sfPrincipalOutstanding) == 0);
|
||||
}
|
||||
if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle))
|
||||
{
|
||||
BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == 0);
|
||||
BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0);
|
||||
}
|
||||
BEAST_EXPECT(env.balance(lender) == lenderBalanceBefore - batchFee + withdrawAmount);
|
||||
// The default moves defaultCovered from the broker's cover to the
|
||||
// vault; the cover withdraw pays out of the broker's own
|
||||
// pseudo-account and does not touch the vault again.
|
||||
if (auto const vaultSle = env.le(broker.vaultKeylet()); BEAST_EXPECT(vaultSle))
|
||||
{
|
||||
BEAST_EXPECT(vaultSle->at(sfAssetsAvailable) == assetsAvailableBefore + defaultCovered);
|
||||
BEAST_EXPECT(vaultSle->at(sfAssetsTotal) == assetsTotalBefore - vaultDefaultAmount);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testIndependentVaultChain(FeatureBitset features)
|
||||
{
|
||||
// tfIndependent runs every inner regardless of earlier failures.
|
||||
// VaultWithdraw fails before anything is deposited (AssetsTotal is
|
||||
// zero, so there are no shares to redeem yet) and VaultDelete fails
|
||||
// once the vault holds a deposit (tecHAS_OBLIGATIONS); both are
|
||||
// tec-class results, so neither stops the inners that follow.
|
||||
testcase("independent batch runs every inner despite failing inners");
|
||||
|
||||
using namespace jtx;
|
||||
|
||||
Env env(*this, features);
|
||||
|
||||
Account const owner{"owner"};
|
||||
env.fund(XRP(100'000), owner);
|
||||
env.close();
|
||||
|
||||
Vault const vault{env};
|
||||
|
||||
auto const seq = env.seq(owner);
|
||||
auto const vaultKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(seq + 1));
|
||||
auto const createTx = std::get<0>(vault.create({.owner = owner, .asset = xrpIssue()}));
|
||||
auto const amount = XRP(1'000);
|
||||
|
||||
auto const batchFee = batch::calcBatchFee(env, 0, 4);
|
||||
env(batch::outer(owner, seq, batchFee, tfIndependent),
|
||||
batch::Inner(createTx, seq + 1),
|
||||
batch::Inner(
|
||||
vault.withdraw({.depositor = owner, .id = vaultKeylet.key, .amount = amount}),
|
||||
seq + 2),
|
||||
batch::Inner(
|
||||
vault.deposit({.depositor = owner, .id = vaultKeylet.key, .amount = amount}),
|
||||
seq + 3),
|
||||
batch::Inner(vault.del({.owner = owner, .id = vaultKeylet.key}), seq + 4),
|
||||
Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
if (auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle))
|
||||
BEAST_EXPECT(vaultSle->at(sfAssetsTotal) == amount.value());
|
||||
BEAST_EXPECT(env.seq(owner) == seq + 5);
|
||||
}
|
||||
|
||||
void
|
||||
testIndependentLoanChain(FeatureBitset features)
|
||||
{
|
||||
// Same claim as testIndependentVaultChain, on a loan chain. The
|
||||
// borrower starts with no IOU balance, so a LoanPay for twice the
|
||||
// loan's principal fails preclaim's balance check
|
||||
// (tecINSUFFICIENT_FUNDS); the cover deposit submitted after it
|
||||
// still lands.
|
||||
testcase("independent batch runs every loan inner despite a failing payment");
|
||||
|
||||
using namespace jtx;
|
||||
|
||||
Env env(*this, features);
|
||||
|
||||
Account const issuer{"issuer"};
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
env.fund(XRP(1'000'000), issuer, lender, borrower);
|
||||
env(fset(issuer, asfDefaultRipple));
|
||||
env.close();
|
||||
|
||||
PrettyAsset const iou = issuer[iouCurrency_];
|
||||
STAmount const iouLimit = iou(1'000'000'000);
|
||||
env(trust(lender, iouLimit));
|
||||
env(trust(borrower, iouLimit));
|
||||
env.close();
|
||||
|
||||
env(pay(issuer, lender, iou(2'000'000)));
|
||||
env.close();
|
||||
|
||||
auto const broker = createVaultAndBroker(env, iou, lender);
|
||||
|
||||
auto const loanKeylet = nextLoanKeylet(env, broker);
|
||||
auto const principal = iou(1'000);
|
||||
auto const brokerSleBefore = env.le(broker.brokerKeylet());
|
||||
if (!BEAST_EXPECT(brokerSleBefore))
|
||||
return;
|
||||
auto const coverAvailableBefore = brokerSleBefore->at(sfCoverAvailable);
|
||||
|
||||
auto const lenderSeq = env.seq(lender);
|
||||
auto const borrowerSeq = env.seq(borrower);
|
||||
|
||||
auto const loanSetTx = loanSetInner(env, lender, broker.brokerID, borrower, principal, 2);
|
||||
auto const loanPayTx =
|
||||
loan::pay(borrower, loanKeylet.key, iou(2'000).value(), tfLoanFullPayment);
|
||||
auto const additionalCover = iou(500);
|
||||
|
||||
auto const batchFee = batch::calcBatchFee(env, 1, 3);
|
||||
env(batch::outer(lender, lenderSeq, batchFee, tfIndependent),
|
||||
batch::Inner(loanSetTx, lenderSeq + 1),
|
||||
batch::Inner(loanPayTx, borrowerSeq),
|
||||
batch::Inner(
|
||||
loan_broker::coverDeposit(lender, broker.brokerID, additionalCover), lenderSeq + 2),
|
||||
batch::Sig(borrower),
|
||||
Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle))
|
||||
BEAST_EXPECT(loanSle->at(sfPrincipalOutstanding) == principal.value());
|
||||
if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle))
|
||||
{
|
||||
BEAST_EXPECT(
|
||||
brokerSle->at(sfCoverAvailable) == coverAvailableBefore + additionalCover.value());
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
{
|
||||
for (auto const& features : {all_, all_ | featureLendingProtocolV1_1})
|
||||
{
|
||||
testVaultLifecycle(features);
|
||||
testArbitrage(features);
|
||||
testArbitrageRollback(features);
|
||||
testDefaultAndCoverWithdraw(features);
|
||||
testIndependentVaultChain(features);
|
||||
testIndependentLoanChain(features);
|
||||
}
|
||||
testClosedEndedVaultLifecycle(all_ | featureLendingProtocolV1_1);
|
||||
testLoanLifecycleOpenEndedVault(all_);
|
||||
testLoanLifecycleClosedEndedVault(all_ | featureLendingProtocolV1_1);
|
||||
}
|
||||
};
|
||||
|
||||
BEAST_DEFINE_TESTSUITE(LendingBatch, tx, xrpl);
|
||||
|
||||
} // namespace xrpl::test
|
||||
Reference in New Issue
Block a user