From 1281c7a222f34eeded150323d45061284df76077 Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:41:30 +0000 Subject: [PATCH 01/11] refactor: Drop unnecessary associateAsset calls from loan delete paths (#7986) Co-authored-by: Cursor --- src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp | 3 --- src/libxrpl/tx/transactors/lending/LoanDelete.cpp | 3 --- 2 files changed, 6 deletions(-) diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp index b36977d225..433d77806a 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp @@ -12,7 +12,6 @@ #include #include #include -#include #include #include #include @@ -198,8 +197,6 @@ LoanBrokerDelete::doApply() view().erase(broker); - associateAsset(*broker, vaultAsset); - return tesSUCCESS; } diff --git a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp index 1a77489b4b..bc8e974d10 100644 --- a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp @@ -130,9 +130,6 @@ LoanDelete::doApply() // Decrement the borrower's owner count decreaseOwnerCountForObject(view, borrowerSle, loanSle, 1, j_); - // These associations shouldn't do anything, but do them just to be safe - associateAsset(*loanSle, vaultAsset); - associateAsset(*brokerSle, vaultAsset); associateAsset(*vaultSle, vaultAsset); return tesSUCCESS; From af36890c1113955894dc441721e928ab3072434a Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:42:02 +0000 Subject: [PATCH 02/11] test: Verify private-vault DEX permissions survive domain loss (#7937) --- src/test/app/Vault_test.cpp | 188 ++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 70527f570d..6b6c4eb875 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -3017,6 +3017,192 @@ class Vault_test : public beast::unit_test::Suite } } + void + testDomainLossAfterAcquisition() + { + using namespace test::jtx; + + testcase("private vault share transfer after depositor loses domain"); + + // The "Private Vault - Access Control Rules" spec requires that a holder who + // loses Layer 2 (Permissioned Domain membership) after acquiring shares be + // blocked from sending them onward, by P2P transfer or DEX offer, the same + // way a brand-new never-authorized holder is blocked. Only withdrawal to + // self is meant to stay open. + // + // For a domain-gated share MPToken, requireAuth()'s escape hatch for + // holders who already have an MPToken (MPTokenHelpers.cpp) only applies to + // the classic explicit-issuer-authorization flag, which + // enforceMPTokenAuthorization documents as "meaningless" for + // domain-authorized holders and never sets. So a stale MPToken does not + // carry authorization forward once the account's domain credential is + // gone, and both actions below are correctly blocked. + + Env env{*this, testableAmendments()}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const bob{"bob"}; + Account const pdOwner{"pdOwner"}; + Account const credIssuer{"credIssuer"}; + std::string const credType = "credential"; + Vault const vault{env}; + env.fund(XRP(1000), issuer, owner, depositor, bob, pdOwner, credIssuer); + env.close(); + + PrettyAsset const asset = issuer["IOU"]; + env.trust(asset(1000), owner); + env(pay(issuer, owner, asset(500))); + env.trust(asset(1000), depositor); + env(pay(issuer, depositor, asset(500))); + env.trust(asset(1000), bob); + env(pay(issuer, bob, asset(500))); + env.close(); + + // Transferable shares (no tfVaultShareNonTransferable): sections 3.3/3.4 of + // the spec (DEX trading / P2P transfer) only apply to transferable shares. + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate}); + env(tx); + env.close(); + + pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}}; + env(pdomain::setTx(pdOwner, credentials)); + auto const domainId = [&]() { + auto tx = env.tx()->getJson(JsonOptions::Values::None); + return pdomain::getNewDomain(env.meta()); + }(); + { + auto domainTx = vault.set({.owner = owner, .id = keylet.key}); + domainTx[sfDomainID] = to_string(domainId); + env(domainTx); + env.close(); + } + + // Both depositor and bob acquire domain membership and deposit, so each + // ends up with an authorized share MPToken. + env(credentials::create(depositor, credIssuer, credType)); + env(credentials::accept(depositor, credIssuer, credType)); + env(credentials::create(bob, credIssuer, credType)); + env(credentials::accept(bob, credIssuer, credType)); + env.close(); + + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)})); + env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(100)})); + env.close(); + + auto const shares = [&env, keylet = keylet, this]() -> PrettyAsset { + auto const sle = env.le(keylet); + BEAST_EXPECT(sle != nullptr); + return MPTIssue(sle->at(sfShareMPTID)); + }(); + + // Depositor loses Layer 2: their Permissioned Domain credential is revoked. + auto const credKeylet = credentials::keylet(depositor, credIssuer, credType); + env(credentials::deleteCred(credIssuer, depositor, credIssuer, credType)); + env.close(); + BEAST_EXPECT(env.le(credKeylet) == nullptr); + + // Sanity check, mirrors testWithDomainCheck's "not authorized yet" case: a + // brand-new depositor with no MPToken yet is still correctly blocked. The + // gap below is specific to holders who already hold shares. + { + Account const charlie{"charlie"}; + env.fund(XRP(1000), charlie); + env.close(); + auto depTx = + vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(1)}); + env(depTx, Ter{tecNO_AUTH}); + } + + // P2P transfer: spec section 3.4 requires this blocked once Layer 2 is + // lost, and it is. + env(pay(depositor, bob, shares(1)), Ter{tecNO_AUTH}); + env.close(); + + // DEX/CLOB: spec section 3.3 requires the seller leg blocked the same way. + // The offer can't even be created: preclaim treats the seller as + // unfunded once their share balance reads as zero for auth purposes. + env(offer(depositor, XRP(1), shares(1)), Ter{tecUNFUNDED_OFFER}); + env.close(); + BEAST_EXPECT(expectOffers(env, depositor, 0)); + } + + void + testDomainCheckBuyerSideOffer() + { + using namespace test::jtx; + + testcase("private vault share purchase via DEX requires buyer domain membership"); + + // The "Private Vault - Access Control Rules" spec requires the buyer leg + // of a DEX trade in private-vault shares to hold Layer 1 and Layer 2 as + // well, not just the seller. + + Env env{*this, testableAmendments()}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const bob{"bob"}; + Account const charlie{"charlie"}; + Account const pdOwner{"pdOwner"}; + Account const credIssuer{"credIssuer"}; + std::string const credType = "credential"; + Vault const vault{env}; + env.fund(XRP(1000), issuer, owner, bob, charlie, pdOwner, credIssuer); + env.close(); + + PrettyAsset const asset = issuer["IOU"]; + env.trust(asset(1000), owner); + env(pay(issuer, owner, asset(500))); + env.trust(asset(1000), bob); + env(pay(issuer, bob, asset(500))); + env.close(); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate}); + env(tx); + env.close(); + + pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}}; + env(pdomain::setTx(pdOwner, credentials)); + auto const domainId = [&]() { + auto tx = env.tx()->getJson(JsonOptions::Values::None); + return pdomain::getNewDomain(env.meta()); + }(); + { + auto domainTx = vault.set({.owner = owner, .id = keylet.key}); + domainTx[sfDomainID] = to_string(domainId); + env(domainTx); + env.close(); + } + + // Only bob joins the domain and deposits; charlie never does. + env(credentials::create(bob, credIssuer, credType)); + env(credentials::accept(bob, credIssuer, credType)); + env.close(); + env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(100)})); + env.close(); + + auto const shares = [&env, keylet = keylet, this]() -> PrettyAsset { + auto const sle = env.le(keylet); + BEAST_EXPECT(sle != nullptr); + return MPTIssue(sle->at(sfShareMPTID)); + }(); + + // Bob (domain member, holds shares) rests a sell offer. + env(offer(bob, XRP(1), shares(1))); + env.close(); + BEAST_EXPECT(expectOffers(env, bob, 1)); + + // Charlie never held the domain credential. Buying shares via a + // crossing offer must be blocked the same way a direct MPTokenAuthorize + // + pay attempt already is (see testWithDomainChecXRP's "cannot pay + // shares to 3rd party"): checkAcceptAsset() rejects the offer outright + // in preclaim, before any funding check is even reached. + env(offer(charlie, shares(1), XRP(1)), Ter{tecNO_AUTH}); + env.close(); + BEAST_EXPECT(expectOffers(env, bob, 1)); + BEAST_EXPECT(expectOffers(env, charlie, 0)); + } + void testWithDomainChecXRP() { @@ -8396,6 +8582,8 @@ public: testWithMPT(); testWithIOU(); testWithDomainCheck(); + testDomainLossAfterAcquisition(); + testDomainCheckBuyerSideOffer(); testWithDomainChecXRP(); testNonTransferableShares(); testFailedPseudoAccount(); From 91360c5126ef4456dbca860d7a69f9e75b546c0e Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:42:55 +0000 Subject: [PATCH 03/11] test: Fix LoanBatch broker cover rates and schedule overflow (#7967) --- src/test/app/lending/LoanMisc_test.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/test/app/lending/LoanMisc_test.cpp b/src/test/app/lending/LoanMisc_test.cpp index 2cb4f38ecf..c5a7d54311 100644 --- a/src/test/app/lending/LoanMisc_test.cpp +++ b/src/test/app/lending/LoanMisc_test.cpp @@ -473,14 +473,21 @@ protected: TenthBips16 const managementFeeRate{managementFeeRateDist_(engine_)}; auto const serviceFee = serviceFeeDist_(engine_); TenthBips32 interest{interestRateDist_(engine_)}; - auto const payTotal = paymentTotalDist_(engine_); + auto payTotal = paymentTotalDist_(engine_); auto const payInterval = paymentIntervalDist_(engine_); + // The end of the last payment's grace period must fit in a 32-bit + // ripple-epoch timestamp, or LoanSet fails with tecKILLED. Cap the + // schedule well below that horizon (2e9 seconds is roughly 63 years, + // leaving ample headroom over the ledger start date). + constexpr std::uint32_t kMaxScheduleSeconds = 2'000'000'000; + payTotal = std::min(payTotal, static_cast(kMaxScheduleSeconds / payInterval)); BrokerParameters const brokerParams{ .vaultDeposit = principalRequest * 10, .debtMax = 0, .coverRateMin = TenthBips32{0}, - .managementFeeRate = managementFeeRate}; + .managementFeeRate = managementFeeRate, + .coverRateLiquidation = TenthBips32{0}}; LoanParameters const loanParams{ .account = lender, .counter = borrower, From 946827b9bd554eab36645c0bccf65bc46a22a986 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Wed, 12 Aug 2026 14:03:37 +0000 Subject: [PATCH 04/11] build: Respect lld linker if it gets auto-selected (#8011) --- cmake/XrplCompiler.cmake | 66 ++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/cmake/XrplCompiler.cmake b/cmake/XrplCompiler.cmake index 21566add01..2b46739d97 100644 --- a/cmake/XrplCompiler.cmake +++ b/cmake/XrplCompiler.cmake @@ -188,32 +188,6 @@ else() endif() endif() -# Linker warnings are errors where we control the toolchain and the dependencies: CI and the Nix dev shell. -# On non-Nix macOS we suppress the deployment target warning: an old Conan profile may not pin os.version. -if(is_macos OR is_linux) - if(is_ci OR is_nix_compiler) - if(is_macos) - set(fatal_warnings_flag "-Wl,-fatal_warnings") - else() - set(fatal_warnings_flag "-Wl,--fatal-warnings") - endif() - message( - STATUS - "Treating all linker warnings as errors (${fatal_warnings_flag})" - ) - target_link_options(common INTERFACE "${fatal_warnings_flag}") - unset(fatal_warnings_flag) - elseif(is_macos) - set(silence_flag "-Wl,-deployment_target_mismatches,suppress") - message( - STATUS - "Silencing macOS deployment target mismatch warnings (${silence_flag})" - ) - target_link_options(common INTERFACE "${silence_flag}") - unset(silence_flag) - endif() -endif() - # Antithesis instrumentation will only be built and deployed using machines running Linux. if(voidstar) if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug") @@ -292,10 +266,50 @@ elseif(use_lld) ) if("${LD_VERSION}" MATCHES "LLD") target_link_libraries(common INTERFACE -fuse-ld=lld) + # remembered for the linker flag probe below + set(fuse_ld_flag "-fuse-ld=lld") endif() unset(LD_VERSION) endif() +# Linker warnings are errors where we control the toolchain and the dependencies: CI and the Nix dev shell. +# On non-Nix macOS we suppress the deployment target warning: an old Conan profile may not pin os.version. +# Only the new Apple linker understands the flag, so probe the actual linker (lld may be selected above). +if(is_macos OR is_linux) + if(is_ci OR is_nix_compiler) + if(is_macos) + set(fatal_warnings_flag "-Wl,-fatal_warnings") + else() + set(fatal_warnings_flag "-Wl,--fatal-warnings") + endif() + message( + STATUS + "Treating all linker warnings as errors (${fatal_warnings_flag})" + ) + target_link_options(common INTERFACE "${fatal_warnings_flag}") + unset(fatal_warnings_flag) + elseif(is_macos) + set(silence_flag "-Wl,-deployment_target_mismatches,suppress") + set(probe_flags ${fuse_ld_flag} "${silence_flag}") + include(CheckLinkerFlag) + check_linker_flag( + CXX + "${probe_flags}" + have_deployment_target_mismatches + ) + if(have_deployment_target_mismatches) + message( + STATUS + "Silencing macOS deployment target mismatch warnings (${silence_flag})" + ) + target_link_options(common INTERFACE "${silence_flag}") + endif() + unset(probe_flags) + unset(silence_flag) + endif() +endif() +unset(fuse_ld_flag) + if(assert) foreach(var_ CMAKE_C_FLAGS_RELEASE CMAKE_CXX_FLAGS_RELEASE) string(REGEX REPLACE "[-/]DNDEBUG" "" ${var_} "${${var_}}") From 8e9b1791c5eed272e28232b953fda6ac9500a2b3 Mon Sep 17 00:00:00 2001 From: Jingchen Date: Wed, 12 Aug 2026 17:07:43 +0000 Subject: [PATCH 05/11] 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()); } } From df85d43d8a57f800f8a3147f4c9d2ecf4777fff3 Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:54:58 +0000 Subject: [PATCH 06/11] test: Make Drop50 message drop deterministic in LedgerReplayer test (#7964) Signed-off-by: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> --- src/test/app/LedgerReplay_test.cpp | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/test/app/LedgerReplay_test.cpp b/src/test/app/LedgerReplay_test.cpp index 2e2c80d6f8..0853affab7 100644 --- a/src/test/app/LedgerReplay_test.cpp +++ b/src/test/app/LedgerReplay_test.cpp @@ -28,7 +28,6 @@ #include #include -#include #include #include #include @@ -53,6 +52,7 @@ #include #include +#include #include #include #include @@ -402,7 +402,7 @@ public: enum class PeerSetBehavior { Good, - Drop50, + DropAlternate, DropAll, DropSkipListReply, DropLedgerDeltaReply, @@ -445,17 +445,13 @@ struct TestPeerSet : public PeerSet protocol::MessageType type, std::shared_ptr const& peer) override { - int dropRate = 0; - if (behavior == PeerSetBehavior::Drop50) - { - dropRate = 50; - } - else if (behavior == PeerSetBehavior::DropAll) - { - dropRate = 100; - } + if (behavior == PeerSetBehavior::DropAll) + return; - if (randInt(1, 100) <= dropRate) + // Drop every other message deterministically. Alternating drops + // still exercise the timeout/retry path while guaranteeing every + // subtask eventually gets a reply. + if (behavior == PeerSetBehavior::DropAlternate && sendCount++ % 2 == 0) return; switch (type) @@ -500,6 +496,7 @@ struct TestPeerSet : public PeerSet LedgerReplayMsgHandler& remote; std::shared_ptr dummyPeer; PeerSetBehavior behavior; + std::atomic sendCount{0}; }; /** @@ -1397,7 +1394,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite case PeerSetBehavior::Good: testcase("good network"); break; - case PeerSetBehavior::Drop50: + case PeerSetBehavior::DropAlternate: testcase("network drops 50% messages"); break; case PeerSetBehavior::Repeat: @@ -1613,7 +1610,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite testAllInboundLedgers(4); testPeerSetBehavior(PeerSetBehavior::Good, 1); testPeerSetBehavior(PeerSetBehavior::Good); - testPeerSetBehavior(PeerSetBehavior::Drop50); + testPeerSetBehavior(PeerSetBehavior::DropAlternate); testPeerSetBehavior(PeerSetBehavior::Repeat); testStop(); testSkipListBadReply(); From 028ccea7a14178d6795705a851c7d6f6a3d17bbe Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 13 Aug 2026 17:48:35 +0000 Subject: [PATCH 07/11] build: Add curl to packaging images (#8024) --- package/Dockerfile | 7 ------- package/install-packaging-tools.sh | 12 ++++++++++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/package/Dockerfile b/package/Dockerfile index 6cb2a09933..978b569bd8 100644 --- a/package/Dockerfile +++ b/package/Dockerfile @@ -2,13 +2,6 @@ ARG BASE_IMAGE=debian:bookworm FROM ${BASE_IMAGE} -# Packaging runs in a vanilla distro image, so the tooling has to come -# from the distro's archive: debhelper for deb, rpm-build (and the -# systemd / find-debuginfo macros it depends on) for rpm. -# The container also uses git (real history) for -# build_pkg.sh's SOURCE_DATE_EPOCH; otherwise it falls back to a tarball -# download and the timestamp comes from wall-clock time. - COPY package/install-packaging-tools.sh /tmp/install-packaging-tools.sh RUN /tmp/install-packaging-tools.sh diff --git a/package/install-packaging-tools.sh b/package/install-packaging-tools.sh index a26159a204..06ab44ac93 100755 --- a/package/install-packaging-tools.sh +++ b/package/install-packaging-tools.sh @@ -22,12 +22,23 @@ case "${ID}" in ;; esac +# Packaging runs in a vanilla distro image, so the tooling comes from the distro's +# archive rather than from nixpkgs: +# +# - debhelper and dpkg-dev build the DEB +# - rpm-build builds the RPM, with systemd-rpm-macros and redhat-rpm-config +# supplying the systemd and find-debuginfo macros the spec uses +# - git gives build_pkg.sh a real history to read SOURCE_DATE_EPOCH from; +# without one the timestamp falls back to the wall clock +# - curl uploads the finished packages in publish_pkg.sh +# - ca-certificates lets curl and git verify TLS function install() { case "${ID}" in debian | ubuntu) apt-get update -y apt-get install -y --no-install-recommends \ ca-certificates \ + curl \ debhelper \ debhelper-compat \ dpkg-dev \ @@ -36,6 +47,7 @@ function install() { rhel | centos | rocky | almalinux) dnf install -y --setopt=install_weak_deps=False \ + curl-minimal \ git \ rpm-build \ redhat-rpm-config \ From a0074f83d35f7fec4532d48f8ad3837d1ddc311e Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Fri, 14 Aug 2026 10:06:49 +0000 Subject: [PATCH 08/11] build: Fix versioned tools for exec wrappers (#8027) --- nix/packages.nix | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/nix/packages.nix b/nix/packages.nix index 0623ff51b9..c7972c9843 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -50,6 +50,9 @@ let # environment (the plain stdenv compiler in the dev shell, the custom-glibc # wrappers in ci-env.nix), so those callers pass their own `package`; the # clang tooling is environment-independent and is linked in commonPackages. + # + # Exec wrappers, not symlinks: the nixpkgs clang-tools wrapper dispatches on + # `$(basename $0)-unwrapped`, which a suffixed symlink turns into a dead path. mkVersionedToolLinks = { name, @@ -57,12 +60,15 @@ let version, tools, }: - pkgs.linkFarm "${name}-${toString version}-versioned-links" ( - map (tool: { - name = "bin/${tool}-${toString version}"; - path = "${package}/bin/${tool}"; - }) tools - ); + pkgs.symlinkJoin { + name = "${name}-${toString version}-versioned-links"; + paths = map ( + tool: + pkgs.writeShellScriptBin "${tool}-${toString version}" '' + exec "${package}/bin/${tool}" "$@" + '' + ) tools; + }; # The cc-wrapper doesn't re-export gcov, but coverage tooling (gcovr) needs a # gcov that exactly matches the compiler. Surface it from a gcc `cc` output. From d34aa37b3c9e7d2a3e71c15a009680fa7279c284 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Fri, 14 Aug 2026 13:49:08 +0000 Subject: [PATCH 09/11] refactor: Use std::format instead of boost::format where it fits (#7996) Co-authored-by: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Co-authored-by: Cursor Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Co-authored-by: Ayaz Salikhov --- include/xrpl/basics/StringUtilities.h | 1 - include/xrpl/net/HTTPClientSSLContext.h | 8 +- include/xrpl/rdb/DBInit.h | 29 +++- include/xrpl/server/Wallet.h | 4 + src/libxrpl/protocol/STLedgerEntry.cpp | 5 +- src/libxrpl/protocol/STTx.cpp | 17 +- src/libxrpl/protocol/STXChainBridge.cpp | 16 +- src/libxrpl/server/Vacuum.cpp | 4 +- src/libxrpl/server/Wallet.cpp | 11 +- src/test/app/AMMCalc_test.cpp | 4 +- src/test/core/Config_test.cpp | 63 ++++--- src/test/rpc/ServerInfo_test.cpp | 16 +- src/tests/libxrpl/protocol/STXChainBridge.cpp | 60 +++++++ src/xrpld/app/misc/Transaction.h | 4 + src/xrpld/app/misc/detail/WorkSSL.cpp | 4 +- src/xrpld/app/misc/detail/WorkSSL.h | 1 - src/xrpld/app/rdb/backend/detail/Node.cpp | 161 ++++++++++-------- src/xrpld/core/detail/Config.cpp | 11 +- src/xrpld/rpc/detail/RPCHelpers.cpp | 9 +- .../rpc/handlers/account/AccountInfo.cpp | 5 +- .../rpc/handlers/orderbook/BookOffers.cpp | 35 ++-- 21 files changed, 286 insertions(+), 182 deletions(-) create mode 100644 src/tests/libxrpl/protocol/STXChainBridge.cpp diff --git a/include/xrpl/basics/StringUtilities.h b/include/xrpl/basics/StringUtilities.h index d606613c65..e3b91c2f25 100644 --- a/include/xrpl/basics/StringUtilities.h +++ b/include/xrpl/basics/StringUtilities.h @@ -2,7 +2,6 @@ #include -#include #include #include diff --git a/include/xrpl/net/HTTPClientSSLContext.h b/include/xrpl/net/HTTPClientSSLContext.h index 51b50a084c..43467faa89 100644 --- a/include/xrpl/net/HTTPClientSSLContext.h +++ b/include/xrpl/net/HTTPClientSSLContext.h @@ -8,11 +8,11 @@ #include #include #include -#include #include #include +#include #include #include #include @@ -38,8 +38,8 @@ public: if (ec && sslVerifyDir.empty()) { - Throw(boost::str( - boost::format("Failed to set_default_verify_paths: %s") % ec.message())); + Throw( + std::format("Failed to set_default_verify_paths: {}", ec.message())); } } else @@ -54,7 +54,7 @@ public: if (ec) { Throw( - boost::str(boost::format("Failed to add verify path: %s") % ec.message())); + std::format("Failed to add verify path: {}", ec.message())); } } } diff --git a/include/xrpl/rdb/DBInit.h b/include/xrpl/rdb/DBInit.h index 10b04905f2..e6e7e87b6b 100644 --- a/include/xrpl/rdb/DBInit.h +++ b/include/xrpl/rdb/DBInit.h @@ -2,6 +2,9 @@ #include #include +#include +#include +#include namespace xrpl { @@ -9,9 +12,29 @@ namespace xrpl { // These pragmas are built at startup and applied to all database // connections, unless otherwise noted. -inline constexpr char const* kCommonDbPragmaJournal{"PRAGMA journal_mode=%s;"}; -inline constexpr char const* kCommonDbPragmaSync{"PRAGMA synchronous=%s;"}; -inline constexpr char const* kCommonDbPragmaTemp{"PRAGMA temp_store=%s;"}; +// +// They are exposed as functions rather than as format-string constants so +// that the un-substituted template can never reach sqlite: an unrecognized +// pragma value is silently ignored, so forgetting to interpolate would +// leave the setting at its default instead of failing loudly. +[[nodiscard]] inline std::string +commonDbPragmaJournal(std::string_view journalMode) +{ + return std::format("PRAGMA journal_mode={};", journalMode); +} + +[[nodiscard]] inline std::string +commonDbPragmaSync(std::string_view synchronous) +{ + return std::format("PRAGMA synchronous={};", synchronous); +} + +[[nodiscard]] inline std::string +commonDbPragmaTemp(std::string_view tempStore) +{ + return std::format("PRAGMA temp_store={};", tempStore); +} + // A warning will be logged if any lower-safety sqlite tuning settings // are used and at least this much ledger history is configured. This // includes full history nodes. This is because such a large amount of diff --git a/include/xrpl/server/Wallet.h b/include/xrpl/server/Wallet.h index ed8378989f..95486cc468 100644 --- a/include/xrpl/server/Wallet.h +++ b/include/xrpl/server/Wallet.h @@ -10,6 +10,10 @@ #include #include +// boost::optional (not std::optional) appears in the declarations below, +// because SOCI's into()/use() bindings only support boost::optional. +#include + #include #include #include diff --git a/src/libxrpl/protocol/STLedgerEntry.cpp b/src/libxrpl/protocol/STLedgerEntry.cpp index 8c5c5b5eae..9ee8d030ff 100644 --- a/src/libxrpl/protocol/STLedgerEntry.cpp +++ b/src/libxrpl/protocol/STLedgerEntry.cpp @@ -18,12 +18,11 @@ #include #include -#include - #include #include #include #include +#include #include #include #include @@ -111,7 +110,7 @@ STLedgerEntry::getSType() const std::string STLedgerEntry::getText() const { - return str(boost::format("{ %s, %s }") % to_string(key_) % STObject::getText()); + return std::format("{{ {}, {} }}", to_string(key_), STObject::getText()); } json::Value diff --git a/src/libxrpl/protocol/STTx.cpp b/src/libxrpl/protocol/STTx.cpp index 7f1e19ea12..ce672b515d 100644 --- a/src/libxrpl/protocol/STTx.cpp +++ b/src/libxrpl/protocol/STTx.cpp @@ -33,13 +33,13 @@ #include #include -#include #include #include #include #include #include +#include #include #include #include @@ -399,16 +399,21 @@ STTx::getMetaSQL( TxnSql status, std::string const& escapedMetaData) const { - static boost::format const kBfTrans("('%s', '%s', '%s', '%d', '%d', '%c', %s, %s)"); std::string rTxn = sqlBlobLiteral(rawTxn.peekData()); auto format = TxFormats::getInstance().findByType(txType_); XRPL_ASSERT(format, "xrpl::STTx::getMetaSQL : non-null type format"); - return str( - boost::format(kBfTrans) % to_string(getTransactionID()) % format->getName() % - toBase58(getAccountID(sfAccount)) % getFieldU32(sfSequence) % inLedger % - safeCast(status) % rTxn % escapedMetaData); + return std::format( + "('{}', '{}', '{}', '{}', '{}', '{}', {}, {})", + to_string(getTransactionID()), + format->getName(), + toBase58(getAccountID(sfAccount)), + getFieldU32(sfSequence), + inLedger, + safeCast(status), + rTxn, + escapedMetaData); } static std::expected diff --git a/src/libxrpl/protocol/STXChainBridge.cpp b/src/libxrpl/protocol/STXChainBridge.cpp index 005c9ccbce..f9f1fd1dcc 100644 --- a/src/libxrpl/protocol/STXChainBridge.cpp +++ b/src/libxrpl/protocol/STXChainBridge.cpp @@ -11,9 +11,8 @@ #include #include -#include - #include +#include #include #include #include @@ -141,10 +140,15 @@ STXChainBridge::getJson(JsonOptions jo) const std::string STXChainBridge::getText() const { - return str( - boost::format("{ %s = %s, %s = %s, %s = %s, %s = %s }") % sfLockingChainDoor.getName() % - lockingChainDoor_.getText() % sfLockingChainIssue.getName() % lockingChainIssue_.getText() % - sfIssuingChainDoor.getName() % issuingChainDoor_.getText() % sfIssuingChainIssue.getName() % + return std::format( + "{{ {} = {}, {} = {}, {} = {}, {} = {} }}", + sfLockingChainDoor.getName(), + lockingChainDoor_.getText(), + sfLockingChainIssue.getName(), + lockingChainIssue_.getText(), + sfIssuingChainDoor.getName(), + issuingChainDoor_.getText(), + sfIssuingChainIssue.getName(), issuingChainIssue_.getText()); } diff --git a/src/libxrpl/server/Vacuum.cpp b/src/libxrpl/server/Vacuum.cpp index df768d509a..c952e722b8 100644 --- a/src/libxrpl/server/Vacuum.cpp +++ b/src/libxrpl/server/Vacuum.cpp @@ -5,8 +5,6 @@ #include #include -#include // IWYU pragma: keep - #include #include @@ -40,7 +38,7 @@ doVacuumDB(DatabaseCon::Setup const& setup, beast::Journal j) // Only the most trivial databases will fit in memory on typical // (recommended) hardware. Force temp files to be written to disk // regardless of the config settings. - session << boost::format(kCommonDbPragmaTemp) % "file"; + session << commonDbPragmaTemp("file"); session << "PRAGMA page_size;", soci::into(pageSize); std::cout << "VACUUM beginning. page_size: " << pageSize << std::endl; diff --git a/src/libxrpl/server/Wallet.cpp b/src/libxrpl/server/Wallet.cpp index 42ac80ef3f..56d0db67d4 100644 --- a/src/libxrpl/server/Wallet.cpp +++ b/src/libxrpl/server/Wallet.cpp @@ -16,7 +16,6 @@ #include #include -#include #include // IWYU pragma: keep #include // IWYU pragma: keep @@ -30,6 +29,7 @@ #include #include +#include #include #include #include @@ -172,11 +172,10 @@ getNodeIdentity(soci::session& session) // If a valid identity wasn't found, we randomly generate a new one: auto [newpublicKey, newsecretKey] = randomKeyPair(KeyType::Secp256k1); - session << str( - boost::format( - "INSERT INTO NodeIdentity (PublicKey,PrivateKey) " - "VALUES ('%s','%s');") % - toBase58(TokenType::NodePublic, newpublicKey) % + session << std::format( + "INSERT INTO NodeIdentity (PublicKey,PrivateKey) " + "VALUES ('{}','{}');", + toBase58(TokenType::NodePublic, newpublicKey), toBase58(TokenType::NodePrivate, newsecretKey)); return {newpublicKey, newsecretKey}; diff --git a/src/test/app/AMMCalc_test.cpp b/src/test/app/AMMCalc_test.cpp index 74080e669c..23f251d57a 100644 --- a/src/test/app/AMMCalc_test.cpp +++ b/src/test/app/AMMCalc_test.cpp @@ -20,6 +20,7 @@ #include #include +#include #include #include #include @@ -188,8 +189,7 @@ class AMMCalc_test : public beast::unit_test::Suite static std::string toString(STAmount const& a) { - return (boost::format("%s/%s") % a.getText() % ::xrpl::to_string(a.get().currency)) - .str(); + return std::format("{}/{}", a.getText(), ::xrpl::to_string(a.get().currency)); } static STAmount diff --git a/src/test/core/Config_test.cpp b/src/test/core/Config_test.cpp index dec6393010..5ed5ef4049 100644 --- a/src/test/core/Config_test.cpp +++ b/src/test/core/Config_test.cpp @@ -10,8 +10,6 @@ #include // IWYU pragma: keep #include -#include // IWYU pragma: keep -#include #include #include @@ -20,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -36,7 +35,7 @@ namespace detail { std::string configContents(std::string const& dbPath, std::string const& validatorsFile) { - static boost::format kConfigContentsTemplate(R"xrpldConfig( + static constexpr char const* kConfigContentsTemplate = R"xrpldConfig( [server] port_rpc port_peer @@ -83,9 +82,9 @@ cache_mb=256 file_size_mb=8 file_size_mult=2 -%1% +{} -%2% +{} # This needs to be an absolute directory reference, not a relative one. # Modify this value as required. @@ -106,7 +105,7 @@ r.ripple.com 51235 # Turn down default logging to save disk space in the long run. # Valid values here are trace, debug, info, warning, error, and fatal [rpc_startup] -{ "command": "log_level", "severity": "warning" } +{{ "command": "log_level", "severity": "warning" }} # Defaults to 1 ("yes") so that certificates will be validated. To allow the use # of self-signed certificates for development or internal use, set to 0 ("no"). @@ -115,12 +114,12 @@ r.ripple.com 51235 [sqdb] backend=sqlite -)xrpldConfig"); +)xrpldConfig"; std::string dbPathSection = dbPath.empty() ? "" : "[database_path]\n" + dbPath; std::string valFileSection = validatorsFile.empty() ? "" : "[validators_file]\n" + validatorsFile; - return boost::str(kConfigContentsTemplate % dbPathSection % valFileSection); + return std::format(kConfigContentsTemplate, dbPathSection, valFileSection); } /** @@ -427,7 +426,7 @@ port_wss_admin using namespace std::filesystem; { - boost::format cc("[database_path]\n%1%\n"); + constexpr char const* cc = "[database_path]\n{}\n"; auto const cwd = current_path(); path const dataDirRel("test_data_dir"); @@ -435,13 +434,13 @@ port_wss_admin { // Dummy test - do we get back what we put in Config c; - c.loadFromString(boost::str(cc % dataDirAbs.string())); + c.loadFromString(std::format(cc, dataDirAbs.string())); BEAST_EXPECT(c.legacy(Sections::kDatabasePath) == dataDirAbs.string()); } { // Rel paths should convert to abs paths Config c; - c.loadFromString(boost::str(cc % dataDirRel.string())); + c.loadFromString(std::format(cc, dataDirRel.string())); BEAST_EXPECT(c.legacy(Sections::kDatabasePath) == dataDirAbs.string()); } { @@ -508,20 +507,20 @@ port_wss_admin { Config c; - static boost::format kConfigTemplate(R"xrpldConfig( + static constexpr char const* kConfigTemplate = R"xrpldConfig( [validation_seed] -%1% +{} [validator_token] -%2% -)xrpldConfig"); +{} +)xrpldConfig"; std::string error; auto const expectedError = "Cannot have both [validation_seed] " "and [validator_token] config sections"; try { - c.loadFromString(boost::str(kConfigTemplate % validationSeed % token)); + c.loadFromString(std::format(kConfigTemplate, validationSeed, token)); } catch (std::runtime_error const& e) { @@ -604,7 +603,7 @@ main using namespace std::filesystem; { // load should throw for missing specified validators file - boost::format cc("[validators_file]\n%1%\n"); + constexpr char const* cc = "[validators_file]\n{}\n"; std::string error; std::string const missingPath = "/no/way/this/path/exists"; auto const expectedError = @@ -612,7 +611,7 @@ main try { Config c; - c.loadFromString(boost::str(cc % missingPath)); + c.loadFromString(std::format(cc, missingPath)); } catch (std::runtime_error const& e) { @@ -624,14 +623,14 @@ main // load should throw for invalid [validators_file] detail::ValidatorsTxtGuard const vtg(*this, "test_cfg", "validators.cfg"); path const invalidFile = current_path() / vtg.subdir(); - boost::format cc("[validators_file]\n%1%\n"); + constexpr char const* cc = "[validators_file]\n{}\n"; std::string error; auto const expectedError = "Invalid file specified in [validators_file]: " + invalidFile.string(); try { Config c; - c.loadFromString(boost::str(cc % invalidFile.string())); + c.loadFromString(std::format(cc, invalidFile.string())); } catch (std::runtime_error const& e) { @@ -829,8 +828,8 @@ trust-these-validators.gov detail::ValidatorsTxtGuard const vtg(*this, "test_cfg", "validators.cfg"); BEAST_EXPECT(vtg.validatorsFileExists()); Config c; - boost::format cc("[validators_file]\n%1%\n"); - c.loadFromString(boost::str(cc % vtg.validatorsFile())); + constexpr char const* cc = "[validators_file]\n{}\n"; + c.loadFromString(std::format(cc, vtg.validatorsFile())); BEAST_EXPECT(c.legacy(Sections::kValidatorsFile) == vtg.validatorsFile()); BEAST_EXPECT(c.section(Sections::kValidators).values().size() == 8); BEAST_EXPECT(c.section(Sections::kValidatorListSites).values().size() == 2); @@ -909,9 +908,9 @@ trust-these-validators.gov { // load validators from both config and validators file - boost::format cc(R"xrpldConfig( + constexpr char const* cc = R"xrpldConfig( [validators_file] -%1% +{} [validators] n949f75evCHwgyP4fPVgaHqNHxUVN15PsJEZ3B3HnXPcPjcZAoy7 @@ -930,11 +929,11 @@ trust-these-validators.gov [validator_list_keys] 021A99A537FDEBC34E4FCA03B39BEADD04299BB19E85097EC92B15A3518801E566 -)xrpldConfig"); +)xrpldConfig"; detail::ValidatorsTxtGuard const vtg(*this, "test_cfg", "validators.cfg"); BEAST_EXPECT(vtg.validatorsFileExists()); Config c; - c.loadFromString(boost::str(cc % vtg.validatorsFile())); + c.loadFromString(std::format(cc, vtg.validatorsFile())); BEAST_EXPECT(c.legacy(Sections::kValidatorsFile) == vtg.validatorsFile()); BEAST_EXPECT(c.section(Sections::kValidators).values().size() == 15); BEAST_EXPECT(c.section(Sections::kValidatorListSites).values().size() == 4); @@ -945,13 +944,13 @@ trust-these-validators.gov { // load should throw if [validator_list_threshold] is present both // in xrpld.cfg and validators file - boost::format cc(R"xrpldConfig( + constexpr char const* cc = R"xrpldConfig( [validators_file] -%1% +{} [validator_list_threshold] 1 -)xrpldConfig"); +)xrpldConfig"; std::string error; detail::ValidatorsTxtGuard const vtg(*this, "test_cfg", "validators.cfg"); BEAST_EXPECT(vtg.validatorsFileExists()); @@ -961,7 +960,7 @@ trust-these-validators.gov try { Config c; - c.loadFromString(boost::str(cc % vtg.validatorsFile())); + c.loadFromString(std::format(cc, vtg.validatorsFile())); fail(); } catch (std::runtime_error const& e) @@ -975,7 +974,7 @@ trust-these-validators.gov // [validator_list_keys] are missing from xrpld.cfg and // validators file Config const c; - boost::format cc("[validators_file]\n%1%\n"); + constexpr char const* cc = "[validators_file]\n{}\n"; std::string error; detail::ValidatorsTxtGuard const vtg(*this, "test_cfg", "validators.cfg"); BEAST_EXPECT(vtg.validatorsFileExists()); @@ -988,7 +987,7 @@ trust-these-validators.gov try { Config c2; - c2.loadFromString(boost::str(cc % vtg.validatorsFile())); + c2.loadFromString(std::format(cc, vtg.validatorsFile())); } catch (std::runtime_error const& e) { diff --git a/src/test/rpc/ServerInfo_test.cpp b/src/test/rpc/ServerInfo_test.cpp index 52a1e6cdb0..100ae0e49b 100644 --- a/src/test/rpc/ServerInfo_test.cpp +++ b/src/test/rpc/ServerInfo_test.cpp @@ -9,8 +9,7 @@ #include #include -#include - +#include #include namespace xrpl::test { @@ -36,12 +35,13 @@ public: makeValidatorConfig() { auto p = std::make_unique(); - boost::format toLoad(R"xrpldConfig( + auto const toLoad = std::format( + R"xrpldConfig( [validator_token] -%1% +{} [validators] -%2% +{} [port_grpc] ip = 0.0.0.0 @@ -52,9 +52,11 @@ ip = 0.0.0.0 port = 50052 protocol = wss2 admin = 127.0.0.1 -)xrpldConfig"); +)xrpldConfig", + validator_data::kToken, + validator_data::kPublicKey); - p->loadFromString(boost::str(toLoad % validator_data::kToken % validator_data::kPublicKey)); + p->loadFromString(toLoad); setupConfigForUnitTests(*p); diff --git a/src/tests/libxrpl/protocol/STXChainBridge.cpp b/src/tests/libxrpl/protocol/STXChainBridge.cpp new file mode 100644 index 0000000000..f4e6e60cc9 --- /dev/null +++ b/src/tests/libxrpl/protocol/STXChainBridge.cpp @@ -0,0 +1,60 @@ +#include + +#include +#include +#include + +#include + +#include +#include + +using namespace xrpl; + +namespace { + +// Built from raw bytes rather than base58 so the test does not depend on +// hand-computed checksums. +AccountID +account(std::string_view hex) +{ + AccountID id; + EXPECT_TRUE(id.parseHex(hex)); + return id; +} + +} // namespace + +// getText() builds its string from eight substitutions of the same type, so a +// transposed pair would still compile and still type check. Pin the output so +// the field/value pairing is actually verified. +TEST(STXChainBridge, getTextPairsEachFieldWithItsValue) +{ + auto const lockingDoor = account("0102030405060708090A0B0C0D0E0F1011121314"); + auto const issuingDoor = account("14131211100F0E0D0C0B0A090807060504030201"); + + auto const lockingIssue = xrpIssue(); + Issue const issuingIssue{toCurrency("USD"), issuingDoor}; + + STXChainBridge const bridge{lockingDoor, lockingIssue, issuingDoor, issuingIssue}; + + std::string const expected = "{ LockingChainDoor = " + toBase58(lockingDoor) + + ", LockingChainIssue = " + lockingIssue.getText() + + ", IssuingChainDoor = " + toBase58(issuingDoor) + + ", IssuingChainIssue = " + issuingIssue.getText() + " }"; + + EXPECT_EQ(bridge.getText(), expected); +} + +TEST(STXChainBridge, getTextOnADefaultBridge) +{ + STXChainBridge const bridge; + auto const text = bridge.getText(); + + // The outer braces are literal, and the four field names appear in + // declaration order regardless of the values. + EXPECT_TRUE(text.starts_with("{ LockingChainDoor = ")); + EXPECT_TRUE(text.ends_with(" }")); + EXPECT_LT(text.find("LockingChainIssue"), text.find("IssuingChainDoor")); + EXPECT_LT(text.find("IssuingChainDoor"), text.find("IssuingChainIssue")); +} diff --git a/src/xrpld/app/misc/Transaction.h b/src/xrpld/app/misc/Transaction.h index b6b6d1a8d5..61951fbb59 100644 --- a/src/xrpld/app/misc/Transaction.h +++ b/src/xrpld/app/misc/Transaction.h @@ -15,6 +15,10 @@ #include #include +// boost::optional (not std::optional) appears in the declarations below, +// because SOCI's into()/use() bindings only support boost::optional. +#include + #include #include #include diff --git a/src/xrpld/app/misc/detail/WorkSSL.cpp b/src/xrpld/app/misc/detail/WorkSSL.cpp index e8d24b55d6..48231b147e 100644 --- a/src/xrpld/app/misc/detail/WorkSSL.cpp +++ b/src/xrpld/app/misc/detail/WorkSSL.cpp @@ -10,8 +10,8 @@ #include #include #include -#include +#include #include #include @@ -38,7 +38,7 @@ WorkSSL::WorkSSL( { auto ec = context_.preConnectVerify(stream_, host_); if (ec) - Throw(boost::str(boost::format("preConnectVerify: %s") % ec.message())); + Throw(std::format("preConnectVerify: {}", ec.message())); } void diff --git a/src/xrpld/app/misc/detail/WorkSSL.h b/src/xrpld/app/misc/detail/WorkSSL.h index d4b3b9ff25..e4b7586054 100644 --- a/src/xrpld/app/misc/detail/WorkSSL.h +++ b/src/xrpld/app/misc/detail/WorkSSL.h @@ -7,7 +7,6 @@ #include #include -#include #include #include diff --git a/src/xrpld/app/rdb/backend/detail/Node.cpp b/src/xrpld/app/rdb/backend/detail/Node.cpp index ff57087ec5..be4c5d29e5 100644 --- a/src/xrpld/app/rdb/backend/detail/Node.cpp +++ b/src/xrpld/app/rdb/backend/detail/Node.cpp @@ -40,7 +40,6 @@ #include #include -#include #include // IWYU pragma: keep #include @@ -58,6 +57,7 @@ #include #include #include +#include #include #include #include @@ -109,18 +109,16 @@ makeLedgerDBs( // ledger database auto lgr{std::make_unique( setup, kLgrDbName, setup.lgrPragma, kLgrDbInit, checkpointerSetup, j)}; - lgr->getSession() << boost::str( - boost::format("PRAGMA cache_size=-%d;") % - kilobytes(config.getValueFor(SizedItem::LgrDbCache))); + lgr->getSession() << std::format( + "PRAGMA cache_size=-{};", kilobytes(config.getValueFor(SizedItem::LgrDbCache))); if (config.useTxTables()) { // transaction database auto tx{std::make_unique( setup, kTxDbName, setup.txPragma, kTxDbInit, checkpointerSetup, j)}; - tx->getSession() << boost::str( - boost::format("PRAGMA cache_size=-%d;") % - kilobytes(config.getValueFor(SizedItem::TxnDbCache))); + tx->getSession() << std::format( + "PRAGMA cache_size=-{};", kilobytes(config.getValueFor(SizedItem::TxnDbCache))); if (!setup.standAlone || setup.startUp == StartUpType::Load || setup.startUp == StartUpType::LoadFile || setup.startUp == StartUpType::Replay) @@ -280,15 +278,17 @@ saveValidatedLedger( } { - static boost::format kDeleteLedger("DELETE FROM Ledgers WHERE LedgerSeq = %u;"); - static boost::format kDeleteTranS1("DELETE FROM Transactions WHERE LedgerSeq = %u;"); - static boost::format kDeleteTranS2("DELETE FROM AccountTransactions WHERE LedgerSeq = %u;"); - static boost::format kDeleteAcctTrans( - "DELETE FROM AccountTransactions WHERE TransID = '%s';"); + static constexpr char const* kDeleteLedger = "DELETE FROM Ledgers WHERE LedgerSeq = {};"; + static constexpr char const* kDeleteTranS1 = + "DELETE FROM Transactions WHERE LedgerSeq = {};"; + static constexpr char const* kDeleteTranS2 = + "DELETE FROM AccountTransactions WHERE LedgerSeq = {};"; + static constexpr char const* kDeleteAcctTrans = + "DELETE FROM AccountTransactions WHERE TransID = '{}';"; { auto db = ldgDB.checkoutDb(); - *db << boost::str(kDeleteLedger % seq); + *db << std::format(kDeleteLedger, seq); } if (app.config().useTxTables()) @@ -305,19 +305,19 @@ saveValidatedLedger( soci::transaction tr(*db); - *db << boost::str(kDeleteTranS1 % seq); - *db << boost::str(kDeleteTranS2 % seq); + *db << std::format(kDeleteTranS1, seq); + *db << std::format(kDeleteTranS2, seq); std::string const ledgerSeq(std::to_string(seq)); for (auto const& acceptedLedgerTx : *aLedger) { - uint256 transactionID = acceptedLedgerTx->getTransactionID(); + uint256 const transactionID = acceptedLedgerTx->getTransactionID(); std::string const txnId(to_string(transactionID)); std::string const txnSeq(std::to_string(acceptedLedgerTx->getTxnSeq())); - *db << boost::str(kDeleteAcctTrans % transactionID); + *db << std::format(kDeleteAcctTrans, txnId); auto const& accts = acceptedLedgerTx->getAffected(); @@ -629,11 +629,11 @@ getHashesByIndex(soci::session& session, LedgerIndex minSeq, LedgerIndex maxSeq, std::pair>, int> getTxHistory(soci::session& session, Application& app, LedgerIndex startIndex, int quantity) { - std::string const sql = boost::str( - boost::format( - "SELECT LedgerSeq, Status, RawTxn " - "FROM Transactions ORDER BY LedgerSeq DESC LIMIT %u,%u;") % - startIndex % quantity); + std::string const sql = std::format( + "SELECT LedgerSeq, Status, RawTxn " + "FROM Transactions ORDER BY LedgerSeq DESC LIMIT {},{};", + startIndex, + quantity); std::vector> txs; int total = 0; @@ -730,41 +730,50 @@ transactionsSQL( if (options.ledgerRange.max != 0u) { - maxClause = boost::str( - boost::format("AND AccountTransactions.LedgerSeq <= '%u'") % options.ledgerRange.max); + maxClause = + std::format("AND AccountTransactions.LedgerSeq <= '{}'", options.ledgerRange.max); } if (options.ledgerRange.min != 0u) { - minClause = boost::str( - boost::format("AND AccountTransactions.LedgerSeq >= '%u'") % options.ledgerRange.min); + minClause = + std::format("AND AccountTransactions.LedgerSeq >= '{}'", options.ledgerRange.min); } std::string sql; if (count) { - sql = boost::str( - boost::format( - "SELECT %s FROM AccountTransactions " - "WHERE Account = '%s' %s %s LIMIT %u, %u;") % - selection % toBase58(options.account) % maxClause % minClause % options.offset % + sql = std::format( + "SELECT {} FROM AccountTransactions " + "WHERE Account = '{}' {} {} LIMIT {}, {};", + selection, + toBase58(options.account), + maxClause, + minClause, + options.offset, numberOfResults); } else { - sql = boost::str( - boost::format( - "SELECT %s FROM " - "AccountTransactions INNER JOIN Transactions " - "ON Transactions.TransID = AccountTransactions.TransID " - "WHERE Account = '%s' %s %s " - "ORDER BY AccountTransactions.LedgerSeq %s, " - "AccountTransactions.TxnSeq %s, AccountTransactions.TransID %s " - "LIMIT %u, %u;") % - selection % toBase58(options.account) % maxClause % minClause % - (descending ? "DESC" : "ASC") % (descending ? "DESC" : "ASC") % - (descending ? "DESC" : "ASC") % options.offset % numberOfResults); + char const* const order = descending ? "DESC" : "ASC"; + sql = std::format( + "SELECT {} FROM " + "AccountTransactions INNER JOIN Transactions " + "ON Transactions.TransID = AccountTransactions.TransID " + "WHERE Account = '{}' {} {} " + "ORDER BY AccountTransactions.LedgerSeq {}, " + "AccountTransactions.TxnSeq {}, AccountTransactions.TransID {} " + "LIMIT {}, {};", + selection, + toBase58(options.account), + maxClause, + minClause, + order, + order, + order, + options.offset, + numberOfResults); } JLOG(j.trace()) << "txSQL query: " << sql; return sql; @@ -1105,14 +1114,6 @@ accountTxPage( std::optional newmarker; - static std::string const kPrefix( - R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq, - Status,RawTxn,TxnMeta - FROM AccountTransactions INNER JOIN Transactions - ON Transactions.TransID = AccountTransactions.TransID - AND AccountTransactions.Account = '%s' WHERE - )"); - std::string sql; // SQL's BETWEEN uses a closed interval ([a,b]) @@ -1121,13 +1122,22 @@ accountTxPage( if (findLedger == 0) { - sql = boost::str( - boost::format(kPrefix + R"(AccountTransactions.LedgerSeq BETWEEN %u AND %u - ORDER BY AccountTransactions.LedgerSeq %s, - AccountTransactions.TxnSeq %s - LIMIT %u;)") % - toBase58(options.account) % options.ledgerRange.min % options.ledgerRange.max % order % - order % queryLimit); + sql = std::format( + R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq, + Status,RawTxn,TxnMeta + FROM AccountTransactions INNER JOIN Transactions + ON Transactions.TransID = AccountTransactions.TransID + AND AccountTransactions.Account = '{}' WHERE + AccountTransactions.LedgerSeq BETWEEN {} AND {} + ORDER BY AccountTransactions.LedgerSeq {}, + AccountTransactions.TxnSeq {} + LIMIT {};)", + toBase58(options.account), + options.ledgerRange.min, + options.ledgerRange.max, + order, + order, + queryLimit); } else { @@ -1136,27 +1146,34 @@ accountTxPage( std::uint32_t const maxLedger = forward ? options.ledgerRange.max : findLedger - 1; auto b58acct = toBase58(options.account); - sql = boost::str( - boost::format( - R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq, + sql = std::format( + R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq, Status,RawTxn,TxnMeta FROM AccountTransactions, Transactions WHERE (AccountTransactions.TransID = Transactions.TransID AND - AccountTransactions.Account = '%s' AND - AccountTransactions.LedgerSeq BETWEEN %u AND %u) + AccountTransactions.Account = '{}' AND + AccountTransactions.LedgerSeq BETWEEN {} AND {}) UNION SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq,Status,RawTxn,TxnMeta FROM AccountTransactions, Transactions WHERE (AccountTransactions.TransID = Transactions.TransID AND - AccountTransactions.Account = '%s' AND - AccountTransactions.LedgerSeq = %u AND - AccountTransactions.TxnSeq %s %u) - ORDER BY AccountTransactions.LedgerSeq %s, - AccountTransactions.TxnSeq %s - LIMIT %u; - )") % - b58acct % minLedger % maxLedger % b58acct % findLedger % compare % findSeq % order % - order % queryLimit); + AccountTransactions.Account = '{}' AND + AccountTransactions.LedgerSeq = {} AND + AccountTransactions.TxnSeq {} {}) + ORDER BY AccountTransactions.LedgerSeq {}, + AccountTransactions.TxnSeq {} + LIMIT {}; + )", + b58acct, + minLedger, + maxLedger, + b58acct, + findLedger, + compare, + findSeq, + order, + order, + queryLimit); } { diff --git a/src/xrpld/core/detail/Config.cpp b/src/xrpld/core/detail/Config.cpp index efe4ab1cc9..3ff62c9b64 100644 --- a/src/xrpld/core/detail/Config.cpp +++ b/src/xrpld/core/detail/Config.cpp @@ -21,7 +21,6 @@ #include #include #include -#include #include #include #include // IWYU pragma: keep @@ -34,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -400,7 +400,7 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand std::filesystem::create_directories(dataDir, ec); if (ec) - Throw(boost::str(boost::format("Can not create %s") % dataDir)); + Throw(std::format("Can not create {}", dataDir.string())); legacy(Sections::kDatabasePath, std::filesystem::absolute(dataDir).string()); } @@ -1315,8 +1315,7 @@ setupDatabaseCon(Config const& c, std::optional j) boost::iequals(journalMode, "truncate") || boost::iequals(journalMode, "persist") || boost::iequals(journalMode, "wal")) { - result->emplace_back( - boost::str(boost::format(kCommonDbPragmaJournal) % journalMode)); + result->emplace_back(commonDbPragmaJournal(journalMode)); } else { @@ -1337,7 +1336,7 @@ setupDatabaseCon(Config const& c, std::optional j) if (higherRisk || boost::iequals(synchronous, "normal") || boost::iequals(synchronous, "full") || boost::iequals(synchronous, "extra")) { - result->emplace_back(boost::str(boost::format(kCommonDbPragmaSync) % synchronous)); + result->emplace_back(commonDbPragmaSync(synchronous)); } else { @@ -1358,7 +1357,7 @@ setupDatabaseCon(Config const& c, std::optional j) if (higherRisk || boost::iequals(tempStore, "default") || boost::iequals(tempStore, "file")) { - result->emplace_back(boost::str(boost::format(kCommonDbPragmaTemp) % tempStore)); + result->emplace_back(commonDbPragmaTemp(tempStore)); } else { diff --git a/src/xrpld/rpc/detail/RPCHelpers.cpp b/src/xrpld/rpc/detail/RPCHelpers.cpp index 4fa0fab6f7..321f8f5a3c 100644 --- a/src/xrpld/rpc/detail/RPCHelpers.cpp +++ b/src/xrpld/rpc/detail/RPCHelpers.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -424,7 +425,7 @@ parseSubUnsubJson( if (jv.isMember(jss::mpt_issuance_id) && (jv.isMember(jss::currency) || jv.isMember(jss::issuer))) { - JLOG(j.info()) << boost::format("Bad %s currency or MPT.") % name.cStr(); + JLOG(j.info()) << std::format("Bad {} currency or MPT.", name.cStr()); return RpcInvalidParams; } @@ -435,7 +436,7 @@ parseSubUnsubJson( if (!jv.isMember(jss::currency) || !toCurrency(issue.currency, jv[jss::currency].asString())) { - JLOG(j.info()) << boost::format("Bad %s currency.") % name.cStr(); + JLOG(j.info()) << std::format("Bad {} currency.", name.cStr()); return assetError; } @@ -445,7 +446,7 @@ parseSubUnsubJson( // Don't allow illegal issuers. || (!issue.currency != !issue.account) || noAccount() == issue.account) { - JLOG(j.info()) << boost::format("Bad %s issuer.") % name.cStr(); + JLOG(j.info()) << std::format("Bad {} issuer.", name.cStr()); return issuerError; } asset = issue; @@ -459,7 +460,7 @@ parseSubUnsubJson( } else { - JLOG(j.info()) << boost::format("Neither %s currency or MPT is present.") % name.cStr(); + JLOG(j.info()) << std::format("Neither {} currency or MPT is present.", name.cStr()); return assetError; } diff --git a/src/xrpld/rpc/handlers/account/AccountInfo.cpp b/src/xrpld/rpc/handlers/account/AccountInfo.cpp index eed4e4cfe3..6b244af1a9 100644 --- a/src/xrpld/rpc/handlers/account/AccountInfo.cpp +++ b/src/xrpld/rpc/handlers/account/AccountInfo.cpp @@ -23,10 +23,9 @@ #include #include -#include - #include #include +#include #include #include #include @@ -60,7 +59,7 @@ injectSLE(json::Value& jv, SLE const& sle) md5 = toLower(md5); // VFALCO TODO Give a name to this constant and move it // to a more visible location. - jv[jss::urlgravatar] = str(boost::format("https://www.gravatar.com/avatar/%s") % md5); + jv[jss::urlgravatar] = std::format("https://www.gravatar.com/avatar/{}", md5); } } diff --git a/src/xrpld/rpc/handlers/orderbook/BookOffers.cpp b/src/xrpld/rpc/handlers/orderbook/BookOffers.cpp index ae539a59f3..219c29d53a 100644 --- a/src/xrpld/rpc/handlers/orderbook/BookOffers.cpp +++ b/src/xrpld/rpc/handlers/orderbook/BookOffers.cpp @@ -22,6 +22,7 @@ #include #include +#include #include #include @@ -32,7 +33,7 @@ validateTakerJSON(json::Value const& taker, json::StaticString const& name) { if (!taker.isMember(jss::currency) && !taker.isMember(jss::mpt_issuance_id)) { - return rpc::missingFieldError((boost::format("%s.currency") % name.cStr()).str()); + return rpc::missingFieldError(std::format("{}.currency", name.cStr())); } if (taker.isMember(jss::mpt_issuance_id) && @@ -44,8 +45,7 @@ validateTakerJSON(json::Value const& taker, json::StaticString const& name) if ((taker.isMember(jss::currency) && !taker[jss::currency].isString()) || (taker.isMember(jss::mpt_issuance_id) && !taker[jss::mpt_issuance_id].isString())) { - return rpc::expectedFieldError( - (boost::format("%s.currency") % name.cStr()).str(), "string"); + return rpc::expectedFieldError(std::format("{}.currency", name.cStr()), "string"); } return std::nullopt; @@ -70,10 +70,9 @@ parseTakerAssetJSON( if (!toCurrency(issue.currency, taker[jss::currency].asString())) { - JLOG(j.info()) << boost::format("Bad %s currency.") % name.cStr(); + JLOG(j.info()) << std::format("Bad {} currency.", name.cStr()); return rpc::makeError( - assetError, - (boost::format("Invalid field '%s.currency', bad currency.") % name.cStr()).str()); + assetError, std::format("Invalid field '{}.currency', bad currency.", name.cStr())); } asset = issue; } @@ -83,8 +82,7 @@ parseTakerAssetJSON( if (!mptid.parseHex(taker[jss::mpt_issuance_id].asString())) { return rpc::makeError( - assetError, - (boost::format("Invalid field '%s.mpt_issuance_id'") % name.cStr()).str()); + assetError, std::format("Invalid field '{}.mpt_issuance_id'", name.cStr())); } asset = mptid; } @@ -113,24 +111,21 @@ parseTakerIssuerJSON( { if (!taker[jss::issuer].isString()) { - return rpc::expectedFieldError( - (boost::format("%s.issuer") % name.cStr()).str(), "string"); + return rpc::expectedFieldError(std::format("{}.issuer", name.cStr()), "string"); } if (!toIssuer(issue.account, taker[jss::issuer].asString())) { return rpc::makeError( issuerError, - (boost::format("Invalid field '%s.issuer', bad issuer.") % name.cStr()).str()); + std::format("Invalid field '{}.issuer', bad issuer.", name.cStr())); } if (issue.account == noAccount()) { return rpc::makeError( issuerError, - (boost::format("Invalid field '%s.issuer', bad issuer account one.") % - name.cStr()) - .str()); + std::format("Invalid field '{}.issuer', bad issuer account one.", name.cStr())); } } else @@ -142,19 +137,17 @@ parseTakerIssuerJSON( { return rpc::makeError( issuerError, - (boost::format( - "Unneeded field '%s.issuer' for XRP currency " - "specification.") % - name.cStr()) - .str()); + std::format( + "Unneeded field '{}.issuer' for XRP currency " + "specification.", + name.cStr())); } if (!isXRP(issue.currency) && isXRP(issue.account)) { return rpc::makeError( issuerError, - (boost::format("Invalid field '%s.issuer', expected non-XRP issuer.") % name.cStr()) - .str()); + std::format("Invalid field '{}.issuer', expected non-XRP issuer.", name.cStr())); } } From bd87edfc75f1ff4ee8e117e05cf37f20380fba20 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Fri, 14 Aug 2026 14:07:55 +0000 Subject: [PATCH 10/11] test: Check versioned tools in check-tools & print nicely (#8030) --- .cspell.config.yaml | 1 + .github/scripts/strategy-matrix/linux.json | 2 +- .github/workflows/publish-docs.yml | 2 +- .github/workflows/reusable-clang-tidy.yml | 2 +- .github/workflows/reusable-upload-recipe.yml | 2 +- bin/check-tools.sh | 66 +++++-- nix/check-tools/README.md | 13 +- nix/check-tools/macos.txt | 170 ++++++++++++---- nix/check-tools/nix-ubuntu-amd64.txt | 198 +++++++++++++++---- nix/check-tools/nix-ubuntu-arm64.txt | 198 +++++++++++++++---- 10 files changed, 511 insertions(+), 143 deletions(-) diff --git a/.cspell.config.yaml b/.cspell.config.yaml index bb763e9935..ec9f87cfdd 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -7,6 +7,7 @@ ignorePaths: - cmake/** - LICENSE.md - .clang-tidy + - nix/check-tools/*.txt # generated, and full of Nix store hashes language: en allowCompoundWords: true # TODO (#6334) ignoreRandomStrings: true diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 33146cff3b..97163fb8ce 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -1,5 +1,5 @@ { - "image_tag": "sha-fecfc0c", + "image_tag": "sha-a0074f8", "configs": { "ubuntu": [ { diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index a3e096315c..6e973a251d 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -41,7 +41,7 @@ env: jobs: build: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-fecfc0c + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index f1fdc0569a..2049b1ce55 100644 --- a/.github/workflows/reusable-clang-tidy.yml +++ b/.github/workflows/reusable-clang-tidy.yml @@ -34,7 +34,7 @@ jobs: needs: [determine-files] if: ${{ needs.determine-files.outputs.cpp_changed_files != '' || needs.determine-files.outputs.need_full_run == 'true' }} runs-on: ["self-hosted", "Linux", "X64", "heavy"] - container: "ghcr.io/xrplf/xrpld/nix-debian:sha-fecfc0c" + container: "ghcr.io/xrplf/xrpld/nix-debian:sha-a0074f8" permissions: contents: read issues: write diff --git a/.github/workflows/reusable-upload-recipe.yml b/.github/workflows/reusable-upload-recipe.yml index b4ab638dee..a8d35fadad 100644 --- a/.github/workflows/reusable-upload-recipe.yml +++ b/.github/workflows/reusable-upload-recipe.yml @@ -40,7 +40,7 @@ defaults: jobs: upload: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-fecfc0c + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 env: REMOTE_NAME: ${{ inputs.remote_name }} CONAN_LOGIN_USERNAME_XRPLF: ${{ secrets.remote_username }} diff --git a/bin/check-tools.sh b/bin/check-tools.sh index e230302742..8273375428 100755 --- a/bin/check-tools.sh +++ b/bin/check-tools.sh @@ -15,10 +15,14 @@ # - Windows: the core build tools only (CMake, Conan, Git, Python). # MSVC is expected to be provided separately and is not checked here. # -# Some tools (clang-format, doxygen, gcovr, gh, git-cliff, gpg, pre-commit, -# run-clang-tidy) are present in our Linux CI images and in local development -# setups, but not in the macOS CI environment. They are checked everywhere -# except when running in CI on macOS. +# Some tools (clang-format, clang-tidy, doxygen, gcovr, gh, git-cliff, gpg, +# pre-commit, run-clang-tidy) are present in our Linux CI images and in local +# development setups, but not in the macOS CI environment. They are checked +# everywhere except when running in CI on macOS. +# +# Tools that Nix also exposes under a version-suffixed name (`clang-tidy-22`, +# `g++-15`, ...) are probed under both names: a suffixed name can break while +# the plain one still works (see mkVersionedToolLinks in nix/packages.nix). # # Environment variables: # CI if set, skip the tools above when on macOS. @@ -26,14 +30,27 @@ set -uo pipefail +# Version suffixes of the Nix tool links, tracking nix/packages.nix. +gcc_version=15 +llvm_version=22 + missing=() checked=0 +# tool_path +# Fully resolved path of a tool, so the snapshots record which derivation +# provides it. Prints nothing when it isn't on PATH. +tool_path() { + local path + path="$(command -v "$1" 2>/dev/null)" || return 0 + readlink -f "${path}" 2>/dev/null || printf '%s' "${path}" +} + # check [probe-command...] # Runs the probe (default: " --version"), capturing both stdout and -# stderr, and prints one aligned line: the status, the name, and the first -# non-blank line of the probe output (its version). Records as missing -# if the command is not found or exits non-zero. +# stderr, and prints three lines: the status and name, the first non-blank line +# of the probe output (its version, or the error when it failed), and the tool's +# resolved path. Records as missing if it is not found or exits non-zero. check() { local name="$1" shift @@ -43,14 +60,17 @@ check() { fi checked=$((checked + 1)) - local output version + local output version path + path="$(tool_path "${name}")" if output="$("${probe[@]}" 2>&1)"; then - version="$(printf '%s\n' "${output}" | grep -m1 '[^[:space:]]' || true)" - printf ' [ ok ] %-20s %s\n' "${name}" "${version}" + printf ' ✅ %s\n' "${name}" else - printf ' [MISS] %s\n' "${name}" + printf ' ❌ %s\n' "${name}" missing+=("${name}") fi + version="$(printf '%s\n' "${output}" | grep -m1 '[^[:space:]]' || true)" + printf ' %s\n' "${version:-(no output)}" + printf ' %s\n' "${path:-(not found)}" } case "$(uname -s)" in @@ -82,7 +102,9 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then echo "Development tooling:" check ccache check clang + check "clang-${llvm_version}" check clang++ + check "clang++-${llvm_version}" check ClangBuildAnalyzer check curl check file @@ -101,7 +123,14 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then # setups, but not in the macOS CI environment. So check them everywhere # except when running in CI on macOS. if [ "${os}" = "linux" ] || [ -z "${CI:-}" ]; then + check clang-apply-replacements + check "clang-apply-replacements-${llvm_version}" check clang-format + check "clang-format-${llvm_version}" + # clang-tidy leads --version with the LLVM banner, not the version. + tidy_probe="--version | grep -m1 -oE 'LLVM version [0-9.]+'" + check clang-tidy sh -c "clang-tidy ${tidy_probe}" + check "clang-tidy-${llvm_version}" sh -c "clang-tidy-${llvm_version} ${tidy_probe}" check dot check doxygen check gcovr @@ -112,6 +141,7 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then # pre-commit, or its alternative implementation prek check pre-commit sh -c 'pre-commit --version || prek --version' check run-clang-tidy run-clang-tidy --help + check "run-clang-tidy-${llvm_version}" "run-clang-tidy-${llvm_version}" --help fi fi @@ -126,7 +156,7 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then check cargo-audit cargo audit --version check cargo-llvm-cov cargo llvm-cov --version check cargo-nextest cargo nextest --version - check clippy clippy-driver --version + check clippy-driver check rust-analyzer check rustc check rustfmt @@ -138,7 +168,11 @@ if [ "${os}" = "linux" ]; then echo echo "GCC toolchain:" check gcc + check "gcc-${gcc_version}" check g++ + check "g++-${gcc_version}" + check cpp + check "cpp-${gcc_version}" check gcov echo @@ -163,9 +197,9 @@ else checked=$((checked + 1)) tmp_clone="$(mktemp -d)" if git clone --depth 1 https://github.com/XRPLF/actions.git "${tmp_clone}/actions" >/dev/null 2>&1; then - printf ' [ ok ] git clone over HTTPS\n' + printf ' ✅ git clone over HTTPS\n' else - printf ' [MISS] git clone over HTTPS\n' + printf ' ❌ git clone over HTTPS\n' missing+=("git-https-clone") fi rm -rf "${tmp_clone}" @@ -173,9 +207,9 @@ fi echo if [ "${#missing[@]}" -eq 0 ]; then - echo "All ${checked} checked tools are present and runnable." + echo "✅ All ${checked} checked tools are present and runnable." else - echo "Missing or non-functional tools (${#missing[@]} of ${checked}):" >&2 + echo "❌ Missing or non-functional tools (${#missing[@]} of ${checked}):" >&2 for tool in "${missing[@]}"; do echo " - ${tool}" >&2 done diff --git a/nix/check-tools/README.md b/nix/check-tools/README.md index 5b7538f2ca..f23b2dcc21 100644 --- a/nix/check-tools/README.md +++ b/nix/check-tools/README.md @@ -1,7 +1,8 @@ # check-tools snapshots These files capture the output of [`bin/check-tools.sh`](../../bin/check-tools.sh) -— the versions of the development tooling — in each Nix environment: +— the version and resolved store path of each development tool — in each Nix +environment: | File | Environment | | ---------------------- | ------------------------------------ | @@ -17,9 +18,13 @@ So if you change the environment (bump the image tag in and commit the affected snapshots. Each snapshot is `check-tools.sh` stdout with the git-clone connectivity check -skipped (`CHECK_TOOLS_SKIP_CLONE=1`), so it contains only deterministic version -data. On macOS the dev-shell greeting that `nix develop` prints first is dropped -with `sed -n '/^Detected OS:/,$p'`. +skipped (`CHECK_TOOLS_SKIP_CLONE=1`), so it is deterministic for a given +environment. On macOS the dev-shell greeting that `nix develop` prints first is +dropped with `sed -n '/^Detected OS:/,$p'`. + +The store paths carry their derivation hash, so they change whenever a tool is +rebuilt — a `flake.lock` update generally rewrites most of them even when no +version moves. That is deliberate: it makes tooling changes visible in review. ## Regenerating diff --git a/nix/check-tools/macos.txt b/nix/check-tools/macos.txt index 93cc926181..8e99aa28e4 100644 --- a/nix/check-tools/macos.txt +++ b/nix/check-tools/macos.txt @@ -1,47 +1,143 @@ Detected OS: macos (Darwin arm64) Core build tools: - [ ok ] cmake cmake version 4.1.2 - [ ok ] conan Conan version 2.28.1 - [ ok ] git git version 2.54.0 - [ ok ] python3 Python 3.13.13 + ✅ cmake + cmake version 4.1.2 + /nix/store/gvabsb4yqb5xsqzqph54rijnn4zpihnp-cmake-4.1.2/bin/cmake + ✅ conan + Conan version 2.28.1 + /nix/store/9jiyxmkpwmn6dcqs0765s83riw3l5ail-conan-2.28.1/bin/conan + ✅ git + git version 2.54.0 + /nix/store/a14yxcqvv9x2l9mllgpirzhvz93pgprg-git-2.54.0/bin/git + ✅ python3 + Python 3.13.13 + /nix/store/ygxqin6ydzjfawywqpp5pal8wv6sf5bh-python3-3.13.13/bin/python3.13 Development tooling: - [ ok ] ccache ccache version 4.13.6 - [ ok ] clang clang version 22.1.7 - [ ok ] clang++ clang version 22.1.7 - [ ok ] ClangBuildAnalyzer ClangBuildAnalyzer 1.6.0 - [ ok ] curl curl 8.20.0 (aarch64-apple-darwin25.3.0) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 - [ ok ] file file-5.47 - [ ok ] less less 692 (PCRE2 regular expressions) - [ ok ] make GNU Make 4.4.1 - [ ok ] netstat present - [ ok ] ninja 1.13.2 - [ ok ] perl v5.42.0 - [ ok ] pkg-config 0.29.2 - [ ok ] vim VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) - [ ok ] zip Zip 3.0 - [ ok ] clang-format clang-format version 22.1.7 - [ ok ] dot dot - graphviz version 12.2.1 (0) - [ ok ] doxygen 1.16.1 - [ ok ] gcovr gcovr 8.4 - [ ok ] gh gh version 2.94.0 (nixpkgs) - [ ok ] git-cliff git-cliff 2.13.1 - [ ok ] git-lfs git-lfs/3.7.1 (3.7.1; darwin arm64; go 1.26.3) - [ ok ] gpg gpg (GnuPG) 2.4.9 - [ ok ] pre-commit pre-commit 4.5.1 - [ ok ] run-clang-tidy usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + ✅ ccache + ccache version 4.13.6 + /nix/store/57davyvs6p6dkrl3svzwg1ph18wsy4cz-ccache-4.13.6/bin/ccache + ✅ clang + clang version 22.1.7 + /nix/store/192glrb2cldvziyf3378mzjqbzx3ih4g-clang-wrapper-22.1.7/bin/clang + ✅ clang-22 + clang version 22.1.7 + /nix/store/rbap7zqq7mw00fyqa02p5rj7gqjp4w5i-clang-22/bin/clang-22 + ✅ clang++ + clang version 22.1.7 + /nix/store/192glrb2cldvziyf3378mzjqbzx3ih4g-clang-wrapper-22.1.7/bin/clang++ + ✅ clang++-22 + clang version 22.1.7 + /nix/store/v9haf787f7bcz0mq1sad4bpyx21pj6li-clang++-22/bin/clang++-22 + ✅ ClangBuildAnalyzer + ClangBuildAnalyzer 1.6.0 + /nix/store/4l50ds9fa2mkvh7wg8qzrlbmjs12sb8l-clangbuildanalyzer-1.6.0/bin/ClangBuildAnalyzer + ✅ curl + curl 8.20.0 (aarch64-apple-darwin25.3.0) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 + /nix/store/kclq0czaxvsgh4ym9ld7b6iwy50l1snk-curl-8.20.0-bin/bin/curl + ✅ file + file-5.47 + /nix/store/dax63li7wwcbqxxkkgzc4g2rx7d4w86x-file-5.47/bin/file + ✅ less + less 692 (PCRE2 regular expressions) + /nix/store/lvr16y75r1pdxpdv0aph5ak2yd0hkvqm-less-692/bin/less + ✅ make + GNU Make 4.4.1 + /nix/store/8wwiw8pwyhrkzyq28hqzxfl4z84lks81-gnumake-4.4.1/bin/make + ✅ netstat + present + /nix/store/qsd1kzqb0ahrk433vmyl245gp623j19s-network_cmds-730.80.3/bin/netstat + ✅ ninja + 1.13.2 + /nix/store/bqykhrblarkj4fl0hz2mf8ngwfv6x6bz-ninja-1.13.2/bin/ninja + ✅ perl + v5.42.0 + /nix/store/js13ri9fvm0ajk1fpd3acigys2a9whdv-perl-5.42.0/bin/perl + ✅ pkg-config + 0.29.2 + /nix/store/lzrwr375jqhhbca116kja96xf1md83l8-pkg-config-wrapper-0.29.2/bin/pkg-config + ✅ vim + VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) + /nix/store/6vbkykg92w603c0sw3mkk7p7mfaawbns-vim-9.2.0389/bin/vim + ✅ zip + Zip 3.0 + /nix/store/z6ph729vcakbvz3wh8ln1wk6mi06w487-zip-3.0/bin/zip + ✅ clang-apply-replacements + clang-apply-replacements version 22.1.7 + /nix/store/vzyyjf3cm1hbj9wcr2qcb66x6j98zpy7-clang-tools-22.1.7/bin/clang-apply-replacements + ✅ clang-apply-replacements-22 + clang-apply-replacements version 22.1.7 + /nix/store/m3ii69rca4077lf4wlk7m3jcag1fs577-clang-apply-replacements-22/bin/clang-apply-replacements-22 + ✅ clang-format + clang-format version 22.1.7 + /nix/store/vzyyjf3cm1hbj9wcr2qcb66x6j98zpy7-clang-tools-22.1.7/bin/clang-format + ✅ clang-format-22 + clang-format version 22.1.7 + /nix/store/4fawqy6ngqcsqd2ygyyzm93q0xy3f5gs-clang-format-22/bin/clang-format-22 + ✅ clang-tidy + LLVM version 22.1.7 + /nix/store/vzyyjf3cm1hbj9wcr2qcb66x6j98zpy7-clang-tools-22.1.7/bin/clang-tidy + ✅ clang-tidy-22 + LLVM version 22.1.7 + /nix/store/jqw4280saixaxxihwdba9ldm2fsm6dr3-clang-tidy-22/bin/clang-tidy-22 + ✅ dot + dot - graphviz version 12.2.1 (0) + /nix/store/ijb4fbnqa6wzlpqnhb6q9knqpf7qqn5z-graphviz-12.2.1/bin/dot + ✅ doxygen + 1.16.1 + /nix/store/kbryjdpq9jizjb0ws0nzbf2h2ymbdiwm-doxygen-1.16.1/bin/doxygen + ✅ gcovr + gcovr 8.4 + /nix/store/wn8jiyh9p0bybs96s4163qp3k8vfmczx-python3.13-gcovr-8.4/bin/gcovr + ✅ gh + gh version 2.94.0 (nixpkgs) + /nix/store/fhnpw0hs0gjms1ha6ap02jq7rx13gkbp-gh-2.94.0/bin/gh + ✅ git-cliff + git-cliff 2.13.1 + /nix/store/cy0wwhgxa7yvrz97zydbq6sqmixc90fq-git-cliff-2.13.1/bin/git-cliff + ✅ git-lfs + git-lfs/3.7.1 (3.7.1; darwin arm64; go 1.26.3) + /nix/store/k9r7zjfjplqa4d5s71cqvf2iv73jd9mc-git-lfs-3.7.1/bin/git-lfs + ✅ gpg + gpg (GnuPG) 2.4.9 + /nix/store/cgh6iwzz5jgx9z5whka4vgj210i6npc6-gnupg-2.4.9/bin/gpg + ✅ pre-commit + pre-commit 4.5.1 + /nix/store/z3cca68620w0w10f090szgzdnmh1waf2-pre-commit-4.5.1/bin/pre-commit + ✅ run-clang-tidy + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/4x28x911z2f9y7adqlh3qspp4a16dig7-run-clang-tidy/bin/run-clang-tidy + ✅ run-clang-tidy-22 + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/x8iymrh76sk5q91ryg5pa7i32s6gfh34-run-clang-tidy-22/bin/run-clang-tidy-22 Rust toolchain: - [ ok ] cargo cargo 1.95.0 (f2d3ce0bd 2026-03-21) - [ ok ] cargo-audit cargo-audit-audit 0.22.1 - [ ok ] cargo-llvm-cov cargo-llvm-cov 0.8.5 - [ ok ] cargo-nextest cargo-nextest 0.9.137 - [ ok ] clippy clippy 0.1.95 (59807616e1 2026-04-14) - [ ok ] rust-analyzer rust-analyzer 1.95.0 (59807616 2026-04-14) - [ ok ] rustc rustc 1.95.0 (59807616e 2026-04-14) - [ ok ] rustfmt rustfmt 1.9.0-stable (59807616e1 2026-04-14) + ✅ cargo + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/cargo + ✅ cargo-audit + cargo-audit-audit 0.22.1 + /nix/store/snwkga2f5gyf404h7mmp9wriwxb8v65f-cargo-audit-0.22.1/bin/cargo-audit + ✅ cargo-llvm-cov + cargo-llvm-cov 0.8.5 + /nix/store/fpiqdh91gwyxalqp409ynm0s0g086w7w-cargo-llvm-cov-0.8.5/bin/cargo-llvm-cov + ✅ cargo-nextest + cargo-nextest 0.9.137 + /nix/store/ylz7m947mhkgsp6i7611id3s3gcd58nq-cargo-nextest-0.9.137/bin/cargo-nextest + ✅ clippy-driver + clippy 0.1.95 (59807616e1 2026-04-14) + /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/clippy-driver + ✅ rust-analyzer + rust-analyzer 1.95.0 (59807616 2026-04-14) + /nix/store/jqvjap2727r9cjpr25fkw5glv2kbxrdx-rust-analyzer-preview-1.95.0-aarch64-apple-darwin/bin/rust-analyzer + ✅ rustc + rustc 1.95.0 (59807616e 2026-04-14) + /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/rustc + ✅ rustfmt + rustfmt 1.9.0-stable (59807616e1 2026-04-14) + /nix/store/03x750yj6fakl7shbhicpnkxiwqxjrrs-rustfmt-preview-1.95.0-aarch64-apple-darwin/bin/rustfmt Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). -All 36 checked tools are present and runnable. +✅ All 44 checked tools are present and runnable. diff --git a/nix/check-tools/nix-ubuntu-amd64.txt b/nix/check-tools/nix-ubuntu-amd64.txt index b922cca4a8..a5857c93f1 100644 --- a/nix/check-tools/nix-ubuntu-amd64.txt +++ b/nix/check-tools/nix-ubuntu-amd64.txt @@ -1,55 +1,171 @@ Detected OS: linux (Linux x86_64) Core build tools: - [ ok ] cmake cmake version 4.1.2 - [ ok ] conan Conan version 2.28.1 - [ ok ] git git version 2.54.0 - [ ok ] python3 Python 3.13.13 + ✅ cmake + cmake version 4.1.2 + /nix/store/r9941n32g4wyvggz2703dlplbdq8a6rd-cmake-4.1.2/bin/cmake + ✅ conan + Conan version 2.28.1 + /nix/store/lxny9y4jvjdws7hgz1mygvb7hjrpmna5-conan-2.28.1/bin/conan + ✅ git + git version 2.54.0 + /nix/store/bcnisk3ydfgv26v2gw3zlky24g00yww2-git-2.54.0/bin/git + ✅ python3 + Python 3.13.13 + /nix/store/60m4rxhg2fldqaak400c0lry96ijrzqn-python3-3.13.13/bin/python3.13 Development tooling: - [ ok ] ccache ccache version 4.13.6 - [ ok ] clang clang version 22.1.7 - [ ok ] clang++ clang version 22.1.7 - [ ok ] ClangBuildAnalyzer ClangBuildAnalyzer 1.6.0 - [ ok ] curl curl 8.20.0 (x86_64-pc-linux-gnu) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 - [ ok ] file file-5.47 - [ ok ] less less 692 (PCRE2 regular expressions) - [ ok ] make GNU Make 4.4.1 - [ ok ] netstat net-tools 2.10 - [ ok ] ninja 1.13.2 - [ ok ] perl v5.42.0 - [ ok ] pkg-config 0.29.2 - [ ok ] vim VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) - [ ok ] zip Zip 3.0 - [ ok ] clang-format clang-format version 22.1.7 - [ ok ] dot dot - graphviz version 12.2.1 (0) - [ ok ] doxygen 1.16.1 - [ ok ] gcovr gcovr 8.4 - [ ok ] gh gh version 2.94.0 (nixpkgs) - [ ok ] git-cliff git-cliff 2.13.1 - [ ok ] git-lfs git-lfs/3.7.1 (3.7.1; linux amd64; go 1.26.3) - [ ok ] gpg gpg (GnuPG) 2.4.9 - [ ok ] pre-commit pre-commit 4.5.1 - [ ok ] run-clang-tidy usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + ✅ ccache + ccache version 4.13.6 + /nix/store/c9wwl7s5i6rsfwvf4v0xbbmzx5m6jgfr-ccache-4.13.6/bin/ccache + ✅ clang + clang version 22.1.7 + /nix/store/ff0hrp9r9i3pa5arkdw0sgmzp8d576qi-clang-wrapper-22.1.7/bin/clang + ✅ clang-22 + clang version 22.1.7 + /nix/store/dagc2rq44gfbr7w7yvvqca3yqpc9gqbq-clang-22/bin/clang-22 + ✅ clang++ + clang version 22.1.7 + /nix/store/ff0hrp9r9i3pa5arkdw0sgmzp8d576qi-clang-wrapper-22.1.7/bin/clang++ + ✅ clang++-22 + clang version 22.1.7 + /nix/store/l5m8clin1npl605wdkd8mr18ggxww3z4-clang++-22/bin/clang++-22 + ✅ ClangBuildAnalyzer + ClangBuildAnalyzer 1.6.0 + /nix/store/bshlmn8fqw55nsnm581xqlfbahfkykxx-clangbuildanalyzer-1.6.0/bin/ClangBuildAnalyzer + ✅ curl + curl 8.20.0 (x86_64-pc-linux-gnu) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 + /nix/store/zbwymrp4lcfjc4kkk0n4779v0kjjz58z-curl-8.20.0-bin/bin/curl + ✅ file + file-5.47 + /nix/store/bizyfqdw0h67wzqmp10knmf9s2pqahdb-file-5.47/bin/file + ✅ less + less 692 (PCRE2 regular expressions) + /nix/store/c6bacbn93qg4a7g9n4czww8rg24dvysr-less-692/bin/less + ✅ make + GNU Make 4.4.1 + /nix/store/d3bwqm6bymhy3pdgbvf7vxjqfp31m3j1-gnumake-4.4.1/bin/make + ✅ netstat + net-tools 2.10 + /nix/store/jmyzqvgflnswmws7rnxx6g3zbj680xvd-net-tools-2.10/bin/netstat + ✅ ninja + 1.13.2 + /nix/store/7a235m7crqbb4h49sak20fqxpw3n7hr0-ninja-1.13.2/bin/ninja + ✅ perl + v5.42.0 + /nix/store/6plwsm6pkq79yjv4xvy8csk2pd4hzr67-perl-5.42.0/bin/perl + ✅ pkg-config + 0.29.2 + /nix/store/1m05k7xgfnw6jc21xxk5681ni3ar97wf-pkg-config-wrapper-0.29.2/bin/pkg-config + ✅ vim + VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) + /nix/store/hvyqx52g4g2fxhgpans3fksjj6lmlyaw-vim-9.2.0389/bin/vim + ✅ zip + Zip 3.0 + /nix/store/qnd2ag67hrjj0b6vbmisdshf50r6s72n-zip-3.0/bin/zip + ✅ clang-apply-replacements + clang-apply-replacements version 22.1.7 + /nix/store/4zp1rjpj2xijrv4kqpwsy3ixwb2r6nlk-clang-tools-22.1.7/bin/clang-apply-replacements + ✅ clang-apply-replacements-22 + clang-apply-replacements version 22.1.7 + /nix/store/py2wihg0a96qcppv4hjmww547xabr0fb-clang-apply-replacements-22/bin/clang-apply-replacements-22 + ✅ clang-format + clang-format version 22.1.7 + /nix/store/4zp1rjpj2xijrv4kqpwsy3ixwb2r6nlk-clang-tools-22.1.7/bin/clang-format + ✅ clang-format-22 + clang-format version 22.1.7 + /nix/store/kz820ccifjlwqnwqjsx7kbiajrgsmbrh-clang-format-22/bin/clang-format-22 + ✅ clang-tidy + LLVM version 22.1.7 + /nix/store/4zp1rjpj2xijrv4kqpwsy3ixwb2r6nlk-clang-tools-22.1.7/bin/clang-tidy + ✅ clang-tidy-22 + LLVM version 22.1.7 + /nix/store/gdrkvpw846lkyzh8y9p3zx50g6ml2v84-clang-tidy-22/bin/clang-tidy-22 + ✅ dot + dot - graphviz version 12.2.1 (0) + /nix/store/12rgns2296s4qcja778gvcbx61z77rc4-graphviz-12.2.1/bin/dot + ✅ doxygen + 1.16.1 + /nix/store/k0vzr5lvgq1byraknzwvk51wcgpnsrkh-doxygen-1.16.1/bin/doxygen + ✅ gcovr + gcovr 8.4 + /nix/store/iyzi7fpyclqrha054adnizvif02lg49x-python3.13-gcovr-8.4/bin/gcovr + ✅ gh + gh version 2.94.0 (nixpkgs) + /nix/store/pidh15szlsb1vc41xdsa3xbdghdazvby-gh-2.94.0/bin/gh + ✅ git-cliff + git-cliff 2.13.1 + /nix/store/1q851fs62shgjhc03fxxdkpzxdjg7k11-git-cliff-2.13.1/bin/git-cliff + ✅ git-lfs + git-lfs/3.7.1 (3.7.1; linux amd64; go 1.26.3) + /nix/store/6ljwpal7b1756708m33vj0crpral7mvl-git-lfs-3.7.1/bin/git-lfs + ✅ gpg + gpg (GnuPG) 2.4.9 + /nix/store/wx7vk8babxkgy813r70yc67vcwnmagbx-gnupg-2.4.9/bin/gpg + ✅ pre-commit + pre-commit 4.5.1 + /nix/store/bj6i9vl34cij5h0r165y40hrjqak0bmz-pre-commit-4.5.1/bin/pre-commit + ✅ run-clang-tidy + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/sbg911hs9dbclrzlp04br3iyfpgnaj6r-run-clang-tidy/bin/run-clang-tidy + ✅ run-clang-tidy-22 + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/n8yak1ap308gvi7gmrniw0ybsx80fjws-run-clang-tidy-22/bin/run-clang-tidy-22 Rust toolchain: - [ ok ] cargo cargo 1.95.0 (f2d3ce0bd 2026-03-21) - [ ok ] cargo-audit cargo-audit-audit 0.22.1 - [ ok ] cargo-llvm-cov cargo-llvm-cov 0.8.5 - [ ok ] cargo-nextest cargo-nextest 0.9.137 - [ ok ] clippy clippy 0.1.95 (59807616e1 2026-04-14) - [ ok ] rust-analyzer rust-analyzer 1.95.0 (5980761 2026-04-14) - [ ok ] rustc rustc 1.95.0 (59807616e 2026-04-14) - [ ok ] rustfmt rustfmt 1.9.0-stable (59807616e1 2026-04-14) + ✅ cargo + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + /nix/store/85qbwr3vzfs58m7ywnjblz105p8ahbrv-cargo-1.95.0-x86_64-unknown-linux-gnu/bin/cargo + ✅ cargo-audit + cargo-audit-audit 0.22.1 + /nix/store/2w9if868piw98xz057sz97jnjvf7hnvf-cargo-audit-0.22.1/bin/cargo-audit + ✅ cargo-llvm-cov + cargo-llvm-cov 0.8.5 + /nix/store/jjpdf1l6izz6607a346ykra9sndzaw7h-cargo-llvm-cov-0.8.5/bin/cargo-llvm-cov + ✅ cargo-nextest + cargo-nextest 0.9.137 + /nix/store/jhkr7gwyrchkml33gyns9cy0yn7b57qc-cargo-nextest-0.9.137/bin/cargo-nextest + ✅ clippy-driver + clippy 0.1.95 (59807616e1 2026-04-14) + /nix/store/bnvg9nmdq4g98dd9v3r6nvjg5h2rr8i7-rust-minimal-1.95.0/bin/clippy-driver + ✅ rust-analyzer + rust-analyzer 1.95.0 (5980761 2026-04-14) + /nix/store/i3cnpngfwa3k4jn431pl6ji1r4qmxky9-rust-analyzer-preview-1.95.0-x86_64-unknown-linux-gnu/bin/rust-analyzer + ✅ rustc + rustc 1.95.0 (59807616e 2026-04-14) + /nix/store/bnvg9nmdq4g98dd9v3r6nvjg5h2rr8i7-rust-minimal-1.95.0/bin/rustc + ✅ rustfmt + rustfmt 1.9.0-stable (59807616e1 2026-04-14) + /nix/store/366hhk2dgwxmnf4hgrj4b8llhjr3hf0i-rustfmt-preview-1.95.0-x86_64-unknown-linux-gnu/bin/rustfmt GCC toolchain: - [ ok ] gcc gcc (GCC) 15.2.0 - [ ok ] g++ g++ (GCC) 15.2.0 - [ ok ] gcov gcov (GCC) 15.2.0 + ✅ gcc + gcc (GCC) 15.2.0 + /nix/store/3dd6y3pq00i3r85l45jvz63wjya403nl-gcc-wrapper-15.2.0/bin/gcc + ✅ gcc-15 + gcc (GCC) 15.2.0 + /nix/store/d6iri2s6bzqq5ac3fg25j6hgnn1lz44f-gcc-15/bin/gcc-15 + ✅ g++ + g++ (GCC) 15.2.0 + /nix/store/3dd6y3pq00i3r85l45jvz63wjya403nl-gcc-wrapper-15.2.0/bin/g++ + ✅ g++-15 + g++ (GCC) 15.2.0 + /nix/store/gm3msmmxq055lm9gprkfjj9d2gdz1mpg-g++-15/bin/g++-15 + ✅ cpp + cpp (GCC) 15.2.0 + /nix/store/3dd6y3pq00i3r85l45jvz63wjya403nl-gcc-wrapper-15.2.0/bin/cpp + ✅ cpp-15 + cpp (GCC) 15.2.0 + /nix/store/bn3gmn0m7g4gn2i0yml46fljc7mghiq5-cpp-15/bin/cpp-15 + ✅ gcov + gcov (GCC) 15.2.0 + /nix/store/xvv5sm5i8x0ks6ypfkzl7c4j9srnxz7k-gcc-15.2.0/bin/gcov Mold: - [ ok ] mold mold 2.41.0 (compatible with GNU ld) + ✅ mold + mold 2.41.0 (compatible with GNU ld) + /nix/store/2w6fpgxjzzyqmd25wzplm23dfa49a0p2-mold-unwrapped-wrapper-2.41.0/bin/mold Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). -All 40 checked tools are present and runnable. +✅ All 52 checked tools are present and runnable. diff --git a/nix/check-tools/nix-ubuntu-arm64.txt b/nix/check-tools/nix-ubuntu-arm64.txt index 5267839682..820c6de086 100644 --- a/nix/check-tools/nix-ubuntu-arm64.txt +++ b/nix/check-tools/nix-ubuntu-arm64.txt @@ -1,55 +1,171 @@ Detected OS: linux (Linux aarch64) Core build tools: - [ ok ] cmake cmake version 4.1.2 - [ ok ] conan Conan version 2.28.1 - [ ok ] git git version 2.54.0 - [ ok ] python3 Python 3.13.13 + ✅ cmake + cmake version 4.1.2 + /nix/store/nkcpxjifkambzlrwh27a8igvhnbchibg-cmake-4.1.2/bin/cmake + ✅ conan + Conan version 2.28.1 + /nix/store/8i2gyqgc00xvxg9xm6y7n0ilncdv8imw-conan-2.28.1/bin/conan + ✅ git + git version 2.54.0 + /nix/store/ixp98f9avf8ikpdrmp40cj33g0dazyp9-git-2.54.0/bin/git + ✅ python3 + Python 3.13.13 + /nix/store/lqn6mbgzzdrqq2qkwddcmxj9z6amdd86-python3-3.13.13/bin/python3.13 Development tooling: - [ ok ] ccache ccache version 4.13.6 - [ ok ] clang clang version 22.1.7 - [ ok ] clang++ clang version 22.1.7 - [ ok ] ClangBuildAnalyzer ClangBuildAnalyzer 1.6.0 - [ ok ] curl curl 8.20.0 (aarch64-unknown-linux-gnu) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 - [ ok ] file file-5.47 - [ ok ] less less 692 (PCRE2 regular expressions) - [ ok ] make GNU Make 4.4.1 - [ ok ] netstat net-tools 2.10 - [ ok ] ninja 1.13.2 - [ ok ] perl v5.42.0 - [ ok ] pkg-config 0.29.2 - [ ok ] vim VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) - [ ok ] zip Zip 3.0 - [ ok ] clang-format clang-format version 22.1.7 - [ ok ] dot dot - graphviz version 12.2.1 (0) - [ ok ] doxygen 1.16.1 - [ ok ] gcovr gcovr 8.4 - [ ok ] gh gh version 2.94.0 (nixpkgs) - [ ok ] git-cliff git-cliff 2.13.1 - [ ok ] git-lfs git-lfs/3.7.1 (3.7.1; linux arm64; go 1.26.3) - [ ok ] gpg gpg (GnuPG) 2.4.9 - [ ok ] pre-commit pre-commit 4.5.1 - [ ok ] run-clang-tidy usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + ✅ ccache + ccache version 4.13.6 + /nix/store/2q39xi2kbi04ibga7635f2sl148d1mzv-ccache-4.13.6/bin/ccache + ✅ clang + clang version 22.1.7 + /nix/store/xjqffrq9i7la058s9865ig71l9sp1ys5-clang-wrapper-22.1.7/bin/clang + ✅ clang-22 + clang version 22.1.7 + /nix/store/vcf6ilfwn57828hwzyp6zlyr24j9j6yw-clang-22/bin/clang-22 + ✅ clang++ + clang version 22.1.7 + /nix/store/xjqffrq9i7la058s9865ig71l9sp1ys5-clang-wrapper-22.1.7/bin/clang++ + ✅ clang++-22 + clang version 22.1.7 + /nix/store/xby0f6gamr7m27zp5cndsvghbp9lgb3c-clang++-22/bin/clang++-22 + ✅ ClangBuildAnalyzer + ClangBuildAnalyzer 1.6.0 + /nix/store/h893hd4q1bb6ily2lby5dzyfrrzd2nvj-clangbuildanalyzer-1.6.0/bin/ClangBuildAnalyzer + ✅ curl + curl 8.20.0 (aarch64-unknown-linux-gnu) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 + /nix/store/i1s0lqwlrmjd2dxzgy2p84cxqqsb0bmk-curl-8.20.0-bin/bin/curl + ✅ file + file-5.47 + /nix/store/dx973zg9km2w9albsib2vw9wyvacfrlw-file-5.47/bin/file + ✅ less + less 692 (PCRE2 regular expressions) + /nix/store/1blb3s7hhsr77wqi598m6k1qkfp3ms0w-less-692/bin/less + ✅ make + GNU Make 4.4.1 + /nix/store/9ngw1ippk25jjj5fjxv36xbp6iq7rxdx-gnumake-4.4.1/bin/make + ✅ netstat + net-tools 2.10 + /nix/store/7vdsz21f0s499s5yyqzp5s4676q4yxdd-net-tools-2.10/bin/netstat + ✅ ninja + 1.13.2 + /nix/store/8ksx98gsbn5lmlizcmw57yd4sg0k2p58-ninja-1.13.2/bin/ninja + ✅ perl + v5.42.0 + /nix/store/5wnly69vv1i3y97al4v3xrqymf9hlzgq-perl-5.42.0/bin/perl + ✅ pkg-config + 0.29.2 + /nix/store/c7vwy0gl1q0agl2h22gi0m9dg7xxad2l-pkg-config-wrapper-0.29.2/bin/pkg-config + ✅ vim + VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) + /nix/store/v8c7pvx26irvy9k5sbwd183cyvckzzb3-vim-9.2.0389/bin/vim + ✅ zip + Zip 3.0 + /nix/store/5mh19mvbv9ym2sm9vymyyaac5l2cj2jq-zip-3.0/bin/zip + ✅ clang-apply-replacements + clang-apply-replacements version 22.1.7 + /nix/store/s53p2m776iqaz7acgr5csgpsd18w15h7-clang-tools-22.1.7/bin/clang-apply-replacements + ✅ clang-apply-replacements-22 + clang-apply-replacements version 22.1.7 + /nix/store/bg4kn8z81hk7b9284rjqvr51wpfjqc24-clang-apply-replacements-22/bin/clang-apply-replacements-22 + ✅ clang-format + clang-format version 22.1.7 + /nix/store/s53p2m776iqaz7acgr5csgpsd18w15h7-clang-tools-22.1.7/bin/clang-format + ✅ clang-format-22 + clang-format version 22.1.7 + /nix/store/79v57mzcw8ng8kl7p961ck08ymhp31v7-clang-format-22/bin/clang-format-22 + ✅ clang-tidy + LLVM version 22.1.7 + /nix/store/s53p2m776iqaz7acgr5csgpsd18w15h7-clang-tools-22.1.7/bin/clang-tidy + ✅ clang-tidy-22 + LLVM version 22.1.7 + /nix/store/wdyd6cb9z1lyi37lbzvwldgcc7yv1n5c-clang-tidy-22/bin/clang-tidy-22 + ✅ dot + dot - graphviz version 12.2.1 (0) + /nix/store/58rrk4yzwpmyxvl8cqm18h3dhv24zf00-graphviz-12.2.1/bin/dot + ✅ doxygen + 1.16.1 + /nix/store/hq32kzwpl89wgr49iq0gmqn9r5n072zq-doxygen-1.16.1/bin/doxygen + ✅ gcovr + gcovr 8.4 + /nix/store/sml3xbbfhhlhk6h7jnlg19pdbx9b764b-python3.13-gcovr-8.4/bin/gcovr + ✅ gh + gh version 2.94.0 (nixpkgs) + /nix/store/7hh2qi0gj2ifbxbl56cjzbiyfc379bji-gh-2.94.0/bin/gh + ✅ git-cliff + git-cliff 2.13.1 + /nix/store/bidn3pz53yd6qlg711917xx0q10hqmqv-git-cliff-2.13.1/bin/git-cliff + ✅ git-lfs + git-lfs/3.7.1 (3.7.1; linux arm64; go 1.26.3) + /nix/store/4rsklvkbac5bayy0zv12kxyvspi4sshd-git-lfs-3.7.1/bin/git-lfs + ✅ gpg + gpg (GnuPG) 2.4.9 + /nix/store/ka4i8zz5ni3rzqnzcxbfvwr95fk8pn6q-gnupg-2.4.9/bin/gpg + ✅ pre-commit + pre-commit 4.5.1 + /nix/store/n981w6hjfar2l81kxbxs2wxl64vwa5kj-pre-commit-4.5.1/bin/pre-commit + ✅ run-clang-tidy + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/4z2fyklg78klallr7x9j02kz92hnxp4m-run-clang-tidy/bin/run-clang-tidy + ✅ run-clang-tidy-22 + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/f8m0p9ad40brp9ahy4i0h27kqjkya1j9-run-clang-tidy-22/bin/run-clang-tidy-22 Rust toolchain: - [ ok ] cargo cargo 1.95.0 (f2d3ce0bd 2026-03-21) - [ ok ] cargo-audit cargo-audit-audit 0.22.1 - [ ok ] cargo-llvm-cov cargo-llvm-cov 0.8.5 - [ ok ] cargo-nextest cargo-nextest 0.9.137 - [ ok ] clippy clippy 0.1.95 (59807616e1 2026-04-14) - [ ok ] rust-analyzer rust-analyzer 1.95.0 (5980761 2026-04-14) - [ ok ] rustc rustc 1.95.0 (59807616e 2026-04-14) - [ ok ] rustfmt rustfmt 1.9.0-stable (59807616e1 2026-04-14) + ✅ cargo + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + /nix/store/yw1rs50s6qpsw0zyl7j3dpm18swbl0ag-cargo-1.95.0-aarch64-unknown-linux-gnu/bin/cargo + ✅ cargo-audit + cargo-audit-audit 0.22.1 + /nix/store/9rxbrn9aa2r1z96186s69pc7vzizyfch-cargo-audit-0.22.1/bin/cargo-audit + ✅ cargo-llvm-cov + cargo-llvm-cov 0.8.5 + /nix/store/vwjsi159n89szrx4yh5pc3jlf2gp4fld-cargo-llvm-cov-0.8.5/bin/cargo-llvm-cov + ✅ cargo-nextest + cargo-nextest 0.9.137 + /nix/store/qb6bcg2fjvm3r9s9j98nmffmf9xwh45s-cargo-nextest-0.9.137/bin/cargo-nextest + ✅ clippy-driver + clippy 0.1.95 (59807616e1 2026-04-14) + /nix/store/nz4qv12pf16c092qr9hh4dsn0fzf47da-rust-minimal-1.95.0/bin/clippy-driver + ✅ rust-analyzer + rust-analyzer 1.95.0 (5980761 2026-04-14) + /nix/store/m1rn67sqfz8s44idcxqallg680ifk71r-rust-analyzer-preview-1.95.0-aarch64-unknown-linux-gnu/bin/rust-analyzer + ✅ rustc + rustc 1.95.0 (59807616e 2026-04-14) + /nix/store/nz4qv12pf16c092qr9hh4dsn0fzf47da-rust-minimal-1.95.0/bin/rustc + ✅ rustfmt + rustfmt 1.9.0-stable (59807616e1 2026-04-14) + /nix/store/jidfsprj2820glyzjn54ldn3j1fmz8c5-rustfmt-preview-1.95.0-aarch64-unknown-linux-gnu/bin/rustfmt GCC toolchain: - [ ok ] gcc gcc (GCC) 15.2.0 - [ ok ] g++ g++ (GCC) 15.2.0 - [ ok ] gcov gcov (GCC) 15.2.0 + ✅ gcc + gcc (GCC) 15.2.0 + /nix/store/rn6svg593xsmn8qcjzk8x9pa1i62c4kb-gcc-wrapper-15.2.0/bin/gcc + ✅ gcc-15 + gcc (GCC) 15.2.0 + /nix/store/h489d1rmjisfbxh5kmsb0a7c35j8qsdf-gcc-15/bin/gcc-15 + ✅ g++ + g++ (GCC) 15.2.0 + /nix/store/rn6svg593xsmn8qcjzk8x9pa1i62c4kb-gcc-wrapper-15.2.0/bin/g++ + ✅ g++-15 + g++ (GCC) 15.2.0 + /nix/store/9ywmhz8bmzknrn3pn84g46z8hj3vrmw5-g++-15/bin/g++-15 + ✅ cpp + cpp (GCC) 15.2.0 + /nix/store/rn6svg593xsmn8qcjzk8x9pa1i62c4kb-gcc-wrapper-15.2.0/bin/cpp + ✅ cpp-15 + cpp (GCC) 15.2.0 + /nix/store/vmjilh1b830qz9yh0a1jj5ads0jxizdk-cpp-15/bin/cpp-15 + ✅ gcov + gcov (GCC) 15.2.0 + /nix/store/rmwf5hpi1y2m1wpnfvlxmrhksm4djk2j-gcc-15.2.0/bin/gcov Mold: - [ ok ] mold mold 2.41.0 (compatible with GNU ld) + ✅ mold + mold 2.41.0 (compatible with GNU ld) + /nix/store/f5qh5a0bx1dslmnf5n5gx0s6aljbswq3-mold-unwrapped-wrapper-2.41.0/bin/mold Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). -All 40 checked tools are present and runnable. +✅ All 52 checked tools are present and runnable. From 2adffaef724f0180ffc44fb0a91c6bb854a2ebaf Mon Sep 17 00:00:00 2001 From: Bart Date: Fri, 14 Aug 2026 15:36:47 +0000 Subject: [PATCH 11/11] refactor: Remove support for protocol version 2.1 (#7432) Co-authored-by: Bart <11445373+bthomee@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 --- include/xrpl/proto/xrpl.proto | 15 +- src/test/app/ValidatorList_test.cpp | 238 +++++-------------- src/test/overlay/ProtocolVersion_test.cpp | 38 +-- src/test/overlay/compression_test.cpp | 30 --- src/xrpld/app/misc/ValidatorList.h | 13 - src/xrpld/app/misc/detail/ValidatorList.cpp | 167 +++---------- src/xrpld/overlay/Peer.h | 2 - src/xrpld/overlay/detail/Message.cpp | 1 - src/xrpld/overlay/detail/PeerImp.cpp | 38 +-- src/xrpld/overlay/detail/PeerImp.h | 2 - src/xrpld/overlay/detail/ProtocolMessage.h | 5 - src/xrpld/overlay/detail/ProtocolVersion.cpp | 1 - src/xrpld/overlay/detail/TrafficCount.cpp | 1 - 13 files changed, 114 insertions(+), 437 deletions(-) diff --git a/include/xrpl/proto/xrpl.proto b/include/xrpl/proto/xrpl.proto index b9cb94e668..644e099179 100644 --- a/include/xrpl/proto/xrpl.proto +++ b/include/xrpl/proto/xrpl.proto @@ -1,10 +1,10 @@ syntax = "proto2"; package protocol; -// Unused numbers in the list below may have been used previously. Please don't -// reassign them for reuse unless you are 100% certain that there won't be a -// conflict. Even if you're sure, it's probably best to assign a new type. enum MessageType { + // Previously used - don't reuse. + reserved 0 to 1, 4, 6 to 14, 16 to 29, 36 to 40, 43 to 54, 61 to 62; + mtMANIFESTS = 2; mtPING = 3; mtCLUSTER = 5; @@ -17,7 +17,6 @@ enum MessageType { mtHAVE_SET = 35; mtVALIDATION = 41; mtGET_OBJECTS = 42; - mtVALIDATOR_LIST = 54; mtSQUELCH = 55; mtVALIDATOR_LIST_COLLECTION = 56; mtPROOF_PATH_REQ = 57; @@ -162,14 +161,6 @@ message TMHaveTransactionSet { required bytes hash = 2; } -// Validator list (UNL) -message TMValidatorList { - required bytes manifest = 1; - required bytes blob = 2; - required bytes signature = 3; - required uint32 version = 4; -} - // Validator List v2 message ValidatorBlobInfo { optional bytes manifest = 1; diff --git a/src/test/app/ValidatorList_test.cpp b/src/test/app/ValidatorList_test.cpp index 323c77c780..d2e6cb24aa 100644 --- a/src/test/app/ValidatorList_test.cpp +++ b/src/test/app/ValidatorList_test.cpp @@ -2253,8 +2253,7 @@ private: { testcase("Sha512 hashing"); // Tests that ValidatorList hash_append helpers with a single blob - // returns the same result as xrpl::Sha512Half used by the - // TMValidatorList protocol message handler + // return the same result as xrpl::Sha512Half std::string const manifest = "This is not really a manifest"; std::string const blob = "This is not really a blob"; std::string const signature = "This is not really a signature"; @@ -2275,17 +2274,6 @@ private: BEAST_EXPECT(global != sha512Half(blob, blobMap, version)); } - { - protocol::TMValidatorList msg1; - msg1.set_manifest(manifest); - msg1.set_blob(blob); - msg1.set_signature(signature); - msg1.set_version(version); - BEAST_EXPECT(global == sha512Half(msg1)); - msg1.set_signature(blob); - BEAST_EXPECT(global != sha512Half(msg1)); - } - { protocol::TMValidatorListCollection msg2; msg2.set_manifest(manifest); @@ -2323,19 +2311,7 @@ private: BEAST_EXPECT(!ec); return std::make_pair(header, buffers); }; - auto extractProtocolMessage1 = [this, &extractHeader](Message& message) { - auto [header, buffers] = extractHeader(message); - if (BEAST_EXPECT(header) && - BEAST_EXPECT(header->messageType == protocol::mtVALIDATOR_LIST)) - { - auto const msg = - detail::parseMessageContent(*header, buffers.data()); - BEAST_EXPECT(msg); - return msg; - } - return std::shared_ptr(); - }; - auto extractProtocolMessage2 = [this, &extractHeader](Message& message) { + auto extractProtocolMessage = [this, &extractHeader](Message& message) { auto [header, buffers] = extractHeader(message); if (BEAST_EXPECT(header) && BEAST_EXPECT(header->messageType == protocol::mtVALIDATOR_LIST_COLLECTION)) @@ -2347,92 +2323,55 @@ private: } return std::shared_ptr(); }; - auto verifyMessage = - [this, manifestCutoff, &extractProtocolMessage1, &extractProtocolMessage2]( - auto const version, - auto const& manifest, - auto const& blobInfos, - auto const& messages, - std::vector>> expectedInfo) { - BEAST_EXPECT(messages.size() == expectedInfo.size()); - auto msgIter = expectedInfo.begin(); - for (auto const& messageWithHash : messages) + auto verifyMessage = [this, manifestCutoff, &extractProtocolMessage]( + auto const version, + auto const& manifest, + auto const& blobInfos, + auto const& messages, + std::vector> expectedInfo) { + BEAST_EXPECT(messages.size() == expectedInfo.size()); + auto msgIter = expectedInfo.begin(); + for (auto const& messageWithHash : messages) + { + if (!BEAST_EXPECT(msgIter != expectedInfo.end())) + break; + if (!BEAST_EXPECT(messageWithHash.message)) + continue; + auto const& expectedSeqs = *msgIter; + auto seqIter = expectedSeqs.begin(); { - if (!BEAST_EXPECT(msgIter != expectedInfo.end())) - break; - if (!BEAST_EXPECT(messageWithHash.message)) - continue; - auto const& expectedSeqs = msgIter->second; - auto seqIter = expectedSeqs.begin(); - auto const size = - messageWithHash.message->getBuffer(compression::Compressed::Off).size(); - // This size is arbitrary, but shouldn't change - BEAST_EXPECT(size == msgIter->first); - if (expectedSeqs.size() == 1) + std::vector hashingBlobs; + hashingBlobs.reserve(expectedSeqs.size()); + + auto const msg = extractProtocolMessage(*messageWithHash.message); + if (BEAST_EXPECT(msg)) { - auto const msg = extractProtocolMessage1(*messageWithHash.message); - auto const expectedVersion = 1; - if (BEAST_EXPECT(msg)) + BEAST_EXPECT(msg->version() == version); + BEAST_EXPECT(msg->manifest() == manifest); + for (auto const& blobInfo : msg->blobs()) { - BEAST_EXPECT(msg->version() == expectedVersion); if (!BEAST_EXPECT(seqIter != expectedSeqs.end())) - continue; + break; auto const& expectedBlob = blobInfos.at(*seqIter); - BEAST_EXPECT((*seqIter < manifestCutoff) == !!expectedBlob.manifest); - auto const expectedManifest = - *seqIter < manifestCutoff && expectedBlob.manifest - ? *expectedBlob.manifest - : manifest; - BEAST_EXPECT(msg->manifest() == expectedManifest); - BEAST_EXPECT(msg->blob() == expectedBlob.blob); - BEAST_EXPECT(msg->signature() == expectedBlob.signature); + hashingBlobs.push_back(expectedBlob); + BEAST_EXPECT(blobInfo.has_manifest() == !!expectedBlob.manifest); + BEAST_EXPECT(blobInfo.has_manifest() == (*seqIter < manifestCutoff)); + + if (*seqIter < manifestCutoff) + BEAST_EXPECT(blobInfo.manifest() == *expectedBlob.manifest); + BEAST_EXPECT(blobInfo.blob() == expectedBlob.blob); + BEAST_EXPECT(blobInfo.signature() == expectedBlob.signature); ++seqIter; - BEAST_EXPECT(seqIter == expectedSeqs.end()); - - BEAST_EXPECT( - messageWithHash.hash == - sha512Half( - expectedManifest, - expectedBlob.blob, - expectedBlob.signature, - expectedVersion)); } + BEAST_EXPECT(seqIter == expectedSeqs.end()); } - else - { - std::vector hashingBlobs; - hashingBlobs.reserve(msgIter->second.size()); - - auto const msg = extractProtocolMessage2(*messageWithHash.message); - if (BEAST_EXPECT(msg)) - { - BEAST_EXPECT(msg->version() == version); - BEAST_EXPECT(msg->manifest() == manifest); - for (auto const& blobInfo : msg->blobs()) - { - if (!BEAST_EXPECT(seqIter != expectedSeqs.end())) - break; - auto const& expectedBlob = blobInfos.at(*seqIter); - hashingBlobs.push_back(expectedBlob); - BEAST_EXPECT(blobInfo.has_manifest() == !!expectedBlob.manifest); - BEAST_EXPECT( - blobInfo.has_manifest() == (*seqIter < manifestCutoff)); - - if (*seqIter < manifestCutoff) - BEAST_EXPECT(blobInfo.manifest() == *expectedBlob.manifest); - BEAST_EXPECT(blobInfo.blob() == expectedBlob.blob); - BEAST_EXPECT(blobInfo.signature() == expectedBlob.signature); - ++seqIter; - } - BEAST_EXPECT(seqIter == expectedSeqs.end()); - } - BEAST_EXPECT( - messageWithHash.hash == sha512Half(manifest, hashingBlobs, version)); - } - ++msgIter; + BEAST_EXPECT( + messageWithHash.hash == sha512Half(manifest, hashingBlobs, version)); } - BEAST_EXPECT(msgIter == expectedInfo.end()); - }; + ++msgIter; + } + BEAST_EXPECT(msgIter == expectedInfo.end()); + }; auto verifyBuildMessages = [this]( std::pair const& result, std::size_t expectedSequence, @@ -2471,66 +2410,10 @@ private: std::vector messages; - // Version 1 - - // This peer has a VL ahead of our "current" - verifyBuildMessages( - ValidatorList::buildValidatorListMessages( - 1, 8, maxSequence, version, manifest, blobInfos, messages), - 0, - 0); - BEAST_EXPECT(messages.empty()); - - // Don't repeat the work if messages is populated, even though the - // peerSequence provided indicates it should. Note that this - // situation is contrived for this test and should never happen in - // real code. - messages.emplace_back(); - verifyBuildMessages( - ValidatorList::buildValidatorListMessages( - 1, 3, maxSequence, version, manifest, blobInfos, messages), - 5, - 0); - BEAST_EXPECT(messages.size() == 1 && !messages.front().message); - - // Generate a version 1 message - messages.clear(); - verifyBuildMessages( - ValidatorList::buildValidatorListMessages( - 1, 3, maxSequence, version, manifest, blobInfos, messages), - 5, - 1); - if (BEAST_EXPECT(messages.size() == 1) && BEAST_EXPECT(messages.front().message)) - { - auto const& messageWithHash = messages.front(); - auto const msg = extractProtocolMessage1(*messageWithHash.message); - auto const size = - messageWithHash.message->getBuffer(compression::Compressed::Off).size(); - // This size is arbitrary, but shouldn't change - BEAST_EXPECT(size == 108); - auto const& expected = blobInfos.at(5); - if (BEAST_EXPECT(msg)) - { - BEAST_EXPECT(msg->version() == 1); - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - BEAST_EXPECT(msg->manifest() == *expected.manifest); - BEAST_EXPECT(msg->blob() == expected.blob); - BEAST_EXPECT(msg->signature() == expected.signature); - } - BEAST_EXPECT( - messageWithHash.hash == - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - sha512Half(*expected.manifest, expected.blob, expected.signature, 1)); - } - - // Version 2 - - messages.clear(); - // This peer has a VL ahead of us. verifyBuildMessages( ValidatorList::buildValidatorListMessages( - 2, maxSequence * 2, maxSequence, version, manifest, blobInfos, messages), + maxSequence * 2, maxSequence, version, manifest, blobInfos, messages), 0, 0); BEAST_EXPECT(messages.empty()); @@ -2542,19 +2425,19 @@ private: messages.emplace_back(); verifyBuildMessages( ValidatorList::buildValidatorListMessages( - 2, 3, maxSequence, version, manifest, blobInfos, messages), + 3, maxSequence, version, manifest, blobInfos, messages), maxSequence, 0); BEAST_EXPECT(messages.size() == 1 && !messages.front().message); - // Generate a version 2 message. Don't send the current + // Generate a message. Don't send the current messages.clear(); verifyBuildMessages( ValidatorList::buildValidatorListMessages( - 2, 5, maxSequence, version, manifest, blobInfos, messages), + 5, maxSequence, version, manifest, blobInfos, messages), maxSequence, 4); - verifyMessage(version, manifest, blobInfos, messages, {{372, {6, 7, 10, 12}}}); + verifyMessage(version, manifest, blobInfos, messages, {{6, 7, 10, 12}}); // Test message splitting on size limits. @@ -2562,50 +2445,39 @@ private: messages.clear(); verifyBuildMessages( ValidatorList::buildValidatorListMessages( - 2, 5, maxSequence, version, manifest, blobInfos, messages, 300), + 5, maxSequence, version, manifest, blobInfos, messages, 300), maxSequence, 4); - verifyMessage(version, manifest, blobInfos, messages, {{212, {6, 7}}, {192, {10, 12}}}); + verifyMessage(version, manifest, blobInfos, messages, {{6, 7}, {10, 12}}); // Set a limit between the size of the two earlier messages so one // will split and the other won't messages.clear(); verifyBuildMessages( ValidatorList::buildValidatorListMessages( - 2, 5, maxSequence, version, manifest, blobInfos, messages, 200), + 5, maxSequence, version, manifest, blobInfos, messages, 200), maxSequence, 4); - verifyMessage( - version, manifest, blobInfos, messages, {{108, {6}}, {108, {7}}, {192, {10, 12}}}); + verifyMessage(version, manifest, blobInfos, messages, {{6}, {7}, {10, 12}}); // Set a limit so that all the VLs are sent individually messages.clear(); verifyBuildMessages( ValidatorList::buildValidatorListMessages( - 2, 5, maxSequence, version, manifest, blobInfos, messages, 150), + 5, maxSequence, version, manifest, blobInfos, messages, 150), maxSequence, 4); - verifyMessage( - version, - manifest, - blobInfos, - messages, - {{108, {6}}, {108, {7}}, {110, {10}}, {110, {12}}}); + verifyMessage(version, manifest, blobInfos, messages, {{6}, {7}, {10}, {12}}); // Set a limit smaller than some of the messages. Because single // messages send regardless, they will all still be sent messages.clear(); verifyBuildMessages( ValidatorList::buildValidatorListMessages( - 2, 5, maxSequence, version, manifest, blobInfos, messages, 108), + 5, maxSequence, version, manifest, blobInfos, messages, 108), maxSequence, 4); - verifyMessage( - version, - manifest, - blobInfos, - messages, - {{108, {6}}, {108, {7}}, {110, {10}}, {110, {12}}}); + verifyMessage(version, manifest, blobInfos, messages, {{6}, {7}, {10}, {12}}); } void diff --git a/src/test/overlay/ProtocolVersion_test.cpp b/src/test/overlay/ProtocolVersion_test.cpp index e31a574502..e7b63a34cb 100644 --- a/src/test/overlay/ProtocolVersion_test.cpp +++ b/src/test/overlay/ProtocolVersion_test.cpp @@ -33,22 +33,30 @@ public: void run() override { - testcase("Convert protocol version to string"); - BEAST_EXPECT(to_string(makeProtocol(1, 3)) == "XRPL/1.3"); - BEAST_EXPECT(to_string(makeProtocol(2, 0)) == "XRPL/2.0"); - BEAST_EXPECT(to_string(makeProtocol(2, 1)) == "XRPL/2.1"); - BEAST_EXPECT(to_string(makeProtocol(10, 10)) == "XRPL/10.10"); + { + testcase("Convert protocol version to string"); + + BEAST_EXPECT(to_string(makeProtocol(0, 0)) == "XRPL/0.0"); + BEAST_EXPECT(to_string(makeProtocol(0, 1)) == "XRPL/0.1"); + BEAST_EXPECT(to_string(makeProtocol(1, 3)) == "XRPL/1.3"); + BEAST_EXPECT(to_string(makeProtocol(2, 0)) == "XRPL/2.0"); + BEAST_EXPECT(to_string(makeProtocol(2, 1)) == "XRPL/2.1"); + BEAST_EXPECT(to_string(makeProtocol(10, 10)) == "XRPL/10.10"); + BEAST_EXPECT(to_string(makeProtocol(65535, 65535)) == "XRPL/65535.65535"); + } { testcase("Convert strings to protocol versions"); - // Empty string + // Invalid versions, either they do not parse as XRPL/N.M or are unsupported. check("", ""); + check("RTXP/1.1,RTXP/1.2,RTXP/1.3", ""); + check("XRPL/-2.1,XRPL/0.3,XRPL/2,XRPL/2.01,websocket", ""); - check("RTXP/1.1,RTXP/1.2,RTXP/1.3,XRPL/2.1,XRPL/2.0,/XRPL/3.0", "XRPL/2.0,XRPL/2.1"); - check("RTXP/0.9,RTXP/1.01,XRPL/0.3,XRPL/2.01,websocket", ""); + // Mixture of valid, duplicate, and invalid versions. + check("RTXP/1.3,XRPL/2.1,XRPL/2.0,/XRPL/3.0", "XRPL/2.0,XRPL/2.1"); check( - "XRPL/2.0,XRPL/2.0,XRPL/19.4,XRPL/7.89,XRPL/XRPL/3.0,XRPL/2.01", + "XRPL/2.0,XRPL/2.0,XRPL/19.4,XRPL/7.89,XRPL/XRPL/3.0,XRPL/2.01,XRPL/-65535.65535", "XRPL/2.0,XRPL/7.89,XRPL/19.4"); check( "XRPL/2.0,XRPL/3.0,XRPL/4,XRPL/,XRPL,OPT XRPL/2.2,XRPL/5.67", @@ -58,15 +66,17 @@ public: { testcase("Protocol version negotiation"); - BEAST_EXPECT(negotiateProtocolVersion("RTXP/1.2") == std::nullopt); + // Only the highest supported protocol version, if any, is returned. + BEAST_EXPECT(negotiateProtocolVersion("") == std::nullopt); + BEAST_EXPECT(negotiateProtocolVersion("XRPL/0.0") == std::nullopt); + BEAST_EXPECT(negotiateProtocolVersion("RTXP/1.2,XRPL/0.1") == std::nullopt); BEAST_EXPECT( - negotiateProtocolVersion("RTXP/1.2, XRPL/2.0, XRPL/2.1") == makeProtocol(2, 1)); + negotiateProtocolVersion("XRPL/999.999, XRPL/-2.2,WebSocket/1.0") == std::nullopt); BEAST_EXPECT(negotiateProtocolVersion("XRPL/2.2") == makeProtocol(2, 2)); BEAST_EXPECT( - negotiateProtocolVersion("RTXP/1.2, XRPL/2.3, XRPL/2.4, XRPL/999.999") == + negotiateProtocolVersion( + "RTXP/1.2, XRPL/2.1, XRPL/2.2, XRPL/2.3, XRPL/2.4, XRPL/999.999") == makeProtocol(2, 3)); - BEAST_EXPECT(negotiateProtocolVersion("XRPL/999.999, WebSocket/1.0") == std::nullopt); - BEAST_EXPECT(negotiateProtocolVersion("") == std::nullopt); } } }; diff --git a/src/test/overlay/compression_test.cpp b/src/test/overlay/compression_test.cpp index a583a3aeab..40dee96c75 100644 --- a/src/test/overlay/compression_test.cpp +++ b/src/test/overlay/compression_test.cpp @@ -292,33 +292,6 @@ public: return getObject; } - static std::shared_ptr - buildValidatorList() - { - auto list = std::make_shared(); - - auto master = randomKeyPair(KeyType::Ed25519); - auto signing = randomKeyPair(KeyType::Ed25519); - STObject st(sfGeneric); - st[sfSequence] = 0; - st[sfPublicKey] = std::get<0>(master); - st[sfSigningPubKey] = std::get<0>(signing); - st[sfDomain] = makeSlice(std::string("example.com")); - sign(st, HashPrefix::Manifest, KeyType::Ed25519, std::get<1>(master), sfMasterSignature); - sign(st, HashPrefix::Manifest, KeyType::Ed25519, std::get<1>(signing)); - Serializer s; - st.add(s); - list->set_manifest(s.data(), s.size()); - list->set_version(3); - STObject const signature(sfSignature); - xrpl::sign(st, HashPrefix::Manifest, KeyType::Ed25519, std::get<1>(signing)); - Serializer s1; - st.add(s1); - list->set_signature(s1.data(), s1.size()); - list->set_blob(strHex(s.slice())); - return list; - } - static std::shared_ptr buildValidatorListCollection() { @@ -359,7 +332,6 @@ public: protocol::TMGetLedger const getLedger; protocol::TMLedgerData const ledgerData; protocol::TMGetObjectByHash const getObject; - protocol::TMValidatorList const validatorList; protocol::TMValidatorListCollection const validatorListCollection; // 4.5KB @@ -386,8 +358,6 @@ public: doTest(buildLedgerData(500000, *logs), protocol::mtLEDGER_DATA, 100, "TMLedgerData500000"); // 7.7KB doTest(buildGetObjectByHash(), protocol::mtGET_OBJECTS, 4, "TMGetObjectByHash"); - // 895B - doTest(buildValidatorList(), protocol::mtVALIDATOR_LIST, 4, "TMValidatorList"); doTest( buildValidatorListCollection(), protocol::mtVALIDATOR_LIST_COLLECTION, diff --git a/src/xrpld/app/misc/ValidatorList.h b/src/xrpld/app/misc/ValidatorList.h index abec6cf4e0..4e001affe8 100644 --- a/src/xrpld/app/misc/ValidatorList.h +++ b/src/xrpld/app/misc/ValidatorList.h @@ -30,7 +30,6 @@ #include namespace protocol { -class TMValidatorList; class TMValidatorListCollection; } // namespace protocol @@ -371,9 +370,6 @@ public: static std::vector parseBlobs(std::uint32_t version, json::Value const& body); - static std::vector - parseBlobs(protocol::TMValidatorList const& body); - static std::vector parseBlobs(protocol::TMValidatorListCollection const& body); @@ -391,7 +387,6 @@ public: [[nodiscard]] static std::pair buildValidatorListMessages( - std::size_t messageVersion, std::uint64_t peerSequence, std::size_t maxSequence, std::uint32_t rawVersion, @@ -987,14 +982,6 @@ hash_append(Hasher& h, std::map const& blobs) namespace protocol { -template -void -hash_append(Hasher& h, TMValidatorList const& msg) -{ - using beast::hash_append; - hash_append(h, msg.manifest(), msg.blob(), msg.signature(), msg.version()); -} - template void hash_append(Hasher& h, TMValidatorListCollection const& msg) diff --git a/src/xrpld/app/misc/detail/ValidatorList.cpp b/src/xrpld/app/misc/detail/ValidatorList.cpp index 0ada8ed55f..f099ebf059 100644 --- a/src/xrpld/app/misc/detail/ValidatorList.cpp +++ b/src/xrpld/app/misc/detail/ValidatorList.cpp @@ -449,13 +449,6 @@ ValidatorList::parseBlobs(std::uint32_t version, json::Value const& body) } } -// static -std::vector -ValidatorList::parseBlobs(protocol::TMValidatorList const& body) -{ - return {{.blob = body.blob(), .signature = body.signature(), .manifest = {}}}; -} - // static std::vector ValidatorList::parseBlobs(protocol::TMValidatorListCollection const& body) @@ -476,7 +469,7 @@ ValidatorList::parseBlobs(protocol::TMValidatorListCollection const& body) } XRPL_ASSERT( result.size() == body.blobs_size(), - "xrpl::ValidatorList::parseBlobs(TMValidatorList) : result size " + "xrpl::ValidatorList::parseBlobs(TMValidatorListCollection) : result size " "match"); return result; } @@ -520,29 +513,6 @@ splitMessageParts( { if (end <= begin) return 0; - if (end - begin == 1) - { - protocol::TMValidatorList smallMsg; - smallMsg.set_version(1); - smallMsg.set_manifest(largeMsg.manifest()); - - auto const& blob = largeMsg.blobs(begin); - smallMsg.set_blob(blob.blob()); - smallMsg.set_signature(blob.signature()); - // This is only possible if "downgrading" a v2 UNL to v1. - if (blob.has_manifest()) - smallMsg.set_manifest(blob.manifest()); - - XRPL_ASSERT( - Message::totalSize(smallMsg) <= kMaximumMessageSize, - "xrpl::splitMessageParts : maximum message size"); - - messages.emplace_back( - std::make_shared(smallMsg, protocol::mtVALIDATOR_LIST), - sha512Half(smallMsg), - 1); - return messages.back().numVLs; - } std::optional smallMsg; smallMsg.emplace(); @@ -554,13 +524,29 @@ splitMessageParts( *smallMsg->add_blobs() = largeMsg.blobs(i); } - if (Message::totalSize(*smallMsg) > maxSize) + auto const size = Message::totalSize(*smallMsg); + + // Split until each message fits, but a single blob can't be split any + // further, so stop recursing at that point regardless of maxSize. + if (size > maxSize && end - begin > 1) { // free up the message space smallMsg.reset(); return splitMessage(messages, largeMsg, maxSize, begin, end); } + // An unsplittable blob is still bounded by the protocol limit: peers drop + // messages exceeding it on receipt, so don't waste the bandwidth. maxSize + // only ever tightens this (it defaults to kMaximumMessageSize), so a blob + // reaching here can exceed maxSize but never the protocol limit. + if (size > kMaximumMessageSize) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::splitMessageParts : maximum message size exceeded"); + return 0; + // LCOV_EXCL_STOP + } + messages.emplace_back( std::make_shared(*smallMsg, protocol::mtVALIDATOR_LIST_COLLECTION), sha512Half(*smallMsg), @@ -568,37 +554,6 @@ splitMessageParts( return messages.back().numVLs; } -// Build a v1 protocol message using only the current VL -std::size_t -buildValidatorListMessage( - std::vector& messages, - std::uint32_t rawVersion, - std::string const& rawManifest, - ValidatorBlobInfo const& currentBlob, - std::size_t maxSize) -{ - XRPL_ASSERT( - messages.empty(), - "xrpl::buildValidatorListMessage(ValidatorBlobInfo) : empty messages " - "input"); - protocol::TMValidatorList msg; - auto const manifest = currentBlob.manifest ? *currentBlob.manifest : rawManifest; - auto const version = 1; - msg.set_manifest(manifest); - msg.set_blob(currentBlob.blob); - msg.set_signature(currentBlob.signature); - // Override the version - msg.set_version(version); - - XRPL_ASSERT( - Message::totalSize(msg) <= kMaximumMessageSize, - "xrpl::buildValidatorListMessage(ValidatorBlobInfo) : maximum " - "message size"); - messages.emplace_back( - std::make_shared(msg, protocol::mtVALIDATOR_LIST), sha512Half(msg), 1); - return 1; -} - // Build a v2 protocol message using all the VLs with sequence larger than the // peer's std::size_t @@ -650,7 +605,6 @@ buildValidatorListMessage( // static std::pair ValidatorList::buildValidatorListMessages( - std::size_t messageVersion, std::uint64_t peerSequence, std::size_t maxSequence, std::uint32_t rawVersion, @@ -663,14 +617,12 @@ ValidatorList::buildValidatorListMessages( !blobInfos.empty(), "xrpl::ValidatorList::buildValidatorListMessages : empty messages " "input"); - auto const& [currentSeq, currentBlob] = *blobInfos.begin(); auto numVLs = std::accumulate( messages.begin(), messages.end(), 0, [](std::size_t total, MessageWithHash const& m) { return total + m.numVLs; }); - if (messageVersion == 2 && peerSequence < maxSequence) + if (peerSequence < maxSequence) { - // Version 2 if (messages.empty()) { numVLs = buildValidatorListMessage( @@ -678,36 +630,13 @@ ValidatorList::buildValidatorListMessages( if (messages.empty()) { // No message was generated. Create an empty placeholder so we - // dont' repeat the work later. + // don't repeat the work later. messages.emplace_back(); } } - // Don't send it next time. return {maxSequence, numVLs}; } - if (messageVersion == 1 && peerSequence < currentSeq) - { - // Version 1 - if (messages.empty()) - { - numVLs = buildValidatorListMessage( - messages, - rawVersion, - currentBlob.manifest ? *currentBlob.manifest : rawManifest, - currentBlob, - maxSize); - if (messages.empty()) - { - // No message was generated. Create an empty placeholder so we - // dont' repeat the work later. - messages.emplace_back(); - } - } - - // Don't send it next time. - return {currentSeq, numVLs}; - } return {0, 0}; } @@ -725,19 +654,8 @@ ValidatorList::sendValidatorList( HashRouter& hashRouter, beast::Journal j) { - std::size_t messageVersion = 0; - if (peer.supportsFeature(ProtocolFeature::ValidatorList2Propagation)) - { - messageVersion = 2; - } - else if (peer.supportsFeature(ProtocolFeature::ValidatorListPropagation)) - { - messageVersion = 1; - } - if (messageVersion == 0u) - return; auto const [newPeerSequence, numVLs] = buildValidatorListMessages( - messageVersion, peerSequence, maxSequence, rawVersion, rawManifest, blobInfos, messages); + peerSequence, maxSequence, rawVersion, rawManifest, blobInfos, messages); if (newPeerSequence != 0u) { XRPL_ASSERT( @@ -764,24 +682,11 @@ ValidatorList::sendValidatorList( "xrpl::ValidatorList::sendValidatorList : sent or one message"); if (sent) { - if (messageVersion > 1) - { - JLOG(j.debug()) << "Sent " << messages.size() - << " validator list collection(s) containing " << numVLs - << " validator list(s) for " << strHex(publisherKey) - << " with sequence range " << peerSequence << ", " - << newPeerSequence << " to " << peer.fingerprint(); - } - else - { - XRPL_ASSERT( - numVLs == 1, - "xrpl::ValidatorList::sendValidatorList : one validator " - "list"); - JLOG(j.debug()) << "Sent validator list for " << strHex(publisherKey) - << " with sequence " << newPeerSequence << " to " - << peer.fingerprint(); - } + JLOG(j.debug()) << "Sent " << messages.size() + << " validator list collection(s) containing " << numVLs + << " validator list(s) for " << strHex(publisherKey) + << " with sequence range " << peerSequence << ", " << newPeerSequence + << " to " << peer.fingerprint(); } } } @@ -856,16 +761,9 @@ ValidatorList::broadcastBlobs( if (toSkip) { - // We don't know what messages or message versions we're sending - // until we examine our peer's properties. Build the message(s) on - // demand, but reuse them when possible. - - // This will hold a v1 message with only the current VL if we have - // any peers that don't support v2 - std::vector messages1; - // This will hold v2 messages indexed by the peer's - // `publisherListSequence`. For each `publisherListSequence`, we'll - // only send the VLs with higher sequences. + // Build v2 messages on demand and reuse them when possible. Messages + // are indexed by the peer's `publisherListSequence`; for each sequence, + // we only send VLs with higher sequences. std::map> messages2; // If any peers are found that are worth considering, this list will // be built to hold info for all of the valid VLs. @@ -885,8 +783,6 @@ ValidatorList::broadcastBlobs( { if (blobInfos.empty()) buildBlobInfos(blobInfos, lists); - auto const v2 = - peer->supportsFeature(ProtocolFeature::ValidatorList2Propagation); sendValidatorList( *peer, peerSequence, @@ -895,11 +791,10 @@ ValidatorList::broadcastBlobs( lists.rawVersion, lists.rawManifest, blobInfos, - v2 ? messages2[peerSequence] : messages1, + messages2[peerSequence], hashRouter, j); - // Even if the peer doesn't support the messages, - // suppress it so it'll be ignored next time. + // Don't send it next time. hashRouter.addSuppressionPeer(hash, peer->id()); } } diff --git a/src/xrpld/overlay/Peer.h b/src/xrpld/overlay/Peer.h index 87750ed40e..6c4cf1dff1 100644 --- a/src/xrpld/overlay/Peer.h +++ b/src/xrpld/overlay/Peer.h @@ -20,8 +20,6 @@ class Charge; } // namespace resource enum class ProtocolFeature { - ValidatorListPropagation, - ValidatorList2Propagation, LedgerReplay, LedgerNodeDepth, }; diff --git a/src/xrpld/overlay/detail/Message.cpp b/src/xrpld/overlay/detail/Message.cpp index c6e0511515..a6af525620 100644 --- a/src/xrpld/overlay/detail/Message.cpp +++ b/src/xrpld/overlay/detail/Message.cpp @@ -82,7 +82,6 @@ Message::compress() case protocol::mtGET_LEDGER: case protocol::mtLEDGER_DATA: case protocol::mtGET_OBJECTS: - case protocol::mtVALIDATOR_LIST: case protocol::mtVALIDATOR_LIST_COLLECTION: case protocol::mtREPLAY_DELTA_RESPONSE: case protocol::mtTRANSACTIONS: diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index 726002fce4..3f0b4453b8 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -542,10 +542,6 @@ PeerImp::supportsFeature(ProtocolFeature f) const { switch (f) { - case ProtocolFeature::ValidatorListPropagation: - return protocol_ >= makeProtocol(2, 1); - case ProtocolFeature::ValidatorList2Propagation: - return protocol_ >= makeProtocol(2, 2); case ProtocolFeature::LedgerNodeDepth: return protocol_ >= makeProtocol(2, 3); case ProtocolFeature::LedgerReplay: @@ -885,7 +881,7 @@ PeerImp::doProtocolStart() onReadMessage(error_code(), 0); // Send all the validator lists that have been loaded - if (inbound_ && supportsFeature(ProtocolFeature::ValidatorListPropagation)) + if (inbound_) { app_.getValidators().forEachAvailable( [&](std::string const& manifest, @@ -2422,43 +2418,11 @@ PeerImp::onValidatorListMessage( } } -void -PeerImp::onMessage(std::shared_ptr const& m) -{ - try - { - if (!supportsFeature(ProtocolFeature::ValidatorListPropagation)) - { - JLOG(pJournal_.debug()) << "ValidatorList: received validator list from peer using " - << "protocol version " << to_string(protocol_) - << " which shouldn't support this feature."; - fee_.update(resource::kFeeUselessData, "unsupported peer"); - return; - } - onValidatorListMessage( - "ValidatorList", m->manifest(), m->version(), ValidatorList::parseBlobs(*m)); - } - catch (std::exception const& e) - { - JLOG(pJournal_.warn()) << "ValidatorList: Exception, " << e.what(); - using namespace std::string_literals; - fee_.update(resource::kFeeInvalidData, e.what()); - } -} - void PeerImp::onMessage(std::shared_ptr const& m) { try { - if (!supportsFeature(ProtocolFeature::ValidatorList2Propagation)) - { - JLOG(pJournal_.debug()) << "ValidatorListCollection: received validator list from peer " - << "using protocol version " << to_string(protocol_) - << " which shouldn't support this feature."; - fee_.update(resource::kFeeUselessData, "unsupported peer"); - return; - } if (m->version() < 2) { JLOG(pJournal_.debug()) diff --git a/src/xrpld/overlay/detail/PeerImp.h b/src/xrpld/overlay/detail/PeerImp.h index 7078d6fb56..0f229bf9d8 100644 --- a/src/xrpld/overlay/detail/PeerImp.h +++ b/src/xrpld/overlay/detail/PeerImp.h @@ -623,8 +623,6 @@ public: void onMessage(std::shared_ptr const& m); void - onMessage(std::shared_ptr const& m); - void onMessage(std::shared_ptr const& m); void onMessage(std::shared_ptr const& m); diff --git a/src/xrpld/overlay/detail/ProtocolMessage.h b/src/xrpld/overlay/detail/ProtocolMessage.h index f7d5e26272..88f50e1e2e 100644 --- a/src/xrpld/overlay/detail/ProtocolMessage.h +++ b/src/xrpld/overlay/detail/ProtocolMessage.h @@ -71,8 +71,6 @@ protocolMessageName(int type) return "status"; case protocol::mtHAVE_SET: return "have_set"; - case protocol::mtVALIDATOR_LIST: - return "validator_list"; case protocol::mtVALIDATOR_LIST_COLLECTION: return "validator_list_collection"; case protocol::mtVALIDATION: @@ -424,9 +422,6 @@ invokeProtocolMessage(Buffers const& buffers, Handler& handler, std::size_t& hin case protocol::mtVALIDATION: success = detail::invoke(*header, buffers, handler); break; - case protocol::mtVALIDATOR_LIST: - success = detail::invoke(*header, buffers, handler); - break; case protocol::mtVALIDATOR_LIST_COLLECTION: success = detail::invoke(*header, buffers, handler); diff --git a/src/xrpld/overlay/detail/ProtocolVersion.cpp b/src/xrpld/overlay/detail/ProtocolVersion.cpp index 93d4fae156..1296041ad5 100644 --- a/src/xrpld/overlay/detail/ProtocolVersion.cpp +++ b/src/xrpld/overlay/detail/ProtocolVersion.cpp @@ -28,7 +28,6 @@ namespace xrpl { */ constexpr ProtocolVersion const kSupportedProtocolList[]{ - {2, 1}, {2, 2}, {2, 3}, }; diff --git a/src/xrpld/overlay/detail/TrafficCount.cpp b/src/xrpld/overlay/detail/TrafficCount.cpp index bdce9e68f0..90d5c0b4ff 100644 --- a/src/xrpld/overlay/detail/TrafficCount.cpp +++ b/src/xrpld/overlay/detail/TrafficCount.cpp @@ -14,7 +14,6 @@ std::unordered_map const kTypeLoo {protocol::mtMANIFESTS, TrafficCount::Category::Manifests}, {protocol::mtENDPOINTS, TrafficCount::Category::Overlay}, {protocol::mtTRANSACTION, TrafficCount::Category::Transaction}, - {protocol::mtVALIDATOR_LIST, TrafficCount::Category::Validatorlist}, {protocol::mtVALIDATOR_LIST_COLLECTION, TrafficCount::Category::Validatorlist}, {protocol::mtVALIDATION, TrafficCount::Category::Validation}, {protocol::mtPROPOSE_LEDGER, TrafficCount::Category::Proposal},