mirror of
https://github.com/XRPLF/rippled.git
synced 2026-09-27 23:38:08 +00:00
Merge remote-tracking branch 'origin/develop' into tapanito/vault-zero-delta
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user