diff --git a/include/xrpl/protocol/LedgerFormats.h b/include/xrpl/protocol/LedgerFormats.h index 99d5d818f14..66bc1b047eb 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -203,7 +203,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 4652cc1bf09..82822cd1113 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -214,6 +214,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/features.macro b/include/xrpl/protocol/detail/features.macro index c99c1e5ce81..911bfd05f50 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -15,6 +15,7 @@ // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. +XRPL_FEATURE(LendingPermissionedDomain, Supported::No, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_2_0, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(MPTokensV2, Supported::No, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_1_3, Supported::Yes, VoteBehavior::DefaultYes) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 632038a9c5b..163d0e429d3 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -519,6 +519,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 450e2558cce..1a9280f9903 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -961,6 +961,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/protocol_autogen/ledger_entries/LoanBroker.h b/include/xrpl/protocol_autogen/ledger_entries/LoanBroker.h index 88f05e34337..433bf368a6f 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/LoanBroker.h +++ b/include/xrpl/protocol_autogen/ledger_entries/LoanBroker.h @@ -335,6 +335,30 @@ class LoanBroker : public LedgerEntryBase { return this->sle_->isFieldPresent(sfCoverRateLiquidation); } + + /** + * @brief Get sfDomainID (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getDomainID() const + { + if (hasDomainID()) + return this->sle_->at(sfDomainID); + return std::nullopt; + } + + /** + * @brief Check if sfDomainID is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasDomainID() const + { + return this->sle_->isFieldPresent(sfDomainID); + } }; /** @@ -576,6 +600,17 @@ class LoanBrokerBuilder : public LedgerEntryBuilderBase return *this; } + /** + * @brief Set sfDomainID (SoeOptional) + * @return Reference to this builder for method chaining. + */ + LoanBrokerBuilder& + setDomainID(std::decay_t const& value) + { + object_[sfDomainID] = value; + return *this; + } + /** * @brief Build and return the completed LoanBroker wrapper. * @param index The ledger entry index. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h index 854022242dd..48a15d604fc 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h @@ -213,6 +213,32 @@ class LoanBrokerSet : public TransactionBase { return this->tx_->isFieldPresent(sfCoverRateLiquidation); } + + /** + * @brief Get sfDomainID (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getDomainID() const + { + if (hasDomainID()) + { + return this->tx_->at(sfDomainID); + } + return std::nullopt; + } + + /** + * @brief Check if sfDomainID is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasDomainID() const + { + return this->tx_->isFieldPresent(sfDomainID); + } }; /** @@ -334,6 +360,17 @@ class LoanBrokerSetBuilder : public TransactionBuilderBase return *this; } + /** + * @brief Set sfDomainID (SoeOptional) + * @return Reference to this builder for method chaining. + */ + LoanBrokerSetBuilder& + setDomainID(std::decay_t const& value) + { + object_[sfDomainID] = value; + return *this; + } + /** * @brief Build and return the LoanBrokerSet wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/tx/transactors/lending/LoanBrokerSet.h b/include/xrpl/tx/transactors/lending/LoanBrokerSet.h index 75175e2dd68..b3393636583 100644 --- a/include/xrpl/tx/transactors/lending/LoanBrokerSet.h +++ b/include/xrpl/tx/transactors/lending/LoanBrokerSet.h @@ -22,6 +22,9 @@ class LoanBrokerSet : public Transactor 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 b4a533905c4..62d10357141 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -979,7 +979,8 @@ NoModifiedUnmodifiableFields::finalize( kFieldChanged(before, after, sfOwner) || kFieldChanged(before, after, sfManagementFeeRate) || kFieldChanged(before, after, sfCoverRateMinimum) || - kFieldChanged(before, after, sfCoverRateLiquidation); + kFieldChanged(before, after, sfCoverRateLiquidation) || + kFieldChanged(before, after, sfFlags); break; case ltLOAN: /* diff --git a/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp b/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp index 8586a27be34..43947e8eb30 100644 --- a/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp @@ -140,6 +140,15 @@ ValidLoanBroker::finalize( auto const& before = broker.brokerBefore; + if (view.rules().enabled(featureLendingPermissionedDomain)) + { + if (after->at(~sfDomainID) && !after->isFlag(lsfLoanBrokerPrivate)) + { + JLOG(j.fatal()) << "Invariant failed: DomainID is set on public Loan Broker"; + return false; + } + } + // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3123-invariants // If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most // one node (the root), which will only hold entries for `RippleState` diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp index 2dc003eb7fe..4b65abf1818 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp @@ -9,7 +9,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -17,9 +19,11 @@ #include #include #include +#include #include #include +#include #include #include @@ -49,8 +53,17 @@ LoanBrokerSet::preflight(PreflightContext const& ctx) if (!validNumericRange(tx[~sfDebtMaximum], Number(kMaxMpTokenAmount), Number(0))) return temINVALID; + if (!ctx.rules.enabled(featureLendingPermissionedDomain)) + { + if (tx.isFlag(tfLoanBrokerPrivate) || tx.isFieldPresent(sfDomainID)) + { + return temDISABLED; + } + } + 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) || @@ -59,6 +72,36 @@ LoanBrokerSet::preflight(PreflightContext const& ctx) if (tx[sfLoanBrokerID] == beast::kZero) return temINVALID; + + if (ctx.rules.enabled(featureLendingPermissionedDomain)) + { + // Cannot change private flag on existing broker + if (tx.isFlag(tfLoanBrokerPrivate)) + { + return temINVALID; + } + } + } + else + { + // We're creating a new LoanBroker. + if (ctx.rules.enabled(featureLendingPermissionedDomain)) + { + auto const domainID = tx.at(~sfDomainID); + if (domainID) + { + if (*domainID == beast::kZero) + { + // DomainID must not be zero if provided + return temMALFORMED; + } + if (!tx.isFlag(tfLoanBrokerPrivate)) + { + // Public brokers cannot have a DomainID + return temINVALID; + } + } + } } if (auto const vaultID = tx.at(~sfVaultID)) @@ -88,6 +131,16 @@ LoanBrokerSet::getValueFields() return kValueFields; } +std::uint32_t +LoanBrokerSet::getFlagsMask(PreflightContext const& ctx) +{ + if (ctx.rules.enabled(fixCleanup3_2_0)) + { + return tfLoanBrokerSetMask; + } + return tfUniversalMask; +} + TER LoanBrokerSet::preclaim(PreclaimContext const& ctx) { @@ -110,6 +163,20 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx) return tecNO_PERMISSION; } + if (ctx.view.rules().enabled(featureLendingPermissionedDomain)) + { + auto const domainID = tx[~sfDomainID]; + if (domainID && *domainID != beast::kZero) + { + 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 @@ -141,6 +208,13 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx) return tecLIMIT_EXCEEDED; } } + + if (ctx.view.rules().enabled(featureLendingPermissionedDomain)) + { + auto const domainID = tx[~sfDomainID]; + if (!sleBroker->isFlag(lsfLoanBrokerPrivate) && domainID) + return tecNO_PERMISSION; + } } else { @@ -199,6 +273,22 @@ LoanBrokerSet::doApply() if (auto const debtMax = tx[~sfDebtMaximum]) broker->at(sfDebtMaximum) = *debtMax; + if (ctx_.view().rules().enabled(featureLendingPermissionedDomain) && + broker->isFlag(lsfLoanBrokerPrivate)) + { + if (auto const domainID = tx[~sfDomainID]) + { + if (*domainID != beast::kZero) + { + broker->setFieldH256(sfDomainID, *domainID); + } + else if (broker->isFieldPresent(sfDomainID)) + { + broker->makeFieldAbsent(sfDomainID); + } + } + } + view.update(broker); associateAsset(*broker, vaultAsset); @@ -270,6 +360,16 @@ LoanBrokerSet::doApply() if (auto const coverLiq = tx[~sfCoverRateLiquidation]) broker->at(sfCoverRateLiquidation) = *coverLiq; + if (ctx_.view().rules().enabled(featureLendingPermissionedDomain)) + { + if (tx.isFlag(tfLoanBrokerPrivate)) + { + broker->setFlag(lsfLoanBrokerPrivate); + if (auto domainID = tx[~sfDomainID]) + broker->setFieldH256(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 573f700f519..3f93aa50b3d 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -362,6 +363,28 @@ LoanSet::preclaim(PreclaimContext const& ctx) return ret; } + if (ctx.view.rules().enabled(featureLendingPermissionedDomain) && + 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) && ter != tecEXPIRED) + return ter; + } + return tesSUCCESS; } @@ -401,6 +424,20 @@ LoanSet::doApply() { return tefBAD_LEDGER; // LCOV_EXCL_LINE } + + if (ctx_.view().rules().enabled(featureLendingPermissionedDomain) && + brokerSle->isFlag(lsfLoanBrokerPrivate)) + { + auto const domainID = brokerSle->at(~sfDomainID); + if (!domainID) + { + return tefBAD_LEDGER; // LCOV_EXCL_LINE + } + + if (auto const ter = verifyValidDomain(view, borrower, *domainID, j_); !isTesSuccess(ter)) + return ter; + } + auto const principalRequested = tx[sfPrincipalRequested]; auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 36540368699..cc95ad061e4 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -2253,6 +2253,7 @@ class Invariants_test : public beast::unit_test::Suite [](SLE::pointer& sle) { sle->at(sfManagementFeeRate) += 1; }, [](SLE::pointer& sle) { sle->at(sfCoverRateMinimum) += 1; }, [](SLE::pointer& sle) { sle->at(sfCoverRateLiquidation) += 1; }, + [](SLE::pointer& sle) { sle->setFlag(lsfLoanBrokerPrivate); }, [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; }, [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = sle->at(sfVaultID).value(); }, }); @@ -2567,6 +2568,25 @@ class Invariants_test : public beast::unit_test::Suite STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, createLoanBroker); + + doInvariantCheck( + {{"DomainID is set on public Loan Broker"}}, + [&](Account const&, Account const&, ApplyContext& ac) { + if (loanBrokerKeylet.type != ltLOAN_BROKER) + return false; + auto sleBroker = ac.view().peek(loanBrokerKeylet); + if (!sleBroker) + return false; + // Set DomainID without lsfLoanBrokerPrivate flag + sleBroker->clearFlag(lsfLoanBrokerPrivate); + sleBroker->at(sfDomainID) = uint256(42); + ac.view().update(sleBroker); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); } } diff --git a/src/test/app/LoanBroker_test.cpp b/src/test/app/LoanBroker_test.cpp index 0edb955b902..0bd40171171 100644 --- a/src/test/app/LoanBroker_test.cpp +++ b/src/test/app/LoanBroker_test.cpp @@ -1,4 +1,3 @@ - #include #include #include @@ -6,10 +5,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -2240,6 +2241,467 @@ class LoanBroker_test : public beast::unit_test::Suite runTestCases(all_ - fixCleanup3_2_0); } + 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 const 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::kZero); + + // Test 1: Cannot set tfLoanBrokerPrivate without fixCleanup3_2_0 + { + Env envNoFix{*this}; + envNoFix.disableFeature(fixCleanup3_2_0); + Vault const 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)); + + // sfDomainID should also be disabled without fixCleanup3_2_0 + envNoFix(set(alice, vaultKeylet2.key), kDomainId(domainId), Ter(temDISABLED)); + } + + // 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), kDomainId(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), kDomainId(domainId), Ter(temINVALID)); + } + + // Test 4: Cannot create broker with zero DomainID + { + env(set(alice, vaultKeylet.key, tfLoanBrokerPrivate), + kDomainId(beast::kZero), + Ter(temMALFORMED)); + } + + // Test 5: Cannot create broker with non-existent DomainID + { + uint256 fakeDomainId; + BEAST_EXPECT(fakeDomainId.parseHex( + "1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF")); + env(set(alice, vaultKeylet.key, tfLoanBrokerPrivate), + kDomainId(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 const 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), kDomainId(domainId1)); + env.close(); + + // Test 1: Modify DomainID to a different domain + env(set(alice, vaultKeylet.key), kLoanBrokerId(brokerKeylet.key), kDomainId(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), kLoanBrokerId(brokerKeylet.key), kDomainId(beast::kZero)); + 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), kLoanBrokerId(brokerKeylet.key), kDomainId(domainId1)); + env.close(); + { + auto const sleBroker = env.le(brokerKeylet); + BEAST_EXPECT(sleBroker); + if (sleBroker) + { + BEAST_EXPECT(sleBroker->at(~sfDomainID) == domainId1); + } + } + + // Test 4: Updating other fields without specifying DomainID does not + // affect DomainID + env(set(alice, vaultKeylet.key), + kLoanBrokerId(brokerKeylet.key), + kData("test_data"), + kDebtMaximum(Number(1000))); + env.close(); + { + auto const sleBroker = env.le(brokerKeylet); + BEAST_EXPECT(sleBroker); + if (sleBroker) + { + // DomainID should be preserved + BEAST_EXPECT(sleBroker->at(~sfDomainID) == domainId1); + // Other fields should be updated + BEAST_EXPECT(sleBroker->isFieldPresent(sfData)); + BEAST_EXPECT(sleBroker->at(~sfDebtMaximum) == Number(1000)); + } + } + + // Test 5: Updating only DomainID does not affect other fields + env(set(alice, vaultKeylet.key), kLoanBrokerId(brokerKeylet.key), kDomainId(domainId2)); + env.close(); + { + auto const sleBroker = env.le(brokerKeylet); + BEAST_EXPECT(sleBroker); + if (sleBroker) + { + // DomainID should be updated + BEAST_EXPECT(sleBroker->at(~sfDomainID) == domainId2); + // Other fields should be preserved + BEAST_EXPECT(sleBroker->isFieldPresent(sfData)); + BEAST_EXPECT(sleBroker->at(~sfDebtMaximum) == Number(1000)); + } + } + + // Test 6: Cannot set tfLoanBrokerPrivate when modifying (flag is immutable) + env(set(alice, vaultKeylet.key, tfLoanBrokerPrivate), + kLoanBrokerId(brokerKeylet.key), + Ter(temINVALID)); + + // Test 7: 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 DomainID on public broker + env(set(alice, vaultKeylet.key), + kLoanBrokerId(publicBrokerKeylet.key), + kDomainId(domainId1), + Ter(tecNO_PERMISSION)); + + // Cannot set DomainID to even zero on public broker + env(set(alice, vaultKeylet.key), + kLoanBrokerId(publicBrokerKeylet.key), + kDomainId(beast::kZero), + Ter(tecNO_PERMISSION)); + 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 const 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), kDomainId(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::kCounterparty(alice)(env, setTx); + loan::kInterestRate(TenthBips32{1000})(env, setTx); + loan::kPaymentTotal(2)(env, setTx); + loan::kPaymentInterval(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), kLoanBrokerId(brokerKeylet.key), kDomainId(beast::kZero)); + 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::kCounterparty(alice)(env, setTx); + loan::kInterestRate(TenthBips32{1000})(env, setTx); + loan::kPaymentTotal(2)(env, setTx); + loan::kPaymentInterval(100)(env, setTx); + env(setTx, Ter(tecNO_AUTH)); + + BEAST_EXPECT(!env.le(loanKeylet2)); + } + + // Set the DomainID back + env(set(alice, vaultKeylet.key), kLoanBrokerId(brokerKeylet.key), kDomainId(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::kCounterparty(alice)(env, setTx); + loan::kInterestRate(TenthBips32{1000})(env, setTx); + loan::kPaymentTotal(2)(env, setTx); + loan::kPaymentInterval(100)(env, setTx); + env(setTx); + env.close(); + + BEAST_EXPECT(env.le(loanKeylet3)); + } + } + + void + testPrivateBrokerRejectsNonMember() + { + testcase("Private broker rejects borrower not in permissioned domain"); + 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 const 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 creds{{credIssuer, credType}}; + env(pdomain::setTx(credIssuer, creds)); + env.close(); + auto const domainId = pdomain::getNewDomain(env.meta()); + + // Only bob gets credentials; carol does not + 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), kDomainId(domainId)); + env.close(); + + // Deposit cover into broker + env(coverDeposit(alice, brokerKeylet.key, asset(1000))); + env.close(); + + // Carol (no credentials) cannot create a 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::kCounterparty(alice)(env, setTx); + loan::kInterestRate(TenthBips32{1000})(env, setTx); + loan::kPaymentTotal(2)(env, setTx); + loan::kPaymentInterval(100)(env, setTx); + env(setTx, Ter(tecNO_AUTH)); + } + + // Bob (has credentials) can create a 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::kCounterparty(alice)(env, setTx); + loan::kInterestRate(TenthBips32{1000})(env, setTx); + loan::kPaymentTotal(2)(env, setTx); + loan::kPaymentInterval(100)(env, setTx); + env(setTx); + env.close(); + + BEAST_EXPECT(env.le(loanKeylet)); + } + } + public: void run() override @@ -2269,6 +2731,11 @@ class LoanBroker_test : public beast::unit_test::Suite testLoanBrokerDeleteFrozenIOU(all_); testLoanBrokerDeleteFrozenIOU(all_ - fixCleanup3_2_0); + testPrivateLoanBroker(); + testPrivateLoanBrokerModify(); + testPrivateBrokerUnsetDomainBlocksLoans(); + testPrivateBrokerRejectsNonMember(); + // 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 c3b58502311..3ba4546e090 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -8702,6 +8702,301 @@ class Loan_test : public beast::unit_test::Suite }); } + 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 const 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), kDomainId(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::kCounterparty(alice)(env, setTx); + loan::kInterestRate(TenthBips32{1000})(env, setTx); + loan::kPaymentTotal(2)(env, setTx); + loan::kPaymentInterval(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::kCounterparty(alice)(env, setTx); + loan::kInterestRate(TenthBips32{1000})(env, setTx); + loan::kPaymentTotal(2)(env, setTx); + loan::kPaymentInterval(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::kCounterparty(alice)(env, setTx); + loan::kInterestRate(TenthBips32{1000})(env, setTx); + loan::kPaymentTotal(2)(env, setTx); + loan::kPaymentInterval(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::kCounterparty(alice)(env, setTx); + loan::kInterestRate(TenthBips32{1000})(env, setTx); + loan::kPaymentTotal(2)(env, setTx); + loan::kPaymentInterval(100)(env, setTx); + env(setTx); + env.close(); + + BEAST_EXPECT(env.le(loanKeylet)); + } + + // Test 5: When the broker owner submits, the domain check must validate + // the borrower (the counterparty), not the submitting account. The + // broker owner (alice) is the domain owner and holds credentials, so a + // bug that checked the submitter instead of the borrower would let a + // borrower without credentials (carol) through. + { + auto setTx = env.jt(loan::set(alice, brokerKeylet.key, asset(100).number())); + Sig(sfCounterpartySignature, carol)(env, setTx); + Fee{env.current()->fees().base * 2}(env, setTx); + loan::kCounterparty(carol)(env, setTx); + loan::kInterestRate(TenthBips32{1000})(env, setTx); + loan::kPaymentTotal(2)(env, setTx); + loan::kPaymentInterval(100)(env, setTx); + env(setTx, Ter(tecNO_AUTH)); + } + + // Test 6: Broker owner submits with a credentialed borrower (bob) as + // counterparty. This must succeed, confirming the domain check uses the + // borrower's credentials regardless of who submits. + { + auto const loanKeylet = keylet::loan(brokerKeylet.key, 2); + auto setTx = env.jt(loan::set(alice, brokerKeylet.key, asset(100).number())); + Sig(sfCounterpartySignature, bob)(env, setTx); + Fee{env.current()->fees().base * 2}(env, setTx); + loan::kCounterparty(bob)(env, setTx); + loan::kInterestRate(TenthBips32{1000})(env, setTx); + loan::kPaymentTotal(2)(env, setTx); + loan::kPaymentInterval(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 const 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), kDomainId(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::kCounterparty(alice)(env, setTx); + loan::kInterestRate(TenthBips32{1000})(env, setTx); + loan::kPaymentTotal(2)(env, setTx); + loan::kPaymentInterval(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)); + + // Capture the pre-payment loan state and borrower balance so we can + // confirm the payment is genuinely applied, not merely that the loan + // object survives. + auto const bobBalanceBefore = env.balance(bob, asset).value(); + auto const sleLoanBefore = env.le(loanKeylet); + if (!BEAST_EXPECT(sleLoanBefore)) + return; + auto const paymentRemainingBefore = sleLoanBefore->at(sfPaymentRemaining); + auto const principalBefore = sleLoanBefore->at(sfPrincipalOutstanding); + auto const totalValueBefore = sleLoanBefore->at(sfTotalValueOutstanding); + + // 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); + + // Verify the payment was actually applied: the borrower's IOU balance + // dropped (fees are charged separately in XRP), one payment period was + // consumed, and the loan's outstanding figures decreased. + BEAST_EXPECT(env.balance(bob, asset).value() < bobBalanceBefore); + if (BEAST_EXPECT(sleLoan)) + { + BEAST_EXPECT(sleLoan->at(sfPaymentRemaining) == paymentRemainingBefore - 1); + BEAST_EXPECT(sleLoan->at(sfPrincipalOutstanding) < principalBefore); + BEAST_EXPECT(sleLoan->at(sfTotalValueOutstanding) < totalValueBefore); + } + + // 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::kCounterparty(alice)(env, setTx); + loan::kInterestRate(TenthBips32{1000})(env, setTx); + loan::kPaymentTotal(2)(env, setTx); + loan::kPaymentInterval(100)(env, setTx); + env(setTx, Ter(tecNO_AUTH)); + + BEAST_EXPECT(!env.le(newLoanKeylet)); + } + } + void runAmendmentIndependent() { @@ -8726,6 +9021,9 @@ class Loan_test : public beast::unit_test::Suite testBugInterestDueDeltaCrash(); testFullLifecycleVaultPnLNearZeroRate(); testLoanSetNearZeroInterestRateSucceeds(); + + testPrivateBrokerCredentials(); + testPrivateBrokerLoanPayAfterCredentialRevoked(); } // Tests run under each entry in amendmentCombinations(). diff --git a/src/test/jtx/TestHelpers.h b/src/test/jtx/TestHelpers.h index 27c54d830be..71fe8446ce9 100644 --- a/src/test/jtx/TestHelpers.h +++ b/src/test/jtx/TestHelpers.h @@ -870,6 +870,8 @@ auto const kCoverRateLiquidation = auto const kDestination = JTxFieldWrapper(sfDestination); +auto const kDomainId = JTxFieldWrapper(sfDomainID); + } // namespace loanBroker /* Loan */ diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/LoanBrokerTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/LoanBrokerTests.cpp index fba8c539d2c..be63f271e37 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/LoanBrokerTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/LoanBrokerTests.cpp @@ -37,6 +37,7 @@ TEST(LoanBrokerTests, BuilderSettersRoundTrip) auto const coverAvailableValue = canonical_NUMBER(); auto const coverRateMinimumValue = canonical_UINT32(); auto const coverRateLiquidationValue = canonical_UINT32(); + auto const domainIDValue = canonical_UINT256(); LoanBrokerBuilder builder{ previousTxnIDValue, @@ -58,6 +59,7 @@ TEST(LoanBrokerTests, BuilderSettersRoundTrip) builder.setCoverAvailable(coverAvailableValue); builder.setCoverRateMinimum(coverRateMinimumValue); builder.setCoverRateLiquidation(coverRateLiquidationValue); + builder.setDomainID(domainIDValue); builder.setLedgerIndex(index); builder.setFlags(0x1u); @@ -186,6 +188,14 @@ TEST(LoanBrokerTests, BuilderSettersRoundTrip) EXPECT_TRUE(entry.hasCoverRateLiquidation()); } + { + auto const& expected = domainIDValue; + auto const actualOpt = entry.getDomainID(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfDomainID"); + EXPECT_TRUE(entry.hasDomainID()); + } + EXPECT_TRUE(entry.hasLedgerIndex()); auto const ledgerIndex = entry.getLedgerIndex(); ASSERT_TRUE(ledgerIndex.has_value()); @@ -216,6 +226,7 @@ TEST(LoanBrokerTests, BuilderFromSleRoundTrip) auto const coverAvailableValue = canonical_NUMBER(); auto const coverRateMinimumValue = canonical_UINT32(); auto const coverRateLiquidationValue = canonical_UINT32(); + auto const domainIDValue = canonical_UINT256(); auto sle = std::make_shared(LoanBroker::entryType, index); @@ -236,6 +247,7 @@ TEST(LoanBrokerTests, BuilderFromSleRoundTrip) sle->at(sfCoverAvailable) = coverAvailableValue; sle->at(sfCoverRateMinimum) = coverRateMinimumValue; sle->at(sfCoverRateLiquidation) = coverRateLiquidationValue; + sle->at(sfDomainID) = domainIDValue; LoanBrokerBuilder builderFromSle{sle}; EXPECT_TRUE(builderFromSle.validate()); @@ -440,6 +452,19 @@ TEST(LoanBrokerTests, BuilderFromSleRoundTrip) expectEqualField(expected, *fromBuilderOpt, "sfCoverRateLiquidation"); } + { + auto const& expected = domainIDValue; + + auto const fromSleOpt = entryFromSle.getDomainID(); + auto const fromBuilderOpt = entryFromBuilder.getDomainID(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfDomainID"); + expectEqualField(expected, *fromBuilderOpt, "sfDomainID"); + } + EXPECT_EQ(entryFromSle.getKey(), index); EXPECT_EQ(entryFromBuilder.getKey(), index); } @@ -526,5 +551,7 @@ TEST(LoanBrokerTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getCoverRateMinimum().has_value()); EXPECT_FALSE(entry.hasCoverRateLiquidation()); EXPECT_FALSE(entry.getCoverRateLiquidation().has_value()); + EXPECT_FALSE(entry.hasDomainID()); + EXPECT_FALSE(entry.getDomainID().has_value()); } } diff --git a/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerSetTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerSetTests.cpp index bcf0a65755a..bcb5c03abdf 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerSetTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerSetTests.cpp @@ -36,6 +36,7 @@ TEST(TransactionsLoanBrokerSetTests, BuilderSettersRoundTrip) auto const debtMaximumValue = canonical_NUMBER(); auto const coverRateMinimumValue = canonical_UINT32(); auto const coverRateLiquidationValue = canonical_UINT32(); + auto const domainIDValue = canonical_UINT256(); LoanBrokerSetBuilder builder{ accountValue, @@ -51,6 +52,7 @@ TEST(TransactionsLoanBrokerSetTests, BuilderSettersRoundTrip) builder.setDebtMaximum(debtMaximumValue); builder.setCoverRateMinimum(coverRateMinimumValue); builder.setCoverRateLiquidation(coverRateLiquidationValue); + builder.setDomainID(domainIDValue); auto tx = builder.build(publicKey, secretKey); @@ -122,6 +124,14 @@ TEST(TransactionsLoanBrokerSetTests, BuilderSettersRoundTrip) EXPECT_TRUE(tx.hasCoverRateLiquidation()); } + { + auto const& expected = domainIDValue; + auto const actualOpt = tx.getDomainID(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfDomainID should be present"; + expectEqualField(expected, *actualOpt, "sfDomainID"); + EXPECT_TRUE(tx.hasDomainID()); + } + } // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, @@ -145,6 +155,7 @@ TEST(TransactionsLoanBrokerSetTests, BuilderFromStTxRoundTrip) auto const debtMaximumValue = canonical_NUMBER(); auto const coverRateMinimumValue = canonical_UINT32(); auto const coverRateLiquidationValue = canonical_UINT32(); + auto const domainIDValue = canonical_UINT256(); // Build an initial transaction LoanBrokerSetBuilder initialBuilder{ @@ -160,6 +171,7 @@ TEST(TransactionsLoanBrokerSetTests, BuilderFromStTxRoundTrip) initialBuilder.setDebtMaximum(debtMaximumValue); initialBuilder.setCoverRateMinimum(coverRateMinimumValue); initialBuilder.setCoverRateLiquidation(coverRateLiquidationValue); + initialBuilder.setDomainID(domainIDValue); auto initialTx = initialBuilder.build(publicKey, secretKey); @@ -226,6 +238,13 @@ TEST(TransactionsLoanBrokerSetTests, BuilderFromStTxRoundTrip) expectEqualField(expected, *actualOpt, "sfCoverRateLiquidation"); } + { + auto const& expected = domainIDValue; + auto const actualOpt = rebuiltTx.getDomainID(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfDomainID should be present"; + expectEqualField(expected, *actualOpt, "sfDomainID"); + } + } // 3) Verify wrapper throws when constructed from wrong transaction type. @@ -295,6 +314,8 @@ TEST(TransactionsLoanBrokerSetTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getCoverRateMinimum().has_value()); EXPECT_FALSE(tx.hasCoverRateLiquidation()); EXPECT_FALSE(tx.getCoverRateLiquidation().has_value()); + EXPECT_FALSE(tx.hasDomainID()); + EXPECT_FALSE(tx.getDomainID().has_value()); } }