From c71682a1f578e9211b30d10c1d9229e5c3a4988c Mon Sep 17 00:00:00 2001 From: JCW Date: Thu, 30 Jul 2026 14:58:06 +0100 Subject: [PATCH 01/12] Closed ended vault --- include/xrpl/ledger/View.h | 9 +- include/xrpl/ledger/helpers/VaultHelpers.h | 22 + include/xrpl/protocol/Protocol.h | 31 + .../xrpl/protocol/detail/ledger_entries.macro | 3 + include/xrpl/protocol/detail/sfields.macro | 3 + .../xrpl/protocol/detail/transactions.macro | 3 + include/xrpl/tx/invariants/LoanInvariant.h | 3 + include/xrpl/tx/invariants/VaultInvariant.h | 26 + src/libxrpl/ledger/View.cpp | 12 +- src/libxrpl/ledger/helpers/VaultHelpers.cpp | 26 + src/libxrpl/tx/invariants/InvariantCheck.cpp | 14 + src/libxrpl/tx/invariants/LoanInvariant.cpp | 31 + src/libxrpl/tx/invariants/VaultInvariant.cpp | 95 +++ .../tx/transactors/lending/LoanSet.cpp | 18 + .../tx/transactors/vault/VaultCreate.cpp | 35 + .../tx/transactors/vault/VaultDeposit.cpp | 7 + .../tx/transactors/vault/VaultWithdraw.cpp | 6 + src/test/app/Invariants_test.cpp | 256 +++++++ src/test/app/Loan_test.cpp | 203 +++++- src/test/app/Vault_test.cpp | 668 ++++++++++++++++++ src/test/jtx/impl/vault.cpp | 6 + src/test/jtx/vault.h | 6 + 22 files changed, 1473 insertions(+), 10 deletions(-) diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 768e5180086..57ea805cbc5 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -54,11 +54,18 @@ enum class SkipEntry : bool { No = false, Yes }; * * @param view The ledger whose parent time is used as the clock. * @param exp The optional expiration time we want to check. + * @param comparison Whether the boundary is inclusive (`now >= exp`, the + * default) or exclusive (`now > exp`). * * @return `true` if `exp` is in the past; `false` otherwise. */ +enum class ExpiryComparison { Inclusive, Exclusive }; + [[nodiscard]] bool -hasExpired(ReadView const& view, std::optional const& exp); +hasExpired( + ReadView const& view, + std::optional const& exp, + ExpiryComparison comparison = ExpiryComparison::Inclusive); // Note, depth parameter is used to limit the recursion depth [[nodiscard]] bool diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index 5681cc57e86..4444b707def 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -123,4 +123,26 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref [[nodiscard]] VaultVersion getVaultVersion(SLE::const_ref vault); +/** + * Resolves the VaultKind of a vault SLE. Returns VaultKind::ClosedEnded when + * sfVaultKind is present and equal to that value; anything else (including an + * absent field or an unrecognised value) is treated as VaultKind::OpenEnded. + * + * @param vault The vault SLE. + */ +[[nodiscard]] VaultKind +getVaultKind(SLE::const_ref vault); + +/** + * Returns the current lifecycle phase of a vault (XLS-103 2.2). Open-ended + * vaults are always NoPhase. For closed-ended vaults the phase is derived + * from the parent ledger close time and the vault's immutable + * SubscriptionDate and RedemptionDate. + * + * @param view The ledger view whose parent close time is used as the clock. + * @param vault The vault SLE. + */ +[[nodiscard]] VaultPhase +getVaultPhase(ReadView const& view, SLE::const_ref vault); + } // namespace xrpl diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 9938a9b768b..1101c861ddd 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -327,6 +328,36 @@ enum class VaultVersion : uint8_t { CashBasis, }; +/** + * Vault kind. Distinguishes closed-ended vaults from the default open-ended + * kind. Persisted as sfVaultKind (UINT8); absent means OpenEnded. + */ +enum class VaultKind : std::uint8_t { + OpenEnded = 0, + ClosedEnded = 1, +}; + +/** + * Lifecycle phase of a vault. Open-ended vaults are always NoPhase; the other + * three values are the phases of a closed-ended vault (XLS-103). + */ +enum class VaultPhase : std::uint8_t { + NoPhase = 0, + Subscription, + Investment, + Redemption, +}; + +/** + * Bounds on the length of a closed-ended vault's Investment phase + * (RedemptionDate - SubscriptionDate). At vault creation the gap must satisfy + * kMinInvestmentPeriod <= gap < kMaxInvestmentPeriod (XLS-103 2.4). + */ +constexpr std::uint32_t kMinInvestmentPeriod = + std::chrono::seconds{std::chrono::minutes{1}}.count(); +constexpr std::uint32_t kMaxInvestmentPeriod = + std::chrono::seconds{std::chrono::days{30 * 365}}.count(); + /** * Maximum recursion depth for vault shares being put as an asset inside * another vault; counted from 0 diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index b6408581a90..8d1730eea4b 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -506,6 +506,9 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ {sfWithdrawalPolicy, SoeRequired}, {sfScale, SoeDefault}, {sfLEVersion, SoeDefault}, + {sfVaultKind, SoeDefault}, + {sfSubscriptionDate, SoeOptional}, + {sfRedemptionDate, SoeOptional}, // no SharesTotal ever (use MPTIssuance.sfOutstandingAmount) // no PermissionedDomainID ever (use MPTIssuance.sfDomainID) })) diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 16defe3ba3f..db3a4ed9d46 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -26,6 +26,7 @@ TYPED_SFIELD(sfUNLModifyDisabling, UINT8, 17) TYPED_SFIELD(sfHookResult, UINT8, 18) TYPED_SFIELD(sfWasLockingChainSend, UINT8, 19) TYPED_SFIELD(sfWithdrawalPolicy, UINT8, 20) +TYPED_SFIELD(sfVaultKind, UINT8, 21) // 16-bit integers (common) TYPED_SFIELD(sfLedgerEntryType, UINT16, 1, SField::kSmdNever) @@ -120,6 +121,8 @@ TYPED_SFIELD(sfSponsoringOwnerCount, UINT32, 71) TYPED_SFIELD(sfSponsoringAccountCount, UINT32, 72) TYPED_SFIELD(sfRemainingOwnerCount, UINT32, 73) TYPED_SFIELD(sfSponsorFlags, UINT32, 74) +TYPED_SFIELD(sfSubscriptionDate, UINT32, 75) +TYPED_SFIELD(sfRedemptionDate, UINT32, 76) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index e805596c008..20356642531 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -862,6 +862,9 @@ TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, {sfWithdrawalPolicy, SoeOptional}, {sfData, SoeOptional}, {sfScale, SoeOptional}, + {sfVaultKind, SoeOptional}, + {sfSubscriptionDate, SoeOptional}, + {sfRedemptionDate, SoeOptional}, })) /** This transaction updates a single asset vault. */ diff --git a/include/xrpl/tx/invariants/LoanInvariant.h b/include/xrpl/tx/invariants/LoanInvariant.h index 0648881423d..b6c11e0021d 100644 --- a/include/xrpl/tx/invariants/LoanInvariant.h +++ b/include/xrpl/tx/invariants/LoanInvariant.h @@ -16,6 +16,9 @@ namespace xrpl { * @brief Invariants: Loans are internally consistent * * 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0` + * 2. A newly-created Loan against a closed-ended vault must satisfy + * `StartDate + PaymentInterval * PaymentRemaining < Vault.RedemptionDate` + * (XLS-103 7.4). * */ class ValidLoan diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index 136c6c4a258..ef446395b87 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -38,7 +38,17 @@ namespace xrpl { * - vault set must not alter the vault assets or shares balance * - no vault transaction can change loss unrealized (it's updated by loan * transactions) + * - a created closed-ended vault must satisfy + * MIN_INVESTMENT_PERIOD <= RedemptionDate - SubscriptionDate < + * MAX_INVESTMENT_PERIOD + * - vault deposit may only succeed when the vault phase is NoPhase or + * Subscription + * - vault withdrawal may not succeed when the vault phase is Investment + * - closed-ended loan origination (ttLOAN_SET) may only succeed when the + * vault phase is Investment * + * Immutability of VaultKind, SubscriptionDate and RedemptionDate is enforced + * by NoModifiedUnmodifiableFields (see InvariantCheck.cpp). */ class ValidVault { @@ -55,6 +65,9 @@ class ValidVault Number assetsAvailable = 0; Number assetsMaximum = 0; Number lossUnrealized = 0; + std::optional vaultKind; + std::optional subscriptionDate; + std::optional redemptionDate; Vault static make(SLE const&); }; @@ -153,6 +166,19 @@ class ValidVault [[nodiscard]] static bool isVaultEmpty(Vault const& vault); + /** + * @brief Invariant check for @c ttLOAN_SET. + * + * Enforces XLS-103 7.4: 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. + */ + [[nodiscard]] bool + finalizeLoanSet(ReadView const& view, beast::Journal const& j) const; + public: // Compute the coarsest scale required to represent all numbers [[nodiscard]] static std::int32_t diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index 8116f4f6417..2dd70e2950c 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -45,12 +45,20 @@ namespace xrpl { //------------------------------------------------------------------------------ bool -hasExpired(ReadView const& view, std::optional const& exp) +hasExpired( + ReadView const& view, + std::optional const& exp, + ExpiryComparison comparison) { using d = NetClock::duration; using tp = NetClock::time_point; - return exp && (view.parentCloseTime() >= tp{d{*exp}}); + if (!exp) + return false; + auto const boundary = tp{d{*exp}}; + return comparison == ExpiryComparison::Inclusive // + ? view.parentCloseTime() >= boundary + : view.parentCloseTime() > boundary; } bool diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index 78f64d20774..691b1a4d96f 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include // IWYU pragma: keep @@ -157,4 +158,29 @@ getVaultVersion(SLE::const_ref vault) return static_cast(version); } +[[nodiscard]] VaultKind +getVaultKind(SLE::const_ref vault) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultKind : valid Vault sle"); + if (vault->isFieldPresent(sfVaultKind) && + vault->at(sfVaultKind) == std::to_underlying(VaultKind::ClosedEnded)) + return VaultKind::ClosedEnded; + return VaultKind::OpenEnded; +} + +[[nodiscard]] VaultPhase +getVaultPhase(ReadView const& view, SLE::const_ref vault) +{ + if (getVaultKind(vault) == VaultKind::OpenEnded) + return VaultPhase::NoPhase; + + // Subscription includes now == SubscriptionDate; Investment starts + // strictly after SubscriptionDate. + if (!hasExpired(view, vault->at(sfSubscriptionDate), ExpiryComparison::Exclusive)) + return VaultPhase::Subscription; + if (!hasExpired(view, vault->at(sfRedemptionDate))) + return VaultPhase::Investment; + return VaultPhase::Redemption; +} + } // namespace xrpl diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 9b997e06dd9..4a006eae97c 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -1177,6 +1177,20 @@ NoModifiedUnmodifiableFields::finalize( kFieldChanged(before, after, sfGracePeriod) || kFieldChanged(before, after, sfLoanScale); break; + case ltVAULT: + /* + * The VaultKind, SubscriptionDate and RedemptionDate + * fields are introduced by featureLendingProtocolV1_1 + * and are the only vault fields whose immutability is + * enforced here; pre-V1_1 vaults do not carry them. + */ + enforce = view.rules().enabled(featureLendingProtocolV1_1); + bad = kFieldChanged(before, after, sfLedgerEntryType) || + kFieldChanged(before, after, sfLedgerIndex) || + kFieldChanged(before, after, sfVaultKind) || + kFieldChanged(before, after, sfSubscriptionDate) || + kFieldChanged(before, after, sfRedemptionDate); + break; default: /* * We check this invariant regardless of lending protocol diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index ce9a7c6e03b..e3fe20ac493 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -4,7 +4,10 @@ #include #include #include +#include +#include #include +#include #include #include #include // IWYU pragma: keep @@ -36,6 +39,34 @@ ValidLoan::finalize( for (auto const& [before, after] : loans_) { + // XLS-103 7.4: 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. + if (!before) + { + auto const broker = view.read(keylet::loanBroker(after->at(sfLoanBrokerID))); + if (broker) + { + auto const vault = view.read(keylet::vault(broker->at(sfVaultID))); + if (vault && getVaultKind(vault) == VaultKind::ClosedEnded) + { + std::uint32_t const startDate = after->at(sfStartDate); + 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 (startDate + (interval * remaining) >= redemption) + { + JLOG(j.fatal()) << "Invariant failed: closed-ended loan final payment " + "must precede RedemptionDate"; + return false; + } + } + } + } + // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3223-invariants // If `Loan.PaymentRemaining = 0` then the loan MUST be fully paid off if (after->at(sfPaymentRemaining) == 0 && diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index a9ba0ec8746..eaa4989ec25 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +30,19 @@ namespace xrpl { +namespace { + +// True iff the recorded sfVaultKind identifies a closed-ended vault. +// Centralizes the presence + enum-value check used by the phase-gate +// invariants below. +[[nodiscard]] bool +isClosedEnded(std::optional const& vaultKind) +{ + return vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded); +} + +} // namespace + ValidVault::Vault ValidVault::Vault::make(SLE const& from) { @@ -44,6 +58,9 @@ ValidVault::Vault::make(SLE const& from) self.assetsAvailable = from.at(sfAssetsAvailable); self.assetsMaximum = from.at(sfAssetsMaximum); self.lossUnrealized = from.at(sfLossUnrealized); + self.vaultKind = from[~sfVaultKind]; + self.subscriptionDate = from[~sfSubscriptionDate]; + self.redemptionDate = from[~sfRedemptionDate]; return self; } @@ -254,6 +271,31 @@ ValidVault::isVaultEmpty(Vault const& vault) return vault.assetsAvailable == 0 && vault.assetsTotal == 0; } +bool +ValidVault::finalizeLoanSet(ReadView const& view, beast::Journal const& j) const +{ + auto const& afterVault = afterVault_[0]; + + // XLS-103 3.5.4: loan origination against a closed-ended vault is only + // permitted while the vault is in the Investment phase — strictly past + // SubscriptionDate and before RedemptionDate. Open-ended vaults have + // NoPhase and are unaffected. + if (!isClosedEnded(afterVault.vaultKind)) + return true; + + bool const inInvestment = + hasExpired(view, afterVault.subscriptionDate, ExpiryComparison::Exclusive) && + !hasExpired(view, afterVault.redemptionDate); + if (!inInvestment) + { + JLOG(j.fatal()) << // + "Invariant failed: loan origination only allowed in Investment phase"; + return false; + } + + return true; +} + std::int32_t ValidVault::computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const { @@ -514,6 +556,9 @@ ValidVault::finalize( result = false; } + // Immutability of VaultKind, SubscriptionDate and RedemptionDate is + // enforced by NoModifiedUnmodifiableFields in InvariantCheck.cpp. + auto const beforeShares = [&]() -> std::optional { if (beforeVault_.empty()) return std::nullopt; @@ -600,6 +645,30 @@ ValidVault::finalize( result = false; } + if (isClosedEnded(afterVault.vaultKind)) + { + if (!afterVault.subscriptionDate || !afterVault.redemptionDate) + { + JLOG(j.fatal()) // + << "Invariant failed: closed-ended vault must have SubscriptionDate " + "and RedemptionDate"; + result = false; + } + else if ( + *afterVault.redemptionDate <= *afterVault.subscriptionDate || + *afterVault.redemptionDate - *afterVault.subscriptionDate < + kMinInvestmentPeriod || + *afterVault.redemptionDate - *afterVault.subscriptionDate >= + kMaxInvestmentPeriod) + { + JLOG(j.fatal()) // + << "Invariant failed: closed-ended vault RedemptionDate - " + "SubscriptionDate must be within [MIN_INVESTMENT_PERIOD, " + "MAX_INVESTMENT_PERIOD)"; + result = false; + } + } + return result; } case ttVAULT_SET: { @@ -660,6 +729,18 @@ ValidVault::finalize( !beforeVault_.empty(), "xrpl::ValidVault::finalize : deposit updated a vault"); auto const& beforeVault = beforeVault_[0]; + // Deposit is only allowed while the vault is in NoPhase or + // Subscription; reject if a closed-ended vault is strictly + // past SubscriptionDate. + if (isClosedEnded(afterVault.vaultKind) && + hasExpired(view, afterVault.subscriptionDate, ExpiryComparison::Exclusive)) + { + JLOG(j.fatal()) << // + "Invariant failed: deposit only allowed in " + "Subscription or NoPhase"; + result = false; + } + auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); if (!maybeVaultDeltaAssets) { @@ -798,6 +879,19 @@ ValidVault::finalize( "xrpl::ValidVault::finalize : withdrawal updated a vault"); auto const& beforeVault = beforeVault_[0]; + // Withdrawal from a closed-ended vault is not allowed during + // the Investment phase (strictly past SubscriptionDate, + // before RedemptionDate). + if (isClosedEnded(afterVault.vaultKind) && + hasExpired(view, afterVault.subscriptionDate, ExpiryComparison::Exclusive) && + !hasExpired(view, afterVault.redemptionDate)) + { + JLOG(j.fatal()) << // + "Invariant failed: withdrawal not allowed during " + "Investment phase"; + result = false; + } + auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); if (!maybeVaultDeltaAssets) { @@ -1046,6 +1140,7 @@ ValidVault::finalize( } case ttLOAN_SET: + return finalizeLoanSet(view, j); case ttLOAN_MANAGE: case ttLOAN_PAY: return true; diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index bafadd7c1d3..63b6e5b8c7f 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -309,6 +310,23 @@ LoanSet::preclaim(PreclaimContext const& ctx) return tefBAD_LEDGER; // LCOV_EXCL_LINE } + if (ctx.view.rules().enabled(featureLendingProtocolV1_1)) + { + auto const phase = getVaultPhase(ctx.view, vault); + if (phase == VaultPhase::Subscription) + return tecTOO_SOON; + if (phase == VaultPhase::Redemption) + return tecEXPIRED; + if (phase == VaultPhase::Investment) + { + auto const interval = ctx.tx.at(~sfPaymentInterval).value_or(kDefaultPaymentInterval); + auto const total = ctx.tx.at(~sfPaymentTotal).value_or(kDefaultPaymentTotal); + auto const finalPayment = getStartDate(ctx.view) + (interval * total); + if (finalPayment >= vault->at(sfRedemptionDate)) + return tecNO_PERMISSION; + } + } + if (vault->at(sfAssetsMaximum) != 0 && vault->at(sfAssetsTotal) >= vault->at(sfAssetsMaximum)) { JLOG(ctx.j.warn()) << "Vault at maximum assets limit. Can't add another loan."; diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index a522f62788e..8970ae81b13 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -43,6 +43,11 @@ VaultCreate::checkExtraFeatures(PreflightContext const& ctx) if (ctx.tx.isFieldPresent(sfDomainID) && !ctx.rules.enabled(featurePermissionedDomains)) return false; + if (!ctx.rules.enabled(featureLendingProtocolV1_1) && + (ctx.tx.isFieldPresent(sfVaultKind) || ctx.tx.isFieldPresent(sfSubscriptionDate) || + ctx.tx.isFieldPresent(sfRedemptionDate))) + return false; + return true; } @@ -99,6 +104,25 @@ VaultCreate::preflight(PreflightContext const& ctx) return temMALFORMED; } + auto const kindField = ctx.tx[~sfVaultKind]; + auto const hasSubscription = ctx.tx.isFieldPresent(sfSubscriptionDate); + auto const hasRedemption = ctx.tx.isFieldPresent(sfRedemptionDate); + auto const isClosedEnded = + kindField && *kindField == std::to_underlying(VaultKind::ClosedEnded); + if (kindField && *kindField > std::to_underlying(VaultKind::ClosedEnded)) + return temMALFORMED; + if (!isClosedEnded && (hasSubscription || hasRedemption)) + return temMALFORMED; + if (isClosedEnded && (!hasSubscription || !hasRedemption)) + return temMALFORMED; + if (isClosedEnded) + { + auto const sub = ctx.tx[sfSubscriptionDate]; + auto const red = ctx.tx[sfRedemptionDate]; + if (red <= sub || red - sub < kMinInvestmentPeriod || red - sub >= kMaxInvestmentPeriod) + return temMALFORMED; + } + return tesSUCCESS; } @@ -136,6 +160,10 @@ VaultCreate::preclaim(PreclaimContext const& ctx) accountId == beast::kZero) return terADDRESS_COLLISION; + if (hasExpired(ctx.view, ctx.tx[~sfSubscriptionDate]) || + hasExpired(ctx.view, ctx.tx[~sfRedemptionDate])) + return tecEXPIRED; + return tesSUCCESS; } @@ -244,6 +272,13 @@ VaultCreate::doApply() vault->at(sfScale) = scale; if (view().rules().enabled(featureLendingProtocolV1_1)) vault->at(sfLEVersion) = std::to_underlying(VaultVersion::CashBasis); + if (auto const kind = tx[~sfVaultKind]; + kind && *kind == std::to_underlying(VaultKind::ClosedEnded)) + { + vault->at(sfVaultKind) = *kind; + vault->at(sfSubscriptionDate) = tx[sfSubscriptionDate]; + vault->at(sfRedemptionDate) = tx[sfRedemptionDate]; + } view().insert(vault); // Explicitly create MPToken for the vault owner diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp index aa9cfc8537b..068e2d9af48 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp @@ -71,6 +71,13 @@ VaultDeposit::preclaim(PreclaimContext const& ctx) if (!vault) return tecNO_ENTRY; + if (ctx.view.rules().enabled(featureLendingProtocolV1_1)) + { + auto const phase = getVaultPhase(ctx.view, vault); + if (phase == VaultPhase::Investment || phase == VaultPhase::Redemption) + return tecNO_PERMISSION; + } + auto const& account = ctx.tx[sfAccount]; auto const amount = ctx.tx[sfAmount]; auto const vaultAsset = vault->at(sfAsset); diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index 353b72c30d1..c57bd1f7f2f 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -73,6 +73,12 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) if (!vault) return tecNO_ENTRY; + if (ctx.view.rules().enabled(featureLendingProtocolV1_1)) + { + if (getVaultPhase(ctx.view, vault) == VaultPhase::Investment) + return tecTOO_SOON; + } + auto const amount = ctx.tx[sfAmount]; auto const vaultAsset = vault->at(sfAsset); auto const vaultShare = vault->at(sfShareMPTID); diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index eaf1f2704c8..517d60f034d 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -2472,6 +2472,57 @@ class Invariants_test : public beast::unit_test::Suite // TODO: Loan Object + // XLS-103 3.4: VaultKind, SubscriptionDate and RedemptionDate are + // immutable once set at creation. Enforced by + // NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged. + Keylet closedEndedVaultKeylet = keylet::amendments(); + Preclose const createClosedEndedVault = [&, this]( + Account const& a, Account const&, Env& env) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod + 1'000'000; + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = a, + .asset = xrpIssue(), + .vaultKind = std::to_underlying(VaultKind::ClosedEnded), + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + closedEndedVaultKeylet = keylet; + return BEAST_EXPECT(env.le(closedEndedVaultKeylet)); + }; + + { + // Each mutation must keep the vault otherwise valid (in + // particular the 4.4 gap invariant) so that only the + // immutability check fires. Shifting both dates by the same + // offset preserves the gap; bumping sfVaultKind stays within + // the recognised range. + auto const mods = std::to_array>({ + [](SLE::pointer& sle) { sle->at(sfVaultKind) += 1; }, + [](SLE::pointer& sle) { sle->at(sfSubscriptionDate) += 1; }, + [](SLE::pointer& sle) { sle->at(sfRedemptionDate) += 1; }, + }); + + for (auto const& mod : mods) + { + doInvariantCheck( + {{"changed an unchangeable field"}}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(closedEndedVaultKeylet); + if (!sle) + return false; + mod(sle); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createClosedEndedVault); + } + } + { auto const mods = std::to_array>({ [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; }, @@ -4321,6 +4372,211 @@ class Invariants_test : public beast::unit_test::Suite }}, {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, precloseMpt); + + // ───────────────────────────────────────────────────────────── + // XLS-103 closed-ended vault invariants added in + // ValidVault::finalize: 4.4 (create must supply both dates and + // satisfy the redemption-buffer gap), 5.4 (deposit only in + // Subscription / NoPhase), 6.4 (withdraw not in Investment), + // 7.4 (loan origination only in Investment). + + using d = NetClock::duration; + using tp = NetClock::time_point; + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + + // Vault keylet captured by precloseClosedEnded so precheck + // does not have to rederive it from ac.view().seq(), which + // depends on how many env.close() calls preclose issued. + Keylet closedEndedKeylet = keylet::amendments(); + + // Preclose that creates a closed-ended vault (in Subscription), + // optionally seeds it with three deposits (so a1/a2/a3 hold a + // share MPToken that kAdjust can then adjust), and optionally + // advances parent close time past SubscriptionDate. A negative + // @p advanceBySub leaves the vault in Subscription. + auto const precloseClosedEnded = [&](std::int32_t advanceBySub, bool doDeposit) { + return [&, advanceBySub, doDeposit]( + Account const& a1, Account const& a2, Env& env) -> bool { + env.fund(XRP(1000), a3, a4); + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod + 1'000'000; + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = a1, + .asset = xrpIssue(), + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + closedEndedKeylet = keylet; + if (doDeposit) + { + env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); + env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)})); + env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)})); + } + if (advanceBySub >= 0) + env.close(tp{d{sub + advanceBySub}}); + return true; + }; + }; + + // Manually insert a bare closed-ended vault (+ pseudo-account + // + share MPTokenIssuance) directly into the view, bypassing + // the transactor path. Used to synthesise ttVAULT_CREATE + // states no legitimate transactor would produce (4.4). + auto const insertBareClosedEndedVault = + [closedEnded]( + ApplyContext& ac, + Account const& owner, + std::optional subscriptionDate, + std::optional redemptionDate) -> bool { + auto const sequence = ac.view().seq(); + auto const vaultKeylet = keylet::vault(owner.id(), sequence); + auto sleVault = std::make_shared(vaultKeylet); + auto const vaultPage = ac.view().dirInsert( + keylet::ownerDir(owner.id()), sleVault->key(), describeOwnerDir(owner.id())); + if (!vaultPage) + return false; + sleVault->setFieldU64(sfOwnerNode, *vaultPage); + + auto const pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key); + auto sleAccount = std::make_shared(keylet::account(pseudoId)); + sleAccount->setAccountID(sfAccount, pseudoId); + sleAccount->setFieldAmount(sfBalance, STAmount{}); + sleAccount->setFieldU32(sfSequence, 0); + sleAccount->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); + sleAccount->setFieldH256(sfVaultID, vaultKeylet.key); + ac.view().insert(sleAccount); + + auto const sharesMptId = makeMptID(sequence, pseudoId); + auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); + auto sleShares = std::make_shared(sharesKeylet); + auto const sharesPage = ac.view().dirInsert( + keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId)); + if (!sharesPage) + return false; + sleShares->setFieldU64(sfOwnerNode, *sharesPage); + sleShares->at(sfFlags) = 0; + sleShares->at(sfIssuer) = pseudoId; + sleShares->at(sfOutstandingAmount) = 0; + sleShares->at(sfSequence) = sequence; + + sleVault->at(sfAccount) = pseudoId; + sleVault->at(sfFlags) = 0; + sleVault->at(sfSequence) = sequence; + sleVault->at(sfOwner) = owner.id(); + sleVault->at(sfAssetsTotal) = Number(0); + sleVault->at(sfAssetsAvailable) = Number(0); + sleVault->at(sfLossUnrealized) = Number(0); + sleVault->at(sfShareMPTID) = sharesMptId; + sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; + sleVault->at(sfVaultKind) = closedEnded; + if (subscriptionDate) + sleVault->at(sfSubscriptionDate) = *subscriptionDate; + if (redemptionDate) + sleVault->at(sfRedemptionDate) = *redemptionDate; + + ac.view().insert(sleVault); + ac.view().insert(sleShares); + return true; + }; + + testcase << "Vault create closed-ended"; + + // 4.4: a fresh closed-ended vault must carry both + // SubscriptionDate and RedemptionDate. + doInvariantCheck( + {"closed-ended vault must have SubscriptionDate and RedemptionDate"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + return insertBareClosedEndedVault(ac, a1, std::nullopt, std::nullopt); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + // 4.4: gap must lie in [MIN_INVESTMENT_PERIOD, + // MAX_INVESTMENT_PERIOD); covers the sub-minimum and degenerate + // RedemptionDate <= SubscriptionDate cases in one branch of the + // invariant. + doInvariantCheck( + {"closed-ended vault RedemptionDate - SubscriptionDate must be " + "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + std::uint32_t const sub = 1'000'000'000; + std::uint32_t const red = sub + kMinInvestmentPeriod - 1; + return insertBareClosedEndedVault(ac, a1, sub, red); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + // 4.4: gap exactly MAX_INVESTMENT_PERIOD is out of range (bound + // is half-open on the right). + doInvariantCheck( + {"closed-ended vault RedemptionDate - SubscriptionDate must be " + "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + std::uint32_t const sub = 1'000'000'000; + std::uint32_t const red = sub + kMaxInvestmentPeriod; + return insertBareClosedEndedVault(ac, a1, sub, red); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + testcase << "Vault deposit closed-ended"; + + // 5.4: a deposit into a closed-ended vault that has advanced + // past SubscriptionDate. kArgs simulates an otherwise valid + // deposit shape so only the phase invariant fires. + doInvariantCheck( + {"deposit only allowed in Subscription or NoPhase"}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + return kAdjust( + ac.view(), closedEndedKeylet, kArgs(a2.id(), 10, [](Adjustments&) {})); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), + TxAccount::A2); + + testcase << "Vault withdrawal closed-ended"; + + // 6.4: a withdrawal from a closed-ended vault in the + // Investment phase. + doInvariantCheck( + {"withdrawal not allowed during Investment phase"}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + return kAdjust( + ac.view(), closedEndedKeylet, kArgs(a2.id(), -10, [](Adjustments&) {})); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), + TxAccount::A2); + + testcase << "Vault loan set"; + + // 7.4: ttLOAN_SET against a closed-ended vault that is not in + // Investment. finalizeLoanSet fires on any vault mutation; + // touching the vault SLE with no field change is sufficient. + doInvariantCheck( + {"loan origination only allowed in Investment phase"}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto sleVault = ac.view().peek(closedEndedKeylet); + if (!sleVault) + return false; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseClosedEnded(/*advanceBySub=*/-1, /*doDeposit=*/false)); } void diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 8a6f1669df8..603fefffdaf 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -163,6 +163,27 @@ class Loan_test : public beast::unit_test::Suite // tests that need finer loanScale to exercise rounding edge cases. std::optional vaultScale = std::nullopt; // NOLINT(readability-redundant-member-init) + // Vault kind axis. When ClosedEnded, createVaultAndBroker sets + // sfSubscriptionDate / sfRedemptionDate from env.now() using the + // offsets below and advances the ledger clock past SubscriptionDate + // so the vault is in the Investment phase by the time the broker is + // set up. Requires featureLendingProtocolV1_1. + VaultKind vaultKind = VaultKind::OpenEnded; + // Seconds past env.now() at which SubscriptionDate lands. Must be + // strictly positive (VaultCreate::preclaim rejects + // SubscriptionDate <= parentCloseTime). + 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. + 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 phase. Ignored for + // open-ended vaults. + bool skipPhaseAdvance = false; [[nodiscard]] Number maxCoveredLoanValue(Number const& currentDebt) const @@ -190,15 +211,23 @@ class Loan_test : public beast::unit_test::Suite uint256 brokerID; uint256 vaultID; BrokerParameters params; + // Absolute dates resolved by createVaultAndBroker when params.vaultKind + // is ClosedEnded; std::nullopt for open-ended vaults. + std::optional subscriptionDate; + std::optional redemptionDate; BrokerInfo( jtx::PrettyAsset const& asset, Keylet const& brokerKeylet, Keylet const& vaultKeylet, - BrokerParameters p) + BrokerParameters p, + std::optional subscriptionDate = std::nullopt, + std::optional redemptionDate = std::nullopt) : asset(asset) , brokerID(brokerKeylet.key) , vaultID(vaultKeylet.key) , params(std::move(p)) + , subscriptionDate(subscriptionDate) + , redemptionDate(redemptionDate) { } @@ -529,7 +558,23 @@ class Loan_test : public beast::unit_test::Suite auto const coverRateMinValue = params.coverRateMin; - auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); + std::optional subscriptionDate; + std::optional redemptionDate; + if (params.vaultKind == VaultKind::ClosedEnded) + { + auto const nowSec = env.now().time_since_epoch().count(); + subscriptionDate = nowSec + params.subscriptionOffset; + redemptionDate = *subscriptionDate + params.redemptionOffset; + } + + auto [tx, vaultKeylet] = vault.create( + {.owner = lender, + .asset = asset, + .vaultKind = params.vaultKind == VaultKind::OpenEnded + ? std::optional{} + : std::optional{std::to_underlying(params.vaultKind)}, + .subscriptionDate = subscriptionDate, + .redemptionDate = redemptionDate}); if (params.vaultScale) tx[sfScale] = *params.vaultScale; env(tx); @@ -543,6 +588,16 @@ class Loan_test : public beast::unit_test::Suite BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value()); } + // For closed-ended vaults, advance past SubscriptionDate so subsequent + // LoanSet operations run in the Investment phase (unless the caller + // explicitly asked to stay in Subscription). + if (subscriptionDate && !params.skipPhaseAdvance) + { + using d = NetClock::duration; + using tp = NetClock::time_point; + env.close(tp{d{*subscriptionDate + 1}}); + } + auto const keylet = keylet::loanBroker(lender.id(), env.seq(lender)); using namespace loanBroker; @@ -558,7 +613,7 @@ class Loan_test : public beast::unit_test::Suite env.close(); - return {asset, keylet, vaultKeylet, params}; + return {asset, keylet, vaultKeylet, params, subscriptionDate, redemptionDate}; } /** @@ -3491,6 +3546,135 @@ class Loan_test : public beast::unit_test::Suite nullptr); } + // Spec 13.5: LoanSet in a closed-ended vault — phase gating and + // maturity bound. Covers spec 7.2 failure conditions and the boundary + // of the finalPayment < RedemptionDate check. + void + testLoanSetClosedEnded() + { + testcase("LoanSet closed-ended: phase and maturity bound"); + using namespace jtx; + using namespace loan; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + // Common loan schedule used by the phase-rejection cases below. + constexpr std::uint32_t kInterval = 3600u * 24u; // 1 day + constexpr std::uint32_t kTotal = 2u; + + // featureLendingProtocolV1_1 is excluded from `all_` by + // convention (see the comment on `all_`), so callers must opt + // in. Closed-ended vaults are gated on this amendment; without + // it VaultCreate returns temDISABLED and every follow-on txn + // sees tecNO_ENTRY. + auto const withEnv = [&, this](auto&& body) { + Env env(*this, testableAmendments() | featureLendingProtocolV1_1); + env.fund(XRP(1'000'000'000), issuer, lender, borrower); + env.close(); + PrettyAsset const asset{xrpIssue(), 1'000'000}; + body(env, asset); + }; + + auto const setLoan = [&](Env& env, BrokerInfo const& broker, TER expected) { + env(set(lender, broker.brokerID, broker.asset(100).value()), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + kPaymentTotal(kTotal), + kPaymentInterval(kInterval), + Ter(expected)); + env.close(); + }; + + // 1. Rejected during Subscription: the broker is created in + // Subscription (skipPhaseAdvance = true), then LoanSet is attempted + // before advancing past SubscriptionDate. + withEnv([&](Env& env, PrettyAsset const& asset) { + auto const broker = createVaultAndBroker( + env, + asset, + lender, + BrokerParameters{.vaultKind = VaultKind::ClosedEnded, .skipPhaseAdvance = true}); + setLoan(env, broker, tecTOO_SOON); + }); + + // 2. Rejected during Redemption: broker is set up normally (which + // lands the vault in Investment), then advance the clock past + // RedemptionDate before attempting LoanSet. + withEnv([&](Env& env, PrettyAsset const& asset) { + auto const broker = createVaultAndBroker( + env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded}); + BEAST_EXPECT(broker.redemptionDate.has_value()); + using d = NetClock::duration; + using tp = NetClock::time_point; + env.close(tp{d{*broker.redemptionDate + 1}}); + setLoan(env, broker, tecEXPIRED); + }); + + // 3. Accepted during Investment when the schedule comfortably fits + // before RedemptionDate. + withEnv([&](Env& env, PrettyAsset const& asset) { + auto const broker = createVaultAndBroker( + env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded}); + 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. + withEnv([&](Env& env, PrettyAsset const& asset) { + constexpr std::uint32_t kRedemptionOffset = 3u * 24u * 3600u; + auto const broker = createVaultAndBroker( + env, + asset, + lender, + BrokerParameters{ + .vaultKind = VaultKind::ClosedEnded, .redemptionOffset = kRedemptionOffset}); + env(set(lender, broker.brokerID, broker.asset(100).value()), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + kPaymentTotal(10u), + kPaymentInterval(kInterval), + Ter(tecNO_PERMISSION)); + 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. + 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; + env(set(lender, broker.brokerID, broker.asset(100).value()), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + kPaymentTotal(1u), + kPaymentInterval(acceptInterval), + Ter(tesSUCCESS)); + env.close(); + + auto const rejectInterval = + *broker.redemptionDate - env.now().time_since_epoch().count(); + env(set(lender, broker.brokerID, broker.asset(100).value()), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + kPaymentTotal(1u), + kPaymentInterval(rejectInterval), + Ter(tecNO_PERMISSION)); + env.close(); + }); + } + void testLifecycle(FeatureBitset features) { @@ -4441,9 +4625,11 @@ class Loan_test : public beast::unit_test::Suite } void - testInvalidLoanSet() + testInvalidLoanSet(VaultKind vaultKind) { - testcase("Invalid LoanSet"); + testcase( + std::string("Invalid LoanSet (") + + (vaultKind == VaultKind::OpenEnded ? "open-ended" : "closed-ended") + " vault)"); using namespace jtx; using namespace loan; Account const lender{"lender"}; @@ -4457,7 +4643,8 @@ class Loan_test : public beast::unit_test::Suite env.fund(XRP(1'000), lender, issuer, borrower, sponsor); env(trust(lender, iou(10'000'000))); env(pay(issuer, lender, iou(5'000'000))); - BrokerInfo const brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; + BrokerInfo const brokerInfo{ + createVaultAndBroker(env, issuer["IOU"], lender, {.vaultKind = vaultKind})}; auto const loanSetFee = Fee(env.current()->fees().base * 2); Number const debtMaximumRequest = brokerInfo.asset(1'000).value(); @@ -9534,7 +9721,9 @@ class Loan_test : public beast::unit_test::Suite runAmendmentIndependent() { testDisabled(); - testInvalidLoanSet(); + for (auto const kind : {VaultKind::OpenEnded, VaultKind::ClosedEnded}) + testInvalidLoanSet(kind); + testLoanSetClosedEnded(); testInvalidLoanDelete(); testInvalidLoanManage(); testInvalidLoanPay(); diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index bd596d61499..686f19c2137 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -66,6 +66,7 @@ #include #include #include +#include #include #include #include @@ -81,6 +82,13 @@ class Vault_test : public beast::unit_test::Suite return {STAmount{asset.raw(), 1ul, 0, true, STAmount::Unchecked{}}, ""}; }; + static void + closeToTime(test::jtx::Env& env, NetClock::time_point time) + { + using namespace std::chrono_literals; + env.close(time - env.closed()->header().closeTimeResolution + 1s); + } + void testSequences() { @@ -1106,6 +1114,575 @@ class Vault_test : public beast::unit_test::Suite }); } + // Spec 13.1: VaultCreate malformation and happy paths for + // closed-ended vaults, plus the featureLendingProtocolV1_1 gate. + void + testVaultCreateClosedEnded() + { + testcase("closed-ended VaultCreate"); + using namespace test::jtx; + + auto const withEnv = [this](FeatureBitset features, auto&& body) { + Env env{*this, features}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + Vault vault{env}; + body(env, owner, vault); + }; + + Asset const asset = xrpIssue(); + auto const minPeriod = kMinInvestmentPeriod; + auto const maxPeriod = kMaxInvestmentPeriod; + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + + // Gate: the three new fields require featureLendingProtocolV1_1. + withEnv( + testableAmendments() - featureLendingProtocolV1_1, + [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub + minPeriod}); + env(tx, Ter{temDISABLED}); + }); + + // Valid closed-ended creation with a comfortably interior gap + // (well above MIN_INVESTMENT_PERIOD and well below + // MAX_INVESTMENT_PERIOD). + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + 86400; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfVaultKind) == closedEnded); + BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub); + BEAST_EXPECT(sle->at(sfRedemptionDate) == red); + } + }); + + // ClosedEnded missing one of SubscriptionDate / RedemptionDate => + // temMALFORMED. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .redemptionDate = sub + minPeriod}); + env(tx, Ter{temMALFORMED}); + }); + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub}); + env(tx, Ter{temMALFORMED}); + }); + + // SubscriptionDate not strictly after parent close time (preclaim, + // state-dependent - returns tecEXPIRED). + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const nowSec = env.now().time_since_epoch().count(); + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = nowSec, + .redemptionDate = nowSec + minPeriod}); + env(tx, Ter{tecEXPIRED}); + }); + + // RedemptionDate not strictly after parent close time => tecEXPIRED. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + using namespace std::chrono_literals; + // nowSec - minPeriod is std::uint32_t subtraction; advance the + // ledger clock past MIN_INVESTMENT_PERIOD so the subtraction + // cannot underflow. + env.close(std::chrono::seconds{minPeriod}); + auto const nowSec = env.now().time_since_epoch().count(); + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = nowSec - minPeriod, + .redemptionDate = nowSec}); + env(tx, Ter{tecEXPIRED}); + }); + + // Gap smaller than MIN_INVESTMENT_PERIOD => temMALFORMED. Includes + // the SubscriptionDate >= RedemptionDate degenerate case. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub + minPeriod - 1}); + env(tx, Ter{temMALFORMED}); + }); + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub}); + env(tx, Ter{temMALFORMED}); + }); + + // Gap equal to MAX_INVESTMENT_PERIOD => temMALFORMED (bound is half-open on the right). + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub + maxPeriod}); + env(tx, Ter{temMALFORMED}); + }); + + // Happy path: gap exactly equal to MIN_INVESTMENT_PERIOD is accepted (lower bound is + // inclusive). + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + minPeriod; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfRedemptionDate) == red); + } + }); + + // Happy path: gap one second less than MAX_INVESTMENT_PERIOD is + // accepted (upper bound is exclusive). + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + maxPeriod - 1; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfRedemptionDate) == red); + } + }); + + // OpenEnded (absent/0) with SubscriptionDate or RedemptionDate present + // => temMALFORMED. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = + vault.create({.owner = owner, .asset = asset, .subscriptionDate = sub}); + env(tx, Ter{temMALFORMED}); + }); + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = + vault.create({.owner = owner, .asset = asset, .redemptionDate = sub + minPeriod}); + env(tx, Ter{temMALFORMED}); + }); + + // Unrecognised VaultKind => temMALFORMED. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = static_cast(closedEnded + 1)}); + env(tx, Ter{temMALFORMED}); + }); + + // Happy path: open-ended vault (no new fields present) is unaffected. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind)); + BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate)); + BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate)); + } + }); + + // Happy path: explicit `VaultKind = 0` (OpenEnded) behaves the same + // as absent. Per spec, absent and OpenEnded are equivalent. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = std::to_underlying(VaultKind::OpenEnded)}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + // OpenEnded is sfVaultKind's default; SoeDefault fields + // aren't serialized when they hold the default value. + BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind)); + BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate)); + BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate)); + } + }); + } + + // Spec 13.2: phase derivation across the SubscriptionDate / RedemptionDate + // boundaries, including the now == SubscriptionDate case (which must + // still resolve to Subscription). + void + testVaultPhaseDerivation() + { + testcase("closed-ended phase derivation"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + env.fund(XRP(1000), owner, depositor); + env.close(); + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + Asset const asset = xrpIssue(); + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod; + + Vault vault{env}; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + + auto const deposit = + [&](TER expected, std::source_location const& loc = std::source_location::current()) { + env( + WithSourceLocation{ + vault.deposit( + {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), + loc}, + Ter{expected}); + env.close(); + }; + + using d = NetClock::duration; + using tp = NetClock::time_point; + + // Deposit at ledger time comfortably before SubscriptionDate: allowed + // (Subscription). + deposit(tesSUCCESS); + + // auto timeTo = [&] { return sub - env.now().time_since_epoch().count(); }; + // Boundary: parent close time exactly at SubscriptionDate must still + // be Subscription (deposit still allowed). + closeToTime(env, tp{d{sub}}); + deposit(tesSUCCESS); + + // One second past SubscriptionDate: Investment (deposit rejected). + closeToTime(env, tp{d{sub + 1}}); + deposit(tecNO_PERMISSION); + + // Any point strictly before RedemptionDate remains Investment. + closeToTime(env, tp{d{red - 1}}); + deposit(tecNO_PERMISSION); + + // Boundary: parent close time == RedemptionDate is Redemption (per + // spec table: now >= RedemptionDate). Deposits still rejected in + // Redemption. + closeToTime(env, tp{d{red}}); + deposit(tecNO_PERMISSION); + } + + // Spec 13.3: VaultDeposit is allowed only during Subscription + // (or NoPhase). Rejected during Investment and Redemption. + void + testVaultDepositClosedEnded() + { + testcase("closed-ended VaultDeposit phase gating"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + env.fund(XRP(1000), owner, depositor); + env.close(); + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + Asset const asset = xrpIssue(); + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod; + + Vault vault{env}; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + + auto const deposit = [&](TER expected) { + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), + Ter{expected}); + env.close(); + }; + + using d = NetClock::duration; + using tp = NetClock::time_point; + + // Subscription: allowed. + deposit(tesSUCCESS); + + // Investment: rejected. + env.close(tp{d{sub + 1}}); + deposit(tecNO_PERMISSION); + + // Redemption: rejected. + env.close(tp{d{red}}); + deposit(tecNO_PERMISSION); + } + + // Spec 13.4: VaultWithdraw is allowed in Subscription and + // Redemption; rejected in Investment. The AssetsAvailable cap continues + // to apply and is exercised in Redemption. + void + testVaultWithdrawClosedEnded() + { + testcase("closed-ended VaultWithdraw phase gating"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + env.fund(XRP(1000), owner, depositor); + env.close(); + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + Asset const asset = xrpIssue(); + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod + 60; + + Vault vault{env}; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + + // Deposit in Subscription so there is capital to withdraw later. + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(10).value()})); + env.close(); + + auto const withdraw = [&](STAmount const& amount, + TER expected, + std::source_location const& loc = + std::source_location::current()) { + env( + WithSourceLocation{ + vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount}), + loc}, + Ter{expected}); + env.close(); + }; + + using d = NetClock::duration; + using tp = NetClock::time_point; + + // Subscription: allowed (LP cancel). + withdraw(XRP(1).value(), tesSUCCESS); + + // Investment: rejected. + closeToTime(env, tp{d{sub}}); + env.close(); + withdraw(XRP(1).value(), tecTOO_SOON); + + // Redemption: allowed. AssetsAvailable cap: attempting to withdraw + // more than the vault holds must still fail with tecINSUFFICIENT_FUNDS. + closeToTime(env, tp{d{red}}); + withdraw(XRP(1).value(), tesSUCCESS); + withdraw(XRP(1'000'000).value(), tecINSUFFICIENT_FUNDS); + } + + // Spec 13.10: end-to-end lifecycle of a closed-ended vault + // (Subscription → Investment → Redemption) with multiple LPs, + // exercising every phase transition and verifying the expected + // deposit and withdrawal behaviour in each phase. + void + testVaultClosedEndedLifecycle() + { + testcase("closed-ended vault lifecycle (subscribe → invest → redeem)"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(10'000), owner, alice, bob); + env.close(); + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + Asset const asset = xrpIssue(); + auto const sub = env.now().time_since_epoch().count() + 300; + auto const red = sub + 60; + + Vault vault{env}; + auto [createTx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(createTx); + env.close(); + + auto const sleCreate = env.le(keylet); + BEAST_EXPECT(sleCreate); + MPTIssue const shares{sleCreate->at(sfShareMPTID)}; + + auto const availableEq = [&](STAmount const& expected) { + auto const sle = env.le(keylet); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == expected); + BEAST_EXPECT(sle->at(sfAssetsTotal) == expected); + }; + + // env.balance(account, mptIssue) name-resolves the issuer via + // Env::lookup, but the share issuer is the vault's pseudo-account + // and is never registered with the jtx Env. Read the MPToken SLE + // directly to avoid the lookup. + auto const sharesEq = [&](Account const& holder, std::uint64_t expected) { + auto const sle = env.le(keylet::mptoken(shares.getMptID(), holder.id())); + std::uint64_t const actual = sle ? sle->getFieldU64(sfMPTAmount) : 0u; + BEAST_EXPECT(actual == expected); + }; + + // ---- Subscription phase ---- + // A legitimate VaultSet succeeds (positive control for 3.7). + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfData] = "AA"; + env(tx); + env.close(); + } + + // alice deposits 100 XRP. + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + sharesEq(alice, 100'000'000); + availableEq(XRP(100).value()); + + // bob deposits 200 XRP. + env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()})); + env.close(); + sharesEq(bob, 200'000'000); + availableEq(XRP(300).value()); + + // alice cancels 25 XRP (LP cancel is permitted in Subscription). + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(25).value()})); + env.close(); + sharesEq(alice, 75'000'000); + availableEq(XRP(275).value()); + + // ---- Investment phase (now == sub + 1) ---- + using d = NetClock::duration; + using tp = NetClock::time_point; + env.close(tp{d{sub + 1}}); + + // Spec 5.2: deposits into a closed-ended vault past + // SubscriptionDate return tecNO_PERMISSION. + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), + Ter{tecNO_PERMISSION}); + env.close(); + // Spec 6.2.1: withdrawals from a closed-ended vault during the + // Investment phase return tecTOO_SOON. + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), + Ter{tecTOO_SOON}); + env.close(); + + // Non-immutable VaultSet still works in Investment (positive control). + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfData] = "BB"; + env(tx); + env.close(); + } + + // Balances unchanged after the two failed txns and one set. + sharesEq(alice, 75'000'000); + sharesEq(bob, 200'000'000); + availableEq(XRP(275).value()); + + // ---- Redemption phase (now == red) ---- + env.close(tp{d{red}}); + + // Spec 5.2: deposits into a closed-ended vault past + // SubscriptionDate return tecNO_PERMISSION, in both Investment + // and Redemption. + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), + Ter{tecNO_PERMISSION}); + env.close(); + + // alice redeems her remaining 75 XRP. + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(75).value()})); + env.close(); + sharesEq(alice, 0); + availableEq(XRP(200).value()); + + // bob redeems his 200 XRP. + env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()})); + env.close(); + sharesEq(bob, 0); + availableEq(XRP(0).value()); + + // Defensive spot-check that the three immutable fields (spec 3.4) + // have not changed across the entire lifecycle. Direct immutability + // coverage lives with the invariant tests (spec 13.9). + auto const sleFinal = env.le(keylet); + if (BEAST_EXPECT(sleFinal)) + { + BEAST_EXPECT(sleFinal->at(sfVaultKind) == closedEnded); + BEAST_EXPECT(sleFinal->at(sfSubscriptionDate) == sub); + BEAST_EXPECT(sleFinal->at(sfRedemptionDate) == red); + } + } + // Test for non-asset specific behaviors. void testCreateFailXRP() @@ -4406,6 +4983,91 @@ class Vault_test : public beast::unit_test::Suite } } + // XLS-103 spec 13.8: RPC coverage: closed-ended vaults must return VaultKind, + // SubscriptionDate and RedemptionDate in both vault_info and + // ledger_entry responses. Open-ended vaults must not. + void + testRPCClosedEnded() + { + using namespace test::jtx; + + testcase("RPC closed-ended vault fields"); + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const owner2{"owner2"}; + env.fund(XRP(1000), owner, owner2); + env.close(); + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + Asset const asset = xrpIssue(); + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod; + + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + + auto [tx2, keylet2] = vault.create({.owner = owner2, .asset = asset}); + env(tx2); + env.close(); + + auto const asUInt = [](json::Value const& jv) -> json::UInt { + return jv.isUInt() ? jv.asUInt() : json::UInt(jv.asInt()); + }; + auto const checkClosedEnded = [&](json::Value const& v) { + BEAST_EXPECT(v.isObject()); + BEAST_EXPECT(v.isMember(sfVaultKind.fieldName)); + BEAST_EXPECT(asUInt(v[sfVaultKind.fieldName]) == json::UInt(closedEnded)); + BEAST_EXPECT(v.isMember(sfSubscriptionDate.fieldName)); + BEAST_EXPECT(asUInt(v[sfSubscriptionDate.fieldName]) == json::UInt(sub)); + BEAST_EXPECT(v.isMember(sfRedemptionDate.fieldName)); + BEAST_EXPECT(asUInt(v[sfRedemptionDate.fieldName]) == json::UInt(red)); + }; + auto const checkOpenEnded = [&](json::Value const& v) { + BEAST_EXPECT(v.isObject()); + BEAST_EXPECT(!v.isMember(sfVaultKind.fieldName)); + BEAST_EXPECT(!v.isMember(sfSubscriptionDate.fieldName)); + BEAST_EXPECT(!v.isMember(sfRedemptionDate.fieldName)); + }; + + { + json::Value jvParams; + jvParams[jss::vault_id] = strHex(keylet.key); + auto jv = env.rpc("json", "vault_info", to_string(jvParams)); + BEAST_EXPECT(!jv[jss::result].isMember(jss::error)); + checkClosedEnded(jv[jss::result][jss::vault]); + } + { + json::Value jvParams; + jvParams[jss::ledger_index] = jss::validated; + jvParams[jss::vault] = strHex(keylet.key); + auto jv = env.rpc("json", "ledger_entry", to_string(jvParams)); + BEAST_EXPECT(!jv[jss::result].isMember(jss::error)); + checkClosedEnded(jv[jss::result][jss::node]); + } + { + json::Value jvParams; + jvParams[jss::vault_id] = strHex(keylet2.key); + auto jv = env.rpc("json", "vault_info", to_string(jvParams)); + BEAST_EXPECT(!jv[jss::result].isMember(jss::error)); + checkOpenEnded(jv[jss::result][jss::vault]); + } + { + json::Value jvParams; + jvParams[jss::ledger_index] = jss::validated; + jvParams[jss::vault] = strHex(keylet2.key); + auto jv = env.rpc("json", "ledger_entry", to_string(jvParams)); + BEAST_EXPECT(!jv[jss::result].isMember(jss::error)); + checkOpenEnded(jv[jss::result][jss::node]); + } + } + void testVaultClawbackBurnShares() { @@ -8381,6 +9043,11 @@ class Vault_test : public beast::unit_test::Suite testCreateFailXRP(); testCreateFailIOU(); testCreateFailMPT(); + testVaultCreateClosedEnded(); + testVaultPhaseDerivation(); + testVaultDepositClosedEnded(); + testVaultWithdrawClosedEnded(); + testVaultClosedEndedLifecycle(); testWithMPT(); testWithIOU(); testWithDomainCheck(); @@ -8389,6 +9056,7 @@ class Vault_test : public beast::unit_test::Suite testFailedPseudoAccount(); testScaleIOU(); testRPC(); + testRPCClosedEnded(); testVaultClawbackBurnShares(); testVaultClawbackAssets(); testVaultEscrowedMPT(); diff --git a/src/test/jtx/impl/vault.cpp b/src/test/jtx/impl/vault.cpp index 70843477630..3c1784cb4ae 100644 --- a/src/test/jtx/impl/vault.cpp +++ b/src/test/jtx/impl/vault.cpp @@ -26,6 +26,12 @@ Vault::create(CreateArgs const& args) const jv[jss::Asset] = toJson(args.asset); if (args.flags) jv[jss::Flags] = *args.flags; + if (args.vaultKind) + jv[sfVaultKind] = *args.vaultKind; + if (args.subscriptionDate) + jv[sfSubscriptionDate] = *args.subscriptionDate; + if (args.redemptionDate) + jv[sfRedemptionDate] = *args.redemptionDate; return {jv, keylet}; } diff --git a/src/test/jtx/vault.h b/src/test/jtx/vault.h index e72eae89b75..992051b61ff 100644 --- a/src/test/jtx/vault.h +++ b/src/test/jtx/vault.h @@ -25,6 +25,12 @@ struct Vault Asset asset; std::optional flags = std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional vaultKind = + std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional subscriptionDate = + std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional redemptionDate = + std::nullopt; // NOLINT(readability-redundant-member-init) }; /** From 5f5f5ad311704496d259cf84f1b102a0cf899315 Mon Sep 17 00:00:00 2001 From: JCW Date: Thu, 6 Aug 2026 14:55:51 +0100 Subject: [PATCH 02/12] WIP --- src/libxrpl/tx/invariants/LoanInvariant.cpp | 2 +- src/libxrpl/tx/invariants/VaultInvariant.cpp | 16 +- .../tx/transactors/lending/LoanSet.cpp | 2 +- .../tx/transactors/vault/VaultCreate.cpp | 6 + src/test/app/Invariants_test.cpp | 96 +++++++++++- src/test/app/Vault_test.cpp | 142 +++++++++++++----- 6 files changed, 215 insertions(+), 49 deletions(-) diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index e3fe20ac493..267ad38e571 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -57,7 +57,7 @@ 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 (startDate + (interval * remaining) >= redemption) + if (std::uint64_t{startDate} + (std::uint64_t{interval} * remaining) >= redemption) { JLOG(j.fatal()) << "Invariant failed: closed-ended loan final payment " "must precede RedemptionDate"; diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index eaa4989ec25..cfad6c14866 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -32,9 +32,11 @@ namespace xrpl { namespace { -// True iff the recorded sfVaultKind identifies a closed-ended vault. -// Centralizes the presence + enum-value check used by the phase-gate -// invariants below. +/* + * True iff the recorded sfVaultKind identifies a closed-ended vault. + * Centralizes the presence + enum-value check used by the phase-gate + * invariants below. + */ [[nodiscard]] bool isClosedEnded(std::optional const& vaultKind) { @@ -274,6 +276,14 @@ ValidVault::isVaultEmpty(Vault const& vault) bool ValidVault::finalizeLoanSet(ReadView const& view, beast::Journal const& j) const { + if (afterVault_.empty()) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::ValidVault::finalizeLoanSet : vault exists"); + return false; + // LCOV_EXCL_STOP + } + auto const& afterVault = afterVault_[0]; // XLS-103 3.5.4: loan origination against a closed-ended vault is only diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 63b6e5b8c7f..7c95f0cb846 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -321,7 +321,7 @@ LoanSet::preclaim(PreclaimContext const& ctx) { auto const interval = ctx.tx.at(~sfPaymentInterval).value_or(kDefaultPaymentInterval); auto const total = ctx.tx.at(~sfPaymentTotal).value_or(kDefaultPaymentTotal); - auto const finalPayment = getStartDate(ctx.view) + (interval * total); + auto const finalPayment = std::uint64_t{getStartDate(ctx.view)} + (std::uint64_t{interval} * total); if (finalPayment >= vault->at(sfRedemptionDate)) return tecNO_PERMISSION; } diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index 8970ae81b13..8993bffdaae 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -160,6 +160,12 @@ VaultCreate::preclaim(PreclaimContext const& ctx) accountId == beast::kZero) return terADDRESS_COLLISION; + // preflight enforces red >= sub + kMinInvestmentPeriod for closed-ended + // vaults, so a past RedemptionDate always implies a strictly-earlier, + // equally-past SubscriptionDate. The RedemptionDate arm below is therefore + // defensive: it cannot be the sole cause of tecEXPIRED. It is kept to + // preserve the invariant locally in case the preflight gap check is ever + // weakened. if (hasExpired(ctx.view, ctx.tx[~sfSubscriptionDate]) || hasExpired(ctx.view, ctx.tx[~sfRedemptionDate])) return tecEXPIRED; diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 517d60f034d..542a5ee043d 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -4467,6 +4467,7 @@ class Invariants_test : public beast::unit_test::Suite sleVault->at(sfFlags) = 0; sleVault->at(sfSequence) = sequence; sleVault->at(sfOwner) = owner.id(); + sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, Asset{xrpIssue()}}); sleVault->at(sfAssetsTotal) = Number(0); sleVault->at(sfAssetsAvailable) = Number(0); sleVault->at(sfLossUnrealized) = Number(0); @@ -4496,10 +4497,9 @@ class Invariants_test : public beast::unit_test::Suite STTx{ttVAULT_CREATE, [](STObject&) {}}, {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - // 4.4: gap must lie in [MIN_INVESTMENT_PERIOD, - // MAX_INVESTMENT_PERIOD); covers the sub-minimum and degenerate - // RedemptionDate <= SubscriptionDate cases in one branch of the - // invariant. + // 4.4: gap smaller than MIN_INVESTMENT_PERIOD but with + // RedemptionDate > SubscriptionDate; exercises the sub-minimum + // branch of the gap check. doInvariantCheck( {"closed-ended vault RedemptionDate - SubscriptionDate must be " "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, @@ -4512,6 +4512,21 @@ class Invariants_test : public beast::unit_test::Suite STTx{ttVAULT_CREATE, [](STObject&) {}}, {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + // 4.4: RedemptionDate strictly before SubscriptionDate; + // exercises the red <= sub short-circuit that guards the + // unsigned red - sub subtraction against wrap-around. + doInvariantCheck( + {"closed-ended vault RedemptionDate - SubscriptionDate must be " + "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + std::uint32_t const sub = 1'000'000'000; + std::uint32_t const red = sub - 1; + return insertBareClosedEndedVault(ac, a1, sub, red); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + // 4.4: gap exactly MAX_INVESTMENT_PERIOD is out of range (bound // is half-open on the right). doInvariantCheck( @@ -4577,6 +4592,79 @@ class Invariants_test : public beast::unit_test::Suite STTx{ttLOAN_SET, [](STObject&) {}}, {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, precloseClosedEnded(/*advanceBySub=*/-1, /*doDeposit=*/false)); + + testcase << "Vault loan set - closed-ended final payment past " + "RedemptionDate"; + + // XLS-103 7.4 (ValidLoan): 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. + Keylet closedEndedBrokerKeylet = keylet::amendments(); + std::uint32_t closedEndedRed = 0; + doInvariantCheck( + {"closed-ended loan final payment must precede RedemptionDate"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + // Touch the vault so ValidVault::finalizeLoanSet sees an + // entry in afterVault_; the vault is in Investment, so + // finalizeLoanSet itself passes. + auto sleVault = ac.view().peek(closedEndedKeylet); + if (!sleVault) + return false; + ac.view().update(sleVault); + + // Read the broker's next loan sequence to build the loan + // keylet the same way LoanSet::doApply would. + auto sleBroker = ac.view().peek(closedEndedBrokerKeylet); + if (!sleBroker) + return false; + std::uint32_t const loanSeq = sleBroker->at(sfLoanSequence); + + // Synthesise a Loan whose final scheduled payment lands + // exactly at RedemptionDate: StartDate = red, interval = 60, + // remaining = 1 => red + 60 >= red. + auto sleLoan = std::make_shared( + keylet::loan(closedEndedBrokerKeylet.key, loanSeq)); + sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key; + sleLoan->at(sfLoanSequence) = loanSeq; + sleLoan->at(sfBorrower) = a1.id(); + sleLoan->at(sfStartDate) = closedEndedRed; + sleLoan->at(sfPaymentInterval) = 60; + sleLoan->at(sfPaymentRemaining) = 1; + sleLoan->at(sfPeriodicPayment) = Number(1); + ac.view().insert(sleLoan); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const&, Env& env) -> bool { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod + 1'000'000; + closedEndedRed = red; + + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = a1, + .asset = xrpIssue(), + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + closedEndedKeylet = keylet; + + // Create the loan broker; LoanBrokerSet has no phase gate. + closedEndedBrokerKeylet = + keylet::loanBroker(a1.id(), env.seq(a1)); + env(loanBroker::set(a1, keylet.key)); + + // Advance parent close time into Investment so + // ValidVault::finalizeLoanSet is satisfied. + env.close(tp{d{sub + 1}}); + return true; + }); } void diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 686f19c2137..8f250fc18a3 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -1150,9 +1150,10 @@ class Vault_test : public beast::unit_test::Suite env(tx, Ter{temDISABLED}); }); - // Valid closed-ended creation with a comfortably interior gap - // (well above MIN_INVESTMENT_PERIOD and well below - // MAX_INVESTMENT_PERIOD). + /* + * Valid closed-ended creation with a comfortably interior gap + * (well above MIN_INVESTMENT_PERIOD and well below MAX_INVESTMENT_PERIOD). + */ withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { auto const sub = env.now().time_since_epoch().count() + 60; auto const red = sub + 86400; @@ -1173,8 +1174,7 @@ class Vault_test : public beast::unit_test::Suite } }); - // ClosedEnded missing one of SubscriptionDate / RedemptionDate => - // temMALFORMED. + // ClosedEnded missing one of SubscriptionDate / RedemptionDate => temMALFORMED. withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { auto const sub = env.now().time_since_epoch().count() + 60; auto [tx, keylet] = vault.create( @@ -1194,8 +1194,18 @@ class Vault_test : public beast::unit_test::Suite env(tx, Ter{temMALFORMED}); }); - // SubscriptionDate not strictly after parent close time (preclaim, - // state-dependent - returns tecEXPIRED). + /* + * SubscriptionDate not strictly after parent close time (preclaim, + * state-dependent - returns tecEXPIRED). This is the only reachable + * path to tecEXPIRED in VaultCreate; see the note below the next case. + * Note: there is no separate "expired RedemptionDate" test case here. + * preflight enforces red >= sub + kMinInvestmentPeriod, so any past + * RedemptionDate implies a strictly-earlier, equally-past + * SubscriptionDate; the SubscriptionDate check above short-circuits + * first. The RedemptionDate arm of the hasExpired check in + * VaultCreate::preclaim is defensive and unreachable as the sole cause + * of tecEXPIRED. + */ withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { auto const nowSec = env.now().time_since_epoch().count(); auto [tx, keylet] = vault.create( @@ -1207,25 +1217,23 @@ class Vault_test : public beast::unit_test::Suite env(tx, Ter{tecEXPIRED}); }); - // RedemptionDate not strictly after parent close time => tecEXPIRED. + /* + * Gap smaller than MIN_INVESTMENT_PERIOD => temMALFORMED. Includes + * the SubscriptionDate >= RedemptionDate degenerate cases: the + * red == sub boundary and the strictly-reversed red < sub case, + * the latter exercising the red <= sub short-circuit that guards + * the unsigned red - sub subtraction against wrap-around. + */ withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - using namespace std::chrono_literals; - // nowSec - minPeriod is std::uint32_t subtraction; advance the - // ledger clock past MIN_INVESTMENT_PERIOD so the subtraction - // cannot underflow. - env.close(std::chrono::seconds{minPeriod}); - auto const nowSec = env.now().time_since_epoch().count(); + auto const sub = env.now().time_since_epoch().count() + 60; auto [tx, keylet] = vault.create( {.owner = owner, .asset = asset, .vaultKind = closedEnded, - .subscriptionDate = nowSec - minPeriod, - .redemptionDate = nowSec}); - env(tx, Ter{tecEXPIRED}); + .subscriptionDate = sub, + .redemptionDate = sub + minPeriod - 1}); + env(tx, Ter{temMALFORMED}); }); - - // Gap smaller than MIN_INVESTMENT_PERIOD => temMALFORMED. Includes - // the SubscriptionDate >= RedemptionDate degenerate case. withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { auto const sub = env.now().time_since_epoch().count() + 60; auto [tx, keylet] = vault.create( @@ -1233,7 +1241,7 @@ class Vault_test : public beast::unit_test::Suite .asset = asset, .vaultKind = closedEnded, .subscriptionDate = sub, - .redemptionDate = sub + minPeriod - 1}); + .redemptionDate = sub}); env(tx, Ter{temMALFORMED}); }); withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { @@ -1243,7 +1251,7 @@ class Vault_test : public beast::unit_test::Suite .asset = asset, .vaultKind = closedEnded, .subscriptionDate = sub, - .redemptionDate = sub}); + .redemptionDate = sub - 1}); env(tx, Ter{temMALFORMED}); }); @@ -1388,6 +1396,11 @@ class Vault_test : public beast::unit_test::Suite env(tx); env.close(); + // Pre-seed shares during Subscription so the depositor has capital to + // withdraw at the Redemption boundary below. + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(10).value()})); + env.close(); + auto const deposit = [&](TER expected, std::source_location const& loc = std::source_location::current()) { env( @@ -1398,33 +1411,52 @@ class Vault_test : public beast::unit_test::Suite Ter{expected}); env.close(); }; + auto const withdraw = + [&](TER expected, std::source_location const& loc = std::source_location::current()) { + env( + WithSourceLocation{ + vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), + loc}, + Ter{expected}); + env.close(); + }; using d = NetClock::duration; using tp = NetClock::time_point; - // Deposit at ledger time comfortably before SubscriptionDate: allowed - // (Subscription). + // Assert both deposit and withdraw return codes at each point so the + // active phase is uniquely identified: + // Subscription: deposit tesSUCCESS, withdraw tesSUCCESS (LP cancel) + // Investment: deposit tecNO_PERMISSION, withdraw tecTOO_SOON + // Redemption: deposit tecNO_PERMISSION, withdraw tesSUCCESS + + // Ledger time comfortably before SubscriptionDate: Subscription. deposit(tesSUCCESS); + withdraw(tesSUCCESS); - // auto timeTo = [&] { return sub - env.now().time_since_epoch().count(); }; // Boundary: parent close time exactly at SubscriptionDate must still - // be Subscription (deposit still allowed). + // be Subscription. closeToTime(env, tp{d{sub}}); deposit(tesSUCCESS); + withdraw(tesSUCCESS); - // One second past SubscriptionDate: Investment (deposit rejected). + // One second past SubscriptionDate: Investment. closeToTime(env, tp{d{sub + 1}}); deposit(tecNO_PERMISSION); + withdraw(tecTOO_SOON); // Any point strictly before RedemptionDate remains Investment. closeToTime(env, tp{d{red - 1}}); deposit(tecNO_PERMISSION); + withdraw(tecTOO_SOON); // Boundary: parent close time == RedemptionDate is Redemption (per - // spec table: now >= RedemptionDate). Deposits still rejected in - // Redemption. + // spec table: now >= RedemptionDate). Deposits are rejected but + // withdrawals succeed. closeToTime(env, tp{d{red}}); deposit(tecNO_PERMISSION); + withdraw(tesSUCCESS); } // Spec 13.3: VaultDeposit is allowed only during Subscription @@ -1479,23 +1511,29 @@ class Vault_test : public beast::unit_test::Suite // Spec 13.4: VaultWithdraw is allowed in Subscription and // Redemption; rejected in Investment. The AssetsAvailable cap continues - // to apply and is exercised in Redemption. + // to apply and is exercised in Redemption against a vault with capital + // deployed as an outstanding loan. void testVaultWithdrawClosedEnded() { testcase("closed-ended VaultWithdraw phase gating"); using namespace test::jtx; + using namespace loanBroker; + using namespace loan; Env env{*this, testableAmendments()}; Account const owner{"owner"}; Account const depositor{"depositor"}; - env.fund(XRP(1000), owner, depositor); + Account const borrower{"borrower"}; + env.fund(XRP(10'000), owner, depositor, borrower); env.close(); auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); Asset const asset = xrpIssue(); auto const sub = env.now().time_since_epoch().count() + 60; - auto const red = sub + kMinInvestmentPeriod + 60; + // Widen the Investment window so a single-payment loan (min payment + // interval kMinPaymentInterval = 60s) fits before RedemptionDate. + auto const red = sub + kMinInvestmentPeriod + 3600; Vault vault{env}; auto [tx, keylet] = vault.create( @@ -1507,8 +1545,17 @@ class Vault_test : public beast::unit_test::Suite env(tx); env.close(); - // Deposit in Subscription so there is capital to withdraw later. - env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(10).value()})); + // Deposit XRP(100) in Subscription so the depositor's shares are + // worth XRP(100). The vault holds XRP(100) with + // AssetsAvailable == AssetsTotal. + env(vault.deposit( + {.depositor = depositor, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + + // Create a loan broker backed by this vault. LoanBrokerSet has no + // phase gate, so this is fine to do in Subscription. + auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner)); + env(loanBroker::set(owner, keylet.key)); env.close(); auto const withdraw = [&](STAmount const& amount, @@ -1530,15 +1577,30 @@ class Vault_test : public beast::unit_test::Suite withdraw(XRP(1).value(), tesSUCCESS); // Investment: rejected. - closeToTime(env, tp{d{sub}}); - env.close(); + closeToTime(env, tp{d{sub + 1}}); withdraw(XRP(1).value(), tecTOO_SOON); - // Redemption: allowed. AssetsAvailable cap: attempting to withdraw - // more than the vault holds must still fail with tecINSUFFICIENT_FUNDS. + // Deploy capital: borrower takes a loan of XRP(60) against the + // vault, dropping AssetsAvailable to ~XRP(39) while AssetsTotal + // remains ~XRP(99). + env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(60), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2)); + env.close(); + + // Redemption: withdrawals are allowed but subject to the + // AssetsAvailable cap. A small withdrawal within AssetsAvailable + // succeeds. A withdrawal within the depositor's share value but + // exceeding the vault's liquid balance fails with + // tecINSUFFICIENT_FUNDS from the vault-shortage guard (not the + // insufficient-shares guard). closeToTime(env, tp{d{red}}); - withdraw(XRP(1).value(), tesSUCCESS); - withdraw(XRP(1'000'000).value(), tecINSUFFICIENT_FUNDS); + withdraw(XRP(10).value(), tesSUCCESS); + withdraw(XRP(80).value(), tecINSUFFICIENT_FUNDS); } // Spec 13.10: end-to-end lifecycle of a closed-ended vault From ec1ad1464ebdcc2ef52411eb9b2f0241844e91f3 Mon Sep 17 00:00:00 2001 From: JCW Date: Thu, 6 Aug 2026 18:35:09 +0100 Subject: [PATCH 03/12] Fix PR comments --- include/xrpl/ledger/View.h | 7 +- include/xrpl/ledger/helpers/VaultHelpers.h | 48 ++- include/xrpl/protocol/Protocol.h | 4 +- include/xrpl/tx/invariants/LoanInvariant.h | 3 +- include/xrpl/tx/invariants/VaultInvariant.h | 10 +- src/libxrpl/ledger/helpers/VaultHelpers.cpp | 52 ++- src/libxrpl/tx/invariants/InvariantCheck.cpp | 26 +- src/libxrpl/tx/invariants/LoanInvariant.cpp | 17 +- src/libxrpl/tx/invariants/VaultInvariant.cpp | 45 +-- .../tx/transactors/lending/LoanSet.cpp | 20 +- .../tx/transactors/vault/VaultCreate.cpp | 25 +- .../tx/transactors/vault/VaultDeposit.cpp | 6 +- .../tx/transactors/vault/VaultWithdraw.cpp | 4 + src/test/app/Invariants_test.cpp | 121 ++++--- src/test/app/Vault_test.cpp | 295 +++++++++--------- src/test/app/lending/LoanSet_test.cpp | 39 +-- src/test/app/lending/LoanTestBase.h | 33 +- 17 files changed, 426 insertions(+), 329 deletions(-) diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 57ea805cbc5..e8b4a932d03 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -35,6 +35,11 @@ enum class SkipEntry : bool { No = false, Yes }; // //------------------------------------------------------------------------------ +/** + * Whether an expiration check should be inclusive or exclusive. + */ +enum class ExpiryComparison { Inclusive, Exclusive }; + /** * Determines whether the given expiration time has passed. * @@ -59,8 +64,6 @@ enum class SkipEntry : bool { No = false, Yes }; * * @return `true` if `exp` is in the past; `false` otherwise. */ -enum class ExpiryComparison { Inclusive, Exclusive }; - [[nodiscard]] bool hasExpired( ReadView const& view, diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index 4444b707def..25ace6a1a08 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -6,10 +6,13 @@ #include #include +#include #include namespace xrpl { +class STTx; + /** * From the perspective of a vault, return the number of shares to give * depositor when they offer a fixed amount of assets. Note, since shares are @@ -134,7 +137,29 @@ getVaultVersion(SLE::const_ref vault); getVaultKind(SLE::const_ref vault); /** - * Returns the current lifecycle phase of a vault (XLS-103 2.2). Open-ended + * Reads sfVaultKind from a transaction. An absent field resolves to + * VaultKind::OpenEnded (matching the on-ledger default); any unrecognised + * value is also treated as VaultKind::OpenEnded, mirroring the SLE overload. + * Callers that need to reject out-of-range values (e.g. preflight) should + * gate on isValidVaultKind() first. + * + * @param tx The transaction. + */ +[[nodiscard]] VaultKind +getVaultKind(STTx const& tx); + +/** + * Returns true iff sfVaultKind is either absent from @p tx or is present and + * equal to a recognised VaultKind enumerator. Intended for use in preflight + * to reject malformed transactions before decoding with getVaultKind(). + * + * @param tx The transaction. + */ +[[nodiscard]] bool +isValidVaultKind(STTx const& tx); + +/** + * Returns the current lifecycle phase of a vault. Open-ended * vaults are always NoPhase. For closed-ended vaults the phase is derived * from the parent ledger close time and the vault's immutable * SubscriptionDate and RedemptionDate. @@ -145,4 +170,25 @@ getVaultKind(SLE::const_ref vault); [[nodiscard]] VaultPhase getVaultPhase(ReadView const& view, SLE::const_ref vault); +/** + * Raw-fields overload of getVaultPhase. Derives the phase from an already + * decomposed vault snapshot: an absent or non-ClosedEnded @p vaultKind + * resolves to VaultPhase::NoPhase; otherwise the phase is computed from + * @p subscriptionDate and @p redemptionDate against the view's parent + * close time using the same boundary semantics as the SLE overload + * (Subscription is inclusive of now == SubscriptionDate; Investment starts + * strictly after). + * + * @param view The ledger view whose parent close time is used as the clock. + * @param vaultKind The value of sfVaultKind, or nullopt if absent. + * @param subscriptionDate The value of sfSubscriptionDate, or nullopt if absent. + * @param redemptionDate The value of sfRedemptionDate, or nullopt if absent. + */ +[[nodiscard]] VaultPhase +getVaultPhase( + ReadView const& view, + std::optional vaultKind, + std::optional subscriptionDate, + std::optional redemptionDate); + } // namespace xrpl diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index e1e487a213b..6215ae97290 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -339,7 +339,7 @@ enum class VaultKind : std::uint8_t { /** * Lifecycle phase of a vault. Open-ended vaults are always NoPhase; the other - * three values are the phases of a closed-ended vault (XLS-103). + * three values are the phases of a closed-ended vault. */ enum class VaultPhase : std::uint8_t { NoPhase = 0, @@ -351,7 +351,7 @@ enum class VaultPhase : std::uint8_t { /** * Bounds on the length of a closed-ended vault's Investment phase * (RedemptionDate - SubscriptionDate). At vault creation the gap must satisfy - * kMinInvestmentPeriod <= gap < kMaxInvestmentPeriod (XLS-103 2.4). + * kMinInvestmentPeriod <= gap < kMaxInvestmentPeriod. */ constexpr std::uint32_t kMinInvestmentPeriod = std::chrono::seconds{std::chrono::minutes{1}}.count(); diff --git a/include/xrpl/tx/invariants/LoanInvariant.h b/include/xrpl/tx/invariants/LoanInvariant.h index b6c11e0021d..fc72b8d4205 100644 --- a/include/xrpl/tx/invariants/LoanInvariant.h +++ b/include/xrpl/tx/invariants/LoanInvariant.h @@ -17,8 +17,7 @@ namespace xrpl { * * 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0` * 2. A newly-created Loan against a closed-ended vault must satisfy - * `StartDate + PaymentInterval * PaymentRemaining < Vault.RedemptionDate` - * (XLS-103 7.4). + * `StartDate + PaymentInterval * PaymentRemaining < Vault.RedemptionDate`. * */ class ValidLoan diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index ef446395b87..2ba42f0ab4a 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -169,12 +169,10 @@ class ValidVault /** * @brief Invariant check for @c ttLOAN_SET. * - * Enforces XLS-103 7.4: 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. + * 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. */ [[nodiscard]] bool finalizeLoanSet(ReadView const& view, beast::Journal const& j) const; diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index 691b1a4d96f..888c9b256ff 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -12,6 +12,7 @@ #include #include #include // IWYU pragma: keep +#include #include #include @@ -158,27 +159,64 @@ getVaultVersion(SLE::const_ref vault) return static_cast(version); } +namespace { + [[nodiscard]] VaultKind -getVaultKind(SLE::const_ref vault) +decodeVaultKind(std::optional vaultKind) { - XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultKind : valid Vault sle"); - if (vault->isFieldPresent(sfVaultKind) && - vault->at(sfVaultKind) == std::to_underlying(VaultKind::ClosedEnded)) + if (vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded)) return VaultKind::ClosedEnded; return VaultKind::OpenEnded; } +} // namespace + +[[nodiscard]] VaultKind +getVaultKind(SLE::const_ref vault) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultKind : valid Vault sle"); + return decodeVaultKind(vault->at(~sfVaultKind)); +} + +[[nodiscard]] VaultKind +getVaultKind(STTx const& tx) +{ + return decodeVaultKind(tx[~sfVaultKind]); +} + +[[nodiscard]] bool +isValidVaultKind(STTx const& tx) +{ + auto const kindField = tx[~sfVaultKind]; + if (!kindField) + return true; + return *kindField == std::to_underlying(VaultKind::OpenEnded) || + *kindField == std::to_underlying(VaultKind::ClosedEnded); +} + [[nodiscard]] VaultPhase getVaultPhase(ReadView const& view, SLE::const_ref vault) { - if (getVaultKind(vault) == VaultKind::OpenEnded) + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultPhase : valid Vault sle"); + return getVaultPhase( + view, (*vault)[~sfVaultKind], (*vault)[~sfSubscriptionDate], (*vault)[~sfRedemptionDate]); +} + +[[nodiscard]] VaultPhase +getVaultPhase( + ReadView const& view, + std::optional vaultKind, + std::optional subscriptionDate, + std::optional redemptionDate) +{ + if (!vaultKind || *vaultKind != std::to_underlying(VaultKind::ClosedEnded)) return VaultPhase::NoPhase; // Subscription includes now == SubscriptionDate; Investment starts // strictly after SubscriptionDate. - if (!hasExpired(view, vault->at(sfSubscriptionDate), ExpiryComparison::Exclusive)) + if (!hasExpired(view, subscriptionDate, ExpiryComparison::Exclusive)) return VaultPhase::Subscription; - if (!hasExpired(view, vault->at(sfRedemptionDate))) + if (!hasExpired(view, redemptionDate)) return VaultPhase::Investment; return VaultPhase::Redemption; } diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 4a006eae97c..364781cb013 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -1178,18 +1178,32 @@ NoModifiedUnmodifiableFields::finalize( kFieldChanged(before, after, sfLoanScale); break; case ltVAULT: + // Fallback checks for ltVAULT copied from below + enforce = view.rules().enabled(featureLendingProtocol); + bad = kFieldChanged(before, after, sfLedgerEntryType) || + kFieldChanged(before, after, sfLedgerIndex); + /* * The VaultKind, SubscriptionDate and RedemptionDate * fields are introduced by featureLendingProtocolV1_1 * and are the only vault fields whose immutability is * enforced here; pre-V1_1 vaults do not carry them. */ - enforce = view.rules().enabled(featureLendingProtocolV1_1); - bad = kFieldChanged(before, after, sfLedgerEntryType) || - kFieldChanged(before, after, sfLedgerIndex) || - kFieldChanged(before, after, sfVaultKind) || - kFieldChanged(before, after, sfSubscriptionDate) || - kFieldChanged(before, after, sfRedemptionDate); + if (view.rules().enabled(featureLendingProtocolV1_1)) + { + // sfAccount, sfAsset, sfShareMPTID are already captured by VaultInvariant + bad = kFieldChanged(before, after, sfLedgerEntryType) || + kFieldChanged(before, after, sfLedgerIndex) || + kFieldChanged(before, after, sfVaultKind) || + kFieldChanged(before, after, sfSubscriptionDate) || + kFieldChanged(before, after, sfRedemptionDate) || + kFieldChanged(before, after, sfSequence) || + kFieldChanged(before, after, sfOwnerNode) || + kFieldChanged(before, after, sfOwner) || + kFieldChanged(before, after, sfWithdrawalPolicy) || + kFieldChanged(before, after, sfScale) || + kFieldChanged(before, after, sfLEVersion); + } break; default: /* diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index 267ad38e571..b812b87c676 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -29,7 +29,7 @@ ValidLoan::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after bool ValidLoan::finalize( STTx const& tx, - TER const, + TER const result, XRPAmount const, ReadView const& view, beast::Journal const& j) @@ -39,13 +39,11 @@ ValidLoan::finalize( for (auto const& [before, after] : loans_) { - // XLS-103 7.4: 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. - if (!before) + // 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. + if (!before && isTesSuccess(result)) { auto const broker = view.read(keylet::loanBroker(after->at(sfLoanBrokerID))); if (broker) @@ -57,7 +55,8 @@ 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) >= redemption) + if (std::uint64_t{startDate} + (std::uint64_t{interval} * remaining) >= + redemption) { JLOG(j.fatal()) << "Invariant failed: closed-ended loan final payment " "must precede RedemptionDate"; diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 5176ac314ba..8e80a4d60bd 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -286,17 +287,15 @@ ValidVault::finalizeLoanSet(ReadView const& view, beast::Journal const& j) const auto const& afterVault = afterVault_[0]; - // XLS-103 3.5.4: loan origination against a closed-ended vault is only - // permitted while the vault is in the Investment phase — strictly past - // SubscriptionDate and before RedemptionDate. Open-ended vaults have - // NoPhase and are unaffected. - if (!isClosedEnded(afterVault.vaultKind)) + // Loan origination against a closed-ended vault is only permitted while the vault is in the + // Investment phase - strictly past SubscriptionDate and before RedemptionDate. Open-ended + // vaults have NoPhase and are unaffected. + auto const phase = getVaultPhase( + view, afterVault.vaultKind, afterVault.subscriptionDate, afterVault.redemptionDate); + if (phase == VaultPhase::NoPhase) return true; - bool const inInvestment = - hasExpired(view, afterVault.subscriptionDate, ExpiryComparison::Exclusive) && - !hasExpired(view, afterVault.redemptionDate); - if (!inInvestment) + if (phase != VaultPhase::Investment) { JLOG(j.fatal()) << // "Invariant failed: loan origination only allowed in Investment phase"; @@ -572,8 +571,8 @@ ValidVault::finalize( result = false; } - // Immutability of VaultKind, SubscriptionDate and RedemptionDate is - // enforced by NoModifiedUnmodifiableFields in InvariantCheck.cpp. + // Immutability of VaultKind, SubscriptionDate and RedemptionDate is enforced by + // NoModifiedUnmodifiableFields in InvariantCheck.cpp. auto const beforeShares = [&]() -> std::optional { if (beforeVault_.empty()) @@ -746,10 +745,13 @@ ValidVault::finalize( auto const& beforeVault = beforeVault_[0]; // Deposit is only allowed while the vault is in NoPhase or - // Subscription; reject if a closed-ended vault is strictly - // past SubscriptionDate. - if (isClosedEnded(afterVault.vaultKind) && - hasExpired(view, afterVault.subscriptionDate, ExpiryComparison::Exclusive)) + // Subscription. + auto const depositPhase = getVaultPhase( + view, + afterVault.vaultKind, + afterVault.subscriptionDate, + afterVault.redemptionDate); + if (depositPhase != VaultPhase::NoPhase && depositPhase != VaultPhase::Subscription) { JLOG(j.fatal()) << // "Invariant failed: deposit only allowed in " @@ -895,12 +897,13 @@ ValidVault::finalize( "xrpl::ValidVault::finalize : withdrawal updated a vault"); auto const& beforeVault = beforeVault_[0]; - // Withdrawal from a closed-ended vault is not allowed during - // the Investment phase (strictly past SubscriptionDate, - // before RedemptionDate). - if (isClosedEnded(afterVault.vaultKind) && - hasExpired(view, afterVault.subscriptionDate, ExpiryComparison::Exclusive) && - !hasExpired(view, afterVault.redemptionDate)) + // Withdrawal from a closed-ended vault is not allowed during the Investment phase + // (strictly past SubscriptionDate, before RedemptionDate). + if (getVaultPhase( + view, + afterVault.vaultKind, + afterVault.subscriptionDate, + afterVault.redemptionDate) == VaultPhase::Investment) { JLOG(j.fatal()) << // "Invariant failed: withdrawal not allowed during " diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 3f2ed143943..303e3abc2a4 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -225,6 +225,8 @@ TER LoanSet::preclaim(PreclaimContext const& ctx) { auto const& tx = ctx.tx; + auto const interval = ctx.tx.at(~sfPaymentInterval).value_or(kDefaultPaymentInterval); + auto const total = ctx.tx.at(~sfPaymentTotal).value_or(kDefaultPaymentTotal); { // Check for numeric overflow of the schedule before we load any @@ -238,9 +240,6 @@ LoanSet::preclaim(PreclaimContext const& ctx) static_assert(kMaxTime == 4'294'967'295); auto const timeAvailable = kMaxTime - getStartDate(ctx.view); - - auto const interval = ctx.tx.at(~sfPaymentInterval).value_or(kDefaultPaymentInterval); - auto const total = ctx.tx.at(~sfPaymentTotal).value_or(kDefaultPaymentTotal); auto const grace = ctx.tx.at(~sfGracePeriod).value_or(kDefaultGracePeriod); // The grace period can't be larger than the interval. Check it first, @@ -314,16 +313,25 @@ LoanSet::preclaim(PreclaimContext const& ctx) { auto const phase = getVaultPhase(ctx.view, vault); if (phase == VaultPhase::Subscription) + { + JLOG(ctx.j.warn()) << "Vault is still in the subscription phase."; return tecTOO_SOON; + } if (phase == VaultPhase::Redemption) + { + JLOG(ctx.j.warn()) << "Vault has entered the redemption phase."; return tecEXPIRED; + } if (phase == VaultPhase::Investment) { - auto const interval = ctx.tx.at(~sfPaymentInterval).value_or(kDefaultPaymentInterval); - auto const total = ctx.tx.at(~sfPaymentTotal).value_or(kDefaultPaymentTotal); - auto const finalPayment = std::uint64_t{getStartDate(ctx.view)} + (std::uint64_t{interval} * total); + auto const finalPayment = + std::uint64_t{getStartDate(ctx.view)} + (std::uint64_t{interval} * total); if (finalPayment >= vault->at(sfRedemptionDate)) + { + JLOG(ctx.j.warn()) << "Final loan payment date is on or after " + "the vault's redemption date."; return tecNO_PERMISSION; + } } } diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index 8993bffdaae..123e0933391 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -104,13 +105,12 @@ VaultCreate::preflight(PreflightContext const& ctx) return temMALFORMED; } - auto const kindField = ctx.tx[~sfVaultKind]; + if (!isValidVaultKind(ctx.tx)) + return temMALFORMED; + auto const kind = getVaultKind(ctx.tx); auto const hasSubscription = ctx.tx.isFieldPresent(sfSubscriptionDate); auto const hasRedemption = ctx.tx.isFieldPresent(sfRedemptionDate); - auto const isClosedEnded = - kindField && *kindField == std::to_underlying(VaultKind::ClosedEnded); - if (kindField && *kindField > std::to_underlying(VaultKind::ClosedEnded)) - return temMALFORMED; + auto const isClosedEnded = kind == VaultKind::ClosedEnded; if (!isClosedEnded && (hasSubscription || hasRedemption)) return temMALFORMED; if (isClosedEnded && (!hasSubscription || !hasRedemption)) @@ -277,13 +277,16 @@ VaultCreate::doApply() if (scale != 0u) vault->at(sfScale) = scale; if (view().rules().enabled(featureLendingProtocolV1_1)) - vault->at(sfLEVersion) = std::to_underlying(VaultVersion::CashBasis); - if (auto const kind = tx[~sfVaultKind]; - kind && *kind == std::to_underlying(VaultKind::ClosedEnded)) { - vault->at(sfVaultKind) = *kind; - vault->at(sfSubscriptionDate) = tx[sfSubscriptionDate]; - vault->at(sfRedemptionDate) = tx[sfRedemptionDate]; + vault->at(sfLEVersion) = std::to_underlying(VaultVersion::CashBasis); + + auto const kind = getVaultKind(tx); + vault->at(sfVaultKind) = std::to_underlying(kind); + if (kind == VaultKind::ClosedEnded) + { + vault->at(sfSubscriptionDate) = tx[sfSubscriptionDate]; + vault->at(sfRedemptionDate) = tx[sfRedemptionDate]; + } } view().insert(vault); diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp index 068e2d9af48..5cf49bd1469 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp @@ -75,7 +75,11 @@ VaultDeposit::preclaim(PreclaimContext const& ctx) { auto const phase = getVaultPhase(ctx.view, vault); if (phase == VaultPhase::Investment || phase == VaultPhase::Redemption) - return tecNO_PERMISSION; + { + JLOG(ctx.j.debug()) << "VaultDeposit: vault deposit is not allowed in the investment " + "or redemption phase."; + return tecEXPIRED; + } } auto const& account = ctx.tx[sfAccount]; diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index c57bd1f7f2f..7b5bb1ea94c 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -76,7 +76,11 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) if (ctx.view.rules().enabled(featureLendingProtocolV1_1)) { if (getVaultPhase(ctx.view, vault) == VaultPhase::Investment) + { + JLOG(ctx.j.debug()) + << "VaultWithdraw: vault withdrawal is not allowed in the investment phase."; return tecTOO_SOON; + } } auto const amount = ctx.tx[sfAmount]; diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index fdbe1d1d518..8ff73da8d2a 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -134,7 +134,8 @@ class Invariants_test : public beast::unit_test::Suite STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, Preclose const& preclose = {}, - TxAccount setTxAccount = TxAccount::None) + TxAccount setTxAccount = TxAccount::None, + std::source_location const& loc = std::source_location::current()) { doInvariantCheck( makeEnv(defaultAmendments()), @@ -144,7 +145,8 @@ class Invariants_test : public beast::unit_test::Suite tx, ters, preclose, - setTxAccount); + setTxAccount, + loc); } void @@ -156,7 +158,8 @@ class Invariants_test : public beast::unit_test::Suite STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, Preclose const& preclose = {}, - TxAccount setTxAccount = TxAccount::None) + TxAccount setTxAccount = TxAccount::None, + std::source_location const& loc = std::source_location::current()) { using namespace test::jtx; @@ -170,7 +173,7 @@ class Invariants_test : public beast::unit_test::Suite if (setTxAccount != TxAccount::None) tx.setAccountID(sfAccount, setTxAccount == TxAccount::A1 ? a1.id() : a2.id()); - doInvariantCheck(std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters); + doInvariantCheck(std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc); } void @@ -183,7 +186,8 @@ class Invariants_test : public beast::unit_test::Suite Precheck const& precheck, XRPAmount fee = XRPAmount{}, STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, - std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}) + std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + std::source_location const& loc = std::source_location::current()) { using namespace test::jtx; @@ -210,23 +214,27 @@ class Invariants_test : public beast::unit_test::Suite for (TER const& terExpect : ters) { terActual = transactor->checkInvariants(terActual, fee); - BEAST_EXPECTS( + expect( terExpect == terActual, - "expected: " + transToken(terExpect) + " got: " + transToken(terActual)); + "expected: " + transToken(terExpect) + " got: " + transToken(terActual), + loc.file_name(), + loc.line()); auto const messages = sink.messages().str(); if (!isTesSuccess(terActual)) { - BEAST_EXPECTS( + expect( messages.starts_with("Invariant failed:") || messages.starts_with("Transaction caused an exception"), - messages); + messages, + loc.file_name(), + loc.line()); } // std::cerr << messages << '\n'; for (auto const& m : expectLogs) { - BEAST_EXPECTS(messages.contains(m), m); + expect(messages.contains(m), m, loc.file_name(), loc.line()); } } } @@ -2472,9 +2480,8 @@ class Invariants_test : public beast::unit_test::Suite // TODO: Loan Object - // XLS-103 3.4: VaultKind, SubscriptionDate and RedemptionDate are - // immutable once set at creation. Enforced by - // NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged. + // VaultKind, SubscriptionDate and RedemptionDate are immutable once set at creation. + // Enforced by NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged. Keylet closedEndedVaultKeylet = keylet::amendments(); Preclose const createClosedEndedVault = [&, this]( Account const& a, Account const&, Env& env) { @@ -2493,11 +2500,9 @@ class Invariants_test : public beast::unit_test::Suite }; { - // Each mutation must keep the vault otherwise valid (in - // particular the 4.4 gap invariant) so that only the - // immutability check fires. Shifting both dates by the same - // offset preserves the gap; bumping sfVaultKind stays within - // the recognised range. + // Each mutation must keep the vault otherwise valid so that only the immutability check + // fires. Shifting both dates by the same offset preserves the gap; bumping sfVaultKind + // stays within the recognised range. auto const mods = std::to_array>({ [](SLE::pointer& sle) { sle->at(sfVaultKind) += 1; }, [](SLE::pointer& sle) { sle->at(sfSubscriptionDate) += 1; }, @@ -4409,27 +4414,23 @@ class Invariants_test : public beast::unit_test::Suite precloseMpt); // ───────────────────────────────────────────────────────────── - // XLS-103 closed-ended vault invariants added in - // ValidVault::finalize: 4.4 (create must supply both dates and - // satisfy the redemption-buffer gap), 5.4 (deposit only in - // Subscription / NoPhase), 6.4 (withdraw not in Investment), - // 7.4 (loan origination only in Investment). + // Closed-ended vault invariants added in ValidVault::finalize (create must supply both + // dates and satisfy the redemption-buffer gap), deposit only in Subscription / NoPhase, + // withdraw not in Investment, loan origination only in Investment. using d = NetClock::duration; using tp = NetClock::time_point; auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); - // Vault keylet captured by precloseClosedEnded so precheck - // does not have to rederive it from ac.view().seq(), which - // depends on how many env.close() calls preclose issued. + // Vault keylet captured by precloseClosedEnded so precheck does not have to rederive it + // from ac.view().seq(), which depends on how many env.close() calls preclose issued. Keylet closedEndedKeylet = keylet::amendments(); - // Preclose that creates a closed-ended vault (in Subscription), - // optionally seeds it with three deposits (so a1/a2/a3 hold a - // share MPToken that kAdjust can then adjust), and optionally - // advances parent close time past SubscriptionDate. A negative - // @p advanceBySub leaves the vault in Subscription. + // Preclose that creates a closed-ended vault (in Subscription), optionally seeds it with + // three deposits (so a1/a2/a3 hold a share MPToken that kAdjust can then adjust), and + // optionally advances parent close time past SubscriptionDate. A negative @p advanceBySub + // leaves the vault in Subscription. auto const precloseClosedEnded = [&](std::int32_t advanceBySub, bool doDeposit) { return [&, advanceBySub, doDeposit]( Account const& a1, Account const& a2, Env& env) -> bool { @@ -4457,10 +4458,9 @@ class Invariants_test : public beast::unit_test::Suite }; }; - // Manually insert a bare closed-ended vault (+ pseudo-account - // + share MPTokenIssuance) directly into the view, bypassing - // the transactor path. Used to synthesise ttVAULT_CREATE - // states no legitimate transactor would produce (4.4). + // Manually insert a bare closed-ended vault (+ pseudo-account + share MPTokenIssuance) + // directly into the view, bypassing the transactor path. Used to synthesize ttVAULT_CREATE + // states no legitimate transactor would produce. auto const insertBareClosedEndedVault = [closedEnded]( ApplyContext& ac, @@ -4521,8 +4521,7 @@ class Invariants_test : public beast::unit_test::Suite testcase << "Vault create closed-ended"; - // 4.4: a fresh closed-ended vault must carry both - // SubscriptionDate and RedemptionDate. + // A fresh closed-ended vault must carry both SubscriptionDate and RedemptionDate. doInvariantCheck( {"closed-ended vault must have SubscriptionDate and RedemptionDate"}, [&](Account const& a1, Account const&, ApplyContext& ac) { @@ -4532,9 +4531,8 @@ class Invariants_test : public beast::unit_test::Suite STTx{ttVAULT_CREATE, [](STObject&) {}}, {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - // 4.4: gap smaller than MIN_INVESTMENT_PERIOD but with - // RedemptionDate > SubscriptionDate; exercises the sub-minimum - // branch of the gap check. + // Gap smaller than MIN_INVESTMENT_PERIOD but with RedemptionDate > SubscriptionDate; + // exercises the sub-minimum branch of the gap check. doInvariantCheck( {"closed-ended vault RedemptionDate - SubscriptionDate must be " "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, @@ -4547,9 +4545,8 @@ class Invariants_test : public beast::unit_test::Suite STTx{ttVAULT_CREATE, [](STObject&) {}}, {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - // 4.4: RedemptionDate strictly before SubscriptionDate; - // exercises the red <= sub short-circuit that guards the - // unsigned red - sub subtraction against wrap-around. + // RedemptionDate strictly before SubscriptionDate; exercises the red <= sub short-circuit + // that guards the unsigned red - sub subtraction against wrap-around. doInvariantCheck( {"closed-ended vault RedemptionDate - SubscriptionDate must be " "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, @@ -4562,8 +4559,7 @@ class Invariants_test : public beast::unit_test::Suite STTx{ttVAULT_CREATE, [](STObject&) {}}, {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - // 4.4: gap exactly MAX_INVESTMENT_PERIOD is out of range (bound - // is half-open on the right). + // Gap exactly MAX_INVESTMENT_PERIOD is out of range (bound is half-open on the right). doInvariantCheck( {"closed-ended vault RedemptionDate - SubscriptionDate must be " "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, @@ -4578,9 +4574,8 @@ class Invariants_test : public beast::unit_test::Suite testcase << "Vault deposit closed-ended"; - // 5.4: a deposit into a closed-ended vault that has advanced - // past SubscriptionDate. kArgs simulates an otherwise valid - // deposit shape so only the phase invariant fires. + // A deposit into a closed-ended vault that has advanced past SubscriptionDate. kArgs + // simulates an otherwise valid deposit shape so only the phase invariant fires. doInvariantCheck( {"deposit only allowed in Subscription or NoPhase"}, [&](Account const&, Account const& a2, ApplyContext& ac) { @@ -4595,8 +4590,7 @@ class Invariants_test : public beast::unit_test::Suite testcase << "Vault withdrawal closed-ended"; - // 6.4: a withdrawal from a closed-ended vault in the - // Investment phase. + // A withdrawal from a closed-ended vault in the Investment phase. doInvariantCheck( {"withdrawal not allowed during Investment phase"}, [&](Account const&, Account const& a2, ApplyContext& ac) { @@ -4611,9 +4605,8 @@ class Invariants_test : public beast::unit_test::Suite testcase << "Vault loan set"; - // 7.4: ttLOAN_SET against a closed-ended vault that is not in - // Investment. finalizeLoanSet fires on any vault mutation; - // touching the vault SLE with no field change is sufficient. + // ttLOAN_SET against a closed-ended vault that is not in Investment. finalizeLoanSet fires + // on any vault mutation; touching the vault SLE with no field change is sufficient. doInvariantCheck( {"loan origination only allowed in Investment phase"}, [&](Account const&, Account const&, ApplyContext& ac) { @@ -4631,12 +4624,10 @@ class Invariants_test : public beast::unit_test::Suite testcase << "Vault loan set - closed-ended final payment past " "RedemptionDate"; - // XLS-103 7.4 (ValidLoan): 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. + // 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. Keylet closedEndedBrokerKeylet = keylet::amendments(); std::uint32_t closedEndedRed = 0; doInvariantCheck( @@ -4657,17 +4648,18 @@ class Invariants_test : public beast::unit_test::Suite return false; std::uint32_t const loanSeq = sleBroker->at(sfLoanSequence); - // Synthesise a Loan whose final scheduled payment lands + // Synthesize a Loan whose final scheduled payment lands // exactly at RedemptionDate: StartDate = red, interval = 60, // remaining = 1 => red + 60 >= red. - auto sleLoan = std::make_shared( - keylet::loan(closedEndedBrokerKeylet.key, loanSeq)); + auto sleLoan = + std::make_shared(keylet::loan(closedEndedBrokerKeylet.key, loanSeq)); sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key; sleLoan->at(sfLoanSequence) = loanSeq; sleLoan->at(sfBorrower) = a1.id(); sleLoan->at(sfStartDate) = closedEndedRed; sleLoan->at(sfPaymentInterval) = 60; sleLoan->at(sfPaymentRemaining) = 1; + sleLoan->at(sfTotalValueOutstanding) = Number(100); sleLoan->at(sfPeriodicPayment) = Number(1); ac.view().insert(sleLoan); return true; @@ -4691,9 +4683,8 @@ class Invariants_test : public beast::unit_test::Suite closedEndedKeylet = keylet; // Create the loan broker; LoanBrokerSet has no phase gate. - closedEndedBrokerKeylet = - keylet::loanBroker(a1.id(), env.seq(a1)); - env(loanBroker::set(a1, keylet.key)); + closedEndedBrokerKeylet = keylet::loanBroker(a1.id(), env.seq(a1)); + env(loan_broker::set(a1, keylet.key)); // Advance parent close time into Investment so // ValidVault::finalizeLoanSet is satisfied. diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 18151aec2c7..994313ce156 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -82,11 +82,73 @@ class Vault_test : public beast::unit_test::Suite return {STAmount{asset.raw(), 1ul, 0, true, STAmount::Unchecked{}}, ""}; }; - static void - closeToTime(test::jtx::Env& env, NetClock::time_point time) + /** + * Get the current ledger's close time resolution. + * @param env The test environment. + */ + static NetClock::duration + getLedgerTimeResolution(test::jtx::Env& env) + { + return env.current()->header().closeTimeResolution; + } + + void + closeToTime( + test::jtx::Env& env, + NetClock::time_point time, + std::source_location const& loc = std::source_location::current()) { using namespace std::chrono_literals; env.close(time - env.closed()->header().closeTimeResolution + 1s); + expect( + env.closed()->header().closeTime == time, + std::format( + "current ledger time {} is not equal to the target ledger time {}", + env.closed()->header().closeTime.time_since_epoch(), + time.time_since_epoch()), + loc.file_name(), + loc.line()); + } + + using d = NetClock::duration; + using tp = NetClock::time_point; + + // Vault holds an Env& so no default initializer is possible; the + // struct is always aggregate-initialized by makeClosedEndedVault. + // NOLINTBEGIN(cppcoreguidelines-pro-type-member-init) + struct ClosedEndedSetup + { + test::jtx::Vault vault; + Keylet keylet; + std::uint32_t sub = 0; + std::uint32_t red = 0; + }; + // NOLINTEND(cppcoreguidelines-pro-type-member-init) + + // Submit a VaultCreate for a closed-ended vault with SubscriptionDate at + // env.now() + subOffset and RedemptionDate at SubscriptionDate + gap, then + // close the ledger. Returns the Vault helper, the vault's keylet and the + // resolved sub/red timestamps. + static ClosedEndedSetup + makeClosedEndedVault( + test::jtx::Env& env, + test::jtx::Account const& owner, + Asset const& asset, + std::uint32_t subOffset, + std::uint32_t gap) + { + auto const sub = env.now().time_since_epoch().count() + subOffset; + auto const red = sub + gap; + test::jtx::Vault vault{env}; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = std::to_underlying(VaultKind::ClosedEnded), + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + return {.vault = vault, .keylet = keylet, .sub = sub, .red = red}; } void @@ -1114,8 +1176,8 @@ class Vault_test : public beast::unit_test::Suite }); } - // Spec 13.1: VaultCreate malformation and happy paths for - // closed-ended vaults, plus the featureLendingProtocolV1_1 gate. + // VaultCreate malformation and happy paths for closed-ended vaults, plus the + // featureLendingProtocolV1_1 gate. void testVaultCreateClosedEnded() { @@ -1151,8 +1213,8 @@ class Vault_test : public beast::unit_test::Suite }); /* - * Valid closed-ended creation with a comfortably interior gap - * (well above MIN_INVESTMENT_PERIOD and well below MAX_INVESTMENT_PERIOD). + * Valid closed-ended creation with a comfortably interior gap (well above + * MIN_INVESTMENT_PERIOD and well below MAX_INVESTMENT_PERIOD). */ withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { auto const sub = env.now().time_since_epoch().count() + 60; @@ -1195,15 +1257,13 @@ class Vault_test : public beast::unit_test::Suite }); /* - * SubscriptionDate not strictly after parent close time (preclaim, - * state-dependent - returns tecEXPIRED). This is the only reachable - * path to tecEXPIRED in VaultCreate; see the note below the next case. - * Note: there is no separate "expired RedemptionDate" test case here. - * preflight enforces red >= sub + kMinInvestmentPeriod, so any past - * RedemptionDate implies a strictly-earlier, equally-past - * SubscriptionDate; the SubscriptionDate check above short-circuits - * first. The RedemptionDate arm of the hasExpired check in - * VaultCreate::preclaim is defensive and unreachable as the sole cause + * SubscriptionDate not strictly after parent close time (preclaim, state-dependent - + * returns tecEXPIRED). This is the only reachable path to tecEXPIRED in VaultCreate; see + * the note below the next case. Note: there is no separate "expired RedemptionDate" test + * case here. preflight enforces red >= sub + kMinInvestmentPeriod, so any past + * RedemptionDate implies a strictly-earlier, equally-past SubscriptionDate; the + * SubscriptionDate check above short-circuits first. The RedemptionDate arm of the + * hasExpired check in VaultCreate::preclaim is defensive and unreachable as the sole cause * of tecEXPIRED. */ withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { @@ -1218,11 +1278,10 @@ class Vault_test : public beast::unit_test::Suite }); /* - * Gap smaller than MIN_INVESTMENT_PERIOD => temMALFORMED. Includes - * the SubscriptionDate >= RedemptionDate degenerate cases: the - * red == sub boundary and the strictly-reversed red < sub case, - * the latter exercising the red <= sub short-circuit that guards - * the unsigned red - sub subtraction against wrap-around. + * Gap smaller than MIN_INVESTMENT_PERIOD => temMALFORMED. Includes the SubscriptionDate >= + * RedemptionDate degenerate cases: the red == sub boundary and the strictly-reversed red < + * sub case, the latter exercising the red <= sub short-circuit that guards the unsigned red + * - sub subtraction against wrap-around. */ withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { auto const sub = env.now().time_since_epoch().count() + 60; @@ -1366,9 +1425,8 @@ class Vault_test : public beast::unit_test::Suite }); } - // Spec 13.2: phase derivation across the SubscriptionDate / RedemptionDate - // boundaries, including the now == SubscriptionDate case (which must - // still resolve to Subscription). + // Phase derivation across the SubscriptionDate / RedemptionDate boundaries, including the now + // == SubscriptionDate case (which must still resolve to Subscription). void testVaultPhaseDerivation() { @@ -1381,20 +1439,9 @@ class Vault_test : public beast::unit_test::Suite env.fund(XRP(1000), owner, depositor); env.close(); - auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); Asset const asset = xrpIssue(); - auto const sub = env.now().time_since_epoch().count() + 60; - auto const red = sub + kMinInvestmentPeriod; - - Vault vault{env}; - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = red}); - env(tx); - env.close(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod); // Pre-seed shares during Subscription so the depositor has capital to // withdraw at the Redemption boundary below. @@ -1409,7 +1456,6 @@ class Vault_test : public beast::unit_test::Suite {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), loc}, Ter{expected}); - env.close(); }; auto const withdraw = [&](TER expected, std::source_location const& loc = std::source_location::current()) { @@ -1419,48 +1465,48 @@ class Vault_test : public beast::unit_test::Suite {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), loc}, Ter{expected}); - env.close(); }; - using d = NetClock::duration; - using tp = NetClock::time_point; + auto const runTest = [&](TER expectedDeposit, + TER expectedWithdraw, + std::source_location const& loc = + std::source_location::current()) { + deposit(expectedDeposit, loc); + withdraw(expectedWithdraw, loc); + }; // Assert both deposit and withdraw return codes at each point so the // active phase is uniquely identified: - // Subscription: deposit tesSUCCESS, withdraw tesSUCCESS (LP cancel) - // Investment: deposit tecNO_PERMISSION, withdraw tecTOO_SOON - // Redemption: deposit tecNO_PERMISSION, withdraw tesSUCCESS + // Subscription: deposit tesSUCCESS, withdraw tesSUCCESS + // Investment: deposit tecEXPIRED, withdraw tecTOO_SOON + // Redemption: deposit tecEXPIRED, withdraw tesSUCCESS // Ledger time comfortably before SubscriptionDate: Subscription. - deposit(tesSUCCESS); - withdraw(tesSUCCESS); + runTest(tesSUCCESS, tesSUCCESS); // Boundary: parent close time exactly at SubscriptionDate must still // be Subscription. closeToTime(env, tp{d{sub}}); - deposit(tesSUCCESS); - withdraw(tesSUCCESS); + runTest(tesSUCCESS, tesSUCCESS); // One second past SubscriptionDate: Investment. - closeToTime(env, tp{d{sub + 1}}); - deposit(tecNO_PERMISSION); - withdraw(tecTOO_SOON); + closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env)); + runTest(tecEXPIRED, tecTOO_SOON); // Any point strictly before RedemptionDate remains Investment. - closeToTime(env, tp{d{red - 1}}); - deposit(tecNO_PERMISSION); - withdraw(tecTOO_SOON); + closeToTime(env, tp{d{red}} - getLedgerTimeResolution(env)); + runTest(tecEXPIRED, tecTOO_SOON); // Boundary: parent close time == RedemptionDate is Redemption (per // spec table: now >= RedemptionDate). Deposits are rejected but // withdrawals succeed. closeToTime(env, tp{d{red}}); - deposit(tecNO_PERMISSION); - withdraw(tesSUCCESS); + runTest(tecEXPIRED, tesSUCCESS); + env.close(); } - // Spec 13.3: VaultDeposit is allowed only during Subscription - // (or NoPhase). Rejected during Investment and Redemption. + // VaultDeposit is allowed only during Subscription (or NoPhase). Rejected during Investment and + // Redemption. void testVaultDepositClosedEnded() { @@ -1473,52 +1519,42 @@ class Vault_test : public beast::unit_test::Suite env.fund(XRP(1000), owner, depositor); env.close(); - auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); Asset const asset = xrpIssue(); - auto const sub = env.now().time_since_epoch().count() + 60; - auto const red = sub + kMinInvestmentPeriod; - - Vault vault{env}; - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = red}); - env(tx); - env.close(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod); - auto const deposit = [&](TER expected) { - env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), - Ter{expected}); - env.close(); - }; - - using d = NetClock::duration; - using tp = NetClock::time_point; + auto const deposit = + [&](TER expected, std::source_location const& loc = std::source_location::current()) { + env( + WithSourceLocation{ + vault.deposit( + {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), + loc}, + Ter{expected}); + env.close(); + }; // Subscription: allowed. deposit(tesSUCCESS); // Investment: rejected. env.close(tp{d{sub + 1}}); - deposit(tecNO_PERMISSION); + deposit(tecEXPIRED); // Redemption: rejected. env.close(tp{d{red}}); - deposit(tecNO_PERMISSION); + deposit(tecEXPIRED); } - // Spec 13.4: VaultWithdraw is allowed in Subscription and - // Redemption; rejected in Investment. The AssetsAvailable cap continues - // to apply and is exercised in Redemption against a vault with capital - // deployed as an outstanding loan. + // VaultWithdraw is allowed in Subscription and Redemption; rejected in Investment. The + // AssetsAvailable cap continues to apply and is exercised in Redemption against a vault with + // capital deployed as an outstanding loan. void testVaultWithdrawClosedEnded() { testcase("closed-ended VaultWithdraw phase gating"); using namespace test::jtx; - using namespace loanBroker; + using namespace loan_broker; using namespace loan; Env env{*this, testableAmendments()}; @@ -1528,34 +1564,22 @@ class Vault_test : public beast::unit_test::Suite env.fund(XRP(10'000), owner, depositor, borrower); env.close(); - auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); Asset const asset = xrpIssue(); - auto const sub = env.now().time_since_epoch().count() + 60; // Widen the Investment window so a single-payment loan (min payment // interval kMinPaymentInterval = 60s) fits before RedemptionDate. - auto const red = sub + kMinInvestmentPeriod + 3600; - - Vault vault{env}; - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = red}); - env(tx); - env.close(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod + 3600u); // Deposit XRP(100) in Subscription so the depositor's shares are // worth XRP(100). The vault holds XRP(100) with // AssetsAvailable == AssetsTotal. - env(vault.deposit( - {.depositor = depositor, .id = keylet.key, .amount = XRP(100).value()})); + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(100).value()})); env.close(); // Create a loan broker backed by this vault. LoanBrokerSet has no // phase gate, so this is fine to do in Subscription. auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner)); - env(loanBroker::set(owner, keylet.key)); + env(loan_broker::set(owner, keylet.key)); env.close(); auto const withdraw = [&](STAmount const& amount, @@ -1570,14 +1594,11 @@ class Vault_test : public beast::unit_test::Suite env.close(); }; - using d = NetClock::duration; - using tp = NetClock::time_point; - // Subscription: allowed (LP cancel). withdraw(XRP(1).value(), tesSUCCESS); // Investment: rejected. - closeToTime(env, tp{d{sub + 1}}); + closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env)); withdraw(XRP(1).value(), tecTOO_SOON); // Deploy capital: borrower takes a loan of XRP(60) against the @@ -1592,21 +1613,18 @@ class Vault_test : public beast::unit_test::Suite Fee(env.current()->fees().base * 2)); env.close(); - // Redemption: withdrawals are allowed but subject to the - // AssetsAvailable cap. A small withdrawal within AssetsAvailable - // succeeds. A withdrawal within the depositor's share value but - // exceeding the vault's liquid balance fails with - // tecINSUFFICIENT_FUNDS from the vault-shortage guard (not the - // insufficient-shares guard). + // Redemption: withdrawals are allowed but subject to the AssetsAvailable cap. A small + // withdrawal within AssetsAvailable succeeds. A withdrawal within the depositor's share + // value but exceeding the vault's liquid balance fails with tecINSUFFICIENT_FUNDS from the + // vault-shortage guard (not the insufficient-shares guard). closeToTime(env, tp{d{red}}); withdraw(XRP(10).value(), tesSUCCESS); withdraw(XRP(80).value(), tecINSUFFICIENT_FUNDS); } - // Spec 13.10: end-to-end lifecycle of a closed-ended vault - // (Subscription → Investment → Redemption) with multiple LPs, - // exercising every phase transition and verifying the expected - // deposit and withdrawal behaviour in each phase. + // End-to-end lifecycle of a closed-ended vault (Subscription → Investment → Redemption) with + // multiple LPs, exercising every phase transition and verifying the expected deposit and + // withdrawal behaviour in each phase. void testVaultClosedEndedLifecycle() { @@ -1622,18 +1640,7 @@ class Vault_test : public beast::unit_test::Suite auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); Asset const asset = xrpIssue(); - auto const sub = env.now().time_since_epoch().count() + 300; - auto const red = sub + 60; - - Vault vault{env}; - auto [createTx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = red}); - env(createTx); - env.close(); + auto const [vault, keylet, sub, red] = makeClosedEndedVault(env, owner, asset, 300u, 60u); auto const sleCreate = env.le(keylet); BEAST_EXPECT(sleCreate); @@ -1645,10 +1652,9 @@ class Vault_test : public beast::unit_test::Suite BEAST_EXPECT(sle->at(sfAssetsTotal) == expected); }; - // env.balance(account, mptIssue) name-resolves the issuer via - // Env::lookup, but the share issuer is the vault's pseudo-account - // and is never registered with the jtx Env. Read the MPToken SLE - // directly to avoid the lookup. + // env.balance(account, mptIssue) name-resolves the issuer via Env::lookup, but the share + // issuer is the vault's pseudo-account and is never registered with the jtx Env. Read the + // MPToken SLE directly to avoid the lookup. auto const sharesEq = [&](Account const& holder, std::uint64_t expected) { auto const sle = env.le(keylet::mptoken(shares.getMptID(), holder.id())); std::uint64_t const actual = sle ? sle->getFieldU64(sfMPTAmount) : 0u; @@ -1683,17 +1689,13 @@ class Vault_test : public beast::unit_test::Suite availableEq(XRP(275).value()); // ---- Investment phase (now == sub + 1) ---- - using d = NetClock::duration; - using tp = NetClock::time_point; env.close(tp{d{sub + 1}}); - // Spec 5.2: deposits into a closed-ended vault past - // SubscriptionDate return tecNO_PERMISSION. + // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED. env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), - Ter{tecNO_PERMISSION}); + Ter{tecEXPIRED}); env.close(); - // Spec 6.2.1: withdrawals from a closed-ended vault during the - // Investment phase return tecTOO_SOON. + // Withdrawals from a closed-ended vault during the Investment phase return tecTOO_SOON. env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), Ter{tecTOO_SOON}); env.close(); @@ -1714,11 +1716,10 @@ class Vault_test : public beast::unit_test::Suite // ---- Redemption phase (now == red) ---- env.close(tp{d{red}}); - // Spec 5.2: deposits into a closed-ended vault past - // SubscriptionDate return tecNO_PERMISSION, in both Investment - // and Redemption. + // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED, in both + // Investment and Redemption. env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), - Ter{tecNO_PERMISSION}); + Ter{tecEXPIRED}); env.close(); // alice redeems her remaining 75 XRP. @@ -1733,9 +1734,8 @@ class Vault_test : public beast::unit_test::Suite sharesEq(bob, 0); availableEq(XRP(0).value()); - // Defensive spot-check that the three immutable fields (spec 3.4) - // have not changed across the entire lifecycle. Direct immutability - // coverage lives with the invariant tests (spec 13.9). + // Defensive spot-check that the three immutable fields have not changed across the entire + // lifecycle. Direct immutability coverage lives with the invariant tests. auto const sleFinal = env.le(keylet); if (BEAST_EXPECT(sleFinal)) { @@ -5045,9 +5045,8 @@ class Vault_test : public beast::unit_test::Suite } } - // XLS-103 spec 13.8: RPC coverage: closed-ended vaults must return VaultKind, - // SubscriptionDate and RedemptionDate in both vault_info and - // ledger_entry responses. Open-ended vaults must not. + // RPC coverage: closed-ended vaults must return VaultKind, SubscriptionDate and RedemptionDate + // in both vault_info and ledger_entry responses. Open-ended vaults must not. void testRPCClosedEnded() { diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp index 83cedcad14a..38a7049c5f7 100644 --- a/src/test/app/lending/LoanSet_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -592,9 +592,7 @@ class LoanSet_test : public LoanTestBase nullptr); } - // Spec 13.5: LoanSet in a closed-ended vault — phase gating and - // maturity bound. Covers spec 7.2 failure conditions and the boundary - // of the finalPayment < RedemptionDate check. + // LoanSet in a closed-ended vault — phase gating and maturity bound. void testLoanSetClosedEnded() { @@ -610,11 +608,9 @@ class LoanSet_test : public LoanTestBase constexpr std::uint32_t kInterval = 3600u * 24u; // 1 day constexpr std::uint32_t kTotal = 2u; - // featureLendingProtocolV1_1 is excluded from `all_` by - // convention (see the comment on `all_`), so callers must opt - // in. Closed-ended vaults are gated on this amendment; without - // it VaultCreate returns temDISABLED and every follow-on txn - // sees tecNO_ENTRY. + // featureLendingProtocolV1_1 is excluded from `all_` by convention (see the comment on + // `all_`), so callers must opt in. Closed-ended vaults are gated on this amendment; without + // it VaultCreate returns temDISABLED and every follow-on txn sees tecNO_ENTRY. auto const withEnv = [&, this](auto&& body) { Env env(*this, testableAmendments() | featureLendingProtocolV1_1); env.fund(XRP(1'000'000'000), issuer, lender, borrower); @@ -634,9 +630,8 @@ class LoanSet_test : public LoanTestBase env.close(); }; - // 1. Rejected during Subscription: the broker is created in - // Subscription (skipPhaseAdvance = true), then LoanSet is attempted - // before advancing past SubscriptionDate. + // 1. Rejected during Subscription: the broker is created in Subscription (skipPhaseAdvance + // = true), then LoanSet is attempted before advancing past SubscriptionDate. withEnv([&](Env& env, PrettyAsset const& asset) { auto const broker = createVaultAndBroker( env, @@ -646,9 +641,8 @@ class LoanSet_test : public LoanTestBase setLoan(env, broker, tecTOO_SOON); }); - // 2. Rejected during Redemption: broker is set up normally (which - // lands the vault in Investment), then advance the clock past - // RedemptionDate before attempting LoanSet. + // 2. Rejected during Redemption: broker is set up normally (which lands the vault in + // Investment), then advance the clock past RedemptionDate before attempting LoanSet. withEnv([&](Env& env, PrettyAsset const& asset) { auto const broker = createVaultAndBroker( env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded}); @@ -659,17 +653,16 @@ class LoanSet_test : public LoanTestBase setLoan(env, broker, tecEXPIRED); }); - // 3. Accepted during Investment when the schedule comfortably fits - // before RedemptionDate. + // 3. Accepted during Investment when the schedule comfortably fits before RedemptionDate. withEnv([&](Env& env, PrettyAsset const& asset) { auto const broker = createVaultAndBroker( env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded}); 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 on or after + // 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( @@ -688,10 +681,9 @@ class LoanSet_test : public LoanTestBase 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: 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. withEnv([&](Env& env, PrettyAsset const& asset) { auto const broker = createVaultAndBroker( env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded}); @@ -720,6 +712,7 @@ class LoanSet_test : public LoanTestBase env.close(); }); } + public: void run() override diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index 53feb39d32f..fb7847dd0d4 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -94,26 +94,22 @@ class LoanTestBase : public beast::unit_test::Suite // tests that need finer loanScale to exercise rounding edge cases. std::optional vaultScale = std::nullopt; // NOLINT(readability-redundant-member-init) - // Vault kind axis. When ClosedEnded, createVaultAndBroker sets - // sfSubscriptionDate / sfRedemptionDate from env.now() using the - // offsets below and advances the ledger clock past SubscriptionDate - // so the vault is in the Investment phase by the time the broker is + // Vault kind axis. When ClosedEnded, createVaultAndBroker sets sfSubscriptionDate / + // sfRedemptionDate from env.now() using the offsets below and advances the ledger clock + // past SubscriptionDate so the vault is in the Investment phase by the time the broker is // set up. Requires featureLendingProtocolV1_1. VaultKind vaultKind = VaultKind::OpenEnded; - // Seconds past env.now() at which SubscriptionDate lands. Must be - // strictly positive (VaultCreate::preclaim rejects - // SubscriptionDate <= parentCloseTime). + // Seconds past env.now() at which SubscriptionDate lands. Must be strictly positive + // (VaultCreate::preclaim rejects SubscriptionDate <= parentCloseTime). 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 + // 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. 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 phase. Ignored for - // open-ended vaults. + // 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 + // phase. Ignored for open-ended vaults. bool skipPhaseAdvance = false; [[nodiscard]] Number @@ -152,7 +148,7 @@ class LoanTestBase : public beast::unit_test::Suite Keylet const& vaultKeylet, BrokerParameters p, std::optional subscriptionDate = std::nullopt, - std::optional redemptionDate = std::nullopt)) + std::optional redemptionDate = std::nullopt) : asset(asset) , brokerID(brokerKeylet.key) , vaultID(vaultKeylet.key) @@ -519,9 +515,8 @@ class LoanTestBase : public beast::unit_test::Suite BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value()); } - // For closed-ended vaults, advance past SubscriptionDate so subsequent - // LoanSet operations run in the Investment phase (unless the caller - // explicitly asked to stay in Subscription). + // For closed-ended vaults, advance past SubscriptionDate so subsequent LoanSet operations + // run in the Investment phase (unless the caller explicitly asked to stay in Subscription). if (subscriptionDate && !params.skipPhaseAdvance) { using d = NetClock::duration; From 04450805fd25aca754acb0cc6398f9b25f722339 Mon Sep 17 00:00:00 2001 From: JCW Date: Fri, 7 Aug 2026 09:52:22 +0100 Subject: [PATCH 04/12] Fix build errors --- .../protocol_autogen/ledger_entries/Vault.h | 105 +++++++++++++++++ .../transactions/VaultCreate.h | 111 ++++++++++++++++++ src/libxrpl/tx/invariants/LoanInvariant.cpp | 1 + src/libxrpl/tx/invariants/VaultInvariant.cpp | 2 +- .../tx/transactors/vault/VaultDeposit.cpp | 1 + src/test/app/Invariants_test.cpp | 2 + src/test/app/Vault_test.cpp | 5 +- src/test/app/lending/LoanSet_test.cpp | 3 + src/test/app/lending/LoanValidation_test.cpp | 1 + .../ledger_entries/VaultTests.cpp | 81 +++++++++++++ .../transactions/VaultCreateTests.cpp | 63 ++++++++++ 11 files changed, 373 insertions(+), 2 deletions(-) diff --git a/include/xrpl/protocol_autogen/ledger_entries/Vault.h b/include/xrpl/protocol_autogen/ledger_entries/Vault.h index a6ab54cb0a4..389ffb4c460 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Vault.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Vault.h @@ -311,6 +311,78 @@ class Vault : public LedgerEntryBase { return this->sle_->isFieldPresent(sfLEVersion); } + + /** + * @brief Get sfVaultKind (SoeDefault) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getVaultKind() const + { + if (hasVaultKind()) + return this->sle_->at(sfVaultKind); + return std::nullopt; + } + + /** + * @brief Check if sfVaultKind is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasVaultKind() const + { + return this->sle_->isFieldPresent(sfVaultKind); + } + + /** + * @brief Get sfSubscriptionDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getSubscriptionDate() const + { + if (hasSubscriptionDate()) + return this->sle_->at(sfSubscriptionDate); + return std::nullopt; + } + + /** + * @brief Check if sfSubscriptionDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasSubscriptionDate() const + { + return this->sle_->isFieldPresent(sfSubscriptionDate); + } + + /** + * @brief Get sfRedemptionDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getRedemptionDate() const + { + if (hasRedemptionDate()) + return this->sle_->at(sfRedemptionDate); + return std::nullopt; + } + + /** + * @brief Check if sfRedemptionDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasRedemptionDate() const + { + return this->sle_->isFieldPresent(sfRedemptionDate); + } }; /** @@ -543,6 +615,39 @@ class VaultBuilder : public LedgerEntryBuilderBase return *this; } + /** + * @brief Set sfVaultKind (SoeDefault) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setVaultKind(std::decay_t const& value) + { + object_[sfVaultKind] = value; + return *this; + } + + /** + * @brief Set sfSubscriptionDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setSubscriptionDate(std::decay_t const& value) + { + object_[sfSubscriptionDate] = value; + return *this; + } + + /** + * @brief Set sfRedemptionDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setRedemptionDate(std::decay_t const& value) + { + object_[sfRedemptionDate] = value; + return *this; + } + /** * @brief Build and return the completed Vault wrapper. * @param index The ledger entry index. diff --git a/include/xrpl/protocol_autogen/transactions/VaultCreate.h b/include/xrpl/protocol_autogen/transactions/VaultCreate.h index b7e15277546..e206925e02b 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultCreate.h +++ b/include/xrpl/protocol_autogen/transactions/VaultCreate.h @@ -214,6 +214,84 @@ class VaultCreate : public TransactionBase { return this->tx_->isFieldPresent(sfScale); } + + /** + * @brief Get sfVaultKind (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getVaultKind() const + { + if (hasVaultKind()) + { + return this->tx_->at(sfVaultKind); + } + return std::nullopt; + } + + /** + * @brief Check if sfVaultKind is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasVaultKind() const + { + return this->tx_->isFieldPresent(sfVaultKind); + } + + /** + * @brief Get sfSubscriptionDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getSubscriptionDate() const + { + if (hasSubscriptionDate()) + { + return this->tx_->at(sfSubscriptionDate); + } + return std::nullopt; + } + + /** + * @brief Check if sfSubscriptionDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasSubscriptionDate() const + { + return this->tx_->isFieldPresent(sfSubscriptionDate); + } + + /** + * @brief Get sfRedemptionDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getRedemptionDate() const + { + if (hasRedemptionDate()) + { + return this->tx_->at(sfRedemptionDate); + } + return std::nullopt; + } + + /** + * @brief Check if sfRedemptionDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasRedemptionDate() const + { + return this->tx_->isFieldPresent(sfRedemptionDate); + } }; /** @@ -338,6 +416,39 @@ class VaultCreateBuilder : public TransactionBuilderBase return *this; } + /** + * @brief Set sfVaultKind (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultCreateBuilder& + setVaultKind(std::decay_t const& value) + { + object_[sfVaultKind] = value; + return *this; + } + + /** + * @brief Set sfSubscriptionDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultCreateBuilder& + setSubscriptionDate(std::decay_t const& value) + { + object_[sfSubscriptionDate] = value; + return *this; + } + + /** + * @brief Set sfRedemptionDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultCreateBuilder& + setRedemptionDate(std::decay_t const& value) + { + object_[sfRedemptionDate] = value; + return *this; + } + /** * @brief Build and return the VaultCreate wrapper. * @param publicKey The public key for signing. diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index b812b87c676..9cecfb095cb 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -14,6 +14,7 @@ #include #include #include +#include namespace xrpl { diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 8e80a4d60bd..6460dc1036c 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include #include #include @@ -26,6 +25,7 @@ #include #include #include +#include #include #include diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp index 5cf49bd1469..68a3ce3f564 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp @@ -22,6 +22,7 @@ #include #include #include +#include "xrpl/protocol/Protocol.h" #include #include diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 8ff73da8d2a..2c395ea0fcd 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -55,6 +55,7 @@ #include #include #include +#include "xrpl/basics/chrono.h" #include #include @@ -64,6 +65,7 @@ #include #include #include +#include #include #include #include diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 994313ce156..cd107510662 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -58,10 +58,13 @@ #include #include #include +#include "xrpl/basics/chrono.h" +#include "xrpl/protocol/Keylet.h" #include #include #include +#include #include #include #include @@ -139,7 +142,7 @@ class Vault_test : public beast::unit_test::Suite { auto const sub = env.now().time_since_epoch().count() + subOffset; auto const red = sub + gap; - test::jtx::Vault vault{env}; + test::jtx::Vault const vault{env}; auto [tx, keylet] = vault.create( {.owner = owner, .asset = asset, diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp index 38a7049c5f7..31cd4a07f58 100644 --- a/src/test/app/lending/LoanSet_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -24,8 +24,11 @@ #include #include #include +#include "xrpl/basics/chrono.h" +#include "xrpl/protocol/Protocol.h" #include +#include #include #include #include diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp index a9d37ae546a..7e34614630f 100644 --- a/src/test/app/lending/LoanValidation_test.cpp +++ b/src/test/app/lending/LoanValidation_test.cpp @@ -32,6 +32,7 @@ #include #include #include +#include "xrpl/protocol/Protocol.h" #include #include diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp index f55d01f6062..26dde555636 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp @@ -36,6 +36,9 @@ TEST(VaultTests, BuilderSettersRoundTrip) auto const withdrawalPolicyValue = canonical_UINT8(); auto const scaleValue = canonical_UINT8(); auto const lEVersionValue = canonical_UINT8(); + auto const vaultKindValue = canonical_UINT8(); + auto const subscriptionDateValue = canonical_UINT32(); + auto const redemptionDateValue = canonical_UINT32(); VaultBuilder builder{ previousTxnIDValue, @@ -56,6 +59,9 @@ TEST(VaultTests, BuilderSettersRoundTrip) builder.setLossUnrealized(lossUnrealizedValue); builder.setScale(scaleValue); builder.setLEVersion(lEVersionValue); + builder.setVaultKind(vaultKindValue); + builder.setSubscriptionDate(subscriptionDateValue); + builder.setRedemptionDate(redemptionDateValue); builder.setLedgerIndex(index); builder.setFlags(0x1u); @@ -176,6 +182,30 @@ TEST(VaultTests, BuilderSettersRoundTrip) EXPECT_TRUE(entry.hasLEVersion()); } + { + auto const& expected = vaultKindValue; + auto const actualOpt = entry.getVaultKind(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfVaultKind"); + EXPECT_TRUE(entry.hasVaultKind()); + } + + { + auto const& expected = subscriptionDateValue; + auto const actualOpt = entry.getSubscriptionDate(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfSubscriptionDate"); + EXPECT_TRUE(entry.hasSubscriptionDate()); + } + + { + auto const& expected = redemptionDateValue; + auto const actualOpt = entry.getRedemptionDate(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfRedemptionDate"); + EXPECT_TRUE(entry.hasRedemptionDate()); + } + EXPECT_TRUE(entry.hasLedgerIndex()); auto const ledgerIndex = entry.getLedgerIndex(); ASSERT_TRUE(ledgerIndex.has_value()); @@ -205,6 +235,9 @@ TEST(VaultTests, BuilderFromSleRoundTrip) auto const withdrawalPolicyValue = canonical_UINT8(); auto const scaleValue = canonical_UINT8(); auto const lEVersionValue = canonical_UINT8(); + auto const vaultKindValue = canonical_UINT8(); + auto const subscriptionDateValue = canonical_UINT32(); + auto const redemptionDateValue = canonical_UINT32(); auto sle = std::make_shared(Vault::entryType, index); @@ -224,6 +257,9 @@ TEST(VaultTests, BuilderFromSleRoundTrip) sle->at(sfWithdrawalPolicy) = withdrawalPolicyValue; sle->at(sfScale) = scaleValue; sle->at(sfLEVersion) = lEVersionValue; + sle->at(sfVaultKind) = vaultKindValue; + sle->at(sfSubscriptionDate) = subscriptionDateValue; + sle->at(sfRedemptionDate) = redemptionDateValue; VaultBuilder builderFromSle{sle}; EXPECT_TRUE(builderFromSle.validate()); @@ -415,6 +451,45 @@ TEST(VaultTests, BuilderFromSleRoundTrip) expectEqualField(expected, *fromBuilderOpt, "sfLEVersion"); } + { + auto const& expected = vaultKindValue; + + auto const fromSleOpt = entryFromSle.getVaultKind(); + auto const fromBuilderOpt = entryFromBuilder.getVaultKind(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfVaultKind"); + expectEqualField(expected, *fromBuilderOpt, "sfVaultKind"); + } + + { + auto const& expected = subscriptionDateValue; + + auto const fromSleOpt = entryFromSle.getSubscriptionDate(); + auto const fromBuilderOpt = entryFromBuilder.getSubscriptionDate(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfSubscriptionDate"); + expectEqualField(expected, *fromBuilderOpt, "sfSubscriptionDate"); + } + + { + auto const& expected = redemptionDateValue; + + auto const fromSleOpt = entryFromSle.getRedemptionDate(); + auto const fromBuilderOpt = entryFromBuilder.getRedemptionDate(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfRedemptionDate"); + expectEqualField(expected, *fromBuilderOpt, "sfRedemptionDate"); + } + EXPECT_EQ(entryFromSle.getKey(), index); EXPECT_EQ(entryFromBuilder.getKey(), index); } @@ -499,5 +574,11 @@ TEST(VaultTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getScale().has_value()); EXPECT_FALSE(entry.hasLEVersion()); EXPECT_FALSE(entry.getLEVersion().has_value()); + EXPECT_FALSE(entry.hasVaultKind()); + EXPECT_FALSE(entry.getVaultKind().has_value()); + EXPECT_FALSE(entry.hasSubscriptionDate()); + EXPECT_FALSE(entry.getSubscriptionDate().has_value()); + EXPECT_FALSE(entry.hasRedemptionDate()); + EXPECT_FALSE(entry.getRedemptionDate().has_value()); } } diff --git a/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp index 9c1e14f6f4a..592d40a6f62 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp @@ -36,6 +36,9 @@ TEST(TransactionsVaultCreateTests, BuilderSettersRoundTrip) auto const withdrawalPolicyValue = canonical_UINT8(); auto const dataValue = canonical_VL(); auto const scaleValue = canonical_UINT8(); + auto const vaultKindValue = canonical_UINT8(); + auto const subscriptionDateValue = canonical_UINT32(); + auto const redemptionDateValue = canonical_UINT32(); VaultCreateBuilder builder{ accountValue, @@ -51,6 +54,9 @@ TEST(TransactionsVaultCreateTests, BuilderSettersRoundTrip) builder.setWithdrawalPolicy(withdrawalPolicyValue); builder.setData(dataValue); builder.setScale(scaleValue); + builder.setVaultKind(vaultKindValue); + builder.setSubscriptionDate(subscriptionDateValue); + builder.setRedemptionDate(redemptionDateValue); auto tx = builder.build(publicKey, secretKey); @@ -122,6 +128,30 @@ TEST(TransactionsVaultCreateTests, BuilderSettersRoundTrip) EXPECT_TRUE(tx.hasScale()); } + { + auto const& expected = vaultKindValue; + auto const actualOpt = tx.getVaultKind(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfVaultKind should be present"; + expectEqualField(expected, *actualOpt, "sfVaultKind"); + EXPECT_TRUE(tx.hasVaultKind()); + } + + { + auto const& expected = subscriptionDateValue; + auto const actualOpt = tx.getSubscriptionDate(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfSubscriptionDate should be present"; + expectEqualField(expected, *actualOpt, "sfSubscriptionDate"); + EXPECT_TRUE(tx.hasSubscriptionDate()); + } + + { + auto const& expected = redemptionDateValue; + auto const actualOpt = tx.getRedemptionDate(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRedemptionDate should be present"; + expectEqualField(expected, *actualOpt, "sfRedemptionDate"); + EXPECT_TRUE(tx.hasRedemptionDate()); + } + } // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, @@ -145,6 +175,9 @@ TEST(TransactionsVaultCreateTests, BuilderFromStTxRoundTrip) auto const withdrawalPolicyValue = canonical_UINT8(); auto const dataValue = canonical_VL(); auto const scaleValue = canonical_UINT8(); + auto const vaultKindValue = canonical_UINT8(); + auto const subscriptionDateValue = canonical_UINT32(); + auto const redemptionDateValue = canonical_UINT32(); // Build an initial transaction VaultCreateBuilder initialBuilder{ @@ -160,6 +193,9 @@ TEST(TransactionsVaultCreateTests, BuilderFromStTxRoundTrip) initialBuilder.setWithdrawalPolicy(withdrawalPolicyValue); initialBuilder.setData(dataValue); initialBuilder.setScale(scaleValue); + initialBuilder.setVaultKind(vaultKindValue); + initialBuilder.setSubscriptionDate(subscriptionDateValue); + initialBuilder.setRedemptionDate(redemptionDateValue); auto initialTx = initialBuilder.build(publicKey, secretKey); @@ -226,6 +262,27 @@ TEST(TransactionsVaultCreateTests, BuilderFromStTxRoundTrip) expectEqualField(expected, *actualOpt, "sfScale"); } + { + auto const& expected = vaultKindValue; + auto const actualOpt = rebuiltTx.getVaultKind(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfVaultKind should be present"; + expectEqualField(expected, *actualOpt, "sfVaultKind"); + } + + { + auto const& expected = subscriptionDateValue; + auto const actualOpt = rebuiltTx.getSubscriptionDate(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfSubscriptionDate should be present"; + expectEqualField(expected, *actualOpt, "sfSubscriptionDate"); + } + + { + auto const& expected = redemptionDateValue; + auto const actualOpt = rebuiltTx.getRedemptionDate(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRedemptionDate should be present"; + expectEqualField(expected, *actualOpt, "sfRedemptionDate"); + } + } // 3) Verify wrapper throws when constructed from wrong transaction type. @@ -295,6 +352,12 @@ TEST(TransactionsVaultCreateTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getData().has_value()); EXPECT_FALSE(tx.hasScale()); EXPECT_FALSE(tx.getScale().has_value()); + EXPECT_FALSE(tx.hasVaultKind()); + EXPECT_FALSE(tx.getVaultKind().has_value()); + EXPECT_FALSE(tx.hasSubscriptionDate()); + EXPECT_FALSE(tx.getSubscriptionDate().has_value()); + EXPECT_FALSE(tx.hasRedemptionDate()); + EXPECT_FALSE(tx.getRedemptionDate().has_value()); } } From a3cf27ade96c69d07cb352f6bca9f69eb18e3193 Mon Sep 17 00:00:00 2001 From: JCW Date: Fri, 7 Aug 2026 09:52:59 +0100 Subject: [PATCH 05/12] Fix errors --- src/libxrpl/tx/invariants/LoanInvariant.cpp | 1 + src/libxrpl/tx/transactors/vault/VaultDeposit.cpp | 2 +- src/test/app/Invariants_test.cpp | 2 +- src/test/app/Vault_test.cpp | 4 ++-- src/test/app/lending/LoanSet_test.cpp | 4 ++-- src/test/app/lending/LoanValidation_test.cpp | 2 +- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index 9cecfb095cb..b7bad9de5d6 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -14,6 +14,7 @@ #include #include #include + #include namespace xrpl { diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp index 68a3ce3f564..a3c0a94eb5c 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -22,7 +23,6 @@ #include #include #include -#include "xrpl/protocol/Protocol.h" #include #include diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 2c395ea0fcd..b6b32a45d4d 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -55,7 +56,6 @@ #include #include #include -#include "xrpl/basics/chrono.h" #include #include diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index cd107510662..c49695062d9 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -28,6 +28,7 @@ #include #include +#include #include #include #include @@ -46,6 +47,7 @@ #include #include #include +#include #include #include #include @@ -58,8 +60,6 @@ #include #include #include -#include "xrpl/basics/chrono.h" -#include "xrpl/protocol/Keylet.h" #include #include diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp index 31cd4a07f58..3571853b47f 100644 --- a/src/test/app/lending/LoanSet_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -13,19 +13,19 @@ #include #include +#include #include #include #include #include #include #include +#include #include #include #include #include #include -#include "xrpl/basics/chrono.h" -#include "xrpl/protocol/Protocol.h" #include #include diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp index 7e34614630f..308fa8dd5eb 100644 --- a/src/test/app/lending/LoanValidation_test.cpp +++ b/src/test/app/lending/LoanValidation_test.cpp @@ -26,13 +26,13 @@ #include #include #include +#include #include #include #include #include #include #include -#include "xrpl/protocol/Protocol.h" #include #include From d027cf35086b4c57916bd9d08e636eb012c481fd Mon Sep 17 00:00:00 2001 From: JCW Date: Fri, 7 Aug 2026 12:15:42 +0100 Subject: [PATCH 06/12] Update the implementation --- src/libxrpl/tx/invariants/VaultInvariant.cpp | 22 +++++++++---------- .../tx/transactors/vault/VaultCreate.cpp | 6 ++--- src/test/app/Invariants_test.cpp | 4 ++-- src/test/app/Vault_test.cpp | 4 ++-- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 6460dc1036c..516cf57ec76 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -669,18 +669,18 @@ ValidVault::finalize( "and RedemptionDate"; result = false; } - else if ( - *afterVault.redemptionDate <= *afterVault.subscriptionDate || - *afterVault.redemptionDate - *afterVault.subscriptionDate < - kMinInvestmentPeriod || - *afterVault.redemptionDate - *afterVault.subscriptionDate >= - kMaxInvestmentPeriod) + else { - JLOG(j.fatal()) // - << "Invariant failed: closed-ended vault RedemptionDate - " - "SubscriptionDate must be within [MIN_INVESTMENT_PERIOD, " - "MAX_INVESTMENT_PERIOD)"; - result = false; + auto const sub = static_cast(*afterVault.subscriptionDate); + auto const red = static_cast(*afterVault.redemptionDate); + if (red < sub + kMinInvestmentPeriod || red >= sub + kMaxInvestmentPeriod) + { + JLOG(j.fatal()) // + << "Invariant failed: closed-ended vault RedemptionDate - " + "SubscriptionDate must be within [MIN_INVESTMENT_PERIOD, " + "MAX_INVESTMENT_PERIOD)"; + result = false; + } } } diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index 123e0933391..80c4826de23 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -117,9 +117,9 @@ VaultCreate::preflight(PreflightContext const& ctx) return temMALFORMED; if (isClosedEnded) { - auto const sub = ctx.tx[sfSubscriptionDate]; - auto const red = ctx.tx[sfRedemptionDate]; - if (red <= sub || red - sub < kMinInvestmentPeriod || red - sub >= kMaxInvestmentPeriod) + auto const sub = static_cast(ctx.tx[sfSubscriptionDate]); + auto const red = static_cast(ctx.tx[sfRedemptionDate]); + if (red < sub + kMinInvestmentPeriod || red >= sub + kMaxInvestmentPeriod) return temMALFORMED; } diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index b6b32a45d4d..95ce48585ab 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -4547,8 +4547,8 @@ class Invariants_test : public beast::unit_test::Suite STTx{ttVAULT_CREATE, [](STObject&) {}}, {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - // RedemptionDate strictly before SubscriptionDate; exercises the red <= sub short-circuit - // that guards the unsigned red - sub subtraction against wrap-around. + // RedemptionDate strictly before SubscriptionDate; the signed int64 gap is negative and + // is caught by the sub-minimum branch of the gap check. doInvariantCheck( {"closed-ended vault RedemptionDate - SubscriptionDate must be " "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index c49695062d9..3006b5f27d0 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -1283,8 +1283,8 @@ class Vault_test : public beast::unit_test::Suite /* * Gap smaller than MIN_INVESTMENT_PERIOD => temMALFORMED. Includes the SubscriptionDate >= * RedemptionDate degenerate cases: the red == sub boundary and the strictly-reversed red < - * sub case, the latter exercising the red <= sub short-circuit that guards the unsigned red - * - sub subtraction against wrap-around. + * sub case, the latter yielding a negative signed int64 gap that is caught by the + * sub-minimum branch of the gap check. */ withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { auto const sub = env.now().time_since_epoch().count() + 60; From 3ff3f7eee29573286d5a0c6db12d4bed155fc620 Mon Sep 17 00:00:00 2001 From: JCW Date: Fri, 7 Aug 2026 17:47:35 +0100 Subject: [PATCH 07/12] Address PR comments --- src/test/app/Vault_test.cpp | 121 +++++++++++++++++++++++++++++++----- 1 file changed, 105 insertions(+), 16 deletions(-) diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 3006b5f27d0..83afcf27996 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -42,6 +42,7 @@ #include #include #include +#include #include #include #include @@ -1329,6 +1330,19 @@ class Vault_test : public beast::unit_test::Suite env(tx, Ter{temMALFORMED}); }); + // Gap strictly greater than MAX_INVESTMENT_PERIOD => temMALFORMED. Same code path as + // gap == MAX_INVESTMENT_PERIOD above, but covers the "gap >= MAX" bullet fully. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub + maxPeriod + 1}); + env(tx, Ter{temMALFORMED}); + }); + // Happy path: gap exactly equal to MIN_INVESTMENT_PERIOD is accepted (lower bound is // inclusive). withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { @@ -1508,6 +1522,42 @@ class Vault_test : public beast::unit_test::Suite env.close(); } + // Open-ended vaults are always in VaultPhase::NoPhase, regardless of the ledger clock or any + // dates present on the vault. + void + testVaultPhaseDerivationOpenEnded() + { + testcase("open-ended phase derivation"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + + Asset const asset = xrpIssue(); + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + auto const checkPhaseAt = [&](NetClock::time_point at) { + closeToTime(env, at); + auto const sle = env.le(keylet); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(getVaultPhase(*env.current(), sle) == VaultPhase::NoPhase); + }; + + // Advance the clock through a wide range of ledger times: an open-ended vault's phase + // must be NoPhase at every one of them, because the derivation short-circuits on + // VaultKind::OpenEnded before it looks at any dates. + auto const now = env.now(); + checkPhaseAt(now); + checkPhaseAt(now + std::chrono::seconds{kMinInvestmentPeriod}); + checkPhaseAt(now + std::chrono::seconds{kMaxInvestmentPeriod} - env.closed()->header().closeTimeResolution); + } + // VaultDeposit is allowed only during Subscription (or NoPhase). Rejected during Investment and // Redemption. void @@ -1626,34 +1676,42 @@ class Vault_test : public beast::unit_test::Suite } // End-to-end lifecycle of a closed-ended vault (Subscription → Investment → Redemption) with - // multiple LPs, exercising every phase transition and verifying the expected deposit and - // withdrawal behaviour in each phase. + // multiple depositors and a real loan originated through the Investment leg. Exercises every phase + // transition and verifies the expected deposit, withdrawal, and lending behaviour in each + // phase. void testVaultClosedEndedLifecycle() { testcase("closed-ended vault lifecycle (subscribe → invest → redeem)"); using namespace test::jtx; + using namespace loan_broker; + using namespace loan; Env env{*this, testableAmendments()}; Account const owner{"owner"}; Account const alice{"alice"}; Account const bob{"bob"}; - env.fund(XRP(10'000), owner, alice, bob); + Account const borrower{"borrower"}; + env.fund(XRP(10'000), owner, alice, bob, borrower); env.close(); auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); Asset const asset = xrpIssue(); - auto const [vault, keylet, sub, red] = makeClosedEndedVault(env, owner, asset, 300u, 60u); + // Widen the Investment window so a single-payment loan (min payment interval + // kMinPaymentInterval = 60s) fits before RedemptionDate with headroom. + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u); auto const sleCreate = env.le(keylet); BEAST_EXPECT(sleCreate); MPTIssue const shares{sleCreate->at(sfShareMPTID)}; - auto const availableEq = [&](STAmount const& expected) { + auto const balancesEq = [&](STAmount const& available, STAmount const& total) { auto const sle = env.le(keylet); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == expected); - BEAST_EXPECT(sle->at(sfAssetsTotal) == expected); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == available); + BEAST_EXPECT(sle->at(sfAssetsTotal) == total); }; + auto const availableEq = [&](STAmount const& expected) { balancesEq(expected, expected); }; // env.balance(account, mptIssue) name-resolves the issuer via Env::lookup, but the share // issuer is the vault's pseudo-account and is never registered with the jtx Env. Read the @@ -1691,6 +1749,12 @@ class Vault_test : public beast::unit_test::Suite sharesEq(alice, 75'000'000); availableEq(XRP(275).value()); + // Create a loan broker backed by this vault. LoanBrokerSet has no phase gate, so it is + // fine to do in Subscription. + auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner)); + env(loan_broker::set(owner, keylet.key)); + env.close(); + // ---- Investment phase (now == sub + 1) ---- env.close(tp{d{sub + 1}}); @@ -1703,6 +1767,24 @@ class Vault_test : public beast::unit_test::Suite Ter{tecTOO_SOON}); env.close(); + // A real loan is originated during Investment (permitted only in this phase). Zero-interest + // one-payment schedule keeps AssetsTotal unchanged (both accrual and cash-basis + // accounting recognise no interest at origination); AssetsAvailable drops by the loan + // principal. + env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(60), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2)); + env.close(); + auto const sleBroker = env.le(keylet::loanBroker(brokerKeylet.key)); + BEAST_EXPECT(sleBroker); + auto const loanKeylet = keylet::loan(brokerKeylet.key, 1u); + BEAST_EXPECT(env.le(loanKeylet)); + balancesEq(XRP(215).value(), XRP(275).value()); + // Non-immutable VaultSet still works in Investment (positive control). { auto tx = vault.set({.owner = owner, .id = keylet.key}); @@ -1711,10 +1793,9 @@ class Vault_test : public beast::unit_test::Suite env.close(); } - // Balances unchanged after the two failed txns and one set. + // Depositor share balances unchanged by the loan origination; only AssetsAvailable moved. sharesEq(alice, 75'000'000); sharesEq(bob, 200'000'000); - availableEq(XRP(275).value()); // ---- Redemption phase (now == red) ---- env.close(tp{d{red}}); @@ -1725,17 +1806,24 @@ class Vault_test : public beast::unit_test::Suite Ter{tecEXPIRED}); env.close(); - // alice redeems her remaining 75 XRP. + // alice redeems her remaining 75 XRP (fits within AssetsAvailable = 215). env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(75).value()})); env.close(); sharesEq(alice, 0); - availableEq(XRP(200).value()); - - // bob redeems his 200 XRP. - env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()})); + balancesEq(XRP(140).value(), XRP(200).value()); + + // bob has 200 XRP-worth of shares but only 140 XRP is available (the remaining 60 XRP + // sits in the outstanding loan). A full 200 XRP withdrawal fails against the + // AssetsAvailable cap; bob redeems 140 XRP instead and is left holding 60M shares backed + // by the loan receivable — the realistic outcome when capital is still deployed at + // Redemption. + env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()}), + Ter{tecINSUFFICIENT_FUNDS}); + env.close(); + env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(140).value()})); env.close(); - sharesEq(bob, 0); - availableEq(XRP(0).value()); + sharesEq(bob, 60'000'000); + balancesEq(XRP(0).value(), XRP(60).value()); // Defensive spot-check that the three immutable fields have not changed across the entire // lifecycle. Direct immutability coverage lives with the invariant tests. @@ -9109,6 +9197,7 @@ class Vault_test : public beast::unit_test::Suite testCreateFailMPT(); testVaultCreateClosedEnded(); testVaultPhaseDerivation(); + testVaultPhaseDerivationOpenEnded(); testVaultDepositClosedEnded(); testVaultWithdrawClosedEnded(); testVaultClosedEndedLifecycle(); From 9964d1c783dda7bd4436f8690b739af7a2b66d8c Mon Sep 17 00:00:00 2001 From: JCW Date: Fri, 7 Aug 2026 18:16:38 +0100 Subject: [PATCH 08/12] Address comments --- src/test/app/Vault_test.cpp | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 8bd07a49149..5d535445c38 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -1552,10 +1552,12 @@ class Vault_test : public beast::unit_test::Suite // Advance the clock through a wide range of ledger times: an open-ended vault's phase // must be NoPhase at every one of them, because the derivation short-circuits on // VaultKind::OpenEnded before it looks at any dates. - auto const now = env.now(); - checkPhaseAt(now); - checkPhaseAt(now + std::chrono::seconds{kMinInvestmentPeriod}); - checkPhaseAt(now + std::chrono::seconds{kMaxInvestmentPeriod} - env.closed()->header().closeTimeResolution); + auto const ledgerTime = tp{d{30}} + env.closed()->header().closeTimeResolution; + checkPhaseAt(ledgerTime); + checkPhaseAt(ledgerTime + std::chrono::seconds{kMinInvestmentPeriod}); + checkPhaseAt( + ledgerTime + std::chrono::seconds{kMaxInvestmentPeriod} - + env.closed()->header().closeTimeResolution); } // VaultDeposit is allowed only during Subscription (or NoPhase). Rejected during Investment and @@ -1676,8 +1678,8 @@ class Vault_test : public beast::unit_test::Suite } // End-to-end lifecycle of a closed-ended vault (Subscription → Investment → Redemption) with - // multiple depositors and a real loan originated through the Investment leg. Exercises every phase - // transition and verifies the expected deposit, withdrawal, and lending behaviour in each + // multiple depositors and a real loan originated through the Investment leg. Exercises every + // phase transition and verifies the expected deposit, withdrawal, and lending behaviour in each // phase. void testVaultClosedEndedLifecycle() From 249a7c2362781b4ec18a8625de8b3c051a0735d6 Mon Sep 17 00:00:00 2001 From: Jingchen Date: Mon, 10 Aug 2026 15:49:59 +0100 Subject: [PATCH 09/12] Update src/libxrpl/tx/transactors/vault/VaultCreate.cpp Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> --- src/libxrpl/tx/transactors/vault/VaultCreate.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index bd65b03f0cf..a94a18ae29c 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -113,10 +113,10 @@ VaultCreate::preflight(PreflightContext const& ctx) auto const isClosedEnded = kind == VaultKind::ClosedEnded; if (!isClosedEnded && (hasSubscription || hasRedemption)) return temMALFORMED; - if (isClosedEnded && (!hasSubscription || !hasRedemption)) - return temMALFORMED; if (isClosedEnded) { + if (!hasSubscription || !hasRedemption) + return temMALFORMED; auto const sub = static_cast(ctx.tx[sfSubscriptionDate]); auto const red = static_cast(ctx.tx[sfRedemptionDate]); if (red < sub + kMinInvestmentPeriod || red >= sub + kMaxInvestmentPeriod) From 98b4f244ad83d72945fa320f211bc9ce9bbf0d3c Mon Sep 17 00:00:00 2001 From: JCW Date: Mon, 10 Aug 2026 17:10:06 +0100 Subject: [PATCH 10/12] Fix PR comments --- include/xrpl/ledger/helpers/VaultHelpers.h | 13 + include/xrpl/protocol/Protocol.h | 4 +- src/libxrpl/ledger/helpers/VaultHelpers.cpp | 8 + src/libxrpl/tx/invariants/InvariantCheck.cpp | 60 +--- src/libxrpl/tx/invariants/VaultInvariant.cpp | 18 +- .../tx/transactors/vault/VaultCreate.cpp | 6 +- src/test/app/Invariants_test.cpp | 9 +- src/test/app/Vault_test.cpp | 295 +++++++++++++++++- 8 files changed, 344 insertions(+), 69 deletions(-) diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index 25ace6a1a08..acbf2c3ac02 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -158,6 +158,19 @@ getVaultKind(STTx const& tx); [[nodiscard]] bool isValidVaultKind(STTx const& tx); +/** + * Returns true iff the (SubscriptionDate, RedemptionDate) gap of a + * closed-ended vault satisfies + * kMinInvestmentPeriod <= (red - sub) < kMaxInvestmentPeriod. The arithmetic + * is performed in std::int64_t so that @p sub near UINT32_MAX does not + * overflow. Shared by VaultCreate::preflight and the ValidVault invariant. + * + * @param sub The value of sfSubscriptionDate. + * @param red The value of sfRedemptionDate. + */ +[[nodiscard]] bool +isValidClosedEndedGap(std::uint32_t sub, std::uint32_t red); + /** * Returns the current lifecycle phase of a vault. Open-ended * vaults are always NoPhase. For closed-ended vaults the phase is derived diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 6215ae97290..345baef8538 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -355,8 +355,8 @@ enum class VaultPhase : std::uint8_t { */ constexpr std::uint32_t kMinInvestmentPeriod = std::chrono::seconds{std::chrono::minutes{1}}.count(); -constexpr std::uint32_t kMaxInvestmentPeriod = - std::chrono::seconds{std::chrono::days{30 * 365}}.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(); /** * Maximum recursion depth for vault shares being put as an asset inside diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index 888c9b256ff..67e0262e147 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -194,6 +194,14 @@ isValidVaultKind(STTx const& tx) *kindField == std::to_underlying(VaultKind::ClosedEnded); } +[[nodiscard]] bool +isValidClosedEndedGap(std::uint32_t sub, std::uint32_t red) +{ + auto const s = static_cast(sub); + auto const r = static_cast(red); + return r >= s + kMinInvestmentPeriod && r < s + kMaxInvestmentPeriod; +} + [[nodiscard]] VaultPhase getVaultPhase(ReadView const& view, SLE::const_ref vault) { diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 364781cb013..369206d9e67 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -1126,20 +1126,17 @@ NoModifiedUnmodifiableFields::finalize( auto const& before = slePair.first; auto const& after = slePair.second; auto const type = after->getType(); - bool bad = false; - [[maybe_unused]] bool enforce = false; + // featureLendingProtocol gates enforcement, not detection: changes are + // always logged, but the transaction is only failed once the amendment + // is enabled. Type-specific field lists may add their own gates (see + // ltVAULT). + bool const enforce = view.rules().enabled(featureLendingProtocol); + bool bad = kFieldChanged(before, after, sfLedgerEntryType) || + kFieldChanged(before, after, sfLedgerIndex); switch (type) { case ltLOAN_BROKER: - /* - * We check this invariant regardless of lending protocol - * amendment status, allowing for detection and logging of - * potential issues even when the amendment is disabled. - */ - enforce = view.rules().enabled(featureLendingProtocol); - bad = kFieldChanged(before, after, sfLedgerEntryType) || - kFieldChanged(before, after, sfLedgerIndex) || - kFieldChanged(before, after, sfSequence) || + bad = bad || kFieldChanged(before, after, sfSequence) || kFieldChanged(before, after, sfOwnerNode) || kFieldChanged(before, after, sfVaultNode) || kFieldChanged(before, after, sfVaultID) || @@ -1150,15 +1147,7 @@ NoModifiedUnmodifiableFields::finalize( kFieldChanged(before, after, sfCoverRateLiquidation); break; case ltLOAN: - /* - * We check this invariant regardless of lending protocol - * amendment status, allowing for detection and logging of - * potential issues even when the amendment is disabled. - */ - enforce = view.rules().enabled(featureLendingProtocol); - bad = kFieldChanged(before, after, sfLedgerEntryType) || - kFieldChanged(before, after, sfLedgerIndex) || - kFieldChanged(before, after, sfSequence) || + bad = bad || kFieldChanged(before, after, sfSequence) || kFieldChanged(before, after, sfOwnerNode) || kFieldChanged(before, after, sfLoanBrokerNode) || kFieldChanged(before, after, sfLoanBrokerID) || @@ -1178,23 +1167,15 @@ NoModifiedUnmodifiableFields::finalize( kFieldChanged(before, after, sfLoanScale); break; case ltVAULT: - // Fallback checks for ltVAULT copied from below - enforce = view.rules().enabled(featureLendingProtocol); - bad = kFieldChanged(before, after, sfLedgerEntryType) || - kFieldChanged(before, after, sfLedgerIndex); - /* - * The VaultKind, SubscriptionDate and RedemptionDate - * fields are introduced by featureLendingProtocolV1_1 - * and are the only vault fields whose immutability is - * enforced here; pre-V1_1 vaults do not carry them. + * sfAccount, sfAsset and sfShareMPTID are already + * captured by VaultInvariant. The additional fields + * below are introduced by featureLendingProtocolV1_1 + * and only exist on V1_1 vaults. */ if (view.rules().enabled(featureLendingProtocolV1_1)) { - // sfAccount, sfAsset, sfShareMPTID are already captured by VaultInvariant - bad = kFieldChanged(before, after, sfLedgerEntryType) || - kFieldChanged(before, after, sfLedgerIndex) || - kFieldChanged(before, after, sfVaultKind) || + bad = bad || kFieldChanged(before, after, sfVaultKind) || kFieldChanged(before, after, sfSubscriptionDate) || kFieldChanged(before, after, sfRedemptionDate) || kFieldChanged(before, after, sfSequence) || @@ -1206,18 +1187,7 @@ NoModifiedUnmodifiableFields::finalize( } break; default: - /* - * We check this invariant regardless of lending protocol - * amendment status, allowing for detection and logging of - * potential issues even when the amendment is disabled. - * - * We use the lending protocol as a gate, even though - * all transactions are affected because that's when it - * was added. - */ - enforce = view.rules().enabled(featureLendingProtocol); - bad = kFieldChanged(before, after, sfLedgerEntryType) || - kFieldChanged(before, after, sfLedgerIndex); + break; } XRPL_ASSERT( !bad || enforce, diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index faf0cf404be..dc6021beb5e 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -669,18 +669,14 @@ ValidVault::finalize( "and RedemptionDate"; result = false; } - else + else if (!isValidClosedEndedGap( + *afterVault.subscriptionDate, *afterVault.redemptionDate)) { - auto const sub = static_cast(*afterVault.subscriptionDate); - auto const red = static_cast(*afterVault.redemptionDate); - if (red < sub + kMinInvestmentPeriod || red >= sub + kMaxInvestmentPeriod) - { - JLOG(j.fatal()) // - << "Invariant failed: closed-ended vault RedemptionDate - " - "SubscriptionDate must be within [MIN_INVESTMENT_PERIOD, " - "MAX_INVESTMENT_PERIOD)"; - result = false; - } + JLOG(j.fatal()) // + << "Invariant failed: closed-ended vault RedemptionDate - " + "SubscriptionDate must be within [MIN_INVESTMENT_PERIOD, " + "MAX_INVESTMENT_PERIOD)"; + result = false; } } diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index df53e849c67..7ade4ed5ab5 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -115,11 +115,9 @@ VaultCreate::preflight(PreflightContext const& ctx) return temMALFORMED; if (isClosedEnded) { - if (!hasSubscription || !hasRedemption) + if (!hasSubscription || !hasRedemption) return temMALFORMED; - auto const sub = static_cast(ctx.tx[sfSubscriptionDate]); - auto const red = static_cast(ctx.tx[sfRedemptionDate]); - if (red < sub + kMinInvestmentPeriod || red >= sub + kMaxInvestmentPeriod) + if (!isValidClosedEndedGap(ctx.tx[sfSubscriptionDate], ctx.tx[sfRedemptionDate])) return temMALFORMED; } diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 98960af4ddb..84b1268ad8e 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -4487,7 +4487,7 @@ class Invariants_test : public beast::unit_test::Suite std::optional subscriptionDate, std::optional redemptionDate) -> bool { auto const sequence = ac.view().seq(); - auto const vaultKeylet = keylet::vault(owner.id(), sequence); + auto const vaultKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(sequence)); auto sleVault = std::make_shared(vaultKeylet); auto const vaultPage = ac.view().dirInsert( keylet::ownerDir(owner.id()), sleVault->key(), describeOwnerDir(owner.id())); @@ -4670,8 +4670,8 @@ class Invariants_test : public beast::unit_test::Suite // Synthesize a Loan whose final scheduled payment lands // exactly at RedemptionDate: StartDate = red, interval = 60, // remaining = 1 => red + 60 >= red. - auto sleLoan = - std::make_shared(keylet::loan(closedEndedBrokerKeylet.key, loanSeq)); + auto sleLoan = std::make_shared( + keylet::loan(closedEndedBrokerKeylet.key, SeqProxy::rawSequence(loanSeq))); sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key; sleLoan->at(sfLoanSequence) = loanSeq; sleLoan->at(sfBorrower) = a1.id(); @@ -4702,7 +4702,8 @@ class Invariants_test : public beast::unit_test::Suite closedEndedKeylet = keylet; // Create the loan broker; LoanBrokerSet has no phase gate. - closedEndedBrokerKeylet = keylet::loanBroker(a1.id(), env.seq(a1)); + closedEndedBrokerKeylet = + keylet::loanBroker(a1.id(), SeqProxy::rawSequence(env.seq(a1))); env(loan_broker::set(a1, keylet.key)); // Advance parent close time into Investment so diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index faf2d4422f3..1d26c23dba5 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -1634,7 +1634,8 @@ class Vault_test : public beast::unit_test::Suite // Create a loan broker backed by this vault. LoanBrokerSet has no // phase gate, so this is fine to do in Subscription. - auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner)); + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); env(loan_broker::set(owner, keylet.key)); env.close(); @@ -1754,7 +1755,8 @@ class Vault_test : public beast::unit_test::Suite // Create a loan broker backed by this vault. LoanBrokerSet has no phase gate, so it is // fine to do in Subscription. - auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner)); + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); env(loan_broker::set(owner, keylet.key)); env.close(); @@ -1784,7 +1786,7 @@ class Vault_test : public beast::unit_test::Suite env.close(); auto const sleBroker = env.le(keylet::loanBroker(brokerKeylet.key)); BEAST_EXPECT(sleBroker); - auto const loanKeylet = keylet::loan(brokerKeylet.key, 1u); + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u)); BEAST_EXPECT(env.le(loanKeylet)); balancesEq(XRP(215).value(), XRP(275).value()); @@ -1839,6 +1841,289 @@ class Vault_test : public beast::unit_test::Suite } } + // SubscriptionDate boundary cases at the top of the UINT32 range. + // (1) The largest legal sub picks red = UINT32_MAX exactly, which hits + // the inclusive lower bound of the kMinInvestmentPeriod gap check. + // (2) sub = UINT32_MAX must be rejected: sub + kMinInvestmentPeriod is + // unrepresentable as the tx's UINT32 sfRedemptionDate, so no red value + // can satisfy the gap check. + void + testVaultCreateSubscriptionDateBoundary() + { + testcase("closed-ended VaultCreate SubscriptionDate near UINT32_MAX"); + using namespace test::jtx; + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + Asset const asset = xrpIssue(); + + { + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + + Vault const vault{env}; + auto const sub = std::numeric_limits::max() - kMinInvestmentPeriod; + auto const red = std::numeric_limits::max(); + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub); + BEAST_EXPECT(sle->at(sfRedemptionDate) == red); + } + } + + // sub = UINT32_MAX: no legal red exists because sub + kMinInvestmentPeriod + // wraps in a UINT32. Every candidate red must fall to temMALFORMED via + // the gap check in preflight. + auto const rejectAtMax = [&, this](std::uint32_t red) { + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = std::numeric_limits::max(), + .redemptionDate = red}); + env(tx, Ter{temMALFORMED}); + }; + rejectAtMax(std::numeric_limits::max()); + rejectAtMax(0u); + rejectAtMax(kMinInvestmentPeriod - 1u); + } + + // A loan whose payment is made after the Investment phase has ended + // (well past its next-due-date and grace period, into Redemption) must + // still be repayable. The vault phase must not gate LoanPay. + void + testVaultLoanLatePaymentAfterInvestment() + { + testcase("closed-ended vault: late loan payment during Redemption succeeds"); + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000), owner, alice, borrower); + env.close(); + + Asset const asset = xrpIssue(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u); + + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, keylet.key)); + env.close(); + + // Investment phase: originate a zero-interest, single-payment loan + // with a 300s payment interval and 60s grace. The payment is due + // shortly after origination and well before RedemptionDate. + env.close(tp{d{sub + 1}}); + env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(300), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2)); + env.close(); + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u)); + BEAST_EXPECT(env.le(loanKeylet)); + + // Advance to Redemption. The payment is now past its due date and + // grace, and the vault is no longer in Investment. + closeToTime(env, tp{d{red}}); + + env(loan::pay(borrower, loanKeylet.key, XRP(60).value(), tfLoanLatePayment)); + env.close(); + + // Loan principal returned to the vault; assetsAvailable == assetsTotal. + auto const sleAfter = env.le(keylet); + if (BEAST_EXPECT(sleAfter)) + { + BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == sleAfter->at(sfAssetsTotal)); + BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == XRP(100).value()); + } + + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + } + + // Two concurrent loans against the same closed-ended vault in Investment + // must coexist: both loan SLEs are created, AssetsAvailable reflects the + // sum of the two outstanding principals, and each can be repaid + // independently. + void + testVaultClosedEndedMultipleLoans() + { + testcase("closed-ended vault: multiple concurrent loans in Investment"); + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const borrower1{"borrower1"}; + Account const borrower2{"borrower2"}; + env.fund(XRP(10'000), owner, alice, bob, borrower1, borrower2); + env.close(); + + Asset const asset = xrpIssue(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u); + + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, keylet.key)); + env.close(); + + env.close(tp{d{sub + 1}}); + + auto const originate = [&](Account const& b, STAmount const& principal) { + env(loan::set(b, brokerKeylet.key, principal), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(300), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2)); + env.close(); + }; + originate(borrower1, XRP(50).value()); + originate(borrower2, XRP(70).value()); + + auto const loan1 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u)); + auto const loan2 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(2u)); + BEAST_EXPECT(env.le(loan1)); + BEAST_EXPECT(env.le(loan2)); + + // Zero-interest at origination: AssetsTotal unchanged, AssetsAvailable + // drops by the sum of the two loan principals. + { + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value()); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(80).value()); + } + } + + // Repay the first loan; the second remains outstanding. + env(loan::pay(borrower1, loan1.key, XRP(50).value())); + env.close(); + { + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value()); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(130).value()); + } + } + + // Repay the second loan; vault is fully liquid again. + env(loan::pay(borrower2, loan2.key, XRP(70).value())); + env.close(); + { + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfAssetsAvailable) == sle->at(sfAssetsTotal)); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(200).value()); + } + } + + // Redemption: both depositors withdraw in full. + env.close(tp{d{red}}); + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + } + + // VaultClawback has no phase gate: an issuer must be able to reclaim + // asset from a depositor in Subscription, Investment and Redemption + // alike. Uses an IOU with asfAllowTrustLineClawback so the issuer path + // is exercised (XRP clawback with an explicit amount is temMALFORMED). + void + testVaultClawbackClosedEndedPhases() + { + testcase("closed-ended vault: VaultClawback succeeds in each phase"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const alice{"alice"}; + env.fund(XRP(10'000), issuer, owner, alice); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const iou = issuer["IOU"]; + env.trust(iou(10'000), alice); + env(pay(issuer, alice, iou(1'000))); + env.close(); + + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, iou, 300u, kMinInvestmentPeriod + 3600u); + + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = iou(300).value()})); + env.close(); + + auto const totalsEq = [&](STAmount const& expected) { + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + BEAST_EXPECT(sle->at(sfAssetsTotal) == expected); + }; + + // Subscription phase clawback. + env(vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()})); + env.close(); + totalsEq(iou(290).value()); + + // Investment phase clawback. + env.close(tp{d{sub + 1}}); + env(vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()})); + env.close(); + totalsEq(iou(280).value()); + + // Redemption phase clawback. + env.close(tp{d{red}}); + env(vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()})); + env.close(); + totalsEq(iou(270).value()); + } + // Test for non-asset specific behaviors. void testCreateFailXRP() @@ -9210,11 +9495,15 @@ class Vault_test : public beast::unit_test::Suite testCreateFailIOU(); testCreateFailMPT(); testVaultCreateClosedEnded(); + testVaultCreateSubscriptionDateBoundary(); testVaultPhaseDerivation(); testVaultPhaseDerivationOpenEnded(); testVaultDepositClosedEnded(); testVaultWithdrawClosedEnded(); testVaultClosedEndedLifecycle(); + testVaultLoanLatePaymentAfterInvestment(); + testVaultClosedEndedMultipleLoans(); + testVaultClawbackClosedEndedPhases(); testWithMPT(); testWithIOU(); testWithDomainCheck(); From 828dfdd9640590322de50f12d01a3eef70af6d3c Mon Sep 17 00:00:00 2001 From: Jingchen Date: Tue, 11 Aug 2026 13:19:32 +0100 Subject: [PATCH 11/12] Update src/libxrpl/tx/invariants/LoanInvariant.cpp Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> --- src/libxrpl/tx/invariants/LoanInvariant.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index b7bad9de5d6..fe54561b4be 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -51,6 +51,8 @@ ValidLoan::finalize( if (broker) { auto const vault = view.read(keylet::vault(broker->at(sfVaultID))); + auto const vault = view.read(keylet::vault(broker->at(sfVaultID))); + // We don't check for LendingProtocolV1_1 amendment because a ClosedEnded Vault will not exist without the amendment enabled if (vault && getVaultKind(vault) == VaultKind::ClosedEnded) { std::uint32_t const startDate = after->at(sfStartDate); From c570849c50b4d7761c3be379f69c279a3ca86910 Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 11 Aug 2026 13:24:20 +0100 Subject: [PATCH 12/12] Fix --- src/libxrpl/tx/invariants/LoanInvariant.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index fe54561b4be..7b967905708 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -51,8 +51,8 @@ ValidLoan::finalize( if (broker) { auto const vault = view.read(keylet::vault(broker->at(sfVaultID))); - auto const vault = view.read(keylet::vault(broker->at(sfVaultID))); - // We don't check for LendingProtocolV1_1 amendment because a ClosedEnded Vault will not exist without the amendment enabled + // We don't check for LendingProtocolV1_1 amendment because a ClosedEnded Vault will + // not exist without the amendment enabled if (vault && getVaultKind(vault) == VaultKind::ClosedEnded) { std::uint32_t const startDate = after->at(sfStartDate);