Merge remote-tracking branch 'origin/develop' into tapanito/vault-zero-delta

This commit is contained in:
Vito
2026-09-01 21:02:48 +02:00
8 changed files with 110 additions and 39 deletions

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -26,6 +26,7 @@
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/Units.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/transactors/lending/LoanSet.h>
#include <array>
#include <cstdint>
@@ -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:

View File

@@ -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

View File

@@ -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);