Integrate Permissioned Domain & Credential Checks for Lending Protocol

Signed-off-by: JCW <a1q123456@users.noreply.github.com>
This commit is contained in:
JCW
2026-03-10 15:54:37 +00:00
parent a67da5c2ed
commit eb46d9e417
12 changed files with 710 additions and 2 deletions

View File

@@ -198,7 +198,10 @@ enum LedgerEntryType : std::uint16_t {
LEDGER_OBJECT(Loan, \
LSF_FLAG(lsfLoanDefault, 0x00010000) \
LSF_FLAG(lsfLoanImpaired, 0x00020000) \
LSF_FLAG(lsfLoanOverpayment, 0x00040000)) /* True, loan allows overpayments */
LSF_FLAG(lsfLoanOverpayment, 0x00040000)) /* True, loan allows overpayments */ \
\
LEDGER_OBJECT(LoanBroker, \
LSF_FLAG(lsfLoanBrokerPrivate, 0x00010000))
// clang-format on

View File

@@ -212,6 +212,10 @@ inline constexpr FlagValue tfUniversalMask = ~tfUniversal;
TF_FLAG(tfLoanDefault, 0x00010000) \
TF_FLAG(tfLoanImpair, 0x00020000) \
TF_FLAG(tfLoanUnimpair, 0x00040000), \
MASK_ADJ(0)) \
\
TRANSACTION(LoanBrokerSet, \
TF_FLAG(tfLoanBrokerPrivate, 0x00010000) , \
MASK_ADJ(0))
// clang-format on

View File

@@ -515,6 +515,7 @@ LEDGER_ENTRY(ltLOAN_BROKER, 0x0088, LoanBroker, loan_broker, ({
{sfCoverAvailable, soeDEFAULT},
{sfCoverRateMinimum, soeDEFAULT},
{sfCoverRateLiquidation, soeDEFAULT},
{sfDomainID, soeOPTIONAL},
}))
/** A ledger object representing a loan between a Borrower and a Loan Broker

View File

@@ -943,6 +943,7 @@ TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet,
{sfDebtMaximum, soeOPTIONAL},
{sfCoverRateMinimum, soeOPTIONAL},
{sfCoverRateLiquidation, soeOPTIONAL},
{sfDomainID, soeOPTIONAL},
}))
/** This transaction deletes a Loan Broker */

View File

@@ -22,6 +22,9 @@ public:
static std::vector<OptionaledField<STNumber>> const&
getValueFields();
static std::uint32_t
getFlagsMask(PreflightContext const& ctx);
static TER
preclaim(PreclaimContext const& ctx);

View File

@@ -947,7 +947,8 @@ NoModifiedUnmodifiableFields::finalize(
fieldChanged(before, after, sfOwner) ||
fieldChanged(before, after, sfManagementFeeRate) ||
fieldChanged(before, after, sfCoverRateMinimum) ||
fieldChanged(before, after, sfCoverRateLiquidation);
fieldChanged(before, after, sfCoverRateLiquidation) ||
fieldChanged(before, after, sfFlags);
break;
case ltLOAN:
/*

View File

@@ -186,6 +186,12 @@ ValidLoanBroker::finalize(
"is less than pseudo-account asset balance";
return false;
}
if (after->at(~sfDomainID) && !after->isFlag(lsfLoanBrokerPrivate))
{
JLOG(j.fatal()) << "Invariant failed: DomainID is set on public Loan Broker";
return false;
}
}
return true;
}

View File

@@ -1,6 +1,7 @@
#include <xrpl/tx/transactors/lending/LoanBrokerSet.h>
//
#include <xrpl/protocol/STTakesAsset.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/tx/transactors/lending/LendingHelpers.h>
namespace xrpl {
@@ -29,8 +30,11 @@ LoanBrokerSet::preflight(PreflightContext const& ctx)
if (!validNumericRange(tx[~sfDebtMaximum], Number(maxMPTokenAmount), Number(0)))
return temINVALID;
auto const domainID = tx.at(~sfDomainID);
if (tx.isFieldPresent(sfLoanBrokerID))
{
// We're modifying an existing LoanBroker.
// Fixed fields can not be specified if we're modifying an existing
// LoanBroker Object
if (tx.isFieldPresent(sfManagementFeeRate) || tx.isFieldPresent(sfCoverRateMinimum) ||
@@ -39,6 +43,43 @@ LoanBrokerSet::preflight(PreflightContext const& ctx)
if (tx[sfLoanBrokerID] == beast::zero)
return temINVALID;
if (ctx.rules.enabled(fixLendingProtocolV1_1))
{
if (tx.isFlag(tfLoanBrokerPrivate))
{
// Cannot change private flag on existing broker
return temINVALID;
}
}
}
else
{
// We're creating a new LoanBroker.
if (ctx.rules.enabled(fixLendingProtocolV1_1))
{
if (domainID)
{
if (*domainID == beast::zero)
{
// DomainID must not be zero if provided
return temMALFORMED;
}
if (!tx.isFlag(tfLoanBrokerPrivate))
{
// Public brokers cannot have a DomainID
return temINVALID;
}
}
}
}
if (!ctx.rules.enabled(fixLendingProtocolV1_1))
{
if (tx.isFlag(tfLoanBrokerPrivate) || tx.isFieldPresent(sfDomainID))
{
return temDISABLED;
}
}
if (auto const vaultID = tx.at(~sfVaultID))
@@ -68,6 +109,16 @@ LoanBrokerSet::getValueFields()
return valueFields;
}
std::uint32_t
LoanBrokerSet::getFlagsMask(PreflightContext const& ctx)
{
if (ctx.rules.enabled(fixLendingProtocolV1_1))
{
return tfLoanSetMask;
}
return tfUniversalMask;
}
TER
LoanBrokerSet::preclaim(PreclaimContext const& ctx)
{
@@ -90,6 +141,17 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx)
return tecNO_PERMISSION;
}
auto const domainID = tx[~sfDomainID];
if (domainID && *domainID != beast::zero)
{
auto const sleDomain = ctx.view.read(keylet::permissionedDomain(*domainID));
if (!sleDomain)
{
JLOG(ctx.j.warn()) << "Domain does not exist.";
return tecOBJECT_NOT_FOUND;
}
}
if (auto const brokerID = tx[~sfLoanBrokerID])
{
// Updating an existing Broker
@@ -121,6 +183,11 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx)
return tecLIMIT_EXCEEDED;
}
}
if (domainID && *domainID != beast::zero && !sleBroker->isFlag(lsfLoanBrokerPrivate))
{
return tecNO_PERMISSION;
}
}
else
{
@@ -179,6 +246,14 @@ LoanBrokerSet::doApply()
if (auto const debtMax = tx[~sfDebtMaximum])
broker->at(sfDebtMaximum) = *debtMax;
auto domainID = tx[~sfDomainID];
if (domainID && *domainID == beast::zero)
{
domainID = std::nullopt;
}
broker->at(~sfDomainID) = domainID;
view.update(broker);
associateAsset(*broker, vaultAsset);
@@ -250,6 +325,14 @@ LoanBrokerSet::doApply()
if (auto const coverLiq = tx[~sfCoverRateLiquidation])
broker->at(sfCoverRateLiquidation) = *coverLiq;
if (tx.isFlag(tfLoanBrokerPrivate))
{
broker->setFlag(lsfLoanBrokerPrivate);
}
auto const domainID = tx[~sfDomainID];
broker->at(~sfDomainID) = domainID;
view.insert(broker);
associateAsset(*broker, vaultAsset);

View File

@@ -1,5 +1,6 @@
#include <xrpl/tx/transactors/lending/LoanSet.h>
//
#include <xrpl/ledger/CredentialHelpers.h>
#include <xrpl/protocol/STTakesAsset.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/tx/transactors/lending/LendingHelpers.h>
@@ -329,6 +330,27 @@ LoanSet::preclaim(PreclaimContext const& ctx)
return ret;
}
if (brokerSle->isFlag(lsfLoanBrokerPrivate))
{
auto const domainID = brokerSle->at(~sfDomainID);
if (!domainID)
{
JLOG(ctx.j.warn()) << "Private LoanBroker must have a DomainID.";
return tecNO_AUTH;
}
auto const sleDomain = ctx.view.read(keylet::permissionedDomain(*domainID));
if (!sleDomain)
{
JLOG(ctx.j.warn()) << "Domain does not exist."; // LCOV_EXCL_LINE
return tecOBJECT_NOT_FOUND; // LCOV_EXCL_LINE
}
if (auto const ter = credentials::validDomain(ctx.view, *domainID, borrower);
!isTesSuccess(ter))
return ter;
}
return tesSUCCESS;
}

View File

@@ -1,4 +1,6 @@
#include <test/jtx.h>
#include <test/jtx/credentials.h>
#include <test/jtx/permissioned_domains.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/tx/transactors/lending/LoanBrokerCoverDeposit.h>
@@ -1797,6 +1799,338 @@ class LoanBroker_test : public beast::unit_test::suite
BEAST_EXPECT(env.enabled(fixLendingProtocolV1_1));
}
void
testPrivateLoanBroker()
{
testcase("Private Loan Broker with Permissioned Domain");
using namespace jtx;
using namespace loanBroker;
Account const issuer{"issuer"};
Account const alice{"alice"};
Account const credIssuer{"credIssuer"};
std::string const credType = "LoanCredential";
Env env{*this};
Vault vault{env};
env.fund(XRP(100'000), issuer, alice, credIssuer);
env.close();
// Create an IOU asset and vault
env(trust(alice, issuer["IOU"](1'000'000)));
env.close();
PrettyAsset const asset{issuer["IOU"]};
env(pay(issuer, alice, asset(100'000)));
env.close();
auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = asset});
env(tx);
env.close();
// Create a permissioned domain
pdomain::Credentials const credentials{{credIssuer, credType}};
env(pdomain::setTx(credIssuer, credentials));
env.close();
auto const domainId = pdomain::getNewDomain(env.meta());
BEAST_EXPECT(domainId != beast::zero);
// Test 1: Cannot set tfLoanBrokerPrivate without fixLendingProtocolV1_1
{
Env envNoFix{*this};
envNoFix.disableFeature(fixLendingProtocolV1_1);
Vault vault2{envNoFix};
envNoFix.fund(XRP(100'000), issuer, alice);
envNoFix.close();
envNoFix(trust(alice, issuer["IOU"](1'000'000)));
envNoFix.close();
envNoFix(pay(issuer, alice, asset(100'000)));
envNoFix.close();
auto [tx2, vaultKeylet2] = vault2.create({.owner = alice, .asset = asset});
envNoFix(tx2);
envNoFix.close();
// tfLoanBrokerPrivate should be disabled
envNoFix(set(alice, vaultKeylet2.key, tfLoanBrokerPrivate), ter(temINVALID_FLAG));
}
// Test 2: Create a private loan broker with DomainID
auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
env(set(alice, vaultKeylet.key, tfLoanBrokerPrivate), domainID(domainId));
env.close();
// Verify the broker was created with the correct flags and DomainID
{
auto const sleBroker = env.le(brokerKeylet);
BEAST_EXPECT(sleBroker);
if (sleBroker)
{
BEAST_EXPECT(sleBroker->isFlag(lsfLoanBrokerPrivate));
BEAST_EXPECT(sleBroker->at(~sfDomainID) == domainId);
}
}
// Test 3: Cannot create public broker with DomainID
{
// Public broker (no tfLoanBrokerPrivate) with DomainID should fail
env(set(alice, vaultKeylet.key), domainID(domainId), ter(temINVALID));
}
// Test 4: Cannot create broker with zero DomainID
{
env(set(alice, vaultKeylet.key, tfLoanBrokerPrivate),
domainID(beast::zero),
ter(temMALFORMED));
}
// Test 5: Cannot create broker with non-existent DomainID
{
uint256 fakeDomainId;
BEAST_EXPECT(fakeDomainId.parseHex(
"1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF"));
env(set(alice, vaultKeylet.key, tfLoanBrokerPrivate),
domainID(fakeDomainId),
ter(tecOBJECT_NOT_FOUND));
}
}
void
testPrivateLoanBrokerModify()
{
testcase("Modify Private Loan Broker DomainID");
using namespace jtx;
using namespace loanBroker;
Account const issuer{"issuer"};
Account const alice{"alice"};
Account const credIssuer{"credIssuer"};
std::string const credType = "LoanCredential";
Env env{*this};
Vault vault{env};
env.fund(XRP(100'000), issuer, alice, credIssuer);
env.close();
// Create an IOU asset and vault
env(trust(alice, issuer["IOU"](1'000'000)));
env.close();
PrettyAsset const asset{issuer["IOU"]};
env(pay(issuer, alice, asset(100'000)));
env.close();
auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = asset});
env(tx);
env.close();
// Create two permissioned domains
pdomain::Credentials const credentials{{credIssuer, credType}};
env(pdomain::setTx(credIssuer, credentials));
env.close();
auto const domainId1 = pdomain::getNewDomain(env.meta());
env(pdomain::setTx(credIssuer, credentials));
env.close();
auto const domainId2 = pdomain::getNewDomain(env.meta());
// Create a private loan broker with DomainID
auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
env(set(alice, vaultKeylet.key, tfLoanBrokerPrivate), domainID(domainId1));
env.close();
// Test 1: Modify DomainID to a different domain
env(set(alice, vaultKeylet.key), loanBrokerID(brokerKeylet.key), domainID(domainId2));
env.close();
{
auto const sleBroker = env.le(brokerKeylet);
BEAST_EXPECT(sleBroker);
if (sleBroker)
{
BEAST_EXPECT(sleBroker->at(~sfDomainID) == domainId2);
}
}
// Test 2: Clear DomainID by setting to zero
env(set(alice, vaultKeylet.key), loanBrokerID(brokerKeylet.key), domainID(beast::zero));
env.close();
{
auto const sleBroker = env.le(brokerKeylet);
BEAST_EXPECT(sleBroker);
if (sleBroker)
{
BEAST_EXPECT(!sleBroker->at(~sfDomainID));
}
}
// Test 3: Set DomainID back
env(set(alice, vaultKeylet.key), loanBrokerID(brokerKeylet.key), domainID(domainId1));
env.close();
{
auto const sleBroker = env.le(brokerKeylet);
BEAST_EXPECT(sleBroker);
if (sleBroker)
{
BEAST_EXPECT(sleBroker->at(~sfDomainID) == domainId1);
}
}
// Test 4: Cannot set tfLoanBrokerPrivate when modifying (flag is immutable)
env(set(alice, vaultKeylet.key, tfLoanBrokerPrivate),
loanBrokerID(brokerKeylet.key),
ter(temINVALID));
// Test 5: Create a public broker and try to set DomainID on it
auto const publicBrokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
env(set(alice, vaultKeylet.key)); // No tfLoanBrokerPrivate = public
env.close();
{
auto const sleBroker = env.le(publicBrokerKeylet);
BEAST_EXPECT(sleBroker);
if (sleBroker)
{
BEAST_EXPECT(!sleBroker->isFlag(lsfLoanBrokerPrivate));
}
}
// Cannot set non-zero DomainID on public broker
env(set(alice, vaultKeylet.key),
loanBrokerID(publicBrokerKeylet.key),
domainID(domainId1),
ter(tecNO_PERMISSION));
// But zero DomainID on public broker is allowed (no-op)
env(set(alice, vaultKeylet.key),
loanBrokerID(publicBrokerKeylet.key),
domainID(beast::zero));
env.close();
}
void
testPrivateBrokerUnsetDomainBlocksLoans()
{
testcase("Unsetting DomainID on private broker blocks new loans");
using namespace jtx;
using namespace loanBroker;
Account const issuer{"issuer"};
Account const alice{"alice"}; // Broker owner
Account const bob{"bob"}; // Borrower
Account const credIssuer{"credIssuer"};
std::string const credType = "LoanCredential";
Env env{*this};
Vault vault{env};
env.fund(XRP(100'000), issuer, alice, bob, credIssuer);
env.close();
// Create an IOU asset and vault
env(trust(alice, issuer["IOU"](1'000'000)));
env(trust(bob, issuer["IOU"](1'000'000)));
env.close();
PrettyAsset const asset{issuer["IOU"]};
env(pay(issuer, alice, asset(100'000)));
env(pay(issuer, bob, asset(10'000)));
env.close();
auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = asset});
env(tx);
env.close();
// Deposit into vault
env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50'000)}));
env.close();
// Create a permissioned domain
pdomain::Credentials const credentials{{credIssuer, credType}};
env(pdomain::setTx(credIssuer, credentials));
env.close();
auto const domainId = pdomain::getNewDomain(env.meta());
// Create credential for bob and accept it
env(credentials::create(bob, credIssuer, credType));
env.close();
env(credentials::accept(bob, credIssuer, credType));
env.close();
// Create a private loan broker with DomainID
auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
env(set(alice, vaultKeylet.key, tfLoanBrokerPrivate), domainID(domainId));
env.close();
// Deposit cover into broker
env(coverDeposit(alice, brokerKeylet.key, asset(1000)));
env.close();
// Bob can create a loan (has credentials, domain is set)
{
auto const loanKeylet = keylet::loan(brokerKeylet.key, 1);
auto setTx = env.jt(loan::set(bob, brokerKeylet.key, asset(100).number()));
sig(sfCounterpartySignature, alice)(env, setTx);
fee{env.current()->fees().base * 2}(env, setTx);
loan::counterparty(alice)(env, setTx);
loan::interestRate(TenthBips32{1000})(env, setTx);
loan::paymentTotal(2)(env, setTx);
loan::paymentInterval(100)(env, setTx);
env(setTx);
env.close();
BEAST_EXPECT(env.le(loanKeylet));
}
// Now unset the DomainID by setting it to zero
env(set(alice, vaultKeylet.key), loanBrokerID(brokerKeylet.key), domainID(beast::zero));
env.close();
// Verify DomainID is unset
{
auto const sleBroker = env.le(brokerKeylet);
BEAST_EXPECT(sleBroker);
if (sleBroker)
{
BEAST_EXPECT(!sleBroker->at(~sfDomainID));
BEAST_EXPECT(sleBroker->isFlag(lsfLoanBrokerPrivate));
}
}
// Bob cannot create a new loan (private broker has no domain configured)
{
auto const loanKeylet2 = keylet::loan(brokerKeylet.key, 2);
auto setTx = env.jt(loan::set(bob, brokerKeylet.key, asset(100).number()));
sig(sfCounterpartySignature, alice)(env, setTx);
fee{env.current()->fees().base * 2}(env, setTx);
loan::counterparty(alice)(env, setTx);
loan::interestRate(TenthBips32{1000})(env, setTx);
loan::paymentTotal(2)(env, setTx);
loan::paymentInterval(100)(env, setTx);
env(setTx, ter(tecNO_AUTH));
BEAST_EXPECT(!env.le(loanKeylet2));
}
// Set the DomainID back
env(set(alice, vaultKeylet.key), loanBrokerID(brokerKeylet.key), domainID(domainId));
env.close();
// Now bob can create a loan again
{
auto const loanKeylet3 = keylet::loan(brokerKeylet.key, 2);
auto setTx = env.jt(loan::set(bob, brokerKeylet.key, asset(100).number()));
sig(sfCounterpartySignature, alice)(env, setTx);
fee{env.current()->fees().base * 2}(env, setTx);
loan::counterparty(alice)(env, setTx);
loan::interestRate(TenthBips32{1000})(env, setTx);
loan::paymentTotal(2)(env, setTx);
loan::paymentInterval(100)(env, setTx);
env(setTx);
env.close();
BEAST_EXPECT(env.le(loanKeylet3));
}
}
public:
void
run() override
@@ -1819,6 +2153,10 @@ public:
testRIPD4274();
testPrivateLoanBroker();
testPrivateLoanBrokerModify();
testPrivateBrokerUnsetDomainBlocksLoans();
// TODO: Write clawback failure tests with an issuer / MPT that doesn't
// have the right flags set.
}

View File

@@ -1,7 +1,9 @@
#include <xrpl/beast/unit_test/suite.h>
//
#include <test/jtx.h>
#include <test/jtx/credentials.h>
#include <test/jtx/mpt.h>
#include <test/jtx/permissioned_domains.h>
#include <xrpl/beast/xor_shift_engine.h>
#include <xrpl/protocol/SField.h>
@@ -7097,6 +7099,245 @@ protected:
attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS);
}
void
testPrivateBrokerCredentials()
{
testcase("Private Broker Credential Validation");
using namespace jtx;
using namespace loanBroker;
Account const issuer{"issuer"};
Account const alice{"alice"}; // Broker owner
Account const bob{"bob"}; // Borrower with credentials
Account const carol{"carol"}; // Borrower without credentials
Account const credIssuer{"credIssuer"};
std::string const credType = "LoanCredential";
Env env{*this};
Vault vault{env};
env.fund(XRP(100'000), issuer, alice, bob, carol, credIssuer);
env.close();
// Create an IOU asset and vault
env(trust(alice, issuer["IOU"](1'000'000)));
env(trust(bob, issuer["IOU"](1'000'000)));
env(trust(carol, issuer["IOU"](1'000'000)));
env.close();
PrettyAsset const asset{issuer["IOU"]};
env(pay(issuer, alice, asset(100'000)));
env(pay(issuer, bob, asset(10'000)));
env(pay(issuer, carol, asset(10'000)));
env.close();
auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = asset});
env(tx);
env.close();
// Deposit into vault
env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50'000)}));
env.close();
// Create a permissioned domain
pdomain::Credentials const credentials{{credIssuer, credType}};
env(pdomain::setTx(credIssuer, credentials));
env.close();
auto const domainId = pdomain::getNewDomain(env.meta());
// Create a private loan broker with DomainID
auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
env(set(alice, vaultKeylet.key, tfLoanBrokerPrivate), domainID(domainId));
env.close();
// Deposit cover into broker
env(coverDeposit(alice, brokerKeylet.key, asset(1000)));
env.close();
// Test 1: Borrower without credentials cannot create loan
{
auto setTx = env.jt(loan::set(carol, brokerKeylet.key, asset(100).number()));
sig(sfCounterpartySignature, alice)(env, setTx);
fee{env.current()->fees().base * 2}(env, setTx);
loan::counterparty(alice)(env, setTx);
loan::interestRate(TenthBips32{1000})(env, setTx);
loan::paymentTotal(2)(env, setTx);
loan::paymentInterval(100)(env, setTx);
env(setTx, ter(tecNO_AUTH));
}
// Create credential for bob and accept it
env(credentials::create(bob, credIssuer, credType));
env.close();
env(credentials::accept(bob, credIssuer, credType));
env.close();
// Test 2: Borrower with valid credentials can create loan
{
auto const loanKeylet = keylet::loan(brokerKeylet.key, 1);
auto setTx = env.jt(loan::set(bob, brokerKeylet.key, asset(100).number()));
sig(sfCounterpartySignature, alice)(env, setTx);
fee{env.current()->fees().base * 2}(env, setTx);
loan::counterparty(alice)(env, setTx);
loan::interestRate(TenthBips32{1000})(env, setTx);
loan::paymentTotal(2)(env, setTx);
loan::paymentInterval(100)(env, setTx);
env(setTx);
env.close();
BEAST_EXPECT(env.le(loanKeylet));
}
// Test 3: Private broker without DomainID configured rejects loans
{
// Create another private broker without DomainID
auto const broker2Keylet = keylet::loanbroker(alice.id(), env.seq(alice));
env(set(alice, vaultKeylet.key, tfLoanBrokerPrivate)); // No domainID
env.close();
env(coverDeposit(alice, broker2Keylet.key, asset(1000)));
env.close();
// Even bob with credentials should fail - broker has no domain configured
auto setTx = env.jt(loan::set(bob, broker2Keylet.key, asset(100).number()));
sig(sfCounterpartySignature, alice)(env, setTx);
fee{env.current()->fees().base * 2}(env, setTx);
loan::counterparty(alice)(env, setTx);
loan::interestRate(TenthBips32{1000})(env, setTx);
loan::paymentTotal(2)(env, setTx);
loan::paymentInterval(100)(env, setTx);
env(setTx, ter(tecNO_AUTH));
}
// Test 4: Public broker allows anyone to create loan
{
auto const publicBrokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
env(set(alice, vaultKeylet.key)); // No tfLoanBrokerPrivate = public
env.close();
env(coverDeposit(alice, publicBrokerKeylet.key, asset(1000)));
env.close();
// Carol without credentials can create loan on public broker
auto const loanKeylet = keylet::loan(publicBrokerKeylet.key, 1);
auto setTx = env.jt(loan::set(carol, publicBrokerKeylet.key, asset(100).number()));
sig(sfCounterpartySignature, alice)(env, setTx);
fee{env.current()->fees().base * 2}(env, setTx);
loan::counterparty(alice)(env, setTx);
loan::interestRate(TenthBips32{1000})(env, setTx);
loan::paymentTotal(2)(env, setTx);
loan::paymentInterval(100)(env, setTx);
env(setTx);
env.close();
BEAST_EXPECT(env.le(loanKeylet));
}
}
void
testPrivateBrokerLoanPayAfterCredentialRevoked()
{
testcase("LoanPay works after borrower credential is revoked");
using namespace jtx;
using namespace loanBroker;
Account const issuer{"issuer"};
Account const alice{"alice"}; // Broker owner
Account const bob{"bob"}; // Borrower
Account const credIssuer{"credIssuer"};
std::string const credType = "LoanCredential";
Env env{*this};
Vault vault{env};
env.fund(XRP(100'000), issuer, alice, bob, credIssuer);
env.close();
// Create an IOU asset and vault
env(trust(alice, issuer["IOU"](1'000'000)));
env(trust(bob, issuer["IOU"](1'000'000)));
env.close();
PrettyAsset const asset{issuer["IOU"]};
env(pay(issuer, alice, asset(100'000)));
env(pay(issuer, bob, asset(10'000)));
env.close();
auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = asset});
env(tx);
env.close();
// Deposit into vault
env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50'000)}));
env.close();
// Create a permissioned domain
pdomain::Credentials const credentials{{credIssuer, credType}};
env(pdomain::setTx(credIssuer, credentials));
env.close();
auto const domainId = pdomain::getNewDomain(env.meta());
// Create credential for bob and accept it
env(credentials::create(bob, credIssuer, credType));
env.close();
env(credentials::accept(bob, credIssuer, credType));
env.close();
// Create a private loan broker with DomainID
auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
env(set(alice, vaultKeylet.key, tfLoanBrokerPrivate), domainID(domainId));
env.close();
// Deposit cover into broker
env(coverDeposit(alice, brokerKeylet.key, asset(1000)));
env.close();
// Create a loan for bob
auto const loanKeylet = keylet::loan(brokerKeylet.key, 1);
{
auto setTx = env.jt(loan::set(bob, brokerKeylet.key, asset(100).number()));
sig(sfCounterpartySignature, alice)(env, setTx);
fee{env.current()->fees().base * 2}(env, setTx);
loan::counterparty(alice)(env, setTx);
loan::interestRate(TenthBips32{1000})(env, setTx);
loan::paymentTotal(2)(env, setTx);
loan::paymentInterval(100)(env, setTx);
env(setTx);
env.close();
BEAST_EXPECT(env.le(loanKeylet));
}
// Now delete bob's credential
env(credentials::deleteCred(bob, bob, credIssuer, credType));
env.close();
// Verify credential is gone
auto const credKeylet = credentials::keylet(bob, credIssuer, credType);
BEAST_EXPECT(!env.le(credKeylet));
// Bob should still be able to make a loan payment even without credentials
env(loan::pay(bob, loanKeylet.key, asset(51)));
env.close();
// Verify the loan still exists and payment was processed
auto const sleLoan = env.le(loanKeylet);
BEAST_EXPECT(sleLoan);
// Bob should NOT be able to create a NEW loan (no credentials)
{
auto const newLoanKeylet = keylet::loan(brokerKeylet.key, 2);
auto setTx = env.jt(loan::set(bob, brokerKeylet.key, asset(100).number()));
sig(sfCounterpartySignature, alice)(env, setTx);
fee{env.current()->fees().base * 2}(env, setTx);
loan::counterparty(alice)(env, setTx);
loan::interestRate(TenthBips32{1000})(env, setTx);
loan::paymentTotal(2)(env, setTx);
loan::paymentInterval(100)(env, setTx);
env(setTx, ter(tecNO_AUTH));
BEAST_EXPECT(!env.le(newLoanKeylet));
}
}
public:
void
run() override
@@ -7151,6 +7392,9 @@ public:
testLoanPayBrokerOwnerNoPermissionedDomainMPT();
testLoanSetBrokerOwnerNoPermissionedDomainMPT();
testSequentialFLCDepletion();
testPrivateBrokerCredentials();
testPrivateBrokerLoanPayAfterCredentialRevoked();
}
};

View File

@@ -717,6 +717,8 @@ auto const coverRateLiquidation =
auto const destination = JTxFieldWrapper<accountIDField>(sfDestination);
auto const domainID = JTxFieldWrapper<uint256Field>(sfDomainID);
} // namespace loanBroker
/* Loan */