From 421af6db796631da4ff8b78f5d3bafae0fd3ca32 Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:08:25 +0000 Subject: [PATCH] fix: Reject open-ended vaults at LoanBrokerSet (#8076) Co-authored-by: Kenny Lei <3003853+kennyzlei@users.noreply.github.com> --- .../tx/transactors/lending/LoanBrokerSet.cpp | 16 ++++++ src/test/app/Batch_test.cpp | 10 +++- src/test/app/Sponsor_test.cpp | 8 ++- src/test/app/invariants/InvariantsBase.cpp | 13 ++++- .../app/invariants/InvariantsVault_test.cpp | 13 ++++- src/test/app/lending/LendingHelpers_test.cpp | 9 ++- src/test/app/lending/LoanBroker_test.cpp | 36 +++++++----- src/test/app/lending/LoanLifecycle_test.cpp | 8 ++- src/test/app/lending/LoanSecurity_test.cpp | 10 +++- src/test/app/lending/LoanTestBase.h | 24 +++++++- src/test/app/lending/LoanValidation_test.cpp | 57 +++++++++++++++++++ src/test/app/vault/VaultBugs_test.cpp | 31 ++++++---- src/test/app/vault/VaultClawback_test.cpp | 26 ++++++++- src/test/app/vault/VaultScale_test.cpp | 8 ++- .../app/vault/VaultSoleShareholder_test.cpp | 28 ++++++--- src/test/jtx/impl/vault.cpp | 26 +++++++++ src/test/jtx/vault.h | 34 +++++++++++ 17 files changed, 310 insertions(+), 47 deletions(-) diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp index d6cda9c326..1ab4eb2ce0 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp @@ -8,7 +8,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -144,6 +146,20 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx) } else { + // LP V1.1: only closed-ended vaults may host a loan broker. The + // lending protocol relies on the closed-ended Subscription / + // Investment / Redemption phase structure; attaching a broker to + // an open-ended vault has no well-defined lifecycle. VaultCreate + // stays unrestricted so existing open-ended flows keep working; + // the constraint is enforced here, at the point where the vault + // is first bound to the lending protocol. + if (ctx.view.rules().enabled(featureLendingProtocolV1_1) && + getVaultKind(sleVault) != VaultKind::ClosedEnded) + { + JLOG(ctx.j.warn()) << "LoanBroker requires a closed-ended Vault."; + return tecNO_PERMISSION; + } + if (auto const ter = canAddHolding(ctx.view, asset)) return ter; diff --git a/src/test/app/Batch_test.cpp b/src/test/app/Batch_test.cpp index c332b26a5b..7e6ecfb8ca 100644 --- a/src/test/app/Batch_test.cpp +++ b/src/test/app/Batch_test.cpp @@ -3169,7 +3169,12 @@ class Batch_test : public beast::unit_test::Suite auto const debtMaximumValue = asset(25'000).value(); auto const coverDepositValue = asset(1000).value(); - auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); + // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only + // accepts closed-ended vaults, so build one with a subscription + // window that lets the lender deposit now, then advance the clock + // past SubscriptionDate before creating loans. + auto [tx, vaultKeylet, subscriptionDate] = + vault.createClosedEnded({.owner = lender, .asset = asset}); env(tx); env.close(); BEAST_EXPECT(env.le(vaultKeylet)); @@ -3177,6 +3182,9 @@ class Batch_test : public beast::unit_test::Suite env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit})); env.close(); + // Move into the Investment phase before creating loans. + vault.closePastSubscription(subscriptionDate); + auto const brokerKeylet = keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender))); diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp index a1a9f80a11..e58d8c9f8f 100644 --- a/src/test/app/Sponsor_test.cpp +++ b/src/test/app/Sponsor_test.cpp @@ -1877,7 +1877,11 @@ public: PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; Vault const vault{env}; - auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = xrpAsset}); + // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only + // accepts closed-ended vaults; build one and advance past + // SubscriptionDate before creating a loan. + auto [vaultTx, vaultKeylet, subscriptionDate] = + vault.createClosedEnded({.owner = alice, .asset = xrpAsset}); env(vaultTx); env.close(); @@ -1885,6 +1889,8 @@ public: {.depositor = alice, .id = vaultKeylet.key, .amount = xrpAsset(1000)})); env.close(); + vault.closePastSubscription(subscriptionDate); + auto const brokerKeylet = keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); env(loan_broker::set(alice, vaultKeylet.key), diff --git a/src/test/app/invariants/InvariantsBase.cpp b/src/test/app/invariants/InvariantsBase.cpp index 92d75eca77..a573cc45ea 100644 --- a/src/test/app/invariants/InvariantsBase.cpp +++ b/src/test/app/invariants/InvariantsBase.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +30,7 @@ #include #include +#include #include #include #include @@ -178,10 +180,17 @@ InvariantsBase::createLoanBroker( { using namespace jtx; - // Create vault + // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only + // accepts closed-ended vaults. Build one with a comfortable + // subscription window; LoanBrokerSet itself is not phase-gated, + // so leaving the vault in the Subscription phase is fine here. uint256 vaultID; Vault const vault{env}; - auto [tx, vKeylet] = vault.create({.owner = a, .asset = asset}); + auto [tx, vKeylet, _] = vault.createClosedEnded( + {.owner = a, + .asset = asset, + .subscriptionOffset = std::chrono::seconds{60}, + .investmentWindow = std::chrono::seconds{kMinInvestmentPeriod + 1'000'000u}}); env(tx); BEAST_EXPECT(env.le(vKeylet)); diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp index 56ccaa46fc..5b2511626e 100644 --- a/src/test/app/invariants/InvariantsVault_test.cpp +++ b/src/test/app/invariants/InvariantsVault_test.cpp @@ -1974,8 +1974,16 @@ class InvariantsVault_test : public InvariantsBase env(pay(issuer, borrower, usd(1'000))); env.close(); + // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only + // accepts closed-ended vaults. The 10-year investment window + // covers this helper's 120 monthly payments so LoanSet's + // RedemptionDate bound is satisfied. Vault const vault{env}; - auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd}); + auto [vaultTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded( + {.owner = owner, + .asset = usd, + .subscriptionOffset = std::chrono::seconds{60}, + .investmentWindow = std::chrono::seconds{10ull * 365ull * 24ull * 60ull * 60ull}}); env(vaultTx); env.close(); @@ -1999,6 +2007,9 @@ class InvariantsVault_test : public InvariantsBase env.close(); } + // LoanSet is gated on Investment; advance out of Subscription. + vault.closePastSubscription(subscriptionDate); + auto const brokerSle = env.le(brokerKeylet); if (!BEAST_EXPECT(brokerSle)) return vaultKeylet; diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp index 32c49feb02..96adfd5254 100644 --- a/src/test/app/lending/LendingHelpers_test.cpp +++ b/src/test/app/lending/LendingHelpers_test.cpp @@ -1901,12 +1901,19 @@ public: env.fund(XRP(10'000), lender, borrower); env.close(); - auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()}); + // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only + // accepts closed-ended vaults, so build one with a near-future + // SubscriptionDate, deposit while still in the Subscription phase, + // and advance past SubscriptionDate before creating the broker. + auto [vaultTx, vaultKeylet, subscriptionDate] = + vault.createClosedEnded({.owner = lender, .asset = xrpIssue()}); env(vaultTx); env.close(); env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = XRP(1'000)})); env.close(); + vault.closePastSubscription(subscriptionDate); + auto const brokerKeylet = keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender))); env(loan_broker::set(lender, vaultKeylet.key)); diff --git a/src/test/app/lending/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp index 437a0cea99..d75b359868 100644 --- a/src/test/app/lending/LoanBroker_test.cpp +++ b/src/test/app/lending/LoanBroker_test.cpp @@ -72,7 +72,13 @@ class LoanBroker_test : public beast::unit_test::Suite { // Ensure that all the features needed for Lending Protocol are included, // even if they are set to unsupported. - FeatureBitset const all_{jtx::testableAmendments()}; + // + // featureLendingProtocolV1_1 is excluded from the default set: it adds + // the closed-ended vault gate on LoanBrokerSet::preclaim (see + // LoanBrokerSet.cpp), but this suite exercises loan-broker mechanics on + // plain open-ended vaults. Tests that specifically exercise the + // amendment opt it back in explicitly and use closed-ended vaults. + FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1}; void testDisabled() @@ -872,7 +878,7 @@ class LoanBroker_test : public beast::unit_test::Suite using namespace loan_broker; Account const issuer{"issuer"}; Account const alice{"alice"}; - Env env(*this); + Env env(*this, all_); Vault const vault{env}; env.fund(XRP(100'000), issuer, alice); @@ -1108,7 +1114,7 @@ class LoanBroker_test : public beast::unit_test::Suite Account const alice{"alice"}; Account const issuer{"issuer"}; auto const usd = alice["USD"]; - Env env(*this); + Env env(*this, all_); env.fund(XRP(100'000), alice); env.close(); @@ -1211,7 +1217,7 @@ class LoanBroker_test : public beast::unit_test::Suite // This test is lifted directly from // https://bugs.immunefi.com/dashboard/submission/57808 using namespace jtx; - Env env(*this); + Env env(*this, all_); Account const alice{"alice"}; env.fund(XRP(10000), alice); @@ -1269,7 +1275,7 @@ class LoanBroker_test : public beast::unit_test::Suite Account const issuer{"issuer"}; Account const alice{"alice"}; - Env env(*this); + Env env(*this, all_); Vault vault{env}; env.fund(XRP(100'000), issuer, alice); @@ -1377,7 +1383,7 @@ class LoanBroker_test : public beast::unit_test::Suite using namespace loan_broker; Account const issuer{"issuer"}; Account const alice{"alice"}; - Env env(*this); + Env env(*this, all_); Vault const vault{env}; env.fund(XRP(100'000), issuer, alice); @@ -1543,7 +1549,7 @@ class LoanBroker_test : public beast::unit_test::Suite Account const& broker = issuer; auto test = [&](auto&& getToken) { - Env env(*this); + Env env(*this, all_); env.fund(XRP(1'000), issuer, holder); env.close(); @@ -1616,7 +1622,7 @@ class LoanBroker_test : public beast::unit_test::Suite { testcase << "RIPD-4466 - LoanBrokerSet disallows frozen vaults"; using namespace jtx; - Env env(*this); + Env env(*this, all_); Account const issuer{"issuer"}, lender{"lender"}, borrower{"borrower"}; env.fund(XRP(20'000), issuer, lender, borrower); @@ -1855,7 +1861,7 @@ class LoanBroker_test : public beast::unit_test::Suite // === IOU === { testcase("LoanBrokerCoverDeposit IOU freeze checks"); - Env env(*this); + Env env(*this, all_); Vault const vault{env}; env.fund(XRP(100'000), issuer, alice); @@ -1922,7 +1928,7 @@ class LoanBroker_test : public beast::unit_test::Suite // === MPT === { testcase("LoanBrokerCoverDeposit MPT lock checks"); - Env env(*this); + Env env(*this, all_); Vault const vault{env}; env.fund(XRP(100'000), issuer, alice); @@ -2005,7 +2011,7 @@ class LoanBroker_test : public beast::unit_test::Suite Account const issuer{"issuer"}; Account const alice{"alice"}; Account const dest{"dest"}; - Env env{*this}; + Env env{*this, all_}; Vault const vault{env}; env.fund(XRP(100'000), issuer, alice, dest); @@ -2071,7 +2077,7 @@ class LoanBroker_test : public beast::unit_test::Suite // === IOU === { testcase("LoanBrokerCoverWithdraw IOU freeze checks"); - Env env(*this); + Env env(*this, all_); Vault const vault{env}; env.fund(XRP(100'000), issuer, alice); @@ -2183,7 +2189,7 @@ class LoanBroker_test : public beast::unit_test::Suite // === MPT === { testcase("LoanBrokerCoverWithdraw MPT lock checks"); - Env env(*this); + Env env(*this, all_); Vault const vault{env}; env.fund(XRP(100'000), issuer, alice); @@ -2304,7 +2310,7 @@ class LoanBroker_test : public beast::unit_test::Suite }; auto test = [&](TrustState trustState) { - Env env(*this); + Env env(*this, all_); testcase << "RIPD-4274 IOU with state: " << static_cast(trustState); @@ -2429,7 +2435,7 @@ class LoanBroker_test : public beast::unit_test::Suite }; auto test = [&](MPTState mptState) { - Env env(*this); + Env env(*this, all_); testcase << "RIPD-4274 MPT with state: " << static_cast(mptState); diff --git a/src/test/app/lending/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp index 6cced5c97a..dae6f6ce16 100644 --- a/src/test/app/lending/LoanLifecycle_test.cpp +++ b/src/test/app/lending/LoanLifecycle_test.cpp @@ -347,7 +347,11 @@ private: auto const& asset = debtMaximumRequest.asset(); auto const initialVault = asset(debtMaximumRequest * 100); - auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset}); + // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim + // only accepts closed-ended vaults, so build one and advance + // past SubscriptionDate before creating broker/loan. + auto [tx, vaultKeylet, subscriptionDate] = + vault.createClosedEnded({.owner = broker, .asset = asset}); env(tx, txFee); env.close(); @@ -356,6 +360,8 @@ private: txFee); env.close(); + vault.closePastSubscription(subscriptionDate); + auto const brokerKeylet = keylet::loanBroker(broker.id(), SeqProxy::rawSequence(env.seq(broker))); diff --git a/src/test/app/lending/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp index 21772d0617..b878547a70 100644 --- a/src/test/app/lending/LoanSecurity_test.cpp +++ b/src/test/app/lending/LoanSecurity_test.cpp @@ -411,13 +411,17 @@ private: Account const depositor{"depositor"}; auto const txFee = Fee(XRP(100)); + // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only + // accepts closed-ended vaults, so build one and advance past + // SubscriptionDate before creating the broker and the loan. Env env(*this); Vault const vault(env); env.fund(XRP(10'000), lender, issuer, borrower, depositor); env.close(); - auto [tx, vaultKeyLet] = vault.create({.owner = lender, .asset = xrpIssue()}); + auto [tx, vaultKeyLet, subscriptionDate] = + vault.createClosedEnded({.owner = lender, .asset = xrpIssue()}); env(tx, txFee); env.close(); @@ -425,6 +429,10 @@ private: txFee); env.close(); + // Move into the Investment phase before creating the broker and + // the loan. + vault.closePastSubscription(subscriptionDate); + auto const brokerKeyLet = keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender))); diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index b3669742fe..99b387938e 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -496,9 +496,27 @@ protected: auto const coverRateMinValue = params.coverRateMin; + // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects + // brokers attached to open-ended vaults. Many callers of this + // helper leave vaultKind at the OpenEnded default and don't care + // about the vault kind per se — they just need a broker on a + // vault. When LP V1.1 is enabled, transparently promote to + // ClosedEnded so those tests keep working without threading + // vaultKind through every call site. Callers that explicitly + // asked for ClosedEnded are left untouched. Tests that want to + // exercise the open-ended rejection under LP V1.1 build their own + // vault directly instead of going through this helper, since it + // always promotes OpenEnded once the amendment is enabled. + auto effectiveVaultKind = params.vaultKind; + if (env.current()->rules().enabled(featureLendingProtocolV1_1) && + effectiveVaultKind == VaultKind::OpenEnded) + { + effectiveVaultKind = VaultKind::ClosedEnded; + } + std::optional subscriptionDate; std::optional redemptionDate; - if (params.vaultKind == VaultKind::ClosedEnded) + if (effectiveVaultKind == VaultKind::ClosedEnded) { auto const nowSec = env.now().time_since_epoch().count(); subscriptionDate = nowSec + params.subscriptionOffset; @@ -508,9 +526,9 @@ protected: auto [tx, vaultKeylet] = vault.create( {.owner = lender, .asset = asset, - .vaultKind = params.vaultKind == VaultKind::OpenEnded + .vaultKind = effectiveVaultKind == VaultKind::OpenEnded ? std::optional{} - : std::optional{std::to_underlying(params.vaultKind)}, + : std::optional{std::to_underlying(effectiveVaultKind)}, .subscriptionDate = subscriptionDate, .redemptionDate = redemptionDate}); if (params.vaultScale) diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp index c6ff22bbb3..ebbef70f40 100644 --- a/src/test/app/lending/LoanValidation_test.cpp +++ b/src/test/app/lending/LoanValidation_test.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -530,6 +531,61 @@ private: env.close(); } + // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects + // attaching a broker to an open-ended vault. VaultCreate itself is + // not gated by the amendment, so the same open-ended vault can be + // built under either feature set; only the broker create is + // amendment-sensitive. Cover both branches: LP V1.1 disabled lets + // the broker create succeed, LP V1.1 enabled rejects it. The gate + // only fires on the create path; existing brokers keep working. + void + testLoanBrokerRequiresClosedEndedVault() + { + testcase("LoanBrokerSet requires closed-ended vault under LP V1.1"); + using namespace jtx; + + Account const owner{"lp11_owner"}; + + auto const build = [&](FeatureBitset features, + TER expected, + std::optional updateExpected = std::nullopt) { + Env env(*this, features); + env.fund(XRP(1'000), owner); + env.close(); + + Vault const vault{env}; + auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = xrpIssue()}); + env(tx); + env.close(); + env(vault.deposit({.depositor = owner, .id = vaultKeylet.key, .amount = XRP(100)})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, vaultKeylet.key), Ter(expected)); + env.close(); + + // The create-path gate is the only new check; updates to an + // existing broker on the same open-ended vault are not + // affected. Only exercise the update path when the create + // succeeded (so there is a broker to update). + if (updateExpected && expected == tesSUCCESS) + { + env(loan_broker::set(owner, vaultKeylet.key), + loan_broker::kLoanBrokerId(brokerKeylet.key), + loan_broker::kDebtMaximum(XRP(1'000).value()), + Ter(*updateExpected)); + env.close(); + } + }; + + // Baseline: LP V1.1 disabled -> open-ended vault + broker succeeds. + build(all_, tesSUCCESS, tesSUCCESS); + + // LP V1.1 enabled -> open-ended vault + broker rejected on create. + build(all_ | featureLendingProtocolV1_1, tecNO_PERMISSION); + } + void runAmendmentIndependent() { @@ -541,6 +597,7 @@ private: testInvalidLoanPay(); testRequireAuth(); testLimitExceeded(); + testLoanBrokerRequiresClosedEndedVault(); } // Tests run under each entry in amendmentCombinations(). diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp index ad6fdcc8b8..e3e0c40bc2 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -564,44 +564,49 @@ private: env.close(); }; + // Strip featureLendingProtocolV1_1: this scenario runs an + // open-ended vault through deposit/broker/loan/repay/deposit, + // which spans both Subscription and post-loan lifetime — a phase + // pattern that only makes sense on open-ended vaults. The gate + // added by LP V1.1 is unrelated to the truncation bug asserted + // here. + auto const legacy = testableAmendments() - featureLendingProtocolV1_1; { testcase( "bug: VaultDeposit share truncation lets depositor debit " "round away to zero (pre-fixCleanup3_4_0)"); - runScenario(testableAmendments() - fixCleanup3_4_0, Line::Holding, tecINVARIANT_FAILED); + runScenario(legacy - fixCleanup3_4_0, Line::Holding, tecINVARIANT_FAILED); } { testcase( "bug: VaultDeposit share truncation lets depositor debit " "round away to zero (pre-fixCleanup3_2_0 and pre-fixCleanup3_4_0)"); runScenario( - testableAmendments() - fixCleanup3_2_0 - fixCleanup3_4_0, - Line::Holding, - tecINVARIANT_FAILED); + legacy - fixCleanup3_2_0 - fixCleanup3_4_0, Line::Holding, tecINVARIANT_FAILED); } { testcase( "bug: VaultDeposit share truncation rejected with " "tecPRECISION_LOSS (post-fixCleanup3_4_0)"); - runScenario(testableAmendments(), Line::Holding, tecPRECISION_LOSS); + runScenario(legacy, Line::Holding, tecPRECISION_LOSS); } { testcase( "bug: VaultDeposit share truncation rejected with " "tecPRECISION_LOSS (post-fixCleanup3_4_0, pre-fixCleanup3_2_0)"); - runScenario(testableAmendments() - fixCleanup3_2_0, Line::Holding, tecPRECISION_LOSS); + runScenario(legacy - fixCleanup3_2_0, Line::Holding, tecPRECISION_LOSS); } { testcase( "bug: VaultDeposit share truncation against a debt balance " "round away to zero (pre-fixCleanup3_4_0)"); - runScenario(testableAmendments() - fixCleanup3_4_0, Line::InDebt, tecINVARIANT_FAILED); + runScenario(legacy - fixCleanup3_4_0, Line::InDebt, tecINVARIANT_FAILED); } { testcase( "bug: VaultDeposit share truncation against a debt balance rejected with " "tecPRECISION_LOSS (post-fixCleanup3_4_0)"); - runScenario(testableAmendments(), Line::InDebt, tecPRECISION_LOSS); + runScenario(legacy, Line::InDebt, tecPRECISION_LOSS); } } @@ -1104,7 +1109,10 @@ private: using namespace test::jtx; auto runScenario = [this](FeatureBitset features, bool withFix) { - Env env{*this, features}; + // This regression requires the open-ended vault lifecycle: deposit, + // originate and repay a loan, then claw back shares. LP V1.1 + // independently rejects attaching a broker to an open-ended vault. + Env env{*this, features - featureLendingProtocolV1_1}; auto const setup = makeRoundTripOvershootVault(env); if (!BEAST_EXPECT(setup)) @@ -1165,7 +1173,10 @@ private: using namespace test::jtx; auto runScenario = [this](FeatureBitset features, bool withFix) { - Env env{*this, features}; + // This regression requires the open-ended vault lifecycle: deposit, + // originate and repay a loan, then withdraw shares. LP V1.1 + // independently rejects attaching a broker to an open-ended vault. + Env env{*this, features - featureLendingProtocolV1_1}; auto const setup = makeRoundTripOvershootVault(env); if (!BEAST_EXPECT(setup)) diff --git a/src/test/app/vault/VaultClawback_test.cpp b/src/test/app/vault/VaultClawback_test.cpp index 2a9fe42b1c..6ce847f9fd 100644 --- a/src/test/app/vault/VaultClawback_test.cpp +++ b/src/test/app/vault/VaultClawback_test.cpp @@ -68,12 +68,20 @@ private: return sleIssuance->at(sfOutstandingAmount); }; + // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only + // accepts closed-ended vaults, so build vaults in this suite as + // closed-ended and advance past SubscriptionDate before creating + // brokers/loans. VaultClawback itself is not phase-gated. The + // subscription offset must be large enough that the deposit + // ledger close does not accidentally push us past SubscriptionDate + // (which would land the deposit in Investment phase and fail). auto const setupVault = [&](PrettyAsset const& asset, Account const& owner, Account const& depositor) -> std::pair { Vault const vault{env}; - auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset}); + auto const& [tx, vaultKeylet, subscriptionDate] = vault.createClosedEnded( + {.owner = owner, .asset = asset, .subscriptionOffset = std::chrono::seconds{60}}); env(tx, Ter(tesSUCCESS)); env.close(); @@ -87,6 +95,10 @@ private: Ter(tesSUCCESS)); env.close(); + // Move past SubscriptionDate so LoanBrokerSet/LoanSet run in + // the Investment phase. + vault.closePastSubscription(subscriptionDate); + auto const& [availablePreDefault, totalPreDefault] = vaultAssetBalance(vaultKeylet); BEAST_EXPECT(availablePreDefault == totalPreDefault); BEAST_EXPECT(availablePreDefault == asset(100).value()); @@ -313,13 +325,21 @@ private: Env env(*this); env.enableFeature(fixCleanup3_1_3); + // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only + // accepts closed-ended vaults; some tests using this helper later + // attach loan brokers to the vault. Build it as closed-ended and + // advance past SubscriptionDate so subsequent broker/loan setup + // runs in the Investment phase. VaultClawback itself is not + // phase-gated. See the other setupVault (share tests) for why the + // subscription offset must be generous. auto const setupVault = [&](PrettyAsset const& asset, Account const& owner, Account const& depositor, Account const& issuer) -> std::pair { Vault const vault{env}; - auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset}); + auto const& [tx, vaultKeylet, subscriptionDate] = vault.createClosedEnded( + {.owner = owner, .asset = asset, .subscriptionOffset = std::chrono::seconds{60}}); env(tx, Ter(tesSUCCESS)); env.close(); @@ -331,6 +351,8 @@ private: Ter(tesSUCCESS)); env.close(); + vault.closePastSubscription(subscriptionDate); + return std::make_pair(vault, vaultKeylet); }; diff --git a/src/test/app/vault/VaultScale_test.cpp b/src/test/app/vault/VaultScale_test.cpp index 28c9729d78..b2ce4a5abf 100644 --- a/src/test/app/vault/VaultScale_test.cpp +++ b/src/test/app/vault/VaultScale_test.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -71,7 +72,12 @@ private: auto testCase = [&, this]( std::uint8_t scale, std::function test) { - Env env{*this, testableAmendments()}; + // These scale-focused tests build an open-ended vault and + // exercise deposit/withdraw/clawback (with one test also + // attaching a loan broker). featureLendingProtocolV1_1 adds a + // closed-ended vault gate on LoanBrokerSet::preclaim and is + // orthogonal to what this suite asserts, so strip it here. + Env env{*this, testableAmendments() - featureLendingProtocolV1_1}; Account const owner{"owner"}; Account const issuer{"issuer"}; Account const depositor{"depositor"}; diff --git a/src/test/app/vault/VaultSoleShareholder_test.cpp b/src/test/app/vault/VaultSoleShareholder_test.cpp index ffaad07112..62cbc28bcd 100644 --- a/src/test/app/vault/VaultSoleShareholder_test.cpp +++ b/src/test/app/vault/VaultSoleShareholder_test.cpp @@ -464,7 +464,10 @@ private: "Vault withdraw: sole-shareholder partial fixed-shares uses " "full-price rate (fixCleanup3_2_0)"); - Env env(*this, all_ | fixCleanup3_2_0); + // Strip featureLendingProtocolV1_1: setupStuckDepositor builds an + // open-ended vault and this test asserts amendment-independent + // withdrawal invariants (see the note on run()). + Env env(*this, (all_ - featureLendingProtocolV1_1) | fixCleanup3_2_0); auto const f = setupStuckDepositor(env); if (!f.vaultKeylet || !f.asset || f.sharesLender == 0) { @@ -551,7 +554,8 @@ private: "Vault withdraw: sole shareholder fully exits after impaired " "loan is repaid (fixCleanup3_2_0)"); - Env env(*this, all_ | fixCleanup3_2_0); + // Strip featureLendingProtocolV1_1 as above. + Env env(*this, (all_ - featureLendingProtocolV1_1) | fixCleanup3_2_0); auto const f = setupStuckDepositor(env); if (!f.vaultKeylet || !f.asset || !f.loanKeylet || f.sharesLender == 0) { @@ -639,12 +643,20 @@ public: void run() override { - testWithdrawSoleShareholderFixedAssetExit(all_ - fixCleanup3_2_0); - testWithdrawSoleShareholderFixedAssetExit(all_); - testWithdrawSoleShareholderFullSharesRejected(all_ - fixCleanup3_2_0); - testWithdrawSoleShareholderFullSharesRejected(all_); - testWithdrawSoleShareholderCleanVaultUnaffected(all_ - fixCleanup3_2_0); - testWithdrawSoleShareholderCleanVaultUnaffected(all_); + // These sole-shareholder exit scenarios build an open-ended vault + // and drive it through deposits, a loan broker, an impaired loan + // and finally a withdrawal by the last shareholder. Under + // featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects + // brokers attached to open-ended vaults, so this suite runs with + // the amendment stripped; the invariants asserted here are + // amendment-independent. + auto const legacy = all_ - featureLendingProtocolV1_1; + testWithdrawSoleShareholderFixedAssetExit(legacy - fixCleanup3_2_0); + testWithdrawSoleShareholderFixedAssetExit(legacy); + testWithdrawSoleShareholderFullSharesRejected(legacy - fixCleanup3_2_0); + testWithdrawSoleShareholderFullSharesRejected(legacy); + testWithdrawSoleShareholderCleanVaultUnaffected(legacy - fixCleanup3_2_0); + testWithdrawSoleShareholderCleanVaultUnaffected(legacy); testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice(); testWithdrawSoleShareholderLoanRepaymentExit(); } diff --git a/src/test/jtx/impl/vault.cpp b/src/test/jtx/impl/vault.cpp index 978c3864d6..4688e8c4a6 100644 --- a/src/test/jtx/impl/vault.cpp +++ b/src/test/jtx/impl/vault.cpp @@ -3,17 +3,22 @@ #include #include +#include #include #include #include #include +#include #include #include #include #include +#include +#include #include #include +#include namespace xrpl::test::jtx { @@ -37,6 +42,27 @@ Vault::create(CreateArgs const& args) const return {jv, keylet}; } +std::tuple +Vault::createClosedEnded(CreateClosedEndedArgs const& args) const +{ + auto const sub = env.now() + args.subscriptionOffset; + auto const red = sub + args.investmentWindow; + auto [jv, keylet] = create( + {.owner = args.owner, + .asset = args.asset, + .flags = args.flags, + .vaultKind = std::to_underlying(VaultKind::ClosedEnded), + .subscriptionDate = static_cast(sub.time_since_epoch().count()), + .redemptionDate = static_cast(red.time_since_epoch().count())}); + return {jv, keylet, sub}; +} + +void +Vault::closePastSubscription(NetClock::time_point subscriptionDate) const +{ + env.close(subscriptionDate + std::chrono::seconds{1}); +} + json::Value Vault::set(SetArgs const& args) { diff --git a/src/test/jtx/vault.h b/src/test/jtx/vault.h index 992051b61f..6b2ffddfb3 100644 --- a/src/test/jtx/vault.h +++ b/src/test/jtx/vault.h @@ -3,10 +3,12 @@ #include #include +#include #include #include #include +#include #include #include #include @@ -39,6 +41,38 @@ struct Vault [[nodiscard]] std::tuple create(CreateArgs const& args) const; + struct CreateClosedEndedArgs + { + Account owner; + Asset asset; + std::optional flags = + std::nullopt; // NOLINT(readability-redundant-member-init) + NetClock::duration subscriptionOffset = std::chrono::seconds{10}; + NetClock::duration investmentWindow = std::chrono::seconds{1'000'000}; + }; + + /** + * Return a VaultCreate transaction for a closed-ended vault, its + * expected keylet, and the vault's SubscriptionDate. + * + * Under featureLendingProtocolV1_1, LoanBrokerSet::preclaim only + * accepts closed-ended vaults, so tests that attach a loan broker + * need one. SubscriptionDate is set to now() + subscriptionOffset, + * giving callers a window to deposit while still in the Subscription + * phase; pass the returned date to closePastSubscription() afterwards + * to advance into the Investment phase. + */ + [[nodiscard]] std::tuple + createClosedEnded(CreateClosedEndedArgs const& args) const; + + /** + * Advance env's clock to just past subscriptionDate, moving a + * closed-ended vault from the Subscription phase into the Investment + * phase. + */ + void + closePastSubscription(NetClock::time_point subscriptionDate) const; + struct SetArgs { Account owner;