From 8e9b1791c5eed272e28232b953fda6ac9500a2b3 Mon Sep 17 00:00:00 2001 From: Jingchen Date: Wed, 12 Aug 2026 17:07:43 +0000 Subject: [PATCH] feat: Add a new closed ended vault to extend SAV (#7921) Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> --- include/xrpl/ledger/View.h | 12 +- include/xrpl/ledger/helpers/VaultHelpers.h | 81 ++ 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 + .../protocol_autogen/ledger_entries/Vault.h | 105 ++ .../transactions/VaultCreate.h | 111 ++ include/xrpl/tx/invariants/LoanInvariant.h | 2 + include/xrpl/tx/invariants/VaultInvariant.h | 24 + src/libxrpl/ledger/View.cpp | 12 +- src/libxrpl/ledger/helpers/VaultHelpers.cpp | 72 ++ src/libxrpl/tx/invariants/InvariantCheck.cpp | 60 +- src/libxrpl/tx/invariants/LoanInvariant.cpp | 36 +- src/libxrpl/tx/invariants/VaultInvariant.cpp | 104 ++ .../tx/transactors/lending/LoanSet.cpp | 32 +- .../tx/transactors/vault/VaultCreate.cpp | 42 + .../tx/transactors/vault/VaultDeposit.cpp | 12 + .../tx/transactors/vault/VaultWithdraw.cpp | 10 + src/test/app/Invariants_test.cpp | 358 +++++- src/test/app/Vault_test.cpp | 1112 +++++++++++++++++ src/test/app/lending/LoanSet_test.cpp | 126 ++ src/test/app/lending/LoanTestBase.h | 56 +- src/test/app/lending/LoanValidation_test.cpp | 13 +- src/test/jtx/impl/vault.cpp | 6 + src/test/jtx/vault.h | 6 + .../ledger_entries/VaultTests.cpp | 81 ++ .../transactions/VaultCreateTests.cpp | 63 + 28 files changed, 2521 insertions(+), 55 deletions(-) diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 768e518008..e8b4a932d0 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. * @@ -54,11 +59,16 @@ 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. */ [[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 5681cc57e8..acbf2c3ac0 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 @@ -123,4 +126,82 @@ 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); + +/** + * 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 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 + * 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); + +/** + * 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 567f66d339..345baef853 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. + */ +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. + */ +constexpr std::uint32_t kMinInvestmentPeriod = + std::chrono::seconds{std::chrono::minutes{1}}.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 * another vault; counted from 0 diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index ffcd025f01..f166473d7f 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 c323e3a496..ec05804253 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -27,6 +27,7 @@ TYPED_SFIELD(sfUNLModifyDisabling, UINT8, 17) TYPED_SFIELD(sfWasLockingChainSend, UINT8, 19) TYPED_SFIELD(sfWithdrawalPolicy, UINT8, 20) TYPED_SFIELD(sfContractResult, UINT8, 21) +TYPED_SFIELD(sfVaultKind, UINT8, 22) // 16-bit integers (common) TYPED_SFIELD(sfLedgerEntryType, UINT16, 1, SField::kSmdNever) @@ -116,6 +117,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 1f9603dbae..f8676d3b63 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/protocol_autogen/ledger_entries/Vault.h b/include/xrpl/protocol_autogen/ledger_entries/Vault.h index a6ab54cb0a..389ffb4c46 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Vault.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Vault.h @@ -311,6 +311,78 @@ public: { 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 @@ public: 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 b7e1527754..e206925e02 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultCreate.h +++ b/include/xrpl/protocol_autogen/transactions/VaultCreate.h @@ -214,6 +214,84 @@ public: { 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 @@ public: 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/include/xrpl/tx/invariants/LoanInvariant.h b/include/xrpl/tx/invariants/LoanInvariant.h index 0648881423..fc72b8d420 100644 --- a/include/xrpl/tx/invariants/LoanInvariant.h +++ b/include/xrpl/tx/invariants/LoanInvariant.h @@ -16,6 +16,8 @@ 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`. * */ class ValidLoan diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index 136c6c4a25..2ba42f0ab4 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,17 @@ private: [[nodiscard]] static bool isVaultEmpty(Vault const& vault); + /** + * @brief Invariant check for @c ttLOAN_SET. + * + * 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 8116f4f641..2dd70e2950 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 78f64d2077..67e0262e14 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 @@ -11,6 +12,7 @@ #include #include #include // IWYU pragma: keep +#include #include #include @@ -157,4 +159,74 @@ getVaultVersion(SLE::const_ref vault) return static_cast(version); } +namespace { + +[[nodiscard]] VaultKind +decodeVaultKind(std::optional vaultKind) +{ + 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]] 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) +{ + 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, subscriptionDate, ExpiryComparison::Exclusive)) + return VaultPhase::Subscription; + if (!hasExpired(view, redemptionDate)) + 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 9b997e06dd..369206d9e6 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) || @@ -1177,19 +1166,28 @@ NoModifiedUnmodifiableFields::finalize( kFieldChanged(before, after, sfGracePeriod) || kFieldChanged(before, after, sfLoanScale); break; - default: + case ltVAULT: /* - * 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. + * 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. */ - enforce = view.rules().enabled(featureLendingProtocol); - bad = kFieldChanged(before, after, sfLedgerEntryType) || - kFieldChanged(before, after, sfLedgerIndex); + if (view.rules().enabled(featureLendingProtocolV1_1)) + { + bad = bad || 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: + break; } XRPL_ASSERT( !bad || enforce, diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index ce9a7c6e03..7b96790570 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 @@ -12,6 +15,8 @@ #include #include +#include + namespace xrpl { void @@ -26,7 +31,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) @@ -36,6 +41,35 @@ ValidLoan::finalize( for (auto const& [before, after] : loans_) { + // A closed-ended vault must not accept a loan whose final scheduled payment falls on or + // after the vault's RedemptionDate. This mirrors the LoanSet::preclaim gate and only fires + // on loan creation; once the loan exists, its StartDate / PaymentInterval are immutable and + // PaymentRemaining only decreases, so the bound is preserved. + if (!before && isTesSuccess(result)) + { + auto const broker = view.read(keylet::loanBroker(after->at(sfLoanBrokerID))); + if (broker) + { + 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); + 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) + { + 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 c577fdf356..dc6021beb5 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -24,11 +25,27 @@ #include #include #include +#include #include #include 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 +61,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 +274,37 @@ ValidVault::isVaultEmpty(Vault const& vault) return vault.assetsAvailable == 0 && vault.assetsTotal == 0; } +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]; + + // 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; + + if (phase != VaultPhase::Investment) + { + 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 { @@ -520,6 +571,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; @@ -606,6 +660,26 @@ 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 (!isValidClosedEndedGap( + *afterVault.subscriptionDate, *afterVault.redemptionDate)) + { + 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: { @@ -666,6 +740,21 @@ 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. + 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 " + "Subscription or NoPhase"; + result = false; + } + auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); if (!maybeVaultDeltaAssets) { @@ -804,6 +893,20 @@ 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 (getVaultPhase( + view, + afterVault.vaultKind, + afterVault.subscriptionDate, + afterVault.redemptionDate) == VaultPhase::Investment) + { + JLOG(j.fatal()) << // + "Invariant failed: withdrawal not allowed during " + "Investment phase"; + result = false; + } + auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); if (!maybeVaultDeltaAssets) { @@ -1052,6 +1155,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 6533a47916..2def3d2eb2 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 @@ -225,6 +226,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 +241,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, @@ -310,6 +310,32 @@ 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) + { + 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 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; + } + } + } + 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 f74a27c39b..7ade4ed5ab 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 @@ -43,6 +44,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 +105,22 @@ VaultCreate::preflight(PreflightContext const& ctx) return temMALFORMED; } + 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 = kind == VaultKind::ClosedEnded; + if (!isClosedEnded && (hasSubscription || hasRedemption)) + return temMALFORMED; + if (isClosedEnded) + { + if (!hasSubscription || !hasRedemption) + return temMALFORMED; + if (!isValidClosedEndedGap(ctx.tx[sfSubscriptionDate], ctx.tx[sfRedemptionDate])) + return temMALFORMED; + } + return tesSUCCESS; } @@ -136,6 +158,16 @@ 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; + return tesSUCCESS; } @@ -242,7 +274,17 @@ VaultCreate::doApply() if (scale != 0u) vault->at(sfScale) = scale; if (view().rules().enabled(featureLendingProtocolV1_1)) + { 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); // 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 aa9cfc8537..a3c0a94eb5 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 @@ -71,6 +72,17 @@ 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) + { + JLOG(ctx.j.debug()) << "VaultDeposit: vault deposit is not allowed in the investment " + "or redemption phase."; + return tecEXPIRED; + } + } + 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 353b72c30d..7b5bb1ea94 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -73,6 +73,16 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) if (!vault) return tecNO_ENTRY; + 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]; 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 ed09b7b660..6878b2b5d0 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 @@ -65,6 +66,7 @@ #include #include #include +#include #include #include #include @@ -135,7 +137,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()), @@ -145,7 +148,8 @@ class Invariants_test : public beast::unit_test::Suite tx, ters, preclose, - setTxAccount); + setTxAccount, + loc); } void @@ -157,7 +161,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; @@ -171,7 +176,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 @@ -184,7 +189,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; @@ -211,23 +217,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()); } } } @@ -2475,6 +2485,54 @@ class Invariants_test : public beast::unit_test::Suite // TODO: Loan Object + // 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 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; }, @@ -4367,6 +4425,286 @@ class Invariants_test : public beast::unit_test::Suite }}, {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, precloseMpt); + + // ───────────────────────────────────────────────────────────── + // 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. + 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 synthesize ttVAULT_CREATE + // states no legitimate transactor would produce. + 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(), SeqProxy::rawSequence(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->setFieldIssue(sfAsset, STIssue{sfAsset, Asset{xrpIssue()}}); + 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"; + + // 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}); + + // 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)"}, + [&](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}); + + // 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)"}, + [&](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}); + + // 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"; + + // 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"; + + // 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"; + + // 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)); + + testcase << "Vault loan set - closed-ended final payment past " + "RedemptionDate"; + + // A newly-created loan against a closed-ended vault must satisfy StartDate + + // PaymentInterval * PaymentRemaining < RedemptionDate. LoanSet::preclaim enforces the same + // bound; this test synthesises an invalid loan directly in the ApplyView so the invariant + // catches it even when preclaim is bypassed. + 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); + + // 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, SeqProxy::rawSequence(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; + }, + 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(), SeqProxy::rawSequence(env.seq(a1))); + env(loan_broker::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 6b6c4eb875..34ac40fb54 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 @@ -41,11 +42,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -63,10 +66,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -82,6 +87,75 @@ class Vault_test : public beast::unit_test::Suite return {STAmount{asset.raw(), 1ul, 0, true, STAmount::Unchecked{}}, ""}; }; + /** + * 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 const 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 testSequences() { @@ -1107,6 +1181,949 @@ class Vault_test : public beast::unit_test::Suite }); } + // 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). 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( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = nowSec, + .redemptionDate = nowSec + minPeriod}); + env(tx, Ter{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 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; + 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}); + }); + 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 - 1}); + 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}); + }); + + // 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) { + 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)); + } + }); + } + + // 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(); + + Asset const asset = xrpIssue(); + 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. + 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( + WithSourceLocation{ + vault.deposit( + {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), + loc}, + Ter{expected}); + }; + 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}); + }; + + 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 + // Investment: deposit tecEXPIRED, withdraw tecTOO_SOON + // Redemption: deposit tecEXPIRED, withdraw tesSUCCESS + + // Ledger time comfortably before SubscriptionDate: Subscription. + runTest(tesSUCCESS, tesSUCCESS); + + // Boundary: parent close time exactly at SubscriptionDate must still + // be Subscription. + closeToTime(env, tp{d{sub}}); + runTest(tesSUCCESS, tesSUCCESS); + + // One second past SubscriptionDate: Investment. + closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env)); + runTest(tecEXPIRED, tecTOO_SOON); + + // Any point strictly before RedemptionDate remains Investment. + 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}}); + runTest(tecEXPIRED, tesSUCCESS); + 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 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 + // 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(); + + Asset const asset = xrpIssue(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod); + + 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(tecEXPIRED); + + // Redemption: rejected. + env.close(tp{d{red}}); + deposit(tecEXPIRED); + } + + // 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 loan_broker; + using namespace loan; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000), owner, depositor, borrower); + env.close(); + + Asset const asset = xrpIssue(); + // Widen the Investment window so a single-payment loan (min payment + // interval kMinPaymentInterval = 60s) fits before RedemptionDate. + 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.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(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, keylet.key)); + 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(); + }; + + // Subscription: allowed (LP cancel). + withdraw(XRP(1).value(), tesSUCCESS); + + // Investment: rejected. + closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env)); + withdraw(XRP(1).value(), tecTOO_SOON); + + // 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(10).value(), tesSUCCESS); + withdraw(XRP(80).value(), tecINSUFFICIENT_FUNDS); + } + + // 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 + // 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"}; + 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(); + // 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 balancesEq = [&](STAmount const& available, STAmount const& total) { + auto const sle = env.le(keylet); + 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 + // 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()); + + // 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(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, keylet.key)); + env.close(); + + // ---- Investment phase (now == sub + 1) ---- + env.close(tp{d{sub + 1}}); + + // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED. + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), + Ter{tecEXPIRED}); + env.close(); + // 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(); + + // 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, SeqProxy::rawSequence(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}); + tx[sfData] = "BB"; + env(tx); + env.close(); + } + + // Depositor share balances unchanged by the loan origination; only AssetsAvailable moved. + sharesEq(alice, 75'000'000); + sharesEq(bob, 200'000'000); + + // ---- Redemption phase (now == red) ---- + env.close(tp{d{red}}); + + // 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{tecEXPIRED}); + env.close(); + + // 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); + 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, 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. + 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); + } + } + + // 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() @@ -4591,6 +5608,90 @@ class Vault_test : public beast::unit_test::Suite } } + // 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() { @@ -8579,6 +9680,16 @@ public: testCreateFailXRP(); testCreateFailIOU(); testCreateFailMPT(); + testVaultCreateClosedEnded(); + testVaultCreateSubscriptionDateBoundary(); + testVaultPhaseDerivation(); + testVaultPhaseDerivationOpenEnded(); + testVaultDepositClosedEnded(); + testVaultWithdrawClosedEnded(); + testVaultClosedEndedLifecycle(); + testVaultLoanLatePaymentAfterInvestment(); + testVaultClosedEndedMultipleLoans(); + testVaultClawbackClosedEndedPhases(); testWithMPT(); testWithIOU(); testWithDomainCheck(); @@ -8589,6 +9700,7 @@ public: testFailedPseudoAccount(); testScaleIOU(); testRPC(); + testRPCClosedEnded(); testVaultClawbackBurnShares(); testVaultClawbackAssets(); testVaultEscrowedMPT(); diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp index 85528ee9a0..3571853b47 100644 --- a/src/test/app/lending/LoanSet_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -13,12 +13,14 @@ #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -26,6 +28,7 @@ #include #include +#include #include #include #include @@ -592,6 +595,127 @@ private: nullptr); } + // LoanSet in a closed-ended vault — phase gating and maturity bound. + 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(); + }); + } + public: void run() override @@ -599,6 +723,8 @@ public: for (auto const& features : jtx::amendmentCombinations( {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) testLoanSet(features); + + testLoanSetClosedEnded(); } }; diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index dabdfc9bed..950b196043 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -95,6 +95,23 @@ protected: // 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 @@ -122,15 +139,23 @@ protected: 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) { } @@ -461,7 +486,23 @@ protected: 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); @@ -475,6 +516,15 @@ protected: 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(), SeqProxy::rawSequence(env.seq(lender))); using namespace loan_broker; @@ -490,7 +540,7 @@ protected: env.close(); - return {asset, keylet, vaultKeylet, params}; + return {asset, keylet, vaultKeylet, params, subscriptionDate, redemptionDate}; } /** diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp index 884384db55..c6ff22bbb3 100644 --- a/src/test/app/lending/LoanValidation_test.cpp +++ b/src/test/app/lending/LoanValidation_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -90,9 +91,11 @@ private: } 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"}; @@ -106,7 +109,8 @@ private: 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(); @@ -530,7 +534,8 @@ private: runAmendmentIndependent() { testDisabled(); - testInvalidLoanSet(); + for (auto const kind : {VaultKind::OpenEnded, VaultKind::ClosedEnded}) + testInvalidLoanSet(kind); testInvalidLoanDelete(); testInvalidLoanManage(); testInvalidLoanPay(); diff --git a/src/test/jtx/impl/vault.cpp b/src/test/jtx/impl/vault.cpp index baff576243..978c3864d6 100644 --- a/src/test/jtx/impl/vault.cpp +++ b/src/test/jtx/impl/vault.cpp @@ -28,6 +28,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 e72eae89b7..992051b61f 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) }; /** diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp index f55d01f606..26dde55563 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 9c1e14f6f4..592d40a6f6 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()); } }