Extend LoanBroaker and Loan unit-tests. (#5863)

- Add convenience functions to MPT test-framework.
This commit is contained in:
Gregory Tsipenyuk
2025-10-23 15:51:30 -04:00
committed by GitHub
parent 9f5bc8f0da
commit f60e298627
9 changed files with 562 additions and 11 deletions

View File

@@ -851,12 +851,335 @@ class LoanBroker_test : public beast::unit_test::suite
BEAST_EXPECT(env.ownerCount(alice) == aliceOriginalCount);
}
enum LoanBrokerTest {
CoverClawback,
CoverDeposit,
CoverWithdraw,
Delete,
Set
};
void
testLoanBroker(
std::function<jtx::PrettyAsset(
jtx::Env&,
jtx::Account const&,
jtx::Account const&)> getAsset,
LoanBrokerTest brokerTest)
{
using namespace jtx;
using namespace loanBroker;
Account const issuer{"issuer"};
Account const alice{"alice"};
Env env(*this);
Vault vault{env};
env.fund(XRP(100'000), issuer, alice);
env.close();
PrettyAsset const asset = [&]() {
if (getAsset)
return getAsset(env, issuer, alice);
env(trust(alice, issuer["IOU"](1'000'000)));
env.close();
return PrettyAsset(issuer["IOU"]);
}();
env(pay(issuer, alice, asset(100'000)));
env.close();
auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = asset});
env(tx);
env.close();
auto const le = env.le(vaultKeylet);
VaultInfo vaultInfo = [&]() {
if (BEAST_EXPECT(le))
return VaultInfo{asset, vaultKeylet.key, le->at(sfAccount)};
return VaultInfo{asset, {}, {}};
}();
if (vaultInfo.vaultID == uint256{})
return;
env(vault.deposit(
{.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)}));
env.close();
auto const brokerKeylet =
keylet::loanbroker(alice.id(), env.seq(alice));
env(set(alice, vaultInfo.vaultID));
env.close();
auto broker = env.le(brokerKeylet);
if (!BEAST_EXPECT(broker))
return;
if (brokerTest == CoverDeposit)
{
// preclaim: tecWRONG_ASET
env(coverDeposit(alice, brokerKeylet.key, issuer["BAD"](10)),
ter(tecWRONG_ASSET));
// preclaim: tecINSUFFICIENT_FUNDS
env(pay(alice, issuer, asset(100'000 - 50)));
env.close();
env(coverDeposit(alice, brokerKeylet.key, vaultInfo.asset(10)),
ter(tecINSUFFICIENT_FUNDS));
// preclaim: tecFROZEN
env(fset(issuer, asfGlobalFreeze));
env.close();
env(coverDeposit(alice, brokerKeylet.key, vaultInfo.asset(10)),
ter(tecFROZEN));
}
else
// Fund the cover deposit
env(coverDeposit(alice, brokerKeylet.key, vaultInfo.asset(10)));
env.close();
if (brokerTest == CoverWithdraw)
{
// preclaim: tecWRONG_ASSSET
env(coverWithdraw(alice, brokerKeylet.key, issuer["BAD"](10)),
ter(tecWRONG_ASSET));
// preclaim: tecNO_DST
Account const bogus{"bogus"};
env(coverWithdraw(alice, brokerKeylet.key, asset(10)),
destination(bogus),
ter(tecNO_DST));
// preclaim: tecDST_TAG_NEEDED
Account const dest{"dest"};
env.fund(XRP(1'000), dest);
env(fset(dest, asfRequireDest));
env.close();
env(coverWithdraw(alice, brokerKeylet.key, asset(10)),
destination(dest),
ter(tecDST_TAG_NEEDED));
// preclaim: tecNO_PERMISSION
env(fclear(dest, asfRequireDest));
env(fset(dest, asfDepositAuth));
env.close();
env(coverWithdraw(alice, brokerKeylet.key, asset(10)),
destination(dest),
ter(tecNO_PERMISSION));
// preclaim: tecFROZEN
env(trust(dest, asset(1'000)));
env(fclear(dest, asfDepositAuth));
env(fset(issuer, asfGlobalFreeze));
env.close();
env(coverWithdraw(alice, brokerKeylet.key, asset(10)),
destination(dest),
ter(tecFROZEN));
// preclaim:: tecFROZEN (deep frozen)
env(fclear(issuer, asfGlobalFreeze));
env(trust(
issuer, asset(1'000), dest, tfSetFreeze | tfSetDeepFreeze));
env(coverWithdraw(alice, brokerKeylet.key, asset(10)),
destination(dest),
ter(tecFROZEN));
}
if (brokerTest == CoverClawback)
{
if (asset.holds<Issue>())
{
// preclaim: AllowTrustLineClaback is not set
env(coverClawback(issuer),
loanBrokerID(brokerKeylet.key),
amount(vaultInfo.asset(2)),
ter(tecNO_PERMISSION));
// preclaim: NoFreeze is set
env(fset(issuer, asfAllowTrustLineClawback | asfNoFreeze));
env.close();
env(coverClawback(issuer),
loanBrokerID(brokerKeylet.key),
amount(vaultInfo.asset(2)),
ter(tecNO_PERMISSION));
}
else
{
// preclaim: MPTCanClawback is not set or MPTCAnLock is not set
env(coverClawback(issuer),
loanBrokerID(brokerKeylet.key),
amount(vaultInfo.asset(2)),
ter(tecNO_PERMISSION));
}
env.close();
}
if (brokerTest == Delete)
{
Account const borrower{"borrower"};
env.fund(XRP(1'000), borrower);
env(loan::set(borrower, brokerKeylet.key, asset(50).value()),
sig(sfCounterpartySignature, alice),
fee(env.current()->fees().base * 2));
// preclaim: tecHAS_OBLIGATIONS
env(del(alice, brokerKeylet.key), ter(tecHAS_OBLIGATIONS));
}
else
env(del(alice, brokerKeylet.key));
if (brokerTest == Set)
{
if (asset.holds<Issue>())
{
env(fclear(issuer, asfDefaultRipple));
env.close();
// preclaim: DefaultRipple is not set
env(set(alice, vaultInfo.vaultID), ter(terNO_RIPPLE));
env(fset(issuer, asfDefaultRipple));
env.close();
}
auto const amt = env.balance(alice) -
env.current()->fees().accountReserve(env.ownerCount(alice));
env(pay(alice, issuer, amt));
// preclaim:: tecINSUFFICIENT_RESERVE
env(set(alice, vaultInfo.vaultID), ter(tecINSUFFICIENT_RESERVE));
}
}
void
testInvalidLoanBrokerCoverClawback()
{
testcase("Invalid LoanBrokerCoverClawback");
using namespace jtx;
using namespace loanBroker;
// preflight
{
Account const alice{"alice"};
Account const issuer{"issuer"};
auto const USD = alice["USD"];
Env env(*this);
env.fund(XRP(100'000), alice);
env.close();
auto jtx = env.jt(coverClawback(alice), amount(USD(100)));
// holder == account
env(jtx, ter(temINVALID));
// holder == beast::zero
STAmount bad(Issue{USD.currency, beast::zero}, 100);
jtx.jv[sfAmount] = bad.getJson();
jtx.stx = env.ust(jtx);
Serializer s;
jtx.stx->add(s);
auto const jrr = env.rpc("submit", strHex(s.slice()))[jss::result];
// fails in doSubmit() on STTx construction
BEAST_EXPECT(jrr[jss::error] == "invalidTransaction");
BEAST_EXPECT(jrr[jss::error_exception] == "invalid native account");
}
// preclaim
// Issue:
// AllowTrustLineClawback is not set or NoFreeze is set
testLoanBroker({}, CoverClawback);
// MPTIssue:
// MPTCanClawback is not set
testLoanBroker(
[&](Env& env, Account const& issuer, Account const& alice) -> MPT {
MPTTester mpt(
{.env = env, .issuer = issuer, .holders = {alice}});
return mpt;
},
CoverClawback);
// MPTCanLock is not set
testLoanBroker(
[&](Env& env, Account const& issuer, Account const& alice) -> MPT {
MPTTester mpt(
{.env = env,
.issuer = issuer,
.holders = {alice},
.flags = MPTDEXFlags | tfMPTCanClawback});
return mpt;
},
CoverClawback);
}
void
testInvalidLoanBrokerCoverDeposit()
{
testcase("Invalid LoanBrokerCoverDeposit");
using namespace jtx;
// preclaim:
// tecWRONG_ASSET, tecINSUFFICIENT_FUNDS, frozen asset
testLoanBroker({}, CoverDeposit);
}
void
testInvalidLoanBrokerCoverWithdraw()
{
testcase("Invalid LoanBrokerCoverWithdraw");
using namespace jtx;
/*
preflight: illegal net
isLegalNet() check is probably redundant. STAmount parsing
should throw an exception on deserialize
preclaim: tecWRONG_ASSET, tecNO_DST, tecDST_TAG_NEEDED,
tecNO_PERMISSION, checkFrozen failure, checkDeepFrozenFailure,
second+third tecINSUFFICIENT_FUNDS (can this happen)?
doApply: tecPATH_DRY (can it happen, funds already checked?)
*/
testLoanBroker({}, CoverWithdraw);
}
void
testInvalidLoanBrokerDelete()
{
using namespace jtx;
testcase("Invalid LoanBrokerDelete");
/*
preclaim: tecHAS_OBLIGATIONS
doApply:
accountSend failure, removeEmptyHolding failure,
all tecHAS_OBLIGATIONS (can any of these happen?)
*/
testLoanBroker({}, Delete);
}
void
testInvalidLoanBrokerSet()
{
using namespace jtx;
testcase("Invalid LoanBrokerSet");
/*preclaim: canAddHolding failure (can it happen with MPT?
can't create Vault if CanTransfer is not enabled.)
doApply:
first+second dirLink failure, createPseudoAccount failure,
addEmptyHolding failure
can any of these happen?
*/
testLoanBroker({}, Set);
}
public:
void
run() override
{
testDisabled();
testLifecycle();
testInvalidLoanBrokerCoverClawback();
testInvalidLoanBrokerCoverDeposit();
testInvalidLoanBrokerCoverWithdraw();
testInvalidLoanBrokerDelete();
testInvalidLoanBrokerSet();
// TODO: Write clawback failure tests with an issuer / MPT that doesn't
// have the right flags set.

View File

@@ -2827,6 +2827,224 @@ class Loan_test : public beast::unit_test::suite
pass();
}
void
testInvalidLoanDelete()
{
testcase("Invalid LoanDelete");
using namespace jtx;
using namespace loan;
// preflight: temINVALID, LoanID == zero
{
Account const alice{"alice"};
Env env(*this);
env.fund(XRP(1'000), alice);
env.close();
env(del(alice, beast::zero), ter(temINVALID));
}
}
void
testInvalidLoanManage()
{
testcase("Invalid LoanManage");
using namespace jtx;
using namespace loan;
// preflight: temINVALID, LoanID == zero
{
Account const alice{"alice"};
Env env(*this);
env.fund(XRP(1'000), alice);
env.close();
env(manage(alice, beast::zero, tfLoanDefault), ter(temINVALID));
}
}
void
testInvalidLoanPay()
{
testcase("Invalid LoanPay");
using namespace jtx;
using namespace loan;
Account const lender{"lender"};
Account const issuer{"issuer"};
Account const borrower{"borrower"};
auto const IOU = issuer["IOU"];
// preclaim
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 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();
std::uint32_t const loanSequence = 1;
auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence);
env(fset(issuer, asfGlobalFreeze));
env.close();
// preclaim: tecFROZEN
env(pay(borrower, loanKeylet.key, debtMaximumRequest), ter(tecFROZEN));
env.close();
env(fclear(issuer, asfGlobalFreeze));
env.close();
auto const pseudoBroker = [&]() -> std::optional<Account> {
if (auto brokerSle =
env.le(keylet::loanbroker(brokerInfo.brokerID));
BEAST_EXPECT(brokerSle))
{
return Account{"pseudo", brokerSle->at(sfAccount)};
}
else
{
return std::nullopt;
}
}();
if (!pseudoBroker)
return;
// Lender and pseudoaccount must both be frozen
env(trust(
issuer,
lender["IOU"](1'000),
lender,
tfSetFreeze | tfSetDeepFreeze));
env(trust(
issuer,
(*pseudoBroker)["IOU"](1'000),
*pseudoBroker,
tfSetFreeze | tfSetDeepFreeze));
env.close();
// preclaim: tecFROZEN due to deep frozen
env(pay(borrower, loanKeylet.key, debtMaximumRequest), ter(tecFROZEN));
env.close();
// Only one needs to be unfrozen
env(trust(
issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze));
env.close();
env(pay(borrower, loanKeylet.key, debtMaximumRequest));
env.close();
// preclaim: tecKILLED
// note that tecKILLED in loanMakePayment()
// doesn't happen because of the preclaim check.
env(pay(borrower, loanKeylet.key, debtMaximumRequest), ter(tecKILLED));
}
void
testInvalidLoanSet()
{
testcase("Invalid LoanSet");
using namespace jtx;
using namespace loan;
Account const lender{"lender"};
Account const issuer{"issuer"};
Account const borrower{"borrower"};
auto const IOU = issuer["IOU"];
auto testWrapper = [&](auto&& test) {
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 brokerInfo{
createVaultAndBroker(env, issuer["IOU"], lender)};
auto const loanSetFee = fee(env.current()->fees().base * 2);
Number const debtMaximumRequest = brokerInfo.asset(1'000).value();
test(env, brokerInfo, loanSetFee, debtMaximumRequest);
};
// preflight:
testWrapper([&](Env& env,
BrokerInfo const& brokerInfo,
jtx::fee const& loanSetFee,
Number const& debtMaximumRequest) {
// first temBAD_SIGNER: TODO
// preflightCheckSigningKey() failure:
// can it happen? the signature is checked before transactor
// executes
JTx tx = env.jt(
set(borrower, brokerInfo.brokerID, debtMaximumRequest),
sig(sfCounterpartySignature, lender),
loanSetFee);
STTx local = *(tx.stx);
auto counterpartySig =
local.getFieldObject(sfCounterpartySignature);
auto badPubKey = counterpartySig.getFieldVL(sfSigningPubKey);
badPubKey[20] ^= 0xAA;
counterpartySig.setFieldVL(sfSigningPubKey, badPubKey);
local.setFieldObject(sfCounterpartySignature, counterpartySig);
Json::Value jvResult;
jvResult[jss::tx_blob] = strHex(local.getSerializer().slice());
auto res = env.rpc("json", "submit", to_string(jvResult))["result"];
BEAST_EXPECT(
res[jss::error] == "invalidTransaction" &&
res[jss::error_exception] ==
"fails local checks: Counterparty: Invalid signature.");
});
// preclaim:
testWrapper([&](Env& env,
BrokerInfo const& brokerInfo,
jtx::fee const& loanSetFee,
Number const& debtMaximumRequest) {
// canAddHoldingFailure (IOU only, if MPT doesn't have
// MPTCanTransfer set, then can't create Vault/LoanBroker,
// and LoanSet will fail with different error
env(fclear(issuer, asfDefaultRipple));
env.close();
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
sig(sfCounterpartySignature, lender),
loanSetFee,
ter(terNO_RIPPLE));
});
// doApply:
testWrapper([&](Env& env,
BrokerInfo const& brokerInfo,
jtx::fee const& loanSetFee,
Number const& debtMaximumRequest) {
auto const amt = env.balance(borrower) -
env.current()->fees().accountReserve(env.ownerCount(borrower));
env(pay(borrower, issuer, amt));
// tecINSUFFICIENT_RESERVE
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
sig(sfCounterpartySignature, lender),
loanSetFee,
ter(tecINSUFFICIENT_RESERVE));
// addEmptyHolding failure
env(pay(issuer, borrower, amt));
env(fset(issuer, asfGlobalFreeze));
env.close();
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
sig(sfCounterpartySignature, lender),
loanSetFee,
ter(tecFROZEN));
});
}
public:
void
run() override
@@ -2841,6 +3059,11 @@ public:
testRPC();
testBasicMath();
testInvalidLoanDelete();
testInvalidLoanManage();
testInvalidLoanPay();
testInvalidLoanSet();
}
};

View File

@@ -296,7 +296,7 @@ public:
operator Asset() const;
private:
using SLEP = std::shared_ptr<SLE const>;
using SLEP = SLE::const_pointer;
bool
forObject(
std::function<bool(SLEP const& sle)> const& cb,

View File

@@ -1549,8 +1549,11 @@ loanMakePayment(
if (paymentRemainingProxy == 0 || principalOutstandingProxy == 0)
{
// Loan complete
// This is already checked in LoanPay::preclaim()
// LCOV_EXCL_START
JLOG(j.warn()) << "Loan is already paid off.";
return Unexpected(tecKILLED);
// LCOV_EXCL_STOP
}
auto totalValueOutstandingProxy = loan->at(sfTotalValueOutstanding);

View File

@@ -81,7 +81,7 @@ determineBrokerID(ReadView const& view, STTx const& tx)
auto const dstAmount = tx[~sfAmount];
if (!dstAmount || !dstAmount->holds<Issue>())
return Unexpected{tecINTERNAL};
return Unexpected{tecINTERNAL}; // LCOV_EXCL_LINE
// Since we don't have a LoanBrokerID, holder _should_ be the loan broker's
// pseudo-account, but we don't know yet whether it is, so use a generic

View File

@@ -209,7 +209,7 @@ LoanBrokerCoverWithdraw::doApply()
Payment::getMaxSourceAmount(brokerPseudoID, amount);
SLE::pointer sleDst = view().peek(keylet::account(dstAcct));
if (!sleDst)
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
Payment::RipplePaymentParams paymentParams{
.ctx = ctx_,

View File

@@ -89,7 +89,7 @@ LoanBrokerDelete::doApply()
broker->key(),
false))
{
return tefBAD_LEDGER;
return tefBAD_LEDGER; // LCOV_EXCL_LINE
}
if (!view().dirRemove(
keylet::ownerDir(vaultPseudoID),
@@ -97,7 +97,7 @@ LoanBrokerDelete::doApply()
broker->key(),
false))
{
return tefBAD_LEDGER;
return tefBAD_LEDGER; // LCOV_EXCL_LINE
}
{
@@ -118,26 +118,26 @@ LoanBrokerDelete::doApply()
auto brokerPseudoSLE = view().peek(keylet::account(brokerPseudoID));
if (!brokerPseudoSLE)
return tefBAD_LEDGER;
return tefBAD_LEDGER; // LCOV_EXCL_LINE
// Making the payment and removing the empty holding should have deleted any
// obligations associated with the broker or broker pseudo-account.
if (*brokerPseudoSLE->at(sfBalance))
{
JLOG(j_.warn()) << "LoanBrokerDelete: Pseudo-account has a balance";
return tecHAS_OBLIGATIONS;
return tecHAS_OBLIGATIONS; // LCOV_EXCL_LINE
}
if (brokerPseudoSLE->at(sfOwnerCount) != 0)
{
JLOG(j_.warn())
<< "LoanBrokerDelete: Pseudo-account still owns objects";
return tecHAS_OBLIGATIONS;
return tecHAS_OBLIGATIONS; // LCOV_EXCL_LINE
}
if (auto const directory = keylet::ownerDir(brokerPseudoID);
view().read(directory))
{
JLOG(j_.warn()) << "LoanBrokerDelete: Pseudo-account has a directory";
return tecHAS_OBLIGATIONS;
return tecHAS_OBLIGATIONS; // LCOV_EXCL_LINE
}
view().erase(brokerPseudoSLE);

View File

@@ -107,14 +107,14 @@ LoanDelete::doApply()
loanSle->at(sfLoanBrokerNode),
loanID,
false))
return tefBAD_LEDGER;
return tefBAD_LEDGER; // LCOV_EXCL_LINE
// Remove LoanID from Directory of the Borrower.
if (!view.dirRemove(
keylet::ownerDir(borrower),
loanSle->at(sfOwnerNode),
loanID,
false))
return tefBAD_LEDGER;
return tefBAD_LEDGER; // LCOV_EXCL_LINE
// Delete the Loan object
view.erase(loanSle);

View File

@@ -241,9 +241,11 @@ LoanManage::defaultLoan(
auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized);
if (vaultLossUnrealizedProxy < totalDefaultAmount)
{
// LCOV_EXCL_START
JLOG(j.warn())
<< "Vault unrealized loss is less than the default amount";
return tefBAD_LEDGER;
// LCOV_EXCL_STOP
}
vaultLossUnrealizedProxy -= totalDefaultAmount;
}