mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-21 22:30:57 +00:00
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Co-authored-by: Ayaz Salikhov <mathbunnyru@users.noreply.github.com>
910 lines
35 KiB
C++
910 lines
35 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/credentials.h>
|
||
#include <test/jtx/fee.h>
|
||
#include <test/jtx/flags.h>
|
||
#include <test/jtx/mpt.h>
|
||
#include <test/jtx/pay.h>
|
||
#include <test/jtx/permissioned_domains.h>
|
||
#include <test/jtx/ter.h>
|
||
#include <test/jtx/trust.h>
|
||
#include <test/jtx/vault.h>
|
||
|
||
#include <xrpl/basics/Number.h>
|
||
#include <xrpl/basics/chrono.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/Indexes.h>
|
||
#include <xrpl/protocol/Issue.h>
|
||
#include <xrpl/protocol/LedgerFormats.h>
|
||
#include <xrpl/protocol/SField.h>
|
||
#include <xrpl/protocol/SeqProxy.h>
|
||
#include <xrpl/protocol/TER.h>
|
||
#include <xrpl/protocol/TxFlags.h>
|
||
#include <xrpl/protocol/Units.h>
|
||
|
||
#include <cstdint>
|
||
|
||
namespace xrpl::test {
|
||
|
||
class LoanCoverFreezeAuth_test : public LoanTestBase
|
||
{
|
||
private:
|
||
void
|
||
testSequentialFLCDepletion(FeatureBitset features)
|
||
{
|
||
testcase << "First-Loss Capital Depletion on Sequential Defaults";
|
||
|
||
using namespace jtx;
|
||
using namespace loan;
|
||
using namespace loan_broker;
|
||
|
||
Env env{*this, features};
|
||
|
||
Account const issuer{"issuer"};
|
||
Account const lender{"lender"};
|
||
Account const borrowerA{"borrowerA"};
|
||
Account const borrowerB{"borrowerB"};
|
||
|
||
env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB);
|
||
env.close();
|
||
|
||
PrettyAsset const asset = xrpIssue();
|
||
auto const vaultDepositAmount =
|
||
asset(200'000); // Enough for 2 x 50k loans plus interest/fees
|
||
|
||
auto const brokerInfo = createVaultAndBroker(
|
||
env,
|
||
asset,
|
||
lender,
|
||
{
|
||
.vaultDeposit = vaultDepositAmount.value(),
|
||
.debtMax = 0,
|
||
.coverRateMin = TenthBips32(20000), // 20%
|
||
.coverDeposit = 21'000,
|
||
.managementFeeRate = TenthBips16(100), // 0.1%
|
||
.coverRateLiquidation = TenthBips32(100000),
|
||
});
|
||
auto const brokerKeylet = brokerInfo.brokerKeylet();
|
||
|
||
// Create two identical loans: each 50,000 XRP principal (scaled down to
|
||
// avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal
|
||
// + interest) Formula will calculate cover as: 100% × (20% × 100,000) =
|
||
// 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first
|
||
// default
|
||
auto const principalAmount = Number(50'000);
|
||
auto const loanPaymentInterval = 2592000; // 30 days
|
||
auto const loanGracePeriod = 604800; // 7 days
|
||
|
||
// Create Loan A
|
||
auto loanATx = env.jt(
|
||
set(borrowerA, brokerKeylet.key, principalAmount),
|
||
Sig(sfCounterpartySignature, lender),
|
||
kInterestRate(TenthBips32(500)), // 5%
|
||
kPaymentTotal(12),
|
||
loan::kPaymentInterval(loanPaymentInterval),
|
||
loan::kGracePeriod(loanGracePeriod),
|
||
Fee(XRP(10))); // Sufficient fee for multi-sig transaction
|
||
env(loanATx);
|
||
env.close();
|
||
|
||
auto const loanAKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
|
||
|
||
// Create Loan B
|
||
auto loanBTx = env.jt(
|
||
set(borrowerB, brokerKeylet.key, principalAmount),
|
||
Sig(sfCounterpartySignature, lender),
|
||
kInterestRate(TenthBips32(500)), // 5%
|
||
kPaymentTotal(12),
|
||
loan::kPaymentInterval(loanPaymentInterval),
|
||
loan::kGracePeriod(loanGracePeriod),
|
||
Fee(XRP(10))); // Sufficient fee for multi-sig transaction
|
||
env(loanBTx);
|
||
env.close();
|
||
|
||
auto const loanBKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(2));
|
||
|
||
auto loanASle = env.le(loanAKeylet);
|
||
if (!BEAST_EXPECT(loanASle))
|
||
return;
|
||
|
||
// Advance time past grace period for both loans to be defaultable
|
||
auto const loanANextDue = loanASle->at(sfNextPaymentDueDate);
|
||
auto const loanAGrace = loanASle->at(sfGracePeriod);
|
||
env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60});
|
||
|
||
env(manage(lender, loanAKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
|
||
env.close();
|
||
|
||
// Verify Loan A is defaulted
|
||
loanASle = env.le(loanAKeylet);
|
||
if (!BEAST_EXPECT(loanASle))
|
||
return;
|
||
BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault));
|
||
BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0);
|
||
|
||
// Check broker state after first default (from committed ledger)
|
||
auto brokerSle = env.le(brokerKeylet);
|
||
if (!BEAST_EXPECT(brokerSle))
|
||
return;
|
||
auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal);
|
||
auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable);
|
||
|
||
// DebtTotal should have decreased by Loan A's debt
|
||
BEAST_EXPECT(afterFirstDebtTotal == 50'134);
|
||
|
||
// CoverAvailable should have decreased significantly
|
||
BEAST_EXPECT(afterFirstCoverAvailable == 946);
|
||
|
||
env(manage(lender, loanBKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
|
||
|
||
brokerSle = env.le(brokerKeylet);
|
||
if (!BEAST_EXPECT(brokerSle))
|
||
return;
|
||
auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal);
|
||
auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable);
|
||
|
||
BEAST_EXPECT(afterSecondDebtTotal == 0);
|
||
|
||
BEAST_EXPECT(afterSecondCoverAvailable == 0);
|
||
}
|
||
|
||
// Tests that vault withdrawals work correctly when the vault has unrealized
|
||
// loss from an impaired loan, ensuring the invariant check properly
|
||
// accounts for the loss.
|
||
void
|
||
testWithdrawReflectsUnrealizedLoss(FeatureBitset features)
|
||
{
|
||
using namespace jtx;
|
||
using namespace loan;
|
||
using namespace std::chrono_literals;
|
||
|
||
testcase("Vault withdraw reflects sfLossUnrealized");
|
||
|
||
// Test constants
|
||
static constexpr std::int64_t kInitialFunding = 1'000'000;
|
||
static constexpr std::int64_t kLenderInitialIou = 5'000'000;
|
||
static constexpr std::int64_t kDepositorInitialIou = 1'000'000;
|
||
static constexpr std::int64_t kBorrowerInitialIou = 100'000;
|
||
static constexpr std::int64_t kDepositAmount = 5'000;
|
||
static constexpr std::int64_t kPrincipalAmount = 99;
|
||
static constexpr std::uint64_t kExpectedSharesPerDepositor = 5'000'000'000;
|
||
static constexpr std::uint32_t kLocalPaymentInterval = 600;
|
||
static constexpr std::uint32_t kLocalPaymentTotal = 2;
|
||
|
||
Env env{*this, features};
|
||
|
||
// Setup accounts
|
||
Account const issuer{"issuer"};
|
||
Account const lender{"lender"};
|
||
Account const depositorA{"lpA"};
|
||
Account const depositorB{"lpB"};
|
||
Account const borrower{"borrowerA"};
|
||
|
||
env.fund(XRP(kInitialFunding), issuer, lender, depositorA, depositorB, borrower);
|
||
env.close();
|
||
|
||
// Setup trust lines
|
||
PrettyAsset const iouAsset = issuer[iouCurrency_];
|
||
env(trust(lender, iouAsset(10'000'000)));
|
||
env(trust(depositorA, iouAsset(10'000'000)));
|
||
env(trust(depositorB, iouAsset(10'000'000)));
|
||
env(trust(borrower, iouAsset(10'000'000)));
|
||
env.close();
|
||
|
||
// Fund accounts with IOUs
|
||
env(pay(issuer, lender, iouAsset(kLenderInitialIou)));
|
||
env(pay(issuer, depositorA, iouAsset(kDepositorInitialIou)));
|
||
env(pay(issuer, depositorB, iouAsset(kDepositorInitialIou)));
|
||
env(pay(issuer, borrower, iouAsset(kBorrowerInitialIou)));
|
||
env.close();
|
||
|
||
// Create vault and broker, then add deposits from two depositors
|
||
auto const broker = createVaultAndBroker(env, iouAsset, lender);
|
||
Vault v{env};
|
||
|
||
env(v.deposit({
|
||
.depositor = depositorA,
|
||
.id = broker.vaultKeylet().key,
|
||
.amount = iouAsset(kDepositAmount),
|
||
}),
|
||
Ter(tesSUCCESS));
|
||
env(v.deposit({
|
||
.depositor = depositorB,
|
||
.id = broker.vaultKeylet().key,
|
||
.amount = iouAsset(kDepositAmount),
|
||
}),
|
||
Ter(tesSUCCESS));
|
||
env.close();
|
||
|
||
// Create a loan
|
||
auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
|
||
if (!BEAST_EXPECT(sleBroker))
|
||
return;
|
||
|
||
auto const loanKeylet =
|
||
keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
|
||
|
||
env(set(borrower, broker.brokerID, kPrincipalAmount),
|
||
Sig(sfCounterpartySignature, lender),
|
||
kPaymentTotal(kLocalPaymentTotal),
|
||
kPaymentInterval(kLocalPaymentInterval),
|
||
Fee(env.current()->fees().base * 2),
|
||
Ter(tesSUCCESS));
|
||
env.close();
|
||
|
||
// Impair the loan to create unrealized loss
|
||
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
|
||
env.close();
|
||
|
||
// Verify unrealized loss is recorded in the vault
|
||
auto const vaultAfterImpair = env.le(broker.vaultKeylet());
|
||
if (!BEAST_EXPECT(vaultAfterImpair))
|
||
return;
|
||
|
||
BEAST_EXPECT(
|
||
vaultAfterImpair->at(sfLossUnrealized) == broker.asset(kPrincipalAmount).value());
|
||
|
||
// Helper to get share balance for a depositor
|
||
auto const shareAsset = vaultAfterImpair->at(sfShareMPTID);
|
||
auto const getShareBalance = [&](Account const& depositor) -> std::uint64_t {
|
||
auto const token = env.le(keylet::mptoken(shareAsset, depositor.id()));
|
||
return token ? token->getFieldU64(sfMPTAmount) : 0;
|
||
};
|
||
|
||
// Verify both depositors have equal shares
|
||
auto const sharesLpA = getShareBalance(depositorA);
|
||
auto const sharesLpB = getShareBalance(depositorB);
|
||
BEAST_EXPECT(sharesLpA == kExpectedSharesPerDepositor);
|
||
BEAST_EXPECT(sharesLpB == kExpectedSharesPerDepositor);
|
||
BEAST_EXPECT(sharesLpA == sharesLpB);
|
||
|
||
// Helper to attempt withdrawal
|
||
auto const attemptWithdrawShares = [&](Account const& depositor,
|
||
std::uint64_t shareAmount,
|
||
TER expected) {
|
||
STAmount const shareAmt{MPTIssue{shareAsset}, Number(shareAmount)};
|
||
env(v.withdraw(
|
||
{.depositor = depositor, .id = broker.vaultKeylet().key, .amount = shareAmt}),
|
||
Ter(expected));
|
||
env.close();
|
||
};
|
||
|
||
// Regression test: Both depositors should successfully withdraw despite
|
||
// unrealized loss. Previously failed with invariant violation:
|
||
// "withdrawal must change vault and destination balance by equal
|
||
// amount". This was caused by sharesToAssetsWithdraw rounding down,
|
||
// creating a mismatch where vaultDeltaAssets * -1 != destinationDelta
|
||
// when unrealized loss exists.
|
||
attemptWithdrawShares(depositorA, sharesLpA, tesSUCCESS);
|
||
attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS);
|
||
}
|
||
|
||
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::trustLine(account, iou));
|
||
BEAST_EXPECT(sleLine))
|
||
{
|
||
STAmount balance = sleLine->at(sfBalance);
|
||
if (account > issuer.id())
|
||
balance.negate();
|
||
return balance;
|
||
}
|
||
}
|
||
return STAmount{iou};
|
||
};
|
||
|
||
env.fund(XRP(20'000), issuer, broker, borrower);
|
||
env.close();
|
||
|
||
env(trust(broker, iou(20'000'000)));
|
||
env(pay(issuer, broker, iou(10'000'000)));
|
||
env.close();
|
||
|
||
auto const brokerInfo = createVaultAndBroker(env, iou, broker);
|
||
|
||
BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000));
|
||
|
||
auto const keylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
|
||
|
||
env(set(borrower, brokerInfo.brokerID, 10'000),
|
||
Sig(sfCounterpartySignature, broker),
|
||
kLoanServiceFee(iou(100).value()),
|
||
kPaymentInterval(100),
|
||
Fee(XRP(100)));
|
||
env.close();
|
||
|
||
env(trust(borrower, iou(20'000'000)));
|
||
// The borrower increases their limit and acquires some IOU so
|
||
// they can pay interest
|
||
env(pay(issuer, borrower, iou(500)));
|
||
env.close();
|
||
|
||
if (auto const le = env.le(keylet::loan(keylet.key)); BEAST_EXPECT(le))
|
||
{
|
||
if (deepFreeze)
|
||
{
|
||
env(trust(issuer, broker["IOU"](0), tfSetFreeze | tfSetDeepFreeze));
|
||
env.close();
|
||
}
|
||
|
||
env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100)));
|
||
env.close();
|
||
|
||
if (deepFreeze)
|
||
{
|
||
// The fee goes to the broker pseudo-account
|
||
BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'100));
|
||
BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'000));
|
||
}
|
||
else
|
||
{
|
||
// The fee goes to the broker account
|
||
BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'100));
|
||
BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000));
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
void
|
||
testLoanDefaultBypassesFreeze()
|
||
{
|
||
testcase("LoanManage: default bypasses asset freeze");
|
||
using namespace jtx;
|
||
using namespace loan;
|
||
Account const lender{"lender"};
|
||
Account const issuer{"issuer"};
|
||
Account const borrower{"borrower"};
|
||
auto const iou = issuer["IOU"];
|
||
|
||
Env env(*this);
|
||
env.fund(XRP(1'000), lender, issuer, borrower);
|
||
env(trust(lender, iou(10'000'000)));
|
||
env(pay(issuer, lender, iou(5'000'000)));
|
||
BrokerInfo const brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)};
|
||
|
||
auto const loanSetFee = Fee(env.current()->fees().base * 2);
|
||
STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value();
|
||
|
||
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
|
||
Sig(sfCounterpartySignature, lender),
|
||
loanSetFee);
|
||
env.close();
|
||
|
||
auto const loanKeylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
|
||
|
||
using tp = NetClock::time_point;
|
||
using d = NetClock::duration;
|
||
|
||
// Get past the grace period so the loan is defaultable.
|
||
if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan))
|
||
{
|
||
env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}});
|
||
}
|
||
|
||
// Global freeze trips the post-apply TransfersNotFrozen invariant.
|
||
env(fset(issuer, asfGlobalFreeze));
|
||
env.close();
|
||
|
||
// Pre-fixCleanup3_4_0, the invariant blocks the default.
|
||
env.disableFeature(fixCleanup3_4_0);
|
||
env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecINVARIANT_FAILED));
|
||
env.close();
|
||
|
||
// Per XLS-0066, a default must succeed despite the freeze.
|
||
env.enableFeature(fixCleanup3_4_0);
|
||
env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
|
||
}
|
||
|
||
// A default must bypass an MPT global lock the same way it bypasses IOU
|
||
// freeze, including when the loan was already impaired beforehand
|
||
// (a different defaultLoan() accounting branch than the un-impaired
|
||
// path exercised above) and after an ordinary LoanPay was correctly
|
||
// blocked by the same lock.
|
||
void
|
||
testLoanDefaultBypassesMptLockAfterImpair()
|
||
{
|
||
testcase("LoanManage: default bypasses MPT lock after impairment");
|
||
using namespace jtx;
|
||
using namespace loan;
|
||
|
||
Account const issuer{"issuer"};
|
||
Account const lender{"lender"};
|
||
Account const borrower{"borrower"};
|
||
|
||
Env env(*this);
|
||
env.fund(XRP(1'000'000), issuer, lender, borrower);
|
||
env.close();
|
||
|
||
MPTTester mptt(
|
||
{.env = env,
|
||
.issuer = issuer,
|
||
.holders = {lender, borrower},
|
||
.flags = tfMPTCanTransfer | tfMPTCanLock});
|
||
PrettyAsset const asset = mptt.issuanceID();
|
||
env(pay(issuer, lender, asset(10'000'000)));
|
||
env.close();
|
||
|
||
BrokerInfo const brokerInfo{createVaultAndBroker(env, asset, lender)};
|
||
|
||
auto const loanSetFee = Fee(env.current()->fees().base * 2);
|
||
STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value();
|
||
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
|
||
Sig(sfCounterpartySignature, lender),
|
||
loanSetFee);
|
||
env.close();
|
||
|
||
auto const loanKeylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
|
||
|
||
// Realize a loss via impairment before locking.
|
||
env(manage(lender, loanKeylet.key, tfLoanImpair));
|
||
env.close();
|
||
|
||
// Issuer applies a global lock.
|
||
mptt.set({.account = issuer, .flags = tfMPTLock});
|
||
env.close();
|
||
|
||
// An ordinary payment is correctly blocked by the lock.
|
||
env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecLOCKED));
|
||
env.close();
|
||
|
||
using tp = NetClock::time_point;
|
||
using d = NetClock::duration;
|
||
if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan))
|
||
{
|
||
env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}});
|
||
}
|
||
|
||
// Pre-fixCleanup3_4_0 the ValidMPTTransfer invariant blocks the
|
||
// default, mirroring the IOU path above.
|
||
env.disableFeature(fixCleanup3_4_0);
|
||
env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecINVARIANT_FAILED));
|
||
env.close();
|
||
|
||
// The default itself must succeed despite the lock.
|
||
env.enableFeature(fixCleanup3_4_0);
|
||
env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
|
||
}
|
||
|
||
// The exemption must hold for an individually deep-frozen trust line, not
|
||
// just a global freeze: deep freeze is what the original report ran into,
|
||
// and it takes a different path through validateFrozenState (the frozen
|
||
// flag comes off the line rather than off the issuer).
|
||
void
|
||
testLoanDefaultBypassesDeepFreeze()
|
||
{
|
||
testcase("LoanManage: default bypasses asset deep freeze");
|
||
using namespace jtx;
|
||
using namespace loan;
|
||
Account const lender{"lender"};
|
||
Account const issuer{"issuer"};
|
||
Account const borrower{"borrower"};
|
||
auto const iou = issuer["IOU"];
|
||
|
||
Env env(*this);
|
||
env.fund(XRP(1'000), lender, issuer, borrower);
|
||
env(trust(lender, iou(10'000'000)));
|
||
env(pay(issuer, lender, iou(5'000'000)));
|
||
BrokerInfo const brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)};
|
||
|
||
auto const loanSetFee = Fee(env.current()->fees().base * 2);
|
||
STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value();
|
||
|
||
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
|
||
Sig(sfCounterpartySignature, lender),
|
||
loanSetFee);
|
||
env.close();
|
||
|
||
auto const loanKeylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
|
||
|
||
using tp = NetClock::time_point;
|
||
using d = NetClock::duration;
|
||
|
||
// Get past the grace period so the loan is defaultable.
|
||
if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan))
|
||
{
|
||
env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}});
|
||
}
|
||
|
||
// The default moves First-Loss Capital off the broker pseudo-account,
|
||
// so that is the line to freeze.
|
||
auto const brokerSle = env.le(brokerInfo.brokerKeylet());
|
||
if (!BEAST_EXPECT(brokerSle))
|
||
return;
|
||
Account const brokerPseudo{"brokerPseudo", brokerSle->at(sfAccount)};
|
||
|
||
env(trust(issuer, brokerPseudo["IOU"](0), tfSetFreeze | tfSetDeepFreeze));
|
||
env.close();
|
||
|
||
// Pre-fixCleanup3_4_0, the invariant blocks the default.
|
||
env.disableFeature(fixCleanup3_4_0);
|
||
env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecINVARIANT_FAILED));
|
||
env.close();
|
||
|
||
// Per XLS-0066, a default must succeed despite the deep freeze.
|
||
env.enableFeature(fixCleanup3_4_0);
|
||
env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
|
||
}
|
||
|
||
void
|
||
testLoanPayBrokerOwnerMissingTrustline(FeatureBitset features)
|
||
{
|
||
testcase << "LoanPay Broker Owner Missing Trustline (PoC)";
|
||
using namespace jtx;
|
||
using namespace loan;
|
||
Account const issuer("issuer");
|
||
Account const borrower("borrower");
|
||
Account const broker("broker");
|
||
auto const iou = issuer["IOU"];
|
||
Env env(*this, features);
|
||
env.fund(XRP(20'000), issuer, broker, borrower);
|
||
env.close();
|
||
// Set up trustlines and fund accounts
|
||
env(trust(broker, iou(20'000'000)));
|
||
env(trust(borrower, iou(20'000'000)));
|
||
env(pay(issuer, broker, iou(10'000'000)));
|
||
env(pay(issuer, borrower, iou(1'000)));
|
||
env.close();
|
||
// Create vault and broker
|
||
auto const brokerInfo = createVaultAndBroker(env, iou, broker);
|
||
// Create a loan first (this creates debt)
|
||
auto const keylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
|
||
env(set(borrower, brokerInfo.brokerID, 10'000),
|
||
Sig(sfCounterpartySignature, broker),
|
||
kLoanServiceFee(iou(100).value()),
|
||
kPaymentInterval(100),
|
||
Fee(XRP(100)));
|
||
env.close();
|
||
// Ensure broker has sufficient cover so brokerPayee == brokerOwner
|
||
// We need coverAvailable >= (debtTotal * coverRateMinimum)
|
||
// Deposit enough cover to ensure the fee goes to broker owner
|
||
// The default coverRateMinimum is 10%, so for a 10,000 loan we need
|
||
// at least 1,000 cover. Default cover is 1,000, so we add more to be
|
||
// safe.
|
||
auto const additionalCover = iou(50'000).value();
|
||
env(loan_broker::coverDeposit(broker, brokerInfo.brokerID, STAmount{iou, additionalCover}));
|
||
env.close();
|
||
// Verify broker owner has a trustline
|
||
auto const brokerTrustline = keylet::trustLine(broker, iou);
|
||
BEAST_EXPECT(env.le(brokerTrustline) != nullptr);
|
||
// Broker owner deletes their trustline
|
||
// First, pay any positive balance to issuer to zero it out
|
||
auto const brokerBalance = env.balance(broker, iou);
|
||
env(pay(broker, issuer, brokerBalance));
|
||
env.close();
|
||
// Remove the trustline by setting limit to 0
|
||
env(trust(broker, iou(0)));
|
||
env.close();
|
||
// Verify trustline is deleted
|
||
BEAST_EXPECT(env.le(brokerTrustline) == nullptr);
|
||
// Now borrower tries to make a payment
|
||
// We should get a tesSUCCESS instead of a tecNO_LINE.
|
||
env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100)), Ter(tesSUCCESS));
|
||
env.close();
|
||
// Verify trustline is still deleted
|
||
BEAST_EXPECT(env.le(brokerTrustline) == nullptr);
|
||
// Verify the service fee went to the broker pseudo-account
|
||
if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
|
||
BEAST_EXPECT(brokerSle))
|
||
{
|
||
Account const pseudo("pseudo-account", brokerSle->at(sfAccount));
|
||
auto const balance = env.balance(pseudo, iou);
|
||
// 1,000 default + 50,000 extra + 100 service fee from LoanPay
|
||
BEAST_EXPECTS(balance == iou(51'100), to_string(json::Value(balance)));
|
||
}
|
||
}
|
||
|
||
void
|
||
testLoanPayBrokerOwnerUnauthorizedMPT(FeatureBitset features)
|
||
{
|
||
testcase << "LoanPay Broker Owner MPT unauthorized";
|
||
using namespace jtx;
|
||
using namespace loan;
|
||
|
||
Account const issuer("issuer");
|
||
Account const borrower("borrower");
|
||
Account const broker("broker");
|
||
|
||
Env env{*this, features};
|
||
env.fund(XRP(20'000), issuer, broker, borrower);
|
||
env.close();
|
||
|
||
MPTTester mptt{env, issuer, kMptInitNoFund};
|
||
mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
|
||
|
||
PrettyAsset const mpt{mptt.issuanceID()};
|
||
|
||
// Authorize broker and borrower
|
||
mptt.authorize({.account = broker});
|
||
mptt.authorize({.account = borrower});
|
||
|
||
env.close();
|
||
|
||
// Fund accounts
|
||
env(pay(issuer, broker, mpt(10'000'000)));
|
||
env(pay(issuer, borrower, mpt(1'000)));
|
||
env.close();
|
||
|
||
// Create vault and broker
|
||
auto const brokerInfo = createVaultAndBroker(env, mpt, broker);
|
||
// Create a loan first (this creates debt)
|
||
auto const keylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
|
||
env(set(borrower, brokerInfo.brokerID, 10'000),
|
||
Sig(sfCounterpartySignature, broker),
|
||
kLoanServiceFee(mpt(100).value()),
|
||
kPaymentInterval(100),
|
||
Fee(XRP(100)));
|
||
env.close();
|
||
// Ensure broker has sufficient cover so brokerPayee == brokerOwner
|
||
// We need coverAvailable >= (debtTotal * coverRateMinimum)
|
||
// Deposit enough cover to ensure the fee goes to broker owner
|
||
// The default coverRateMinimum is 10%, so for a 10,000 loan we need
|
||
// at least 1,000 cover. Default cover is 1,000, so we add more to be
|
||
// safe.
|
||
auto const additionalCover = mpt(50'000).value();
|
||
env(loan_broker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover}));
|
||
env.close();
|
||
// Verify broker owner is authorized
|
||
auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker);
|
||
BEAST_EXPECT(env.le(brokerMpt) != nullptr);
|
||
// Broker owner unauthorizes.
|
||
// First, pay any positive balance to issuer to zero it out
|
||
auto const brokerBalance = env.balance(broker, mpt);
|
||
env(pay(broker, issuer, brokerBalance));
|
||
env.close();
|
||
// Then, unauthorize the MPT.
|
||
mptt.authorize({.account = broker, .flags = tfMPTUnauthorize});
|
||
env.close();
|
||
// Verify the MPT is unauthorized.
|
||
BEAST_EXPECT(env.le(brokerMpt) == nullptr);
|
||
// Now borrower tries to make a payment
|
||
// We should get a tesSUCCESS instead of a tecNO_AUTH.
|
||
env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS));
|
||
env.close();
|
||
// Verify the MPT is still unauthorized.
|
||
BEAST_EXPECT(env.le(brokerMpt) == nullptr);
|
||
// Verify the service fee went to the broker pseudo-account
|
||
if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
|
||
BEAST_EXPECT(brokerSle))
|
||
{
|
||
Account const pseudo("pseudo-account", brokerSle->at(sfAccount));
|
||
auto const balance = env.balance(pseudo, mpt);
|
||
// 1,000 default + 50,000 extra + 100 service fee from LoanPay
|
||
BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance)));
|
||
}
|
||
}
|
||
|
||
void
|
||
testLoanPayBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features)
|
||
{
|
||
testcase << "LoanPay Broker Owner without permissioned domain of the MPT";
|
||
using namespace jtx;
|
||
using namespace loan;
|
||
|
||
Account const issuer("issuer");
|
||
Account const borrower("borrower");
|
||
Account const broker("broker");
|
||
|
||
Env env{*this, features};
|
||
env.fund(XRP(20'000), issuer, broker, borrower);
|
||
env.close();
|
||
|
||
auto credType = "credential1";
|
||
|
||
pdomain::Credentials const credentials1 = {{.issuer = issuer, .credType = credType}};
|
||
env(pdomain::setTx(issuer, credentials1));
|
||
env.close();
|
||
|
||
auto domainID = pdomain::getNewDomain(env.meta());
|
||
|
||
env(credentials::create(broker, issuer, credType));
|
||
env(credentials::accept(broker, issuer, credType));
|
||
env.close();
|
||
|
||
env(credentials::create(borrower, issuer, credType));
|
||
env(credentials::accept(borrower, issuer, credType));
|
||
env.close();
|
||
|
||
MPTTester mptt{env, issuer, kMptInitNoFund};
|
||
mptt.create({
|
||
.flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock,
|
||
.domainID = domainID,
|
||
});
|
||
|
||
PrettyAsset const mpt{mptt.issuanceID()};
|
||
|
||
// Authorize broker and borrower
|
||
mptt.authorize({.account = broker});
|
||
mptt.authorize({.account = borrower});
|
||
|
||
env.close();
|
||
|
||
// Fund accounts
|
||
env(pay(issuer, broker, mpt(10'000'000)));
|
||
env(pay(issuer, borrower, mpt(1'000)));
|
||
env.close();
|
||
|
||
// Create vault and broker
|
||
auto const brokerInfo = createVaultAndBroker(env, mpt, broker);
|
||
// Create a loan first (this creates debt)
|
||
auto const keylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
|
||
env(set(borrower, brokerInfo.brokerID, 10'000),
|
||
Sig(sfCounterpartySignature, broker),
|
||
kLoanServiceFee(mpt(100).value()),
|
||
kPaymentInterval(100),
|
||
Fee(XRP(100)));
|
||
env.close();
|
||
// Ensure broker has sufficient cover so brokerPayee == brokerOwner
|
||
// We need coverAvailable >= (debtTotal * coverRateMinimum)
|
||
// Deposit enough cover to ensure the fee goes to broker owner
|
||
// The default coverRateMinimum is 10%, so for a 10,000 loan we need
|
||
// at least 1,000 cover. Default cover is 1,000, so we add more to be
|
||
// safe.
|
||
auto const additionalCover = mpt(50'000).value();
|
||
env(loan_broker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover}));
|
||
env.close();
|
||
// Verify broker owner is authorized
|
||
auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker);
|
||
BEAST_EXPECT(env.le(brokerMpt) != nullptr);
|
||
// Remove the credentials for the Broker owner.
|
||
// First, pay any positive balance to issuer to zero it out
|
||
auto const brokerBalance = env.balance(broker, mpt);
|
||
env(pay(broker, issuer, brokerBalance));
|
||
env.close();
|
||
|
||
env(credentials::deleteCred(broker, broker, issuer, credType));
|
||
env.close();
|
||
|
||
// Make sure the broker is not authorized to hold the MPT after we
|
||
// deleted the credentials
|
||
env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH));
|
||
|
||
// Now borrower tries to make a payment
|
||
// We should get a tesSUCCESS instead of a tecNO_AUTH.
|
||
env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS));
|
||
env.close();
|
||
// Verify broker is still not authorized
|
||
env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH));
|
||
// Verify the service fee went to the broker pseudo-account
|
||
if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
|
||
BEAST_EXPECT(brokerSle))
|
||
{
|
||
Account const pseudo("pseudo-account", brokerSle->at(sfAccount));
|
||
auto const balance = env.balance(pseudo, mpt);
|
||
// 1,000 default + 50,000 extra + 100 service fee from LoanPay
|
||
BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance)));
|
||
}
|
||
}
|
||
|
||
void
|
||
testLoanSetBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features)
|
||
{
|
||
testcase << "LoanSet Broker Owner without permissioned domain of the MPT";
|
||
using namespace jtx;
|
||
using namespace loan;
|
||
|
||
Account const issuer("issuer");
|
||
Account const borrower("borrower");
|
||
Account const broker("broker");
|
||
|
||
Env env{*this, features};
|
||
env.fund(XRP(20'000), issuer, broker, borrower);
|
||
env.close();
|
||
|
||
auto credType = "credential1";
|
||
|
||
pdomain::Credentials const credentials1{{.issuer = issuer, .credType = credType}};
|
||
env(pdomain::setTx(issuer, credentials1));
|
||
env.close();
|
||
|
||
auto domainID = pdomain::getNewDomain(env.meta());
|
||
|
||
// Add credentials for the broker and borrower
|
||
env(credentials::create(broker, issuer, credType));
|
||
env(credentials::accept(broker, issuer, credType));
|
||
env.close();
|
||
|
||
env(credentials::create(borrower, issuer, credType));
|
||
env(credentials::accept(borrower, issuer, credType));
|
||
env.close();
|
||
|
||
MPTTester mptt{env, issuer, kMptInitNoFund};
|
||
mptt.create({
|
||
.flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock,
|
||
.domainID = domainID,
|
||
});
|
||
|
||
PrettyAsset const mpt{mptt.issuanceID()};
|
||
|
||
// Authorize broker and borrower
|
||
mptt.authorize({.account = broker});
|
||
mptt.authorize({.account = borrower});
|
||
env.close();
|
||
|
||
// Fund accounts
|
||
env(pay(issuer, broker, mpt(10'000'000)));
|
||
env(pay(issuer, borrower, mpt(1'000)));
|
||
env.close();
|
||
|
||
// Create vault and broker
|
||
auto const brokerInfo = createVaultAndBroker(env, mpt, broker);
|
||
|
||
// Remove the credentials for the Broker owner.
|
||
// Clear the balance first.
|
||
auto const brokerBalance = env.balance(broker, mpt);
|
||
env(pay(broker, issuer, brokerBalance));
|
||
env.close();
|
||
// Delete the credentials
|
||
env(credentials::deleteCred(broker, broker, issuer, credType));
|
||
env.close();
|
||
|
||
// Create a loan, this should fail for tecNO_AUTH
|
||
env(set(borrower, brokerInfo.brokerID, 10'000),
|
||
Sig(sfCounterpartySignature, broker),
|
||
kLoanServiceFee(mpt(100).value()),
|
||
kPaymentInterval(100),
|
||
Fee(XRP(100)),
|
||
Ter(tecNO_AUTH));
|
||
env.close();
|
||
}
|
||
|
||
void
|
||
runAmendmentIndependent()
|
||
{
|
||
testServiceFeeOnBrokerDeepFreeze();
|
||
testLoanDefaultBypassesFreeze();
|
||
testLoanDefaultBypassesDeepFreeze();
|
||
testLoanDefaultBypassesMptLockAfterImpair();
|
||
}
|
||
|
||
// Tests run under each entry in amendmentCombinations().
|
||
void
|
||
runAmendmentSensitive(FeatureBitset features)
|
||
{
|
||
testSequentialFLCDepletion(features);
|
||
testWithdrawReflectsUnrealizedLoss(features);
|
||
testLoanPayBrokerOwnerMissingTrustline(features);
|
||
testLoanPayBrokerOwnerUnauthorizedMPT(features);
|
||
testLoanPayBrokerOwnerNoPermissionedDomainMPT(features);
|
||
testLoanSetBrokerOwnerNoPermissionedDomainMPT(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(LoanCoverFreezeAuth, tx, xrpl);
|
||
|
||
} // namespace xrpl::test
|