diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 7907f2d666..c9ab9d81b9 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -474,6 +474,11 @@ LoanPay::doApply() Number const assetsAvailableBefore = *assetsAvailableProxy; Number const assetsTotalBefore = *assetsTotalProxy; + // AssetsReserved holds funds still in the pseudo-account that are earmarked + // for pending loans awaiting acceptance. LoanPay does not touch it, so the + // invariant is pseudo_balance == AssetsAvailable + AssetsReserved both + // before and after the payment. + Number const assetsReserved = *vaultSle->at(sfAssetsReserved); #if !NDEBUG { Number const pseudoAccountBalanceBefore = accountHolds( @@ -485,7 +490,7 @@ LoanPay::doApply() j_); XRPL_ASSERT_PARTS( - assetsAvailableBefore == pseudoAccountBalanceBefore, + assetsAvailableBefore + assetsReserved == pseudoAccountBalanceBefore, "xrpl::LoanPay::doApply", "vault pseudo balance agrees before"); } @@ -663,7 +668,7 @@ LoanPay::doApply() AuthHandling::IgnoreAuth, j_); XRPL_ASSERT_PARTS( - assetsAvailableAfter == pseudoAccountBalanceAfter, + assetsAvailableAfter + assetsReserved == pseudoAccountBalanceAfter, "xrpl::LoanPay::doApply", "vault pseudo balance agrees after"); } diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 387a0e3db5..b9384fd0c0 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -628,13 +628,17 @@ LoanSet::preflight(PreflightContext const& ctx) return temINVALID_FLAG; } - // Special case for Batch inner transactions + // Special case for Batch inner transactions. A Batch inner LoanSet + // must identify the borrower explicitly, since the inner transaction + // cannot carry a CounterpartySignature. That means either a Counterparty + // (immediate flow) or, once V1.1 enables it, a Borrower (two-step flow). if (tx.isFlag(tfInnerBatchTxn) && ctx.rules.enabled(featureBatchV1_1) && - !tx.isFieldPresent(sfCounterparty)) + !tx.isFieldPresent(sfCounterparty) && + !(isTwoStepFlowEnabled(ctx.rules) && tx.isFieldPresent(sfBorrower))) { auto const parentBatchId = ctx.parentBatchId.value_or(uint256{0}); JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " - << "no Counterparty for inner LoanSet transaction."; + << "no Counterparty or Borrower for inner LoanSet transaction."; return temBAD_SIGNER; } diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 34ac40fb54..ce5f4c2760 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -5266,6 +5266,7 @@ class Vault_test : public beast::unit_test::Suite BEAST_EXPECT(kCheckString(vault, sfAssetsAvailable, "50")); BEAST_EXPECT(kCheckString(vault, sfAssetsMaximum, "1000")); BEAST_EXPECT(kCheckString(vault, sfAssetsTotal, "50")); + BEAST_EXPECT(!vault.isMember(sfAssetsReserved.getJsonName())); BEAST_EXPECT(!vault.isMember(sfLossUnrealized.getJsonName())); auto const strShareID = strHex(sle->at(sfShareMPTID)); @@ -5606,6 +5607,36 @@ class Vault_test : public beast::unit_test::Suite json::Value jv = env.rpc("vault_info", strHex(keylet.key), "0"); BEAST_EXPECT(jv[jss::result][jss::error].asString() == "lgrNotFound"); } + + // vault_info reflects AssetsReserved when the vault holds reserved + // assets. The field is a SoeDefault Number that is elided from the JSON + // when zero (asserted in `check(...)` above); after mutating the SLE to + // a non-zero value the response must expose it as a string matching + // the ledger. + { + testcase("RPC vault_info reflects AssetsReserved when non-zero"); + Number const reserved{25}; + + auto const changed = env.app().getOpenLedger().modify( + [&](OpenView& view, beast::Journal) -> bool { + Sandbox sb(&view, TapNone); + auto v = sb.peek(keylet); + if (!v) + return false; + v->at(sfAssetsReserved) = reserved; + sb.update(v); + sb.apply(view); + return true; + }); + BEAST_EXPECT(changed); + + json::Value jv = env.rpc("vault_info", strHex(keylet.key)); + BEAST_EXPECT(!jv[jss::result].isMember(jss::error)); + auto const& vaultJv = jv[jss::result][jss::vault]; + BEAST_EXPECT(vaultJv.isMember(sfAssetsReserved.getJsonName())); + BEAST_EXPECT( + vaultJv[sfAssetsReserved.getJsonName()].asString() == to_string(reserved)); + } } // RPC coverage: closed-ended vaults must return VaultKind, SubscriptionDate and RedemptionDate diff --git a/src/test/app/lending/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp index cdbeec7a51..5b4260a28c 100644 --- a/src/test/app/lending/LoanLifecycle_test.cpp +++ b/src/test/app/lending/LoanLifecycle_test.cpp @@ -609,6 +609,45 @@ private: } } + // Once V1.1 enables the two-step flow, a Batch inner LoanSet may + // name a Borrower (with a StartDate) instead of a Counterparty: + // the borrower is identified explicitly on the inner tx and no + // CounterpartySignature is required. Preflight must accept it. + if (features[featureLendingProtocolV1_1]) + { + auto const jtx = env.jt( + set(lender, broker.brokerID, principalRequest), + Txflags(tfInnerBatchTxn), + kBorrower(borrower), + kStartDate((env.now() + 1h).time_since_epoch().count())); + if (BEAST_EXPECT(jtx.stx)) + { + PreflightContext const pfCtx( + env.app(), *jtx.stx, uint256{1}, env.current()->rules(), TapBatch, env.journal); + BEAST_EXPECT(Transactor::invokePreflight(pfCtx) == tesSUCCESS); + } + + // A Batch inner LoanSet with Borrower but no StartDate is not a + // valid two-step proposal and no longer masquerades as a + // missing-Counterparty error: it is rejected as temINVALID by + // getLoanFlow, past the Batch-specific check. + auto const jtxNoStart = env.jt( + set(lender, broker.brokerID, principalRequest), + Txflags(tfInnerBatchTxn), + kBorrower(borrower)); + if (BEAST_EXPECT(jtxNoStart.stx)) + { + PreflightContext const pfCtx( + env.app(), + *jtxNoStart.stx, + uint256{1}, + env.current()->rules(), + TapBatch, + env.journal); + BEAST_EXPECT(Transactor::invokePreflight(pfCtx) == temINVALID); + } + } + // Success: a Batch containing an inner LoanSet that names a // Counterparty (but carries no CounterpartySignature) is accepted // when the counterparty signs the outer Batch. The immediate flow's diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index 03f2a8be1c..654b860f4f 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -236,7 +236,7 @@ protected: if (twoStep) { kBorrower(account)(env, jt); - kStartDate (*startDate)(env, jt); + kStartDate(startDate.value())(env, jt); } else { diff --git a/src/test/app/lending/LoanTwoStep_test.cpp b/src/test/app/lending/LoanTwoStep_test.cpp index 6829079d42..5f40602ddb 100644 --- a/src/test/app/lending/LoanTwoStep_test.cpp +++ b/src/test/app/lending/LoanTwoStep_test.cpp @@ -106,6 +106,21 @@ private: .total = v->at(sfAssetsTotal)}; }; + // Snapshot of the LoanBroker's own bookkeeping. + struct BrokerAmounts + { + Number debtTotal; + Number coverAvailable; + std::uint32_t ownerCount{}; + }; + auto const readBroker = [&](Env& env, BrokerInfo const& broker) -> BrokerAmounts { + auto const b = env.le(broker.brokerKeylet()); + return { + .debtTotal = b->at(sfDebtTotal), + .coverAvailable = b->at(sfCoverAvailable), + .ownerCount = b->at(sfOwnerCount)}; + }; + // 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, @@ -123,6 +138,14 @@ private: extra...); }; + // Per spec §4.3, a failed LoanAccept must leave the pending Loan + // intact so the borrower can rectify the issue and retry until the + // StartDate expires. + auto const expectStillPending = [this](Env& env, Keylet const& k) { + if (auto const loan = env.le(k); BEAST_EXPECT(loan)) + BEAST_EXPECT(loan->isFlag(lsfLoanPending)); + }; + auto const featureEnabled = (features & featureLendingProtocolV1_1).any(); if (!featureEnabled) @@ -166,6 +189,7 @@ private: Number const principal = broker.asset(200).number(); auto const vault0 = readVault(env, broker); + auto const broker0 = readBroker(env, broker); auto const lenderOwners0 = env.ownerCount(lender); auto const borrowerOwners0 = env.ownerCount(borrower); @@ -197,6 +221,13 @@ private: BEAST_EXPECT(vault1.total > vault0.total); Number const interestDue = vault1.total - vault0.total; + // Broker bookkeeping: DebtTotal += P + InterestDue, OwnerCount += + // 1, CoverAvailable is untouched by the proposal. + auto const broker1 = readBroker(env, broker); + BEAST_EXPECT(broker1.debtTotal == broker0.debtTotal + principal + interestDue); + BEAST_EXPECT(broker1.ownerCount == broker0.ownerCount + 1); + BEAST_EXPECT(broker1.coverAvailable == broker0.coverAvailable); + // Capture pre-acceptance balances to verify disbursement. auto const vaultPseudo = [&]() { auto const v = env.le(broker.vaultKeylet()); @@ -227,6 +258,13 @@ private: BEAST_EXPECT(vault2.available == vault0.available - principal); BEAST_EXPECT(vault2.total == vault0.total + interestDue); + // Broker bookkeeping: acceptance leaves DebtTotal, OwnerCount, and + // CoverAvailable unchanged from the pending snapshot. + auto const broker2 = readBroker(env, broker); + BEAST_EXPECT(broker2.debtTotal == broker1.debtTotal); + BEAST_EXPECT(broker2.ownerCount == broker1.ownerCount); + BEAST_EXPECT(broker2.coverAvailable == broker1.coverAvailable); + // 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). @@ -236,13 +274,18 @@ private: BEAST_EXPECT(env.balance(borrower, broker.asset).value() > borrowerBal0); } + // Exercise a proposal with a non-zero origination fee, then verify at + // acceptance that the principal leaves the vault pseudo-account, the + // borrower receives the net, and the broker owner receives the fee. + // XRP is excluded because the borrower's LoanAccept fee would perturb + // the exact borrower balance assertion. + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) { - testcase("Two-step: propose then accept with origination fee"); + testcase << "Two-step: propose then accept with origination fee (" + << assetTypeName(assetType) << ")"; - // Use an IOU so the disbursed amounts can be checked exactly, - // without the borrower's XRP transaction fee getting in the way. Env env(*this, features); - auto const broker = makeBroker(env, AssetType::IOU); + auto const broker = makeBroker(env, assetType); Number const principal = broker.asset(200).number(); Number const originationFee = broker.asset(5).number(); @@ -288,6 +331,97 @@ private: BEAST_EXPECT(env.balance(lender, broker.asset).value() == lenderBal0 + feeToOwner); } + { + testcase("Two-step: accepted loan behaves as a normal loan"); + + // Once accepted, a two-step loan is indistinguishable from a + // one-step loan for the rest of its lifecycle: it can be + // impaired, unimpaired, paid, and finally deleted. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + 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(); + + env(accept(borrower, loanKeylet.key)); + env.close(); + + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(!loan->isFlag(lsfLoanPending)); + BEAST_EXPECT(loan->at(sfPaymentRemaining) == payTotal); + } + + // LoanManage: impair then unimpair. + env(manage(lender, loanKeylet.key, tfLoanImpair)); + env.close(); + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + BEAST_EXPECT(loan->isFlag(lsfLoanImpaired)); + + env(manage(lender, loanKeylet.key, tfLoanUnimpair)); + env.close(); + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired)); + + // LoanPay: a regular periodic payment succeeds, then the borrower + // clears the remainder with tfLoanFullPayment. + env.close(NetClock::time_point{NetClock::duration{startDate}} + 1h); + env(pay(borrower, loanKeylet.key, broker.asset(30))); + env.close(); + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + BEAST_EXPECT(loan->at(sfPaymentRemaining) < payTotal); + + // A generous upper bound (2x principal) clears principal + interest. + env(pay(borrower, loanKeylet.key, broker.asset(400), tfLoanFullPayment)); + env.close(); + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0); + + // LoanDelete succeeds once the loan is fully paid. + env(del(borrower, loanKeylet.key)); + env.close(); + BEAST_EXPECT(!env.le(loanKeylet)); + } + + { + testcase("Two-step: LoanPay on accepted loan while another loan is pending"); + + // Regression: LoanPay::doApply's vault-balance invariant used to + // assert AssetsAvailable == pseudo_balance, ignoring + // AssetsReserved. Whenever a pending loan bumped AssetsReserved, + // any LoanPay on an accepted loan would fire the debug assertion. + // The correct invariant is + // pseudo_balance == AssetsAvailable + AssetsReserved, + // and this test locks that in. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + // L1: accepted (borrower) — disburses principal, drains + // AssetsReserved back to 0. + auto const l1Keylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + env(accept(borrower, l1Keylet.key)); + env.close(); + if (auto const l1 = env.le(l1Keylet); BEAST_EXPECT(l1)) + BEAST_EXPECT(!l1->isFlag(lsfLoanPending)); + + // L2: still pending (evan) — leaves AssetsReserved > 0. + propose(env, broker, lender, evan, (env.now() + 1h).time_since_epoch().count()); + env.close(); + if (auto const v = env.le(broker.vaultKeylet()); BEAST_EXPECT(v)) + BEAST_EXPECT(v->at(sfAssetsReserved) > beast::kZero); + + // A payment on L1 must succeed with L2 still pending. Before the + // fix, LoanPay's debug invariant tripped here. + env(pay(borrower, l1Keylet.key, broker.asset(30))); + env.close(); + if (auto const l1 = env.le(l1Keylet); BEAST_EXPECT(l1)) + BEAST_EXPECT(l1->at(sfPaymentRemaining) < payTotal); + } + { testcase("Two-step: proposal failures"); @@ -325,6 +459,20 @@ private: env(set(lender, broker.brokerID, broker.asset(200).number()), kStartDate((env.now() + 1h).time_since_epoch().count()), Ter(temINVALID)); + + // A LoanSet with Borrower and Counterparty is ambiguous. + env(set(lender, broker.brokerID, broker.asset(200).number()), + kBorrower(borrower), + kStartDate((env.now() + 1h).time_since_epoch().count()), + kCounterparty(borrower), + Ter(temINVALID)); + + // A LoanSet with Borrower and CounterpartySignature is ambiguous. + env(set(lender, broker.brokerID, broker.asset(200).number()), + kBorrower(borrower), + kStartDate((env.now() + 1h).time_since_epoch().count()), + Sig(sfCounterpartySignature, borrower), + Ter(temINVALID)); } { @@ -348,6 +496,7 @@ private: // Only the borrower may accept. env(accept(evan, loanKeylet.key), Ter(tecNO_PERMISSION)); env(accept(lender, loanKeylet.key), Ter(tecNO_PERMISSION)); + expectStillPending(env, loanKeylet); // The borrower accepts successfully. env(accept(borrower, loanKeylet.key)); @@ -391,6 +540,42 @@ private: BEAST_EXPECT(!loan->isFlag(lsfLoanPending)); } + // LoanManage::preclaim rejects pending loans before it inspects the + // payment schedule. Guard that ordering by advancing the ledger past + // NextPaymentDueDate + GracePeriod on a still-pending loan: the tx + // must still return tecNO_PERMISSION, never tecTOO_SOON or success. + for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: pending loan rejects LoanManage after due date (" + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + + 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 past StartDate + PaymentInterval + GracePeriod. payInterval + // is 200s and the default GracePeriod is 60s, so +2h from StartDate + // is comfortably past both. + env.close(NetClock::time_point{NetClock::duration{startDate}} + 2h); + + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(loan->isFlag(lsfLoanPending)); + BEAST_EXPECT( + env.now() > + NetClock::time_point{NetClock::duration{ + loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod)}}); + } + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); + } + { testcase("Two-step: LoanAccept after expiry"); @@ -406,6 +591,7 @@ private: env.close(NetClock::time_point{NetClock::duration{startDate}} + 1h); env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED)); + expectStillPending(env, loanKeylet); } { @@ -435,6 +621,7 @@ private: // The proposal has expired, so it can no longer be accepted. env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED)); + expectStillPending(env, loanKeylet); // But it can still be deleted. env(del(lender, loanKeylet.key)); @@ -477,6 +664,159 @@ private: Ter(tecINSUFFICIENT_RESERVE)); } + // The issuer freezes the trust line (IOU) or locks the MPToken (MPT) + // on the vault pseudo-account before LoanSet is submitted. The + // proposal must be rejected by checkLoanFreeze in preclaim, and no + // pending Loan is created. XRP cannot be frozen, so it is excluded. + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: LoanSet with frozen vault pseudo-account (" + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + auto const loanKeylet = nextLoanKeylet(env, broker); + + auto const vaultPseudo = [&]() { + auto const v = env.le(broker.vaultKeylet()); + return Account("vault pseudo-account", v->at(sfAccount)); + }(); + + TER expected = tesSUCCESS; + if (assetType == AssetType::IOU) + { + env(trust(issuer, vaultPseudo[iouCurrency_](0), tfSetFreeze)); + env.close(); + expected = TER{tecFROZEN}; + } + else + { + MPTTester mptt{env, issuer, broker.asset.raw().get().getMptID()}; + mptt.set({.account = issuer, .holder = vaultPseudo, .flags = tfMPTLock}); + env.close(); + expected = TER{tecLOCKED}; + } + + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(expected)); + BEAST_EXPECT(!env.le(loanKeylet)); + } + + // Same as above, but for the LoanBroker pseudo-account (deep freeze). + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: LoanSet with deep frozen broker pseudo-account (" + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + auto const loanKeylet = nextLoanKeylet(env, broker); + + auto const brokerPseudo = [&]() { + auto const b = env.le(broker.brokerKeylet()); + return Account("broker pseudo-account", b->at(sfAccount)); + }(); + + TER expected = tesSUCCESS; + if (assetType == AssetType::IOU) + { + env(trust(issuer, brokerPseudo[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze)); + env.close(); + expected = TER{tecFROZEN}; + } + else + { + MPTTester mptt{env, issuer, broker.asset.raw().get().getMptID()}; + mptt.set({.account = issuer, .holder = brokerPseudo, .flags = tfMPTLock}); + env.close(); + expected = TER{tecLOCKED}; + } + + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(expected)); + BEAST_EXPECT(!env.le(loanKeylet)); + } + + // Same as above, but for the Borrower. + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: LoanSet with frozen borrower (" << assetTypeName(assetType) + << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + auto const loanKeylet = nextLoanKeylet(env, broker); + + TER expected = tesSUCCESS; + if (assetType == AssetType::IOU) + { + env(trust(issuer, borrower[iouCurrency_](0), tfSetFreeze)); + env.close(); + expected = TER{tecFROZEN}; + } + else + { + MPTTester mptt{env, issuer, broker.asset.raw().get().getMptID()}; + mptt.set({.account = issuer, .holder = borrower, .flags = tfMPTLock}); + env.close(); + expected = TER{tecLOCKED}; + } + + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(expected)); + BEAST_EXPECT(!env.le(loanKeylet)); + } + + // Same as above, but for the LoanBroker owner (deep freeze). + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: LoanSet with deep frozen broker owner (" + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + auto const loanKeylet = nextLoanKeylet(env, broker); + + TER expected = tesSUCCESS; + if (assetType == AssetType::IOU) + { + env(trust(issuer, lender[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze)); + env.close(); + expected = TER{tecFROZEN}; + } + else + { + MPTTester mptt{env, issuer, broker.asset.raw().get().getMptID()}; + mptt.set({.account = issuer, .holder = lender, .flags = tfMPTLock}); + env.close(); + expected = TER{tecLOCKED}; + } + + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(expected)); + BEAST_EXPECT(!env.le(loanKeylet)); + } + { testcase("Two-step: LoanAccept with insufficient reserve"); @@ -498,6 +838,7 @@ private: env.close(); env(accept(borrower, loanKeylet.key), Ter(tecINSUFFICIENT_RESERVE)); + expectStillPending(env, loanKeylet); } // Between the LoanSet proposal and the LoanAccept, the issuer @@ -538,6 +879,7 @@ private: } env(accept(borrower, loanKeylet.key), Ter(expected)); + expectStillPending(env, loanKeylet); } // Between the LoanSet proposal and the LoanAccept, the issuer deep @@ -578,6 +920,7 @@ private: } env(accept(borrower, loanKeylet.key), Ter(expected)); + expectStillPending(env, loanKeylet); } // Between the LoanSet proposal and the LoanAccept, the issuer @@ -612,6 +955,7 @@ private: } env(accept(borrower, loanKeylet.key), Ter(expected)); + expectStillPending(env, loanKeylet); } // Between the LoanSet proposal and the LoanAccept, the issuer deep @@ -646,6 +990,7 @@ private: } env(accept(borrower, loanKeylet.key), Ter(expected)); + expectStillPending(env, loanKeylet); } { @@ -668,6 +1013,7 @@ private: env.close(); env(accept(borrower, loanKeylet.key), Ter(terNO_RIPPLE)); + expectStillPending(env, loanKeylet); } { @@ -706,6 +1052,7 @@ private: env.close(); env(accept(borrower, loanKeylet.key), Ter(tecNO_AUTH)); + expectStillPending(env, loanKeylet); } { @@ -741,6 +1088,7 @@ private: env.close(); env(accept(borrower, loanKeylet.key), Ter(tecNO_AUTH)); + expectStillPending(env, loanKeylet); } // Deleting a pending loan reverses the proposal-time bookkeeping and @@ -751,6 +1099,7 @@ private: auto const broker = makeBroker(env, assetType); auto const vault0 = readVault(env, broker); + auto const broker0 = readBroker(env, broker); auto const lenderOwners0 = env.ownerCount(lender); auto const borrowerOwners0 = env.ownerCount(borrower); @@ -778,6 +1127,14 @@ private: BEAST_EXPECT(vault1.available == vault0.available); BEAST_EXPECT(vault1.reserved == vault0.reserved); BEAST_EXPECT(vault1.total == vault0.total); + + // Broker bookkeeping is also fully reversed: DebtTotal and + // OwnerCount return to their pre-proposal values, CoverAvailable + // is untouched throughout. + auto const broker1 = readBroker(env, broker); + BEAST_EXPECT(broker1.debtTotal == broker0.debtTotal); + BEAST_EXPECT(broker1.ownerCount == broker0.ownerCount); + BEAST_EXPECT(broker1.coverAvailable == broker0.coverAvailable); }; for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT}) @@ -790,6 +1147,40 @@ private: << assetTypeName(assetType) << ")"; testDeletePending(assetType, borrower); } + + { + testcase("Two-step: LoanBrokerDelete blocked by pending loan"); + + // A pending loan bumps the LoanBroker's OwnerCount, so + // LoanBrokerDelete must fail with tecHAS_OBLIGATIONS while the + // pending loan is outstanding, just as it does for an active + // (accepted) loan. Once the pending loan is deleted, the broker + // can be deleted too. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + // The loan is pending; the broker's OwnerCount is non-zero. + if (auto const b = env.le(broker.brokerKeylet()); BEAST_EXPECT(b)) + BEAST_EXPECT(b->at(sfOwnerCount) != 0u); + + env(jtx::loan_broker::del(lender, broker.brokerID), Ter(tecHAS_OBLIGATIONS)); + env.close(); + + // Broker and loan are both still present. + BEAST_EXPECT(env.le(broker.brokerKeylet())); + BEAST_EXPECT(env.le(loanKeylet)); + + // Delete the pending loan, then the broker can be deleted. + env(del(lender, loanKeylet.key)); + env.close(); + env(jtx::loan_broker::del(lender, broker.brokerID)); + env.close(); + BEAST_EXPECT(!env.le(broker.brokerKeylet())); + } } public: