From d5bb723e04c7e971ff8729085232a89bca413aee Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 14 Jul 2026 20:02:10 +0100 Subject: [PATCH] Fix issues --- .../xrpl/protocol/detail/transactions.macro | 2 +- include/xrpl/tx/invariants/VaultInvariant.h | 1 + include/xrpl/tx/transactors/lending/LoanSet.h | 4 +- src/libxrpl/tx/invariants/VaultInvariant.cpp | 121 +++++++- .../tx/transactors/lending/LoanSet.cpp | 76 +++-- src/test/app/Loan_test.cpp | 275 +++++++++++++++++- src/test/jtx/TestHelpers.h | 9 + src/test/jtx/impl/TestHelpers.cpp | 11 + 8 files changed, 454 insertions(+), 45 deletions(-) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 870afdb161..8c6f9132c2 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1052,7 +1052,7 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet, TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, Delegation::NotDelegable, featureLendingProtocol, - NoPriv, ({ + MayModifyVault, ({ {sfLoanID, SoeRequired}, })) diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index f6d12676a8..b3984548c8 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -96,6 +96,7 @@ class ValidVault Number principalOutstanding = 0; Number totalValueOutstanding = 0; Number managementFeeOutstanding = 0; + uint32_t flags = 0; // Interest booked to the vault at loan creation: the portion of the // total value owed that is neither principal nor broker management fee. diff --git a/include/xrpl/tx/transactors/lending/LoanSet.h b/include/xrpl/tx/transactors/lending/LoanSet.h index 0f3a1226eb..03374848d0 100644 --- a/include/xrpl/tx/transactors/lending/LoanSet.h +++ b/include/xrpl/tx/transactors/lending/LoanSet.h @@ -24,9 +24,11 @@ class LoanSet : public Transactor private: static std::uint32_t getStartDate(ReadView const& view, STTx const& tx); + static bool + isTwoStepFlowEnabled(Rules const& rules); /* Returns true if the transaction is using the two-step flow. */ static bool - isTwoStepFlow(STTx const& tx, Rules const& rules); + isTwoStepFlow(STTx const& tx); /* Returns true if the transaction is using the one-step flow. */ static bool isOneStepFlow(STTx const& tx); diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index cfb058dfa0..bf93e373b1 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -87,6 +87,7 @@ ValidVault::Loan::make(SLE const& from) self.principalOutstanding = from.at(sfPrincipalOutstanding); self.totalValueOutstanding = from.at(sfTotalValueOutstanding); self.managementFeeOutstanding = from.at(sfManagementFeeOutstanding); + self.flags = from.getFlags(); return self; } @@ -1110,11 +1111,22 @@ ValidVault::finalize( !beforeVault_.empty(), "xrpl::ValidVault::finalize : loan set updated a vault"); auto const& beforeVault = beforeVault_[0]; + // A loan set must create exactly one loan object; the interest + // it books is the only permitted change to assets outstanding. + if (afterLoan_.size() != 1 || !beforeLoan_.empty()) + { + JLOG(j.fatal()) << // + "Invariant failed: loan set must create exactly one loan"; + return false; // That's all we can do + } + auto const& loan = afterLoan_[0]; + auto const isPendingLoan = (loan.flags & lsfLoanPending) != 0; + // Funding a loan moves the requested principal out of the vault // pseudo-account to the borrower (and, if any, the origination // fee to the broker owner). auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); - if (!maybeVaultDeltaAssets) + if (!maybeVaultDeltaAssets && !isPendingLoan) { JLOG(j.fatal()) << // "Invariant failed: loan set must change vault balance"; @@ -1132,7 +1144,7 @@ ValidVault::finalize( auto const vaultDeltaAssets = roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale); - if (vaultDeltaAssets != principalDelta) + if (vaultDeltaAssets != principalDelta && !isPendingLoan) { JLOG(j.fatal()) << // "Invariant failed: loan set must decrease vault balance " @@ -1150,16 +1162,6 @@ ValidVault::finalize( result = false; } - // A loan set must create exactly one loan object; the interest - // it books is the only permitted change to assets outstanding. - if (afterLoan_.size() != 1 || !beforeLoan_.empty()) - { - JLOG(j.fatal()) << // - "Invariant failed: loan set must create exactly one loan"; - return false; // That's all we can do - } - auto const& loan = afterLoan_[0]; - // The created loan must record exactly the principal the vault // released. Otherwise the borrower's claim (and thus the assets // booked back to the vault on repayment) is decoupled from the @@ -1536,6 +1538,101 @@ ValidVault::finalize( return result; } + case ttLOAN_DELETE: { + bool result = true; + + XRPL_ASSERT( + !beforeVault_.empty(), + "xrpl::ValidVault::finalize : loan delete updated a vault"); + auto const& beforeVault = beforeVault_[0]; + + // Only the deletion of a pending loan touches the vault: it + // reverses the bookkeeping LoanSet performed at proposal time. + // The reserved principal returns to the available pool and the + // booked interest is removed from assets outstanding. No funds + // move, so the vault (pseudo-account) balance must not change. + // (Deleting an active loan never modifies the vault, so it does + // not reach this switch.) + auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); + auto const vaultDelta = maybeVaultDeltaAssets.value_or( + DeltaInfo{.delta = kZero, .scale = scale(afterVault.assetsTotal, vaultAsset)}); + + // Get the posterior scale to round calculations to + auto const minScale = computeVaultMinScale(vaultDelta, view.rules()); + + auto const vaultDeltaAssets = roundToAsset(vaultAsset, vaultDelta.delta, minScale); + if (vaultDeltaAssets != kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan delete must not change vault " + "balance"; + result = false; + } + + // A pending loan delete removes exactly the loan being deleted; + // its principal outstanding is returned to the available pool. + if (beforeLoan_.size() != 1 || !afterLoan_.empty()) + { + JLOG(j.fatal()) << // + "Invariant failed: loan delete must delete exactly one " + "loan"; + return false; // That's all we can do + } + auto const& loan = beforeLoan_[0]; + + auto const principalDelta = + roundToAsset(vaultAsset, loan.principalOutstanding, minScale); + + // The reserved principal (held back when the pending loan was + // created) must be released by exactly the loan's principal. + auto const assetsReservedDelta = roundToAsset( + vaultAsset, afterVault.assetsReserved - beforeVault.assetsReserved, minScale); + if (assetsReservedDelta != -principalDelta) + { + JLOG(j.fatal()) << // + "Invariant failed: loan delete must decrease assets " + "reserved by the principal outstanding"; + result = false; + } + + // That same principal returns to the available pool. + auto const assetAvailableDelta = roundToAsset( + vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); + if (assetAvailableDelta != principalDelta) + { + JLOG(j.fatal()) << // + "Invariant failed: loan delete must increase assets " + "available by the principal outstanding"; + result = false; + } + + // The interest booked at loan creation is reversed: assets + // outstanding fall by exactly the interest due removed with the + // loan. + auto const assetsTotalDelta = roundToAsset( + vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); + auto const interestDue = roundToAsset(vaultAsset, loan.interestDue(), minScale); + if (assetsTotalDelta != -interestDue) + { + JLOG(j.fatal()) << // + "Invariant failed: loan delete must decrease assets " + "outstanding by the interest due"; + result = false; + } + + // A loan delete neither mints nor burns vault shares. + if (beforeShares && updatedShares && + beforeShares->sharesTotal != updatedShares->sharesTotal) + { + JLOG(j.fatal()) << // + "Invariant failed: loan delete must not change shares " + "outstanding"; + result = false; + } + + return result; + } + // LCOV_EXCL_START default: UNREACHABLE("xrpl::ValidVault::finalize : unknown transaction type"); diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index b1228c395e..dcaa480948 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -45,7 +45,7 @@ currentLedgerCloseTime(ReadView const& view) std::uint32_t LoanSet::getStartDate(ReadView const& view, STTx const& tx) { - if (isTwoStepFlow(tx, view.rules())) + if (isTwoStepFlow(tx) && isTwoStepFlowEnabled(view.rules())) { return tx[sfStartDate]; } @@ -65,11 +65,14 @@ LoanSet::getFlagsMask(PreflightContext const& ctx) } bool -LoanSet::isTwoStepFlow(STTx const& tx, Rules const& rules) +LoanSet::isTwoStepFlowEnabled(Rules const& rules) { - if (!rules.enabled(featureLendingProtocolV1_1)) - return false; + return rules.enabled(featureLendingProtocolV1_1); +} +bool +LoanSet::isTwoStepFlow(STTx const& tx) +{ // The two-step (Borrower) flow is started when the LoanSet names a // Borrower and a StartDate but carries neither a Counterparty nor a // CounterpartySignature. @@ -107,41 +110,54 @@ LoanSet::preflight(PreflightContext const& ctx) return std::nullopt; }(); - bool twoStepFlow = isTwoStepFlow(tx, ctx.rules); - bool oneStepFlow = isOneStepFlow(tx); + bool const twoStepFlowEnabled = isTwoStepFlowEnabled(ctx.rules); + bool const twoStepFlow = isTwoStepFlow(tx); + bool const oneStepFlow = isOneStepFlow(tx); // In the two-step (Borrower) flow introduced by V1.1, a CounterpartySignature // is not required even for non-batch transactions. The immediate flow still // requires one. - if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig && !twoStepFlow) + if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig && !twoStepFlowEnabled) { JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature."; return temBAD_SIGNER; } - if (!twoStepFlow && !oneStepFlow) + if (twoStepFlowEnabled) { - JLOG(ctx.j.warn()) << "LoanSet transaction must specify either a Borrower with a " - "StartDate or a CounterpartySignature."; - return temINVALID; - } + if (!twoStepFlow && !oneStepFlow) + { + JLOG(ctx.j.warn()) << "LoanSet transaction must specify either a Borrower with a " + "StartDate or a CounterpartySignature."; + return temINVALID; + } - if (oneStepFlow) + if (oneStepFlow) + { + if (tx.isFieldPresent(sfBorrower) || tx.isFieldPresent(sfStartDate)) + { + JLOG(ctx.j.warn()) << "LoanSet transaction cannot specify both a Borrower with a " + "StartDate and a CounterpartySignature."; + return temINVALID; + } + } + + if (twoStepFlow) + { + if (tx.isFieldPresent(sfCounterpartySignature)) + { + JLOG(ctx.j.warn()) << "LoanSet transaction cannot specify both a Borrower with a " + "StartDate and a CounterpartySignature."; + return temINVALID; + } + } + } + else { if (tx.isFieldPresent(sfBorrower) || tx.isFieldPresent(sfStartDate)) { - JLOG(ctx.j.warn()) << "LoanSet transaction cannot specify both a Borrower with a " - "StartDate and a CounterpartySignature."; - return temINVALID; - } - } - - if (twoStepFlow) - { - if (tx.isFieldPresent(sfCounterpartySignature)) - { - JLOG(ctx.j.warn()) << "LoanSet transaction cannot specify both a Borrower with a " - "StartDate and a CounterpartySignature."; - return temINVALID; + JLOG(ctx.j.warn()) << "LoanSet transaction cannot specify a Borrower with a " + "StartDate without the two-step flow being enabled."; + return temDISABLED; } } @@ -212,7 +228,7 @@ LoanSet::checkSign(PreclaimContext const& ctx) // In the two-step (Borrower) flow introduced by V1.1 there is no // counterparty, so there is no CounterpartySignature to check. - if (isTwoStepFlow(ctx.tx, ctx.view.rules())) + if (isTwoStepFlowEnabled(ctx.view.rules()) && isTwoStepFlow(ctx.tx)) return tesSUCCESS; // Counter signer is optional. If it's not specified, it's assumed to be @@ -343,7 +359,7 @@ LoanSet::preclaim(PreclaimContext const& ctx) return tecNO_ENTRY; } auto const brokerOwner = brokerSle->at(sfOwner); - bool const twoStepFlow = isTwoStepFlow(tx, ctx.view.rules()); + bool const twoStepFlow = isTwoStepFlow(tx); // Determine the Borrower and validate the submitter's permission. In the // two-step flow the LoanBroker owner proposes the loan on behalf of the @@ -422,7 +438,7 @@ LoanSet::preclaim(PreclaimContext const& ctx) if (tx[sfStartDate] <= currentLedgerCloseTime(ctx.view)) { JLOG(ctx.j.warn()) << "Start date is in the past."; - return tecTOO_SOON; + return tecEXPIRED; } } @@ -434,7 +450,7 @@ LoanSet::doApply() { auto const& tx = ctx_.tx; auto& view = ctx_.view(); - bool const twoStepFlow = isTwoStepFlow(tx, view.rules()); + bool const twoStepFlow = isTwoStepFlow(tx); auto const brokerID = tx[sfLoanBrokerID]; diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 3e62af48ff..95ea0b9921 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -3461,6 +3461,277 @@ protected: nullptr); } + // Exercises the two-step (LendingProtocolV1_1) flow, where the LoanBroker + // owner proposes a pending Loan (LoanSet with a Borrower and StartDate) that + // the Borrower later accepts (LoanAccept) or that either party cancels + // (LoanDelete). Requires the LendingProtocolV1_1 amendment. + void + testTwoStep(FeatureBitset features) + { + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + + Account const lender{"lender"}; // Vault + LoanBroker owner + Account const borrower{"borrower"}; + Account const evan{"evan"}; // unrelated third party + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + // Loan terms shared across the scenarios. + Number const principal = xrpAsset(200).number(); + auto const interest = TenthBips32{50'000}; + std::uint32_t const payTotal = 10; + std::uint32_t const payInterval = 200; + + // Build a funded environment with a Vault + LoanBroker owned by + // `lender`, and return the broker. + auto const makeBroker = [&](Env& env) -> BrokerInfo { + env.fund(XRP(100'000'000), noripple(lender)); + env.fund(XRP(1'000'000), borrower, evan); + env.close(); + return createVaultAndBroker(env, xrpAsset, lender); + }; + + // The keylet of the next loan the broker will create. + auto const nextLoanKeylet = [&](Env& env, BrokerInfo const& broker) -> Keylet { + auto const brokerSle = env.le(broker.brokerKeylet()); + return keylet::loan(broker.brokerID, brokerSle->at(sfLoanSequence)); + }; + + // A StartDate comfortably in the future. + auto const futureStart = [&](Env& env) -> std::uint32_t { + return (env.now() + 1h).time_since_epoch().count(); + }; + + // Snapshot of the vault's asset accounting. + struct VaultAmounts + { + Number available; + Number reserved; + Number total; + }; + auto const readVault = [&](Env& env, BrokerInfo const& broker) -> VaultAmounts { + auto const v = env.le(broker.vaultKeylet()); + return { + .available = v->at(sfAssetsAvailable), + .reserved = v->at(sfAssetsReserved), + .total = v->at(sfAssetsTotal)}; + }; + + // Submit a valid two-step proposal from `proposer` on behalf of + // `theBorrower`, with the supplied StartDate and any extra functors. + auto const propose = [&](Env& env, + BrokerInfo const& broker, + Account const& proposer, + Account const& theBorrower, + std::uint32_t startDate, + auto const&... extra) { + env(set(proposer, broker.brokerID, principal), + kBorrower(theBorrower), + kStartDate(startDate), + kInterestRate(interest), + kPaymentTotal(payTotal), + kPaymentInterval(payInterval), + extra...); + }; + + auto const featureEnabled = [&]() -> bool { + return (features & featureLendingProtocolV1_1).any(); + }(); + + if (!featureEnabled) + { + testcase("Two-step: rejected as before"); + + Env env(*this, features); + auto const broker = makeBroker(env); + propose(env, broker, lender, borrower, futureStart(env), Ter(temBAD_SIGNER)); + + // Rest of the tests are not applicable + return; + } + + { + testcase("Two-step: propose then accept"); + + Env env(*this, features); + auto const broker = makeBroker(env); + + auto const vault0 = readVault(env, broker); + auto const lenderOwners0 = env.ownerCount(lender); + auto const borrowerOwners0 = env.ownerCount(borrower); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, futureStart(env)); + env.close(); + + // The proposal creates a pending Loan, linked only into the broker + // pseudo-account's directory. + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(loan->isFlag(lsfLoanPending)); + BEAST_EXPECT(loan->at(sfBorrower) == borrower.id()); + BEAST_EXPECT(loan->isFieldPresent(sfLoanBrokerNode)); + BEAST_EXPECT(!loan->isFieldPresent(sfOwnerNode)); + } + + // The owner reserve is charged to the broker owner, not the + // borrower. + BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0 + 1); + BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwners0); + + // Vault bookkeeping: Available -= P, Reserved += P, Total += + // InterestDue. + auto const vault1 = readVault(env, broker); + BEAST_EXPECT(vault1.available == vault0.available - principal); + BEAST_EXPECT(vault1.reserved == vault0.reserved + principal); + BEAST_EXPECT(vault1.total > vault0.total); + Number const interestDue = vault1.total - vault0.total; + + // Capture pre-acceptance balances to verify disbursement. + auto const vaultPseudo = [&]() { + auto const v = env.le(broker.vaultKeylet()); + return Account("vault pseudo-account", v->at(sfAccount)); + }(); + STAmount const pseudoBal0 = env.balance(vaultPseudo).value(); + STAmount const borrowerBal0 = env.balance(borrower).value(); + + env(accept(borrower, loanKeylet.key)); + env.close(); + + // The loan is now active and linked into the borrower's directory. + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(!loan->isFlag(lsfLoanPending)); + BEAST_EXPECT(loan->isFieldPresent(sfLoanBrokerNode)); + BEAST_EXPECT(loan->isFieldPresent(sfOwnerNode)); + } + + // The reserve is swapped from the broker owner to the borrower. + BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0); + BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwners0 + 1); + + // Reserved principal is released; Available and Total are unchanged + // from the proposal. + auto const vault2 = readVault(env, broker); + BEAST_EXPECT(vault2.reserved == vault0.reserved); + BEAST_EXPECT(vault2.available == vault0.available - principal); + BEAST_EXPECT(vault2.total == vault0.total + interestDue); + + // The principal is disbursed from the vault pseudo-account to the + // borrower (origination fee is zero, so the borrower receives it + // all, less the transaction fee it paid). + BEAST_EXPECT(env.balance(vaultPseudo).value() == pseudoBal0 - xrpAsset(200).value()); + BEAST_EXPECT(env.balance(borrower).value() > borrowerBal0); + } + + { + testcase("Two-step: proposal failures"); + + Env env(*this, features); + auto const epoch = env.now(); + auto const broker = makeBroker(env); + + // The submitter must be the LoanBroker owner + propose(env, broker, evan, borrower, futureStart(env), Ter(tecNO_PERMISSION)); + + // The StartDate must be in the future. + std::uint32_t const pastDate = epoch.time_since_epoch().count(); + propose(env, broker, lender, borrower, pastDate, Ter(tecEXPIRED)); + } + + { + testcase("Two-step: LoanAccept validation"); + + Env env(*this, features); + auto const broker = makeBroker(env); + + // Zero LoanID fails preflight. + env(accept(borrower, uint256{}), Ter(temINVALID)); + + // A LoanID that does not resolve to a Loan object. + env(accept(borrower, keylet::loan(broker.brokerID, 999).key), Ter(tecNO_ENTRY)); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, futureStart(env)); + env.close(); + + // Only the borrower may accept. + env(accept(evan, loanKeylet.key), Ter(tecNO_PERMISSION)); + env(accept(lender, loanKeylet.key), Ter(tecNO_PERMISSION)); + + // The borrower accepts successfully. + env(accept(borrower, loanKeylet.key)); + env.close(); + + // The loan is no longer pending, so it cannot be accepted again. + env(accept(borrower, loanKeylet.key), Ter(tecNO_PERMISSION)); + } + + { + testcase("Two-step: LoanAccept after expiry"); + + Env env(*this, features); + auto const broker = makeBroker(env); + + auto const loanKeylet = nextLoanKeylet(env, broker); + std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count(); + propose(env, broker, lender, borrower, startDate); + env.close(); + + // Advance the ledger beyond the StartDate. + env.close(NetClock::time_point{NetClock::duration{startDate}} + 1h); + + env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED)); + } + + // Deleting a pending loan reverses the proposal-time bookkeeping and + // releases the broker owner's reserve. It can be done by either the + // broker owner or the borrower. + auto const testDeletePending = [&](Account const& deleter) { + Env env(*this, features); + auto const broker = makeBroker(env); + + auto const vault0 = readVault(env, broker); + auto const lenderOwners0 = env.ownerCount(lender); + auto const borrowerOwners0 = env.ownerCount(borrower); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, futureStart(env)); + env.close(); + + BEAST_EXPECT(env.le(loanKeylet)); + BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0 + 1); + + // An unrelated account cannot delete the loan. + env(del(evan, loanKeylet.key), Ter(tecNO_PERMISSION)); + + env(del(deleter, loanKeylet.key)); + env.close(); + + // The loan is gone, the reserve is released, and the vault + // bookkeeping is fully reversed. + BEAST_EXPECT(!env.le(loanKeylet)); + BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0); + BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwners0); + + auto const vault1 = readVault(env, broker); + BEAST_EXPECT(vault1.available == vault0.available); + BEAST_EXPECT(vault1.reserved == vault0.reserved); + BEAST_EXPECT(vault1.total == vault0.total); + }; + + { + testcase("Two-step: LoanDelete of pending loan by broker owner"); + testDeletePending(lender); + } + { + testcase("Two-step: LoanDelete of pending loan by borrower"); + testDeletePending(borrower); + } + } + void testLifecycle(FeatureBitset features) { @@ -8560,6 +8831,7 @@ protected: // Lifecycle testLifecycle(features); testLoanSet(features); + testTwoStep(features); testDosLoanPay(features); testSelfLoan(features); @@ -8608,7 +8880,8 @@ public: { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2, featureLendingProtocolV1_1}, + all_)) runAmendmentSensitive(features); } }; diff --git a/src/test/jtx/TestHelpers.h b/src/test/jtx/TestHelpers.h index cb2193db99..33046528c2 100644 --- a/src/test/jtx/TestHelpers.h +++ b/src/test/jtx/TestHelpers.h @@ -919,6 +919,11 @@ set(AccountID const& account, auto const kCounterparty = JTxFieldWrapper(sfCounterparty); +// Two-step (LendingProtocolV1_1) proposal fields. +auto const kBorrower = JTxFieldWrapper(sfBorrower); + +auto const kStartDate = simpleField(sfStartDate); + // For `CounterPartySignature`, use `Sig(sfCounterpartySignature, ...)` auto const kLoanOriginationFee = simpleField(sfLoanOriginationFee); @@ -950,6 +955,10 @@ auto const kGracePeriod = simpleField(sfGracePeriod); json::Value manage(AccountID const& account, uint256 const& loanID, std::uint32_t flags); +// Two-step (LendingProtocolV1_1) acceptance of a pending loan proposal. +json::Value +accept(AccountID const& account, uint256 const& loanID, std::uint32_t flags = 0); + json::Value del(AccountID const& account, uint256 const& loanID, std::uint32_t flags = 0); diff --git a/src/test/jtx/impl/TestHelpers.cpp b/src/test/jtx/impl/TestHelpers.cpp index 95c8a68436..467748d22a 100644 --- a/src/test/jtx/impl/TestHelpers.cpp +++ b/src/test/jtx/impl/TestHelpers.cpp @@ -823,6 +823,17 @@ manage(AccountID const& account, uint256 const& loanID, std::uint32_t flags) return jv; } +json::Value +accept(AccountID const& account, uint256 const& loanID, std::uint32_t flags) +{ + json::Value jv; + jv[sfTransactionType] = jss::LoanAccept; + jv[sfAccount] = to_string(account); + jv[sfLoanID] = to_string(loanID); + jv[sfFlags] = flags; + return jv; +} + json::Value del(AccountID const& account, uint256 const& loanID, std::uint32_t flags) {