From eb46d9e4171b8038b0f8d9e111dbf35146a1f7e5 Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 10 Mar 2026 15:54:37 +0000 Subject: [PATCH] Integrate Permissioned Domain & Credential Checks for Lending Protocol Signed-off-by: JCW --- include/xrpl/protocol/LedgerFormats.h | 5 +- include/xrpl/protocol/TxFlags.h | 4 + .../xrpl/protocol/detail/ledger_entries.macro | 1 + .../xrpl/protocol/detail/transactions.macro | 1 + .../tx/transactors/lending/LoanBrokerSet.h | 3 + src/libxrpl/tx/invariants/InvariantCheck.cpp | 3 +- src/libxrpl/tx/invariants/LoanInvariant.cpp | 6 + .../tx/transactors/lending/LoanBrokerSet.cpp | 83 +++++ .../tx/transactors/lending/LoanSet.cpp | 22 ++ src/test/app/LoanBroker_test.cpp | 338 ++++++++++++++++++ src/test/app/Loan_test.cpp | 244 +++++++++++++ src/test/jtx/TestHelpers.h | 2 + 12 files changed, 710 insertions(+), 2 deletions(-) diff --git a/include/xrpl/protocol/LedgerFormats.h b/include/xrpl/protocol/LedgerFormats.h index a43f6a7134..0e5a6547dd 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -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 diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index 7c2085109f..e80d43f7b1 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -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 diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 216f404bec..83d671e0cd 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -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 diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 15881effc8..4037944dd8 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -943,6 +943,7 @@ TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, {sfDebtMaximum, soeOPTIONAL}, {sfCoverRateMinimum, soeOPTIONAL}, {sfCoverRateLiquidation, soeOPTIONAL}, + {sfDomainID, soeOPTIONAL}, })) /** This transaction deletes a Loan Broker */ diff --git a/include/xrpl/tx/transactors/lending/LoanBrokerSet.h b/include/xrpl/tx/transactors/lending/LoanBrokerSet.h index ce1e069791..94ff4d31a8 100644 --- a/include/xrpl/tx/transactors/lending/LoanBrokerSet.h +++ b/include/xrpl/tx/transactors/lending/LoanBrokerSet.h @@ -22,6 +22,9 @@ public: static std::vector> const& getValueFields(); + static std::uint32_t + getFlagsMask(PreflightContext const& ctx); + static TER preclaim(PreclaimContext const& ctx); diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 79c593c57c..f4bb65e0d7 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -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: /* diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index 01c4da46ac..0c2dc8a313 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -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; } diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp index c825a7fafe..41922e1c18 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp @@ -1,6 +1,7 @@ #include // #include +#include #include 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); diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index f74522e737..a3ce6f79f9 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -1,5 +1,6 @@ #include // +#include #include #include #include @@ -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; } diff --git a/src/test/app/LoanBroker_test.cpp b/src/test/app/LoanBroker_test.cpp index 4c108d9007..1952508d47 100644 --- a/src/test/app/LoanBroker_test.cpp +++ b/src/test/app/LoanBroker_test.cpp @@ -1,4 +1,6 @@ #include +#include +#include #include #include @@ -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. } diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 715d03c3df..78cb34056a 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -1,7 +1,9 @@ #include // #include +#include #include +#include #include #include @@ -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(); } }; diff --git a/src/test/jtx/TestHelpers.h b/src/test/jtx/TestHelpers.h index 2836748ec3..d47338df09 100644 --- a/src/test/jtx/TestHelpers.h +++ b/src/test/jtx/TestHelpers.h @@ -717,6 +717,8 @@ auto const coverRateLiquidation = auto const destination = JTxFieldWrapper(sfDestination); +auto const domainID = JTxFieldWrapper(sfDomainID); + } // namespace loanBroker /* Loan */