From 7863ac8cf6a3d30a236d213e6c6bd3c0795b7fd4 Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:27:05 +0000 Subject: [PATCH] fix: Add 60s buffer before closed-ended vault redemption (#8151) --- include/xrpl/protocol/Protocol.h | 15 ++++- include/xrpl/tx/invariants/VaultInvariant.h | 4 +- src/libxrpl/tx/invariants/LoanInvariant.cpp | 15 +++-- .../tx/transactors/lending/LoanSet.cpp | 13 +++- .../app/invariants/InvariantsVault_test.cpp | 19 +++--- src/test/app/lending/LoanSet_test.cpp | 66 ++++++++++++++++--- src/test/app/lending/LoanTestBase.h | 4 +- src/test/app/vault/VaultClosedEnded_test.cpp | 13 ++-- 8 files changed, 110 insertions(+), 39 deletions(-) diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index e6768efd76..8edd4bf4fd 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -348,13 +348,24 @@ enum class VaultPhase : std::uint8_t { Redemption, }; +/** + * Minimum gap between a closed-ended loan's final scheduled payment and the + * vault's RedemptionDate. LoanSet rejects a schedule whose final payment is + * fewer than this many seconds before RedemptionDate. + */ +constexpr std::uint32_t kLoanRedemptionBuffer = std::chrono::seconds{60}.count(); + /** * Bounds on the length of a closed-ended vault's Investment phase * (RedemptionDate - SubscriptionDate). At vault creation the gap must satisfy * kMinInvestmentPeriod <= gap < kMaxInvestmentPeriod. + * + * 180s is enough to originate a loan that uses the minimum payment interval + * and kLoanRedemptionBuffer after StartDate, which is strictly after + * SubscriptionDate. The interval and buffer need not be equal; only their + * sum plus one second must fit in this floor. */ -constexpr std::uint32_t kMinInvestmentPeriod = - std::chrono::seconds{std::chrono::minutes{1}}.count(); +constexpr std::uint32_t kMinInvestmentPeriod = std::chrono::seconds{180}.count(); // This is 946708560 seconds which 30 x 365.2425 days (the average length of a Gregorian year). constexpr std::uint32_t kMaxInvestmentPeriod = std::chrono::seconds{std::chrono::years{30}}.count(); diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index 8297b941d1..ee52f4edb3 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -211,8 +211,8 @@ private: * * For a closed-ended vault, a loan may only be originated while the vault is in the Investment * phase (strictly past @c SubscriptionDate and before @c RedemptionDate). Open-ended vaults (@c - * NoPhase) are unaffected. The complementary maturity bound (final payment strictly precedes @c - * RedemptionDate) is enforced by @c ValidLoan. + * NoPhase) are unaffected. The complementary maturity bound (final payment precedes @c + * RedemptionDate by at least @c kLoanRedemptionBuffer) is enforced by @c ValidLoan. */ [[nodiscard]] bool finalizeLoanSet(ReadView const& view, beast::Journal const& j) const; diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index 2db627c272..b34d7088be 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -62,10 +62,11 @@ ValidLoan::finalize( // Ledger entry validation checks. for (auto const& [before, after] : loans_) { - // A closed-ended vault must not accept a loan whose final scheduled payment falls on or - // after the vault's RedemptionDate. This mirrors the LoanSet::preclaim gate and only fires - // on loan creation; once the loan exists, its StartDate / PaymentInterval are immutable and - // PaymentRemaining only decreases, so the bound is preserved. + // A closed-ended vault must not accept a loan whose final scheduled payment falls fewer + // than kLoanRedemptionBuffer seconds before the vault's RedemptionDate. This mirrors the + // LoanSet::preclaim gate and only fires on loan creation; once the loan exists, its + // StartDate / PaymentInterval are immutable and PaymentRemaining only decreases, so the + // bound is preserved. if (!before && isTesSuccess(result)) { auto const broker = view.read(keylet::loanBroker(after->at(sfLoanBrokerID))); @@ -80,11 +81,13 @@ ValidLoan::finalize( std::uint32_t const interval = after->at(sfPaymentInterval); std::uint32_t const remaining = after->at(sfPaymentRemaining); std::uint32_t const redemption = vault->at(sfRedemptionDate); - if (std::uint64_t{startDate} + (std::uint64_t{interval} * remaining) >= + if (std::uint64_t{startDate} + (std::uint64_t{interval} * remaining) + + kLoanRedemptionBuffer > redemption) { JLOG(j.fatal()) << "Invariant failed: closed-ended loan final payment " - "must precede RedemptionDate"; + "must precede RedemptionDate by at least " + "kLoanRedemptionBuffer"; return false; } } diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index d84f5b09b3..b67c244bac 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -40,6 +40,12 @@ namespace xrpl { +// StartDate is strictly after SubscriptionDate. A min-gap vault must still +// fit a minimum-interval loan plus kLoanRedemptionBuffer. The interval and +// buffer constants are independent; only their sum (plus the +1 for a +// strictly-later StartDate) is required to fit in kMinInvestmentPeriod. +static_assert(kMinInvestmentPeriod >= LoanSet::kMinPaymentInterval + kLoanRedemptionBuffer + 1); + bool LoanSet::checkExtraFeatures(PreflightContext const& ctx) { @@ -327,10 +333,11 @@ LoanSet::preclaim(PreclaimContext const& ctx) { auto const finalPayment = std::uint64_t{getStartDate(ctx.view)} + (std::uint64_t{interval} * total); - if (finalPayment >= vault->at(sfRedemptionDate)) + if (finalPayment + kLoanRedemptionBuffer > vault->at(sfRedemptionDate)) { - JLOG(ctx.j.warn()) << "Final loan payment date is on or after " - "the vault's redemption date."; + JLOG(ctx.j.warn()) + << "Final loan payment date is fewer than " << kLoanRedemptionBuffer + << " seconds before the vault's redemption date."; return tecNO_PERMISSION; } } diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp index caf4e9cfb6..dcf783a1b5 100644 --- a/src/test/app/invariants/InvariantsVault_test.cpp +++ b/src/test/app/invariants/InvariantsVault_test.cpp @@ -2794,13 +2794,15 @@ class InvariantsVault_test : public InvariantsBase "RedemptionDate"; // A newly-created loan against a closed-ended vault must satisfy StartDate + - // PaymentInterval * PaymentRemaining < RedemptionDate. LoanSet::preclaim enforces the same - // bound; this test synthesises an invalid loan directly in the ApplyView so the invariant - // catches it even when preclaim is bypassed. + // PaymentInterval * PaymentRemaining + kLoanRedemptionBuffer <= RedemptionDate. + // LoanSet::preclaim enforces the same bound; this test synthesises a loan whose + // final payment is still before RedemptionDate (so the old unbuffered check would + // pass) but inside the buffer zone. Keylet closedEndedBrokerKeylet = keylet::amendments(); std::uint32_t closedEndedRed = 0; doInvariantCheck( - {"closed-ended loan final payment must precede RedemptionDate"}, + {"closed-ended loan final payment must precede RedemptionDate by at least " + "kLoanRedemptionBuffer"}, [&](Account const& a1, Account const&, ApplyContext& ac) { // Touch the vault so ValidVault::finalizeLoanSet sees an // entry in afterVault_; the vault is in Investment, so @@ -2817,15 +2819,14 @@ class InvariantsVault_test : public InvariantsBase return false; std::uint32_t const loanSeq = sleBroker->at(sfLoanSequence); - // Synthesize a Loan whose final scheduled payment lands - // exactly at RedemptionDate: StartDate = red, interval = 60, - // remaining = 1 => red + 60 >= red. + // Final payment at RedemptionDate - (kLoanRedemptionBuffer - 1): still + // strictly before RedemptionDate, but inside the buffer. auto sleLoan = makeLoanSle(closedEndedBrokerKeylet.key, loanSeq, a1.id()); sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key; sleLoan->at(sfLoanSequence) = loanSeq; sleLoan->at(sfBorrower) = a1.id(); - sleLoan->at(sfStartDate) = closedEndedRed; - sleLoan->at(sfPaymentInterval) = 60; + sleLoan->at(sfStartDate) = closedEndedRed - kLoanRedemptionBuffer; + sleLoan->at(sfPaymentInterval) = 1; sleLoan->at(sfPaymentRemaining) = 1; sleLoan->at(sfTotalValueOutstanding) = Number(100); sleLoan->at(sfPeriodicPayment) = Number(1); diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp index 3571853b47..9829f23138 100644 --- a/src/test/app/lending/LoanSet_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -602,6 +603,8 @@ private: testcase("LoanSet closed-ended: phase and maturity bound"); using namespace jtx; using namespace loan; + using d = NetClock::duration; + using tp = NetClock::time_point; Account const issuer{"issuer"}; Account const lender{"lender"}; @@ -663,9 +666,9 @@ private: setLoan(env, broker, tesSUCCESS); }); - // 4. Rejected during Investment when the loan's final payment would land on or after - // RedemptionDate. Use a tight redemptionOffset and a schedule whose final payment is well - // past that boundary. + // 4. Rejected during Investment when the loan's final payment would land fewer than + // kLoanRedemptionBuffer seconds before RedemptionDate. Use a tight redemptionOffset and a + // schedule whose final payment is well past that boundary. withEnv([&](Env& env, PrettyAsset const& asset) { constexpr std::uint32_t kRedemptionOffset = 3u * 24u * 3600u; auto const broker = createVaultAndBroker( @@ -684,16 +687,16 @@ private: env.close(); }); - // 5. Boundary: schedule whose finalPayment lands exactly (RedemptionDate - 1) is accepted, - // and one second later (== RedemptionDate) is rejected. Uses payTotal = 1 so the arithmetic - // is simple: finalPayment = startDate + interval. + // 5. Boundary: a finalPayment exactly kLoanRedemptionBuffer seconds before + // RedemptionDate is accepted; one second later is rejected. Uses payTotal = 1 so + // finalPayment = startDate + interval. withEnv([&](Env& env, PrettyAsset const& asset) { auto const broker = createVaultAndBroker( env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded}); BEAST_EXPECT(broker.redemptionDate.has_value()); auto const startDate = env.now().time_since_epoch().count(); - auto const acceptInterval = *broker.redemptionDate - 1 - startDate; + auto const acceptInterval = *broker.redemptionDate - kLoanRedemptionBuffer - startDate; env(set(lender, broker.brokerID, broker.asset(100).value()), kCounterparty(borrower), Sig(sfCounterpartySignature, borrower), @@ -703,8 +706,8 @@ private: Ter(tesSUCCESS)); env.close(); - auto const rejectInterval = - *broker.redemptionDate - env.now().time_since_epoch().count(); + auto const rejectInterval = *broker.redemptionDate - (kLoanRedemptionBuffer - 1) - + env.now().time_since_epoch().count(); env(set(lender, broker.brokerID, broker.asset(100).value()), kCounterparty(borrower), Sig(sfCounterpartySignature, borrower), @@ -714,6 +717,51 @@ private: Ter(tecNO_PERMISSION)); env.close(); }); + + // 6. A vault whose Investment window is exactly kMinInvestmentPeriod can originate a + // minimum-interval, single-payment loan at the start of Investment, and rejects the same + // schedule once StartDate no longer leaves kLoanRedemptionBuffer before RedemptionDate. + // Do not pin an unrounded wall-clock instant: Env::close rounds to the close-time + // resolution. Read env.now() (the same clock LoanSet::preclaim uses) and assert the + // buffer relationship before each LoanSet. + withEnv([&](Env& env, PrettyAsset const& asset) { + auto const broker = createVaultAndBroker( + env, + asset, + lender, + BrokerParameters{ + .vaultKind = VaultKind::ClosedEnded, + .subscriptionOffset = 300u, + .redemptionOffset = kMinInvestmentPeriod, + .skipPhaseAdvance = true}); + BEAST_EXPECT(broker.subscriptionDate.has_value()); + BEAST_EXPECT(broker.redemptionDate.has_value()); + + auto const red = *broker.redemptionDate; + auto const startDate = [&]() { return env.now().time_since_epoch().count(); }; + auto const minLoan = [&](TER expected) { + env(set(lender, broker.brokerID, broker.asset(100).value()), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + kPaymentTotal(1u), + kPaymentInterval(LoanSet::kMinPaymentInterval), + Ter(expected)); + env.close(); + }; + + // First Investment ledger: the minimum schedule still clears the buffer. + env.close(tp{d{*broker.subscriptionDate + 1}}); + BEAST_EXPECT(startDate() > *broker.subscriptionDate); + BEAST_EXPECT(startDate() + LoanSet::kMinPaymentInterval + kLoanRedemptionBuffer <= red); + minLoan(tesSUCCESS); + + // Still Investment, but the minimum schedule no longer clears the buffer. + while (startDate() + LoanSet::kMinPaymentInterval + kLoanRedemptionBuffer <= red) + env.close(); + BEAST_EXPECT(startDate() < red); + minLoan(tecNO_PERMISSION); + }); } public: diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index a1241d804e..13dffb6b9e 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -119,8 +119,8 @@ protected: std::uint32_t subscriptionOffset = 60; // Seconds between SubscriptionDate and RedemptionDate. Must be >= kMinInvestmentPeriod, < // kMaxInvestmentPeriod, and generous enough to fit any loan schedule the test runs - // (finalPayment must be strictly before RedemptionDate). Default sized to comfortably - // exceed any schedule realistic tests are likely to configure. + // (finalPayment must precede RedemptionDate by at least kLoanRedemptionBuffer). Default + // sized to comfortably exceed any schedule realistic tests are likely to configure. std::uint32_t redemptionOffset = 10u * 365u * 24u * 60u * 60u; // When true, createVaultAndBroker skips its automatic clock advance past SubscriptionDate. // Useful for tests that need to observe the vault while it is still in the Subscription diff --git a/src/test/app/vault/VaultClosedEnded_test.cpp b/src/test/app/vault/VaultClosedEnded_test.cpp index 252a7f4990..5ed242f8a4 100644 --- a/src/test/app/vault/VaultClosedEnded_test.cpp +++ b/src/test/app/vault/VaultClosedEnded_test.cpp @@ -81,7 +81,7 @@ private: /* * Valid closed-ended creation with a comfortably interior gap (well above - * MIN_INVESTMENT_PERIOD and well below MAX_INVESTMENT_PERIOD). + * kMinInvestmentPeriod and well below kMaxInvestmentPeriod). */ withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { auto const sub = env.now().time_since_epoch().count() + 60; @@ -145,7 +145,7 @@ private: }); /* - * Gap smaller than MIN_INVESTMENT_PERIOD => temMALFORMED. Includes the SubscriptionDate >= + * Gap smaller than kMinInvestmentPeriod => temMALFORMED. Includes the SubscriptionDate >= * RedemptionDate degenerate cases: the red == sub boundary and the strictly-reversed red < * sub case, the latter yielding a negative signed int64 gap that is caught by the * sub-minimum branch of the gap check. @@ -206,8 +206,9 @@ private: env(tx, Ter{temMALFORMED}); }); - // Happy path: gap exactly equal to MIN_INVESTMENT_PERIOD is accepted (lower bound is - // inclusive). + // Happy path: gap exactly equal to kMinInvestmentPeriod is accepted (lower bound is + // inclusive). A min-gap vault can originate a minimum-interval loan; see + // LoanSet_test::testLoanSetClosedEnded. withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { auto const sub = env.now().time_since_epoch().count() + 60; auto const red = sub + minPeriod; @@ -547,7 +548,7 @@ private: Asset const asset = xrpIssue(); // Widen the Investment window so a single-payment loan (min payment - // interval kMinPaymentInterval = 60s) fits before RedemptionDate. + // interval 60s plus kLoanRedemptionBuffer) fits before RedemptionDate. auto const [vault, keylet, sub, red] = makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod + 3600u); @@ -627,7 +628,7 @@ private: auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); Asset const asset = xrpIssue(); // Widen the Investment window so a single-payment loan (min payment interval - // kMinPaymentInterval = 60s) fits before RedemptionDate with headroom. + // 60s plus kLoanRedemptionBuffer) fits before RedemptionDate with headroom. auto const [vault, keylet, sub, red] = makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u);