diff --git a/.clang-tidy b/.clang-tidy index 68fc9e75fc..41df5470ff 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -75,6 +75,8 @@ Checks: "-*, # readability-static-accessed-through-instance, # this check is probably unnecessary. It makes the code less readable # --- +FormatStyle: file + CheckOptions: bugprone-unsafe-functions.ReportMoreUnsafeFunctions: true bugprone-unused-return-value.CheckedReturnTypes: ::std::error_code;::std::error_condition;::std::errc diff --git a/.cspell.config.yaml b/.cspell.config.yaml index f5ef2d2bba..38504ece7a 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -174,7 +174,6 @@ words: - MPTAMM - MPTDEX - Merkle - - Metafuncton - misprediction - missingok - mptbalance diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 60f3da09f1..9510212344 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -2,12 +2,21 @@ "image_tag": "sha-fecfc0c", "configs": { "ubuntu": [ + { + "compiler": ["gcc"], + "build_type": ["Debug"], + "arch": ["amd64"], + "minimal": true, + "suffix": "coverage", + "extra_cmake_args": "-DUNIT_TEST_REFERENCE_FEE=500 -Dcoverage=ON -Dcoverage_format=xml -DCODE_COVERAGE_VERBOSE=ON -DCMAKE_C_FLAGS=-O0 -DCMAKE_CXX_FLAGS=-O0" + }, { "compiler": ["clang"], "build_type": ["Release"], "arch": ["amd64"], "minimal": true }, + { "compiler": ["gcc"], "build_type": ["Release"], @@ -29,14 +38,6 @@ "sanitizers": ["address", "undefinedbehavior"] }, - { - "compiler": ["gcc"], - "build_type": ["Debug"], - "arch": ["amd64"], - "minimal": true, - "suffix": "coverage", - "extra_cmake_args": "-DUNIT_TEST_REFERENCE_FEE=500 -Dcoverage=ON -Dcoverage_format=xml -DCODE_COVERAGE_VERBOSE=ON -DCMAKE_C_FLAGS=-O0 -DCMAKE_CXX_FLAGS=-O0" - }, { "compiler": ["clang"], "build_type": ["Debug"], diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index 3eb5269deb..9b2f692026 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -266,7 +266,7 @@ jobs: ./xrpld --definitions | python3 -m json.tool >server_definitions.json - name: Upload server definitions - if: ${{ github.event.repository.visibility == 'public' && inputs.config_name == 'debian-gcc-release-amd64' }} + if: ${{ github.event.repository.visibility == 'public' && inputs.config_name == 'ubuntu-gcc-debug-amd64-coverage' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: server-definitions diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index 559fb81739..983972e8c1 100644 --- a/.github/workflows/reusable-clang-tidy.yml +++ b/.github/workflows/reusable-clang-tidy.yml @@ -101,7 +101,7 @@ jobs: TARGETS: ${{ needs.determine-files.outputs.need_full_run != 'true' && needs.determine-files.outputs.cpp_changed_files || 'include src tests' }} run: | set -o pipefail - run-clang-tidy -j ${{ steps.nproc.outputs.nproc }} -p "${BUILD_DIR}" -quiet -fix -allow-no-checks ${TARGETS} 2>&1 | tee "${OUTPUT_FILE}" + run-clang-tidy -j ${{ steps.nproc.outputs.nproc }} -p "${BUILD_DIR}" -quiet -fix -format -allow-no-checks ${TARGETS} 2>&1 | tee "${OUTPUT_FILE}" - name: Print filtered clang-tidy errors if: ${{ steps.run_clang_tidy.outcome != 'success' }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7632741e35..fc385cf6ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -348,12 +348,14 @@ run-clang-tidy -p build -allow-no-checks src tests ``` This will check all source files in the `src`, `include` and `tests` directories using the compile commands from your `build` directory. -If you wish to automatically fix whatever clang-tidy finds _and_ is capable of fixing, add `-fix` to the above command: +If you wish to automatically fix whatever clang-tidy finds _and_ is capable of fixing, add `-fix -format` to the above command: ``` -run-clang-tidy -p build -quiet -fix -allow-no-checks src tests +run-clang-tidy -p build -quiet -fix -format -allow-no-checks src tests ``` +`-format` reformats the fixed code with [`.clang-format`](./.clang-format); without it the fixes are inserted in LLVM style and the `clang-format` hook rewrites them afterwards. + ## Contracts and instrumentation We are using [Antithesis](https://antithesis.com/) for continuous fuzzing, diff --git a/bin/pre-commit/clang_tidy_check.py b/bin/pre-commit/clang_tidy_check.py index cf4808d2ea..118d9619e2 100755 --- a/bin/pre-commit/clang_tidy_check.py +++ b/bin/pre-commit/clang_tidy_check.py @@ -144,7 +144,11 @@ def main(): + files ) canonicalize_fix_paths(Path(fixes_dir)) - applied = subprocess.run([clang_apply_replacements, fixes_dir]) + # `FormatStyle` in .clang-tidy does not reach this path, + # so ask for the repository style here. + applied = subprocess.run( + [clang_apply_replacements, "--format", "--style=file", fixes_dir] + ) return result.returncode or applied.returncode diff --git a/cmake/XrplAddBenchmark.cmake b/cmake/XrplAddBenchmark.cmake index 1dd875dd61..921deb0658 100644 --- a/cmake/XrplAddBenchmark.cmake +++ b/cmake/XrplAddBenchmark.cmake @@ -1,3 +1,5 @@ +include_guard() + include(isolate_headers) # Define a benchmark executable for the module `name`. diff --git a/include/xrpl/basics/Slice.h b/include/xrpl/basics/Slice.h index 36e7615c3a..75c9b8c7bd 100644 --- a/include/xrpl/basics/Slice.h +++ b/include/xrpl/basics/Slice.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -251,4 +252,11 @@ makeSlice(std::basic_string const& s) return Slice(s.data(), s.size()); } +template +Slice +makeSlice(std::basic_string_view s) +{ + return Slice(s.data(), s.size()); +} + } // namespace xrpl diff --git a/include/xrpl/beast/core/LexicalCast.h b/include/xrpl/beast/core/LexicalCast.h index 7cf21892bd..288c5d6673 100644 --- a/include/xrpl/beast/core/LexicalCast.h +++ b/include/xrpl/beast/core/LexicalCast.h @@ -58,7 +58,7 @@ struct LexicalCast "beast::LexicalCast can only be used with integral types"); template - bool + constexpr bool operator()(Integral& out, std::string_view in) const requires(std::is_integral_v && !std::is_same_v) { @@ -110,7 +110,7 @@ struct LexicalCast> { explicit LexicalCast() = default; - bool + constexpr bool operator()(Out& out, boost::core::basic_string_view in) const { return LexicalCast()(out, in); @@ -123,7 +123,7 @@ struct LexicalCast { explicit LexicalCast() = default; - bool + constexpr bool operator()(Out& out, std::string in) const { return LexicalCast()(out, in); @@ -136,7 +136,7 @@ struct LexicalCast { explicit LexicalCast() = default; - bool + constexpr bool operator()(Out& out, char const* in) const { XRPL_ASSERT(in, "beast::detail::LexicalCast(char const*) : non-null input"); @@ -151,7 +151,7 @@ struct LexicalCast { explicit LexicalCast() = default; - bool + constexpr bool operator()(Out& out, char* in) const { XRPL_ASSERT(in, "beast::detail::LexicalCast(char*) : non-null input"); @@ -177,7 +177,7 @@ struct BadLexicalCast : public std::bad_cast * @return `false` if there was a parsing or range error */ template -bool +constexpr bool lexicalCastChecked(Out& out, In in) { return detail::LexicalCast()(out, in); @@ -191,7 +191,7 @@ lexicalCastChecked(Out& out, In in) * @return The new type. */ template -Out +constexpr Out lexicalCastThrow(In in) { if (Out out; lexicalCastChecked(out, in)) @@ -207,7 +207,7 @@ lexicalCastThrow(In in) * @return The new type. */ template -Out +constexpr Out lexicalCast(In in, Out defaultValue = Out()) { if (Out out; lexicalCastChecked(out, in)) diff --git a/include/xrpl/beast/core/SemanticVersion.h b/include/xrpl/beast/core/SemanticVersion.h index 338942c252..c2e395e3f4 100644 --- a/include/xrpl/beast/core/SemanticVersion.h +++ b/include/xrpl/beast/core/SemanticVersion.h @@ -17,14 +17,14 @@ namespace beast { class SemanticVersion { public: - using identifier_list = std::vector; + using IdentifierList = std::vector; int majorVersion; int minorVersion; int patchVersion; - identifier_list preReleaseIdentifiers; - identifier_list metaData; + IdentifierList preReleaseIdentifiers; + IdentifierList metaData; SemanticVersion(); diff --git a/include/xrpl/json/json_value.h b/include/xrpl/json/json_value.h index 47ad3ac1e0..260917face 100644 --- a/include/xrpl/json/json_value.h +++ b/include/xrpl/json/json_value.h @@ -623,6 +623,7 @@ class ValueConstIterator : public ValueIteratorBase public: using size_t = unsigned int; using difference_type = int; + using value_type = Value const; using reference = Value const&; using pointer = Value const*; using SelfType = ValueConstIterator; @@ -687,6 +688,7 @@ class ValueIterator : public ValueIteratorBase public: using size_t = unsigned int; using difference_type = int; + using value_type = Value; using reference = Value&; using pointer = Value*; using SelfType = ValueIterator; diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 8e0d11cccb..fef18e3e09 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -286,6 +286,77 @@ computeFullPaymentInterest( std::uint32_t startDate, TenthBips32 closeInterestRate); +// Deltas applied to Vault.AssetsTotal and LoanBroker.DebtTotal at a single +// accounting touch point (origination, payment, impair/unimpair/default). +struct AccountingDeltas +{ + Number assetsTotalDelta; + Number debtTotalDelta; +}; + +// Whole-life (pre-LendingProtocolV1_1) recognition model: interest is +// recognized into AssetsTotal/DebtTotal up front, at origination. +namespace Accrual { + +// LoanSet origination: what's added to Vault.AssetsTotal and LoanBroker.DebtTotal +AccountingDeltas +loanOriginationDeltas(Number const& principalRequested, Number const& interestDue); + +// LoanSet origination: would recognizing this loan's interest push +// Vault.AssetsTotal past Vault.AssetsMaximum? +bool +loanOriginationExceedsVaultMaximum( + Number const& vaultMaximum, + Number const& vaultTotal, + Number const& interestDue); + +// LoanManage impair/unimpair/default: the vault's exposure to this loan +Number +loanVaultExposure(SLE::const_ref loanSle); + +// LoanPay: what's added to Vault.AssetsTotal and subtracted from LoanBroker.DebtTotal for a payment +AccountingDeltas +loanPaymentDeltas(LoanPaymentParts const& parts); + +} // namespace Accrual + +// Cash-basis (LendingProtocolV1_1) recognition model: AssetsTotal/DebtTotal +// are principal-only, interest is recognized only as it's actually paid. +namespace CashBasis { + +AccountingDeltas +loanOriginationDeltas(Number const& principalRequested); + +Number +loanVaultExposure(SLE::const_ref loanSle); + +AccountingDeltas +loanPaymentDeltas(LoanPaymentParts const& parts); + +} // namespace CashBasis + +// Public dispatchers: pick CashBasis:: if featureLendingProtocolV1_1 is +// enabled AND the Vault's LEVersion (VaultHelpers::getVaultVersion) is +// VaultVersion::CashBasis, else Accrual::. These are the only entry points +// transactors call. +AccountingDeltas +loanOriginationDeltas( + SLE::const_ref vaultSle, + Number const& principalRequested, + Number const& interestDue); + +bool +loanOriginationExceedsVaultMaximum( + SLE::const_ref vaultSle, + Number const& vaultTotal, + Number const& interestDue); + +Number +loanVaultExposure(SLE::const_ref vaultSle, SLE::const_ref loanSle); + +AccountingDeltas +loanPaymentDeltas(SLE::const_ref vaultSle, LoanPaymentParts const& parts); + namespace detail { // These classes and functions should only be accessed by LendingHelper // functions and unit tests diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index 1bd1663314..5681cc57e8 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -107,4 +108,19 @@ sharesToAssetsWithdraw( [[nodiscard]] bool isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref issuance); +/** + * Resolves a Vault's LEVersion, the single point every accounting touch + * point should call to determine which recognition model (accrual vs. + * cash-basis) a Vault uses. Vaults created before featureLendingProtocolV1_1 + * activated never have sfLEVersion set, which resolves here to + * VaultVersion::Legacy. + * + * @param vault The vault SLE. + * + * @return The Vault's LEVersion, or VaultVersion::Legacy if the field is + * absent. + */ +[[nodiscard]] VaultVersion +getVaultVersion(SLE::const_ref vault); + } // namespace xrpl diff --git a/include/xrpl/nodestore/detail/varint.h b/include/xrpl/nodestore/detail/Varint.h similarity index 91% rename from include/xrpl/nodestore/detail/varint.h rename to include/xrpl/nodestore/detail/Varint.h index afbf71cdea..5474cdc8b4 100644 --- a/include/xrpl/nodestore/detail/varint.h +++ b/include/xrpl/nodestore/detail/Varint.h @@ -13,18 +13,18 @@ namespace xrpl::node_store { // https://developers.google.com/protocol-buffers/docs/encoding#varints // field tag -struct varint; +struct Varint; -// Metafuncton to return largest +// Metafunction to return largest // possible size of T represented as varint. // T must be unsigned template > -struct varint_traits; +struct VarintTraits; template -struct varint_traits +struct VarintTraits { - explicit varint_traits() = default; + explicit VarintTraits() = default; static constexpr std::size_t kMax = ((8 * sizeof(T)) + 6) / 7; }; @@ -104,7 +104,7 @@ writeVarint(void* p0, std::size_t v) template void read(nudb::detail::istream& is, std::size_t& u) - requires(std::is_same_v) + requires(std::is_same_v) { auto p0 = is(1); auto p1 = p0; @@ -118,7 +118,7 @@ read(nudb::detail::istream& is, std::size_t& u) template void write(nudb::detail::ostream& os, std::size_t t) - requires(std::is_same_v) + requires(std::is_same_v) { writeVarint(os.data(sizeVarint(t)), t); } diff --git a/include/xrpl/nodestore/detail/codec.h b/include/xrpl/nodestore/detail/codec.h index a3dfa7c944..47ad2da50a 100644 --- a/include/xrpl/nodestore/detail/codec.h +++ b/include/xrpl/nodestore/detail/codec.h @@ -10,7 +10,7 @@ #include #include #include -#include +#include #include #include @@ -59,7 +59,7 @@ lz4Compress(void const* in, std::size_t inSize, BufferFactory&& bf) using std::runtime_error; using namespace nudb::detail; std::pair result; - std::array::kMax> vi{}; + std::array::kMax> vi{}; auto const n = writeVarint(vi.data(), inSize); auto const outMax = LZ4_compressBound(inSize); auto* out = reinterpret_cast(bf(n + outMax)); @@ -240,7 +240,7 @@ nodeobjectCompress(void const* in, std::size_t inSize, BufferFactory&& bf) auto* out = reinterpret_cast(bf(result.second)); result.first = out; ostream os(out, result.second); - write(os, type); + write(os, type); write(os, mask); write(os, vh.data(), n * 32); return result; @@ -252,13 +252,13 @@ nodeobjectCompress(void const* in, std::size_t inSize, BufferFactory&& bf) auto* out = reinterpret_cast(bf(result.second)); result.first = out; ostream os(out, result.second); - write(os, type); + write(os, type); write(os, vh.data(), n * 32); return result; } } - std::array::kMax> vi{}; + std::array::kMax> vi{}; static constexpr std::size_t kCodecType = 1; auto const vn = writeVarint(vi.data(), kCodecType); diff --git a/include/xrpl/proto/xrpl.proto b/include/xrpl/proto/xrpl.proto index d49920201e..bef5ec1d76 100644 --- a/include/xrpl/proto/xrpl.proto +++ b/include/xrpl/proto/xrpl.proto @@ -246,7 +246,15 @@ message TMGetObjectByHash { message TMLedgerNode { required bytes nodedata = 1; - optional bytes nodeid = 2; // missing for ledger base data + + // Used when protocol version <2.3. Not set for ledger base data. + optional bytes nodeid = 2; + + // Used when protocol version >=2.3. Neither value is set for ledger base data. + oneof reference { + bytes id = 3; // Set for inner nodes. + uint32 depth = 4; // Set for leaf nodes. + } } enum TMLedgerInfoType { diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index e83e1c97b6..9938a9b768 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -316,6 +316,17 @@ constexpr std::uint8_t kVaultDefaultIouScale = 6; */ constexpr std::uint8_t kVaultMaximumIouScale = 18; +/** + * Vault ledger-entry schema versions. Assigned to newly created + * Vaults once featureLendingProtocolV1_1 is enabled. Vaults created before + * activation are left without LEVersion (implicit legacy version 0, + * accrual-basis accounting). + */ +enum class VaultVersion : uint8_t { + Legacy = 0, + CashBasis, +}; + /** * 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 90810e06d2..b6408581a9 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -505,6 +505,7 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ {sfShareMPTID, SoeRequired}, {sfWithdrawalPolicy, SoeRequired}, {sfScale, SoeDefault}, + {sfLEVersion, SoeDefault}, // 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 4ef76c8b75..16defe3ba3 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -18,6 +18,7 @@ TYPED_SFIELD(sfMethod, UINT8, 2) TYPED_SFIELD(sfTransactionResult, UINT8, 3) TYPED_SFIELD(sfScale, UINT8, 4) TYPED_SFIELD(sfAssetScale, UINT8, 5) +TYPED_SFIELD(sfLEVersion, UINT8, 6) // 8-bit integers (uncommon) TYPED_SFIELD(sfTickSize, UINT8, 16) diff --git a/include/xrpl/protocol_autogen/ledger_entries/Vault.h b/include/xrpl/protocol_autogen/ledger_entries/Vault.h index 2bf92b4f5d..a6ab54cb0a 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Vault.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Vault.h @@ -287,6 +287,30 @@ public: { return this->sle_->isFieldPresent(sfScale); } + + /** + * @brief Get sfLEVersion (SoeDefault) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getLEVersion() const + { + if (hasLEVersion()) + return this->sle_->at(sfLEVersion); + return std::nullopt; + } + + /** + * @brief Check if sfLEVersion is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasLEVersion() const + { + return this->sle_->isFieldPresent(sfLEVersion); + } }; /** @@ -508,6 +532,17 @@ public: return *this; } + /** + * @brief Set sfLEVersion (SoeDefault) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setLEVersion(std::decay_t const& value) + { + object_[sfLEVersion] = value; + return *this; + } + /** * @brief Build and return the completed Vault wrapper. * @param index The ledger entry index. diff --git a/include/xrpl/shamap/SHAMap.h b/include/xrpl/shamap/SHAMap.h index a1194ccfd3..e198c472fa 100644 --- a/include/xrpl/shamap/SHAMap.h +++ b/include/xrpl/shamap/SHAMap.h @@ -3,7 +3,6 @@ #include #include #include -#include #include #include #include @@ -95,6 +94,21 @@ enum class SHAMapState { * * See https://en.wikipedia.org/wiki/Merkle_tree */ + +/** + * Holds a SHAMap node's identity, leaf status, and serialized data. Used by + * getNodeFat to return node data for peer synchronization. + */ +struct SHAMapNodeData +{ + SHAMapNodeID nodeID; + // The `data` field (a Blob, 8-byte aligned) needs 4 bytes of padding after the `nodeID` field + // (36 bytes, 4-byte aligned) regardless of what comes between them, so `isLeaf` costs nothing + // extra here. Moving it after `data` would add 8 bytes to the size of this struct instead. + bool isLeaf; + Blob data; +}; + class SHAMap { private: @@ -289,10 +303,10 @@ public: std::vector> getMissingNodes(int maxNodes, SHAMapSyncFilter const* filter); - bool + [[nodiscard]] bool getNodeFat( SHAMapNodeID const& wanted, - std::vector>& data, + std::vector& data, bool fatLeaves, std::uint32_t depth) const; @@ -321,10 +335,45 @@ public: void serializeRoot(Serializer& s) const; + /** + * Add a root node to the SHAMap during synchronization. + * + * This function is used when receiving the root node of a SHAMap from a peer during ledger + * synchronization. The node must already have been deserialized. + * + * @param hash The expected hash of the root node. + * @param rootNode A deserialized root node to add. + * @param filter Optional sync filter to track received nodes. + * @return Status indicating whether the node was useful, duplicate, or invalid. + * + * @note This function expects the rootNode to be a valid, deserialized SHAMapTreeNode. The + * caller is responsible for deserialization and basic validation before calling this + * function. + */ SHAMapAddNode - addRootNode(SHAMapHash const& hash, Slice const& rootNode, SHAMapSyncFilter const* filter); + addRootNode(SHAMapHash const& hash, SHAMapTreeNodePtr rootNode, SHAMapSyncFilter const* filter); + + /** + * Add a known node at a specific position in the SHAMap during synchronization. + * + * This function is used when receiving nodes from peers during ledger synchronization. The node + * is inserted at the position specified by nodeID. The node must already have been + * deserialized. + * + * @param nodeID The position in the tree where this node belongs. + * @param treeNode A deserialized tree node to add. + * @param filter Optional sync filter to track received nodes. + * @return Status indicating whether the node was useful, duplicate, or invalid. + * + * @note This function expects the treeNode to be a valid, deserialized SHAMapTreeNode. The + * caller is responsible for deserialization and basic validation before calling this + * function. This also means that the nodeID must be consistent with the node's content. + */ SHAMapAddNode - addKnownNode(SHAMapNodeID const& nodeID, Slice const& rawNode, SHAMapSyncFilter const* filter); + addKnownNode( + SHAMapNodeID const& nodeID, + SHAMapTreeNodePtr treeNode, + SHAMapSyncFilter const* filter); // status functions void diff --git a/include/xrpl/shamap/SHAMapLeafNode.h b/include/xrpl/shamap/SHAMapLeafNode.h index 26cfde9fe8..ab5bd574ed 100644 --- a/include/xrpl/shamap/SHAMapLeafNode.h +++ b/include/xrpl/shamap/SHAMapLeafNode.h @@ -1,6 +1,9 @@ #pragma once #include +#include +#include +#include #include #include #include @@ -60,4 +63,16 @@ public: getString(SHAMapNodeID const&) const final; }; +/** + * Return the key of the item held by a SHAMap leaf node. + * + * @param node a node known to be a leaf (see SHAMapTreeNode::isLeaf). + */ +inline uint256 const& +leafKey(SHAMapTreeNode const& node) +{ + XRPL_ASSERT(node.isLeaf(), "xrpl::leafKey : node is a leaf"); + return safeDowncast(node).peekItem()->key(); +} + } // namespace xrpl diff --git a/sanitizers/suppressions/ubsan.supp b/sanitizers/suppressions/ubsan.supp index 7e3e02f855..a67a4a0ca3 100644 --- a/sanitizers/suppressions/ubsan.supp +++ b/sanitizers/suppressions/ubsan.supp @@ -192,6 +192,7 @@ unsigned-integer-overflow:rpc/handlers/orderbook/GetAggregatePrice.cpp # Test-only intentional overflow/underflow in fixture and unit-test arithmetic. unsigned-integer-overflow:tests/libxrpl/basics/RangeSet.cpp unsigned-integer-overflow:test/app/Batch_test.cpp +unsigned-integer-overflow:test/app/ConfidentialTransfer_test.cpp unsigned-integer-overflow:test/app/Invariants_test.cpp unsigned-integer-overflow:test/app/Loan_test.cpp unsigned-integer-overflow:test/app/NFToken_test.cpp diff --git a/src/benchmarks/libxrpl/nodestore/Backend.cpp b/src/benchmarks/libxrpl/nodestore/Backend.cpp index f854dbd3a7..cd3e15bd65 100644 --- a/src/benchmarks/libxrpl/nodestore/Backend.cpp +++ b/src/benchmarks/libxrpl/nodestore/Backend.cpp @@ -20,21 +20,22 @@ namespace xrpl::node_store { namespace { -constexpr std::size_t kPoolSizes[] = {1000, 10000, 100000}; -constexpr int kThreadCounts[] = {1, 4, 8}; +constexpr auto kPoolSizes = std::to_array({1000, 10000, 100000}); +constexpr auto kThreadCounts = std::to_array({1, 4, 8}); constexpr std::size_t kBatchSize = 256; +constexpr std::size_t kMissRatio = 5; constexpr std::string_view kNamePrefix = "BM_Backend_"; constexpr std::string_view kNameSeparator = "/"; struct RunState { - std::unique_ptr harness; - Batch present; // prefix-1 objects, eligible to be stored - Batch recent; // prefix-1 objects in the "future" key space - std::vector missing; // prefix-2 keys that are never stored - std::vector shuffle; // [0, poolSize) permutation for random-like access - std::size_t avgPayload = 0; // mean getData().size() over `present` + std::unique_ptr harness; ///< backend under test, rebuilt per run + Batch present; ///< prefix-1 objects, eligible to be stored + Batch recent; ///< prefix-1 objects in the "future" key space + std::vector missing; ///< prefix-2 keys that are never stored + std::vector shuffle; ///< [0, poolSize) permutation for random-like access + std::size_t avgPayload = 0; ///< mean getData().size() over `present` void release() @@ -85,7 +86,7 @@ Workload const kInsert{ }, .iterate = [](IterateContext const& ctx) { - auto& [rs, backend, index, poolSize] = ctx; + auto const& [rs, backend, index, poolSize] = ctx; backend.store(rs.present[index % poolSize]); }, .reportBytes = true, @@ -104,7 +105,7 @@ Workload const kFetch{ }, .iterate = [](IterateContext const& ctx) { - auto& [rs, backend, index, poolSize] = ctx; + auto const& [rs, backend, index, poolSize] = ctx; std::shared_ptr result; backend.fetch(rs.present[index % poolSize]->getHash(), &result); benchmark::DoNotOptimize(result); @@ -118,7 +119,7 @@ Workload const kMissing{ .setup = [](SetupContext const& ctx) { ctx.rs.missing = makeMissingKeys(ctx.poolSize); }, .iterate = [](IterateContext const& ctx) { - auto& [rs, backend, index, poolSize] = ctx; + auto const& [rs, backend, index, poolSize] = ctx; std::shared_ptr result; backend.fetch(rs.missing[index % poolSize], &result); benchmark::DoNotOptimize(result); @@ -139,10 +140,10 @@ Workload const kMixed{ }, .iterate = [](IterateContext const& ctx) { - auto& [rs, backend, index, poolSize] = ctx; + auto const& [rs, backend, index, poolSize] = ctx; std::shared_ptr result; auto const pick = rs.shuffle[index % poolSize]; - if (index % 5 == 0) + if (index % kMissRatio == 0) { backend.fetch(rs.missing[pick], &result); } @@ -170,7 +171,7 @@ Workload const kWork{ }, .iterate = [](IterateContext const& ctx) { - auto& [rs, backend, index, poolSize] = ctx; + auto const& [rs, backend, index, poolSize] = ctx; auto const slot = index % poolSize; auto const pick = rs.shuffle[slot]; @@ -239,7 +240,7 @@ registerWorkload(BackendConfig const& bc, Workload const& w) { auto rs = std::make_shared(); auto* b = benchmark::RegisterBenchmark(name, makeRunner(w, cfg, rs)); - b->RangeMultiplier(10)->Range(kPoolSizes[0], kPoolSizes[std::size(kPoolSizes) - 1]); + b->RangeMultiplier(10)->Range(kPoolSizes.front(), kPoolSizes.back()); b->Threads(1)->Threads(4)->Threads(8)->UseRealTime(); return; @@ -249,14 +250,14 @@ registerWorkload(BackendConfig const& bc, Workload const& w) { for (auto const threads : kThreadCounts) { - if (poolSize % static_cast(threads) != 0) + if (poolSize % threads != 0) continue; auto rs = std::make_shared(); benchmark::RegisterBenchmark(name, makeRunner(w, cfg, rs)) ->Arg(poolSize) - ->Iterations(poolSize / static_cast(threads)) - ->Threads(threads) + ->Iterations(poolSize / threads) + ->Threads(static_cast(threads)) ->UseRealTime(); } } @@ -289,7 +290,7 @@ registerStoreBatch(BackendConfig const& bc) rs->harness = std::make_unique(cfg); rs->present = makePool(1, poolSize); rs->avgPayload = averagePayload(rs->present); - std::vector const batches = sliceBatches(rs->present, kBatchSize); + std::vector const batches = sliceFixedBatches(rs->present, kBatchSize); if (batches.empty()) { state.SkipWithError("pool smaller than one batch"); diff --git a/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h b/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h index 57abf42e89..debdc5d47a 100644 --- a/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h +++ b/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -40,18 +41,13 @@ inline void rngcpy(void* buffer, std::size_t bytes, Generator& g) { using result_type = typename Generator::result_type; - while (bytes >= sizeof(result_type)) + while (bytes > 0) { auto const v = g(); - std::memcpy(buffer, &v, sizeof(v)); - buffer = reinterpret_cast(buffer) + sizeof(v); - bytes -= sizeof(v); - } - - if (bytes > 0) - { - auto const v = g(); - std::memcpy(buffer, &v, bytes); + auto const chunk = std::min(bytes, sizeof(result_type)); + std::memcpy(buffer, &v, chunk); + buffer = reinterpret_cast(buffer) + chunk; + bytes -= chunk; } } @@ -145,7 +141,7 @@ makePool(std::uint8_t prefix, std::size_t count, std::size_t start = 0) Sequence seq(prefix); Batch pool; pool.reserve(count); - for (std::size_t i = 0; i < count; ++i) + for (auto i = 0uz; i < count; ++i) pool.push_back(seq.obj(start + i)); return pool; } @@ -158,7 +154,7 @@ makeMissingKeys(std::size_t count) Sequence seq(2); std::vector keys; keys.reserve(count); - for (std::size_t i = 0; i < count; ++i) + for (auto i = 0uz; i < count; ++i) keys.push_back(seq.key(i)); return keys; } @@ -206,16 +202,16 @@ inline std::vector makeShuffle(std::size_t size, std::uint64_t seed) { std::vector v(size); - std::iota(v.begin(), v.end(), std::size_t{0}); + std::ranges::iota(v, 0uz); beast::xor_shift_engine gen(seed); - std::shuffle(v.begin(), v.end(), gen); + std::ranges::shuffle(v, gen); return v; } // Partition a pool into fixed-size batches. Any trailing remainder shorter than // `batchSize` is dropped, so every returned batch has exactly `batchSize`. inline std::vector -sliceBatches(Batch const& pool, std::size_t batchSize) +sliceFixedBatches(Batch const& pool, std::size_t batchSize) { std::vector batches; if (batchSize == 0) @@ -228,13 +224,10 @@ sliceBatches(Batch const& pool, std::size_t batchSize) /** * @brief RAII owner of a NodeStore Backend opened on a private temporary directory. - * - * Member declaration order matters: `tempDir` is declared first so it is - * destroyed last, after the backend has closed and released its files. */ struct BackendHarness { - beast::TempDir tempDir; + beast::TempDir tempDir; ///< Declared first so it is destroyed last DummyScheduler scheduler; beast::Journal journal{beast::Journal::getNullSink()}; std::unique_ptr backend; diff --git a/src/libxrpl/beast/core/SemanticVersion.cpp b/src/libxrpl/beast/core/SemanticVersion.cpp index a99437f8f2..f902a14b07 100644 --- a/src/libxrpl/beast/core/SemanticVersion.cpp +++ b/src/libxrpl/beast/core/SemanticVersion.cpp @@ -15,7 +15,7 @@ namespace beast { std::string -printIdentifiers(SemanticVersion::identifier_list const& list) +printIdentifiers(SemanticVersion::IdentifierList const& list) { std::string ret; @@ -115,7 +115,7 @@ extractIdentifier(std::string& value, bool allowLeadingZeroes, std::string& inpu bool extractIdentifiers( - SemanticVersion::identifier_list& identifiers, + SemanticVersion::IdentifierList& identifiers, bool allowLeadingZeroes, std::string& input) { diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index e6c3d632c1..dac2c67181 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -130,6 +131,127 @@ isRounded(Asset const& asset, Number const& value, std::int32_t scale) roundToAsset(asset, value, scale, Number::RoundingMode::Upward); } +namespace Accrual { + +AccountingDeltas +loanOriginationDeltas(Number const& principalRequested, Number const& interestDue) +{ + return {.assetsTotalDelta = interestDue, .debtTotalDelta = principalRequested + interestDue}; +} + +bool +loanOriginationExceedsVaultMaximum( + Number const& vaultMaximum, + Number const& vaultTotal, + Number const& interestDue) +{ + return vaultMaximum != 0 && interestDue > vaultMaximum - vaultTotal; +} + +/* +XLS-66 section 3.2.3.2, defines the default amount as + +DefaultAmount = (Loan.PrincipalOutstanding + Loan.InterestOutstanding) + +Which is equivalent to (Loan.TotalValueOutstanding - Loan.ManagementFeeOutstanding) +*/ +Number +loanVaultExposure(SLE::const_ref loanSle) +{ + return loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfManagementFeeOutstanding); +} + +AccountingDeltas +loanPaymentDeltas(LoanPaymentParts const& parts) +{ + return { + .assetsTotalDelta = parts.valueChange, + .debtTotalDelta = (parts.principalPaid + parts.interestPaid) - parts.valueChange}; +} + +} // namespace Accrual + +namespace CashBasis { + +AccountingDeltas +loanOriginationDeltas(Number const& principalRequested) +{ + return {.assetsTotalDelta = kNumZero, .debtTotalDelta = principalRequested}; +} + +/* + * Under CashBasis accounting, Loan default amount is: + * + * DefaultAmount = Loan.PrincipalOutstanding + */ +Number +loanVaultExposure(SLE::const_ref loanSle) +{ + return loanSle->at(sfPrincipalOutstanding); +} + +AccountingDeltas +loanPaymentDeltas(LoanPaymentParts const& parts) +{ + return {.assetsTotalDelta = parts.interestPaid, .debtTotalDelta = parts.principalPaid}; +} + +} // namespace CashBasis + +namespace { + +// Cash-basis accounting applies only when featureLendingProtocolV1_1 is +// enabled AND the specific Vault was created under it (LEVersion == +// VaultVersion::CashBasis). Vaults created before activation keep accrual-basis +// accounting forever, even after the amendment later turns on. +bool +cashBasisEnabled(SLE::const_ref vaultSle) +{ + return getVaultVersion(vaultSle) == VaultVersion::CashBasis; +} + +} // namespace + +AccountingDeltas +loanOriginationDeltas( + SLE::const_ref vaultSle, + Number const& principalRequested, + Number const& interestDue) +{ + return cashBasisEnabled(vaultSle) + ? CashBasis::loanOriginationDeltas(principalRequested) + : Accrual::loanOriginationDeltas(principalRequested, interestDue); +} + +bool +loanOriginationExceedsVaultMaximum( + SLE::const_ref vaultSle, + Number const& vaultTotal, + Number const& interestDue) +{ + // Cash-basis origination doesn't recognize interest into AssetsTotal, so + // interest due can never push the vault past AssetsMaximum at origination. + if (cashBasisEnabled(vaultSle)) + return false; + + auto const vaultMaximum = vaultSle->at(sfAssetsMaximum); + return Accrual::loanOriginationExceedsVaultMaximum(vaultMaximum, vaultTotal, interestDue); +} + +Number +loanVaultExposure(SLE::const_ref vaultSle, SLE::const_ref loanSle) +{ + return cashBasisEnabled(vaultSle) ? CashBasis::loanVaultExposure(loanSle) + : Accrual::loanVaultExposure(loanSle); +} + +AccountingDeltas +loanPaymentDeltas(SLE::const_ref vaultSle, LoanPaymentParts const& parts) +{ + return cashBasisEnabled(vaultSle) ? CashBasis::loanPaymentDeltas(parts) + : Accrual::loanPaymentDeltas(parts); +} + namespace detail { void diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index b5b076d1cb..78f64d2077 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -6,6 +6,7 @@ #include #include #include // IWYU pragma: keep +#include #include #include #include @@ -13,6 +14,7 @@ #include #include +#include namespace xrpl { @@ -137,4 +139,22 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref return sleToken->getFieldU64(sfMPTAmount) == outstanding; } +[[nodiscard]] VaultVersion +getVaultVersion(SLE::const_ref vault) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultVersion : valid Vault sle"); + if (!vault->isFieldPresent(sfLEVersion)) + return VaultVersion::Legacy; + + auto const version = vault->at(sfLEVersion); + if (version > std::to_underlying(VaultVersion::CashBasis)) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::getVaultVersion : invalid vault version"); + return VaultVersion::Legacy; + // LCOV_EXCL_STOP + } + return static_cast(version); +} + } // namespace xrpl diff --git a/src/libxrpl/shamap/SHAMapNodeID.cpp b/src/libxrpl/shamap/SHAMapNodeID.cpp index 16aaafe709..a511fc038c 100644 --- a/src/libxrpl/shamap/SHAMapNodeID.cpp +++ b/src/libxrpl/shamap/SHAMapNodeID.cpp @@ -129,7 +129,8 @@ selectBranch(SHAMapNodeID const& id, uint256 const& hash) SHAMapNodeID SHAMapNodeID::createID(int depth, uint256 const& key) { - XRPL_ASSERT((depth >= 0) && (depth < 65), "xrpl::SHAMapNodeID::createID : valid branch input"); + XRPL_ASSERT( + depth >= 0 && depth <= SHAMap::kLeafDepth, "xrpl::SHAMapNodeID::createID : valid depth"); return SHAMapNodeID(depth, key & depthMask(depth)); } diff --git a/src/libxrpl/shamap/SHAMapSync.cpp b/src/libxrpl/shamap/SHAMapSync.cpp index cc30426f9d..cbed6885c9 100644 --- a/src/libxrpl/shamap/SHAMapSync.cpp +++ b/src/libxrpl/shamap/SHAMapSync.cpp @@ -107,7 +107,7 @@ SHAMap::visitNodes(std::function const& function) const void SHAMap::visitDifferences( - SHAMap const* have, + SHAMap const* map, std::function const& function) const { // Visit every node in this SHAMap that is not present @@ -118,13 +118,13 @@ SHAMap::visitDifferences( if (root_->getHash().isZero()) return; - if ((have != nullptr) && (root_->getHash() == have->root_->getHash())) + if ((map != nullptr) && (root_->getHash() == map->root_->getHash())) return; if (root_->isLeaf()) { auto leaf = intr_ptr::staticPointerCast(root_); - if ((have == nullptr) || !have->hasLeafNode(leaf->peekItem()->key(), leaf->getHash())) + if ((map == nullptr) || !map->hasLeafNode(leaf->peekItem()->key(), leaf->getHash())) function(*root_); return; } @@ -149,18 +149,15 @@ SHAMap::visitDifferences( if (!node->isEmptyBranch(i)) { auto const& childHash = node->getChildHash(i); - SHAMapNodeID const childID = nodeID.getChildNodeID(i); + auto const childID = nodeID.getChildNodeID(i); auto next = descendThrow(node, i); if (next->isInner()) { - if ((have == nullptr) || !have->hasInnerNode(childID, childHash)) + if ((map == nullptr) || !map->hasInnerNode(childID, childHash)) stack.emplace(safeDowncast(next), childID); } - else if ( - (have == nullptr) || - !have->hasLeafNode( - safeDowncast(next)->peekItem()->key(), childHash)) + else if ((map == nullptr) || !map->hasLeafNode(leafKey(*next), childHash)) { if (!function(*next)) return; @@ -414,7 +411,7 @@ SHAMap::getMissingNodes(int max, SHAMapSyncFilter const* filter) bool SHAMap::getNodeFat( SHAMapNodeID const& wanted, - std::vector>& data, + std::vector& data, bool fatLeaves, std::uint32_t depth) const { @@ -460,7 +457,7 @@ SHAMap::getNodeFat( // Add this node to the reply s.erase(); node->serializeForWire(s); - data.emplace_back(nodeID, s.getData()); + data.emplace_back(nodeID, node->isLeaf(), s.getData()); if (node->isInner()) { @@ -490,7 +487,7 @@ SHAMap::getNodeFat( // Just include this node s.erase(); childNode->serializeForWire(s); - data.emplace_back(childID, s.getData()); + data.emplace_back(childID, childNode->isLeaf(), s.getData()); } } } @@ -508,25 +505,33 @@ SHAMap::serializeRoot(Serializer& s) const } SHAMapAddNode -SHAMap::addRootNode(SHAMapHash const& hash, Slice const& rootNode, SHAMapSyncFilter const* filter) +SHAMap::addRootNode( + SHAMapHash const& hash, + SHAMapTreeNodePtr rootNode, + SHAMapSyncFilter const* filter) { + XRPL_ASSERT(cowid_ >= 1, "xrpl::SHAMap::addRootNode : valid cowid"); + XRPL_ASSERT(rootNode, "xrpl::SHAMap::addRootNode : non-null root node"); + // we already have a root_ node if (root_->getHash().isNonZero()) { - JLOG(journal_.trace()) << "got root node, already have one"; - XRPL_ASSERT(root_->getHash() == hash, "xrpl::SHAMap::addRootNode : valid hash input"); + JLOG(journal_.trace()) << "Got root node, already have one"; + XRPL_ASSERT(root_->getHash() == hash, "xrpl::SHAMap::addRootNode : valid hash"); return SHAMapAddNode::duplicate(); } - XRPL_ASSERT(cowid_ >= 1, "xrpl::SHAMap::addRootNode : valid cowid"); - auto node = SHAMapTreeNode::makeFromWire(rootNode); - if (!node || node->getHash() != hash) + if (rootNode->getHash() != hash) + { + JLOG(journal_.warn()) << "Corrupt root node received: expected hash " << hash << ", got " + << rootNode->getHash(); return SHAMapAddNode::invalid(); + } if (backed_) - canonicalize(hash, node); + canonicalize(hash, rootNode); - root_ = node; + root_ = std::move(rootNode); if (root_->isLeaf()) clearSynching(); @@ -543,9 +548,18 @@ SHAMap::addRootNode(SHAMapHash const& hash, Slice const& rootNode, SHAMapSyncFil } SHAMapAddNode -SHAMap::addKnownNode(SHAMapNodeID const& node, Slice const& rawNode, SHAMapSyncFilter const* filter) +SHAMap::addKnownNode( + SHAMapNodeID const& nodeID, + SHAMapTreeNodePtr treeNode, + SHAMapSyncFilter const* filter) { - XRPL_ASSERT(!node.isRoot(), "xrpl::SHAMap::addKnownNode : valid node input"); + XRPL_ASSERT(!nodeID.isRoot(), "xrpl::SHAMap::addKnownNode : valid node"); + XRPL_ASSERT(treeNode, "xrpl::SHAMap::addKnownNode : non-null tree node"); + XRPL_ASSERT( + !treeNode->isLeaf() || + SHAMapNodeID::createID(nodeID.getDepth(), leafKey(*treeNode)).getNodeID() == + nodeID.getNodeID(), + "xrpl::SHAMap::addKnownNode : leaf position consistent with node ID"); if (!isSynching()) { @@ -559,14 +573,15 @@ SHAMap::addKnownNode(SHAMapNodeID const& node, Slice const& rawNode, SHAMapSyncF while (currNode->isInner() && !safeDowncast(currNode)->isFullBelow(generation) && - (currNodeID.getDepth() < node.getDepth())) + (currNodeID.getDepth() < nodeID.getDepth())) { - int const branch = selectBranch(currNodeID, node.getNodeID()); + int const branch = selectBranch(currNodeID, nodeID.getNodeID()); XRPL_ASSERT(branch >= 0, "xrpl::SHAMap::addKnownNode : valid branch"); auto inner = safeDowncast(currNode); if (inner->isEmptyBranch(branch)) { - JLOG(journal_.warn()) << "Add known node for empty branch" << node; + JLOG(journal_.warn()) << "Add known node " << nodeID << " for empty branch " << branch + << " at " << currNodeID; return SHAMapAddNode::invalid(); } @@ -582,67 +597,45 @@ SHAMap::addKnownNode(SHAMapNodeID const& node, Slice const& rawNode, SHAMapSyncF if (currNode != nullptr) continue; - auto newNode = SHAMapTreeNode::makeFromWire(rawNode); - - if (!newNode || childHash != newNode->getHash()) + if (childHash != treeNode->getHash()) { - JLOG(journal_.warn()) << "Corrupt node received"; + JLOG(journal_.warn()) << "Corrupt node " << nodeID << " received: expected hash " + << childHash << ", got " << treeNode->getHash(); return SHAMapAddNode::invalid(); } - // In rare cases, a node can still be corrupt even after hash - // validation. For leaf nodes, we perform an additional check to - // ensure the node's position in the tree is consistent with its - // content to prevent inconsistencies that could - // propagate further down the line. - if (newNode->isLeaf()) - { - auto const& actualKey = - safeDowncast(newNode.get())->peekItem()->key(); - - // Validate that this leaf belongs at the target position - auto const expectedNodeID = SHAMapNodeID::createID(node.getDepth(), actualKey); - if (expectedNodeID.getNodeID() != node.getNodeID()) - { - JLOG(journal_.debug()) - << "Leaf node position mismatch: " - << "expected=" << expectedNodeID.getNodeID() << ", actual=" << node.getNodeID(); - return SHAMapAddNode::invalid(); - } - } - // Inner nodes must be at a level strictly less than 64 // but leaf nodes (while notionally at level 64) can be // at any depth up to and including 64: if ((currNodeID.getDepth() > kLeafDepth) || - (newNode->isInner() && currNodeID.getDepth() == kLeafDepth)) + (treeNode->isInner() && currNodeID.getDepth() == kLeafDepth)) { // Map is provably invalid state_ = SHAMapState::Invalid; return SHAMapAddNode::useful(); } - if (currNodeID != node) + if (currNodeID != nodeID) { // Either this node is broken or we didn't request it (yet) - JLOG(journal_.warn()) << "unable to hook node " << node; + JLOG(journal_.warn()) << "unable to hook node " << nodeID; JLOG(journal_.info()) << " stuck at " << currNodeID; - JLOG(journal_.info()) << "got depth=" << node.getDepth() + JLOG(journal_.info()) << "got depth=" << nodeID.getDepth() << ", walked to= " << currNodeID.getDepth(); return SHAMapAddNode::useful(); } if (backed_) - canonicalize(childHash, newNode); + canonicalize(childHash, treeNode); - newNode = prevNode->canonicalizeChild(branch, std::move(newNode)); + treeNode = prevNode->canonicalizeChild(branch, std::move(treeNode)); if (filter != nullptr) { Serializer s; - newNode->serializeWithPrefix(s); + treeNode->serializeWithPrefix(s); filter->gotNode( - false, childHash, ledgerSeq_, std::move(s.modData()), newNode->getType()); + false, childHash, ledgerSeq_, std::move(s.modData()), treeNode->getType()); } return SHAMapAddNode::useful(); diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index a9ba0ec874..eca50eb809 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -483,6 +483,12 @@ ValidVault::finalize( result = false; } + if (view.rules().enabled(fixCleanup3_4_0) && afterVault.lossUnrealized < kZero) + { + JLOG(j.fatal()) << "Invariant failed: loss unrealized must not be negative"; + result = false; + } + if (afterVault.assetsTotal < kZero) { JLOG(j.fatal()) << "Invariant failed: assets outstanding must be positive"; diff --git a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp index fb47cf0f97..b95d1001e1 100644 --- a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp +++ b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp @@ -283,10 +283,23 @@ OfferCreate::checkAcceptAsset( return asset.visit( [&](Issue const& issue) -> TER { auto const& issuer = issue.getIssuer(); + auto const trustLine = view.read(keylet::trustLine(id, issuer, issue.currency)); + + // Check if the issuer has lsfDisallowIncomingTrustline set. + // If so, the account must already have a trustline to receive tokens. + if (view.rules().enabled(fixCleanup3_4_0) && + issuerAccount->isFlag(lsfDisallowIncomingTrustline)) + { + if (!trustLine) + { + JLOG(j.debug()) << "delay: can't receive IOUs from issuer with " + "DisallowIncomingTrustline set"; + return ((flags & TapRetry) != 0u) ? TER{terNO_LINE} : TER{tecNO_LINE}; + } + } + if (issuerAccount->isFlag(lsfRequireAuth)) { - auto const trustLine = view.read(keylet::trustLine(id, issuer, issue.currency)); - if (!trustLine) { return ((flags & TapRetry) != 0u) ? TER{terNO_LINE} : TER{tecNO_LINE}; @@ -309,8 +322,6 @@ OfferCreate::checkAcceptAsset( } } - auto const trustLine = view.read(keylet::trustLine(id, issue.account, issue.currency)); - if (!trustLine) { return tesSUCCESS; diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp index a0aa948876..a312dba3b3 100644 --- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp @@ -127,23 +127,6 @@ LoanManage::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } -static Number -owedToVault(SLE::ref loanSle) -{ - // Spec section 3.2.3.2, defines the default amount as - // - // DefaultAmount = (Loan.PrincipalOutstanding + Loan.InterestOutstanding) - // - // Loan.InterestOutstanding is not stored directly on ledger. - // It is computed as - // - // Loan.TotalValueOutstanding - Loan.PrincipalOutstanding - - // Loan.ManagementFeeOutstanding - // - // Add that to the original formula, and you get this: - return loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfManagementFeeOutstanding); -} - TER LoanManage::defaultLoan( ApplyView& view, @@ -158,7 +141,7 @@ LoanManage::defaultLoan( std::int32_t const loanScale = loanSle->at(sfLoanScale); auto brokerDebtTotalProxy = brokerSle->at(sfDebtTotal); - Number const totalDefaultAmount = owedToVault(loanSle); + Number const totalDefaultAmount = loanVaultExposure(vaultSle, loanSle); // Apply the First-Loss Capital to the Default Amount TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; @@ -304,7 +287,7 @@ LoanManage::impairLoan( Asset const& vaultAsset, beast::Journal j) { - Number const lossUnrealized = owedToVault(loanSle); + Number const lossUnrealized = loanVaultExposure(vaultSle, loanSle); // The vault may be at a different scale than the loan. Reduce rounding // errors during the accounting by rounding some of the values to that @@ -353,7 +336,7 @@ LoanManage::unimpairLoan( // Update the Vault object(clear "paper loss") auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); - Number const lossReversed = owedToVault(loanSle); + Number const lossReversed = loanVaultExposure(vaultSle, loanSle); if (vaultLossUnrealizedProxy < lossReversed) { // LCOV_EXCL_START diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 54ee85b186..0053ed496e 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -420,10 +420,13 @@ LoanPay::doApply() // LCOV_EXCL_STOP } + auto const [assetsTotalDelta, debtTotalDelta] = loanPaymentDeltas(vaultSle, *paymentParts); + JLOG(j_.debug()) << "Loan Pay: principal paid: " << paymentParts->principalPaid << ", interest paid: " << paymentParts->interestPaid << ", fee paid: " << paymentParts->feePaid - << ", value change: " << paymentParts->valueChange; + << ", assets total delta: " << assetsTotalDelta + << ", debt total delta: " << debtTotalDelta; //------------------------------------------------------ // LoanBroker object state changes @@ -439,13 +442,6 @@ LoanPay::doApply() !asset.integral() || totalPaidToVaultRaw == totalPaidToVaultRounded, "xrpl::LoanPay::doApply", "rounding does nothing for integral asset"); - // Account for value changes when reducing the broker's debt: - // - Positive value change (from full/late/overpayments): Subtract from the - // amount credited toward debt to avoid over-reducing the debt. - // - Negative value change (from full/overpayments): Add to the amount - // credited toward debt,effectively increasing the debt reduction. - auto const totalPaidToVaultForDebt = totalPaidToVaultRaw - paymentParts->valueChange; - auto const totalPaidToBroker = paymentParts->feePaid; XRPL_ASSERT_PARTS( @@ -455,16 +451,16 @@ LoanPay::doApply() "payments add up"); // Decrease LoanBroker Debt by the amount paid, add the Loan value change - // (which might be negative). totalPaidToVaultForDebt may be negative, - // increasing the debt + // (which might be negative). debtTotalDelta may be negative, increasing the + // debt XRPL_ASSERT_PARTS( - isRounded(asset, totalPaidToVaultForDebt, loanScale), + isRounded(asset, debtTotalDelta, loanScale), "xrpl::LoanPay::doApply", - "totalPaidToVaultForDebt rounding good"); + "debtTotalDelta rounding good"); // Despite our best efforts, it's possible for rounding errors to accumulate // in the loan broker's debt total. This is because the broker may have more // than one loan with significantly different scales. - adjustImpreciseNumber(debtTotalProxy, -totalPaidToVaultForDebt, asset, vaultScale); + adjustImpreciseNumber(debtTotalProxy, -debtTotalDelta, asset, vaultScale); //------------------------------------------------------ // Vault object state changes @@ -490,7 +486,7 @@ LoanPay::doApply() #endif assetsAvailableProxy += totalPaidToVaultRounded; - assetsTotalProxy += paymentParts->valueChange; + assetsTotalProxy += assetsTotalDelta; XRPL_ASSERT_PARTS( *assetsAvailableProxy <= *assetsTotalProxy, @@ -543,11 +539,11 @@ LoanPay::doApply() return tecPRECISION_LOSS; // LCOV_EXCL_STOP } - if (paymentParts->valueChange != beast::kZero && assetsTotalAfter == assetsTotalBefore) + if (assetsTotalDelta != beast::kZero && assetsTotalAfter == assetsTotalBefore) { - // Non-zero valueChange with an unchanged assetsTotal indicates that the - // actual value change rounded to zero. That should be impossible, but I - // can't rule it out for extreme edge cases, so fail gracefully if it + // Non-zero assetsTotalDelta with an unchanged assetsTotal indicates that + // the actual value change rounded to zero. That should be impossible, but + // I can't rule it out for extreme edge cases, so fail gracefully if it // happens. // // LCOV_EXCL_START @@ -555,20 +551,21 @@ LoanPay::doApply() << "LoanPay: Vault assets expected change, but unchanged after rounding: " // << "Before: " << assetsTotalBefore // << ", After: " << assetsTotalAfter // - << ", ValueChange: " << paymentParts->valueChange; + << ", AssetsTotalDelta: " << assetsTotalDelta; return tecPRECISION_LOSS; // LCOV_EXCL_STOP } - if (paymentParts->valueChange == beast::kZero && assetsTotalAfter != assetsTotalBefore) + if (assetsTotalDelta == beast::kZero && assetsTotalAfter != assetsTotalBefore) { - // A change in assetsTotal when there was no valueChange indicates that - // something really weird happened. That should be flat out impossible. + // A change in assetsTotal when there was no assetsTotalDelta indicates + // that something really weird happened. That should be flat out + // impossible. // // LCOV_EXCL_START JLOG(j_.fatal()) << "LoanPay: Vault assets changed unexpectedly after rounding: " // << "Before: " << assetsTotalBefore // << ", After: " << assetsTotalAfter // - << ", ValueChange: " << paymentParts->valueChange; + << ", AssetsTotalDelta: " << assetsTotalDelta; return tecINTERNAL; // LCOV_EXCL_STOP } diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 694d01c69f..bafadd7c1d 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -439,12 +439,12 @@ LoanSet::doApply() principalRequested, properties.loanState.managementFeeDue); - auto const vaultMaximum = *vaultSle->at(sfAssetsMaximum); XRPL_ASSERT_PARTS( - vaultMaximum == 0 || vaultMaximum > *vaultTotalProxy, + *vaultSle->at(sfAssetsMaximum) == 0 || *vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy, "xrpl::LoanSet::doApply", "Vault is below maximum limit"); - if (vaultMaximum != 0 && state.interestDue > vaultMaximum - vaultTotalProxy) + + if (loanOriginationExceedsVaultMaximum(vaultSle, vaultTotalProxy, state.interestDue)) { JLOG(j_.warn()) << "Loan would exceed the maximum assets of the vault"; return tecLIMIT_EXCEEDED; @@ -490,8 +490,9 @@ LoanSet::doApply() auto const loanAssetsToBorrower = principalRequested - originationFee; - auto const newDebtDelta = principalRequested + state.interestDue; - auto const newDebtTotal = brokerSle->at(sfDebtTotal) + newDebtDelta; + auto const [assetsTotalDelta, debtTotalDelta] = + loanOriginationDeltas(vaultSle, principalRequested, state.interestDue); + auto const newDebtTotal = brokerSle->at(sfDebtTotal) + debtTotalDelta; if (auto const debtMaximum = brokerSle->at(sfDebtMaximum); debtMaximum != 0 && debtMaximum < newDebtTotal) { @@ -634,7 +635,7 @@ LoanSet::doApply() // Update the balances in the vault vaultAvailableProxy -= principalRequested; - vaultTotalProxy += state.interestDue; + vaultTotalProxy += assetsTotalDelta; XRPL_ASSERT_PARTS( *vaultAvailableProxy <= *vaultTotalProxy, "xrpl::LoanSet::doApply", @@ -642,7 +643,7 @@ LoanSet::doApply() view.update(vaultSle); // Update the balances in the loan broker - adjustImpreciseNumber(brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale); + adjustImpreciseNumber(brokerSle->at(sfDebtTotal), debtTotalDelta, vaultAsset, vaultScale); adjustLoanBrokerOwnerCount(view, brokerSle, 1, j_); loanSequenceProxy += 1; // The sequence should be extremely unlikely to roll over, but fail if it diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index e1f5873a89..a522f62788 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -30,6 +30,7 @@ #include #include #include +#include namespace xrpl { @@ -241,6 +242,8 @@ VaultCreate::doApply() } if (scale != 0u) vault->at(sfScale) = scale; + if (view().rules().enabled(featureLendingProtocolV1_1)) + vault->at(sfLEVersion) = std::to_underlying(VaultVersion::CashBasis); view().insert(vault); // Explicitly create MPToken for the vault owner diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp index d3e0182db5..0fc5d6f845 100644 --- a/src/test/app/ConfidentialTransfer_test.cpp +++ b/src/test/app/ConfidentialTransfer_test.cpp @@ -5469,6 +5469,429 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase } } + void + testSendOverdraftBulletproof(FeatureBitset features) + { + uint64_t const balance = 100; + testSendOverdraftBulletproofImpl(features, balance, balance); // SUCCEED + testSendOverdraftBulletproofImpl(features, balance, balance + 1); // FAIL + } + + void + testSendOverdraftBulletproofImpl(FeatureBitset features, unsigned balance, unsigned amt) + { + testcase("Send: overdraft prevention via bulletproof"); + using namespace test::jtx; + + // Attack scenario: Alice has 100 tokens, tries to send 101 to Bob. + // The client-side check in mpt-crypto:mpt_utility.cpp:743 prevents honest + // clients from creating this proof. We bypass it by manually + // constructing a forged proof to demonstrate that the ledger's + // range proof verification catches the overdraft. + + Env env{*this, features}; + Account const alice("alice"), bob("bob"), issuer("issuer"); + + uint64_t const aliceBalance = balance; + uint64_t const aliceAmount = amt; + uint64_t const aliceRemaining = aliceBalance - aliceAmount; + + // Setup: Alice has 100 tokens converted to confidential + ConfidentialEnv confEnv{ + env, + issuer, + {{.account = alice, .payAmount = 1000, .convertAmount = aliceBalance}, + {.account = bob, .payAmount = 1000, .convertAmount = 30}}}; + auto& mptIssuer = confEnv.mpt; + + std::pair errors = aliceAmount > aliceBalance + ? std::make_pair(-1, TER(tecBAD_PROOF)) + : std::make_pair(0, TER(tesSUCCESS)); + + unsigned const numParticipants = 3; + + // Verify Alice's actual balance before attack + { + auto const balance = requireOptional( + mptIssuer.getDecryptedBalance(alice, MPTTester::holderEncryptedSpending), + "Missing Alice's balance"); + BEAST_EXPECT(balance == aliceBalance); + } + + // We cannot use ConfidentialSendSetup directly because it would + // call mpt_get_confidential_send_proof which has a client-side + // check (amount > balance) at line 743 in mpt_utility.cpp. + // Instead, we manually construct the transaction components. + + Buffer const randomElgamal = generateBlindingFactor(); + Buffer const randomBalance = generateBlindingFactor(); + + // Create encrypted amounts (using the OVERDRAFT amount) + Buffer const aliceEncAmt = mptIssuer.encryptAmount(alice, aliceAmount, randomElgamal); + Buffer const bobEncAmt = mptIssuer.encryptAmount(bob, aliceAmount, randomElgamal); + Buffer const issuerEncAmt = mptIssuer.encryptAmount(issuer, aliceAmount, randomElgamal); + + // Create commitments + // IMPORTANT: Amount commitment uses same randomness as ElGamal encryption! + Buffer const amtCommit = mptIssuer.getPedersenCommitment(aliceAmount, randomElgamal); + Buffer const balanceCommit = mptIssuer.getPedersenCommitment(aliceBalance, randomBalance); + + // Get Alice's current encrypted spending balance + Buffer const aliceEncBalance = requireOptional( + mptIssuer.getEncryptedBalance(alice, MPTTester::holderEncryptedSpending), + "Missing Alice's encrypted spending balance"); + + uint32_t const version = mptIssuer.getMPTokenVersion(alice); + auto const ctxHash = getSendContextHash( + alice.id(), mptIssuer.issuanceID(), env.seq(alice), bob.id(), version); + + // Now we need to manually generate the sigma proof part. + // The sigma proof verifies ciphertext consistency and commitments, + // but doesn't check the range. We'll construct it with the overdraft + // amount to bypass the client-side check. + + // Generate the sigma proof manually using the lower-level secp256k1 API + auto* ctx = mpt_secp256k1_context(); + Buffer sigmaProof(SECP256K1_COMPACT_STANDARD_PROOF_SIZE); + + // Parse all public keys and ciphertexts + secp256k1_pubkey c1, c2Alice, c2Bob, c2Issuer; + // Parse sender's ciphertext C1 (first 33(kCompressedEcPointLength) bytes) + auto x = secp256k1_ec_pubkey_parse(ctx, &c1, aliceEncAmt.data(), kCompressedEcPointLength); + if (!BEAST_EXPECTS(x == 1, "Failed to parse C1")) + return; + // Parse C2 components for all recipients + x = secp256k1_ec_pubkey_parse( + ctx, &c2Alice, aliceEncAmt.data() + kCompressedEcPointLength, kCompressedEcPointLength); + auto y = secp256k1_ec_pubkey_parse( + ctx, &c2Bob, bobEncAmt.data() + kCompressedEcPointLength, kCompressedEcPointLength); + auto z = secp256k1_ec_pubkey_parse( + ctx, + &c2Issuer, + issuerEncAmt.data() + kCompressedEcPointLength, + kCompressedEcPointLength); + if (!BEAST_EXPECTS(x == 1 && y == 1 && z == 1, "Failed to parse C2 components")) + return; + secp256k1_pubkey c2Vec[] = {c2Alice, c2Bob, c2Issuer}; + + // Parse public keys + secp256k1_pubkey pkAlice, pkBob, pkIssuer; + auto alicePubKey = requireOptional(mptIssuer.getPubKey(alice), "Missing alice pubkey"); + auto bobPubKey = requireOptional(mptIssuer.getPubKey(bob), "Missing bob pubkey"); + auto issuerPubKey = requireOptional(mptIssuer.getPubKey(issuer), "Missing issuer pubkey"); + x = secp256k1_ec_pubkey_parse(ctx, &pkAlice, alicePubKey.data(), kCompressedEcPointLength); + y = secp256k1_ec_pubkey_parse(ctx, &pkBob, bobPubKey.data(), kCompressedEcPointLength); + z = secp256k1_ec_pubkey_parse( + ctx, &pkIssuer, issuerPubKey.data(), kCompressedEcPointLength); + if (!BEAST_EXPECTS(x == 1 && y == 1 && z == 1, "Failed to parse public keys")) + return; + secp256k1_pubkey pkVec[] = {pkAlice, pkBob, pkIssuer}; + + // Parse commitments + secp256k1_pubkey pcAmount, pcBalance, b1, b2; + x = secp256k1_ec_pubkey_parse(ctx, &pcAmount, amtCommit.data(), kCompressedEcPointLength); + y = secp256k1_ec_pubkey_parse( + ctx, &pcBalance, balanceCommit.data(), kCompressedEcPointLength); + if (!BEAST_EXPECTS(x == 1 && y == 1, "Failed to parse commitments")) + return; + // Parse balance ciphertext + x = secp256k1_ec_pubkey_parse(ctx, &b1, aliceEncBalance.data(), kCompressedEcPointLength); + y = secp256k1_ec_pubkey_parse( + ctx, &b2, aliceEncBalance.data() + kCompressedEcPointLength, kCompressedEcPointLength); + if (!BEAST_EXPECTS(x == 1 && y == 1, "Failed to parse balance ciphertext")) + return; + + // Get Alice's private key + auto alicePrivKey = requireOptional(mptIssuer.getPrivKey(alice), "Missing alice privkey"); + + // Generate the compact sigma proof (part of mpt_get_confidential_send_proof) + // This will succeed because sigma proof doesn't check amount vs balance + x = secp256k1_compact_standard_prove( + ctx, + sigmaProof.data(), + aliceAmount, + aliceBalance, + randomElgamal.data(), + alicePrivKey.data(), + randomBalance.data(), + numParticipants, + &c1, + c2Vec, + pkVec, + &pcAmount, + &pkAlice, + &pcBalance, + &b1, + &b2, + ctxHash.data()); + if (!BEAST_EXPECTS(x == 1, "Failed to generate sigma proof")) + return; + + // Direct verification + x = secp256k1_compact_standard_verify( + ctx, + sigmaProof.data(), + numParticipants, + &c1, + c2Vec, + pkVec, + &pcAmount, + &pkAlice, + &pcBalance, + &b1, + &b2, + ctxHash.data()); + if (!BEAST_EXPECTS(x == 1, "Sigma verification failed")) + return; + + // Compute the remaining blinding factor: r_remaining = r_balance - r_amount + // This is required because the ledger homomorphically computes: + // C_remaining = C_balance - C_amount = Commit(remaining, r_balance - r_amount) + Buffer randomRemaining(kEcBlindingFactorLength); + Buffer negRandomElgamal(kEcBlindingFactorLength); + secp256k1_mpt_scalar_negate(negRandomElgamal.data(), randomElgamal.data()); + secp256k1_mpt_scalar_add( + randomRemaining.data(), randomBalance.data(), negRandomElgamal.data()); + + // Now forge the bulletproof claiming + auto const forgedBulletproof = getForgedBulletproof( + {aliceAmount, aliceRemaining}, {randomElgamal, randomRemaining}, ctxHash); + + // Combine sigma proof + forged bulletproof + Buffer combinedProof(SECP256K1_COMPACT_STANDARD_PROOF_SIZE + kEcDoubleBulletproofLength); + std::memcpy(combinedProof.data(), sigmaProof.data(), SECP256K1_COMPACT_STANDARD_PROOF_SIZE); + std::memcpy( + combinedProof.data() + SECP256K1_COMPACT_STANDARD_PROOF_SIZE, + forgedBulletproof.data(), + kEcDoubleBulletproofLength); + + // Direct verification + x = mpt_verify_send_range_proof( + combinedProof.data() + SECP256K1_COMPACT_STANDARD_PROOF_SIZE, + amtCommit.data(), + balanceCommit.data(), + ctxHash.data()); + if (!BEAST_EXPECTS(x == errors.first, "Forged proof passed validation")) + return; + + // Attempt the transaction with forged proof + // Expected to FAIL with tecBAD_PROOF + mptIssuer.send({ + .account = alice, + .dest = bob, + .amt = aliceAmount, + .proof = strHex(combinedProof), + .senderEncryptedAmt = aliceEncAmt, + .destEncryptedAmt = bobEncAmt, + .issuerEncryptedAmt = issuerEncAmt, + .amountCommitment = amtCommit, + .balanceCommitment = balanceCommit, + .err = errors.second, + }); + + // Verify Alice's balance unchanged (attack prevented!) + { + auto const balance = requireOptional( + mptIssuer.getDecryptedBalance(alice, MPTTester::holderEncryptedSpending), + "Missing post-attack balance"); + if (aliceAmount > aliceBalance) + { + BEAST_EXPECT(balance == aliceBalance); + } + else + { + BEAST_EXPECT(balance < aliceBalance); + } + } + } + + void + testConvertBackOverdraftBulletproof(FeatureBitset features) + { + uint64_t const balance = 100; + testConvertBackOverdraftBulletproofImpl(features, balance, balance); // SUCCEED + testConvertBackOverdraftBulletproofImpl(features, balance, balance + 1); // FAIL + } + + void + testConvertBackOverdraftBulletproofImpl(FeatureBitset features, uint64_t balance, uint64_t amt) + { + testcase("Convert back: overdraft prevention via bulletproof"); + using namespace test::jtx; + + // Attack scenario: Bob has 100 confidential tokens, tries to convert back 101. + // The client-side check in mpt_get_convert_back_proof would prevent honest + // clients from creating this proof. We bypass it by manually constructing + // a forged proof to demonstrate that the ledger's bulletproof verification + // catches the overdraft. + + Env env{*this, features}; + Account const alice("alice"), bob("bob"), carol("carol"); + + uint64_t const bobBalance = balance; + uint64_t const convertAmount = amt; + uint64_t const bobRemaining = bobBalance - convertAmount; + + // Setup: Bob and Carol both have confidential balance + // Carol ensures outstanding amount >= convertAmount (bypass preclaim check) + // This allows us to test the bulletproof specifically + ConfidentialEnv confEnv{ + env, + alice, + { + {.account = bob, .payAmount = 1000, .convertAmount = bobBalance}, + {.account = carol, + .payAmount = 1000, + .convertAmount = std::max(convertAmount, bobBalance + 1)}, + }}; + auto& mptAlice = confEnv.mpt; + + std::pair errors = convertAmount > bobBalance + ? std::make_pair(-1, TER(tecBAD_PROOF)) + : std::make_pair(0, TER(tesSUCCESS)); + + // Verify Bob's actual balance before attack + { + auto const balance = requireOptional( + mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending), + "Missing Bob's balance"); + BEAST_EXPECT(balance == bobBalance); + } + + // We cannot use the standard getConvertBackProof because it calls + // mpt_get_convert_back_proof which has client-side validation. + // Instead, we manually construct the sigma proof and forge the bulletproof. + + Buffer const blindingFactor = generateBlindingFactor(); + Buffer const pcBlindingFactor = generateBlindingFactor(); + + // Create encrypted amounts for the conversion + Buffer const bobEncAmt = mptAlice.encryptAmount(bob, convertAmount, blindingFactor); + Buffer const issuerEncAmt = mptAlice.encryptAmount(alice, convertAmount, blindingFactor); + + // Create Pedersen commitment to the current balance + Buffer const balanceCommit = mptAlice.getPedersenCommitment(bobBalance, pcBlindingFactor); + + // Get Bob's current encrypted spending balance + Buffer const bobEncBalance = requireOptional( + mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending), + "Missing Bob's encrypted spending balance"); + + uint32_t const version = mptAlice.getMPTokenVersion(bob); + auto const ctxHash = + getConvertBackContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob), version); + + // Now manually generate the compact sigma proof for ConvertBack + auto* ctx = mpt_secp256k1_context(); + Buffer sigmaProof(SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE); + + // Parse the holder's public key + secp256k1_pubkey pkBob; + auto bobPubKey = requireOptional(mptAlice.getPubKey(bob), "Missing bob pubkey"); + auto x = secp256k1_ec_pubkey_parse(ctx, &pkBob, bobPubKey.data(), kCompressedEcPointLength); + if (!BEAST_EXPECTS(x == 1, "Failed to parse Bob's public key")) + return; + + // Parse balance commitment + secp256k1_pubkey pcBalance; + x = secp256k1_ec_pubkey_parse( + ctx, &pcBalance, balanceCommit.data(), kCompressedEcPointLength); + if (!BEAST_EXPECTS(x == 1, "Failed to parse balance commitment")) + return; + + // Parse balance ciphertext (B1, B2) + secp256k1_pubkey b1, b2; + x = secp256k1_ec_pubkey_parse(ctx, &b1, bobEncBalance.data(), kCompressedEcPointLength); + auto y = secp256k1_ec_pubkey_parse( + ctx, &b2, bobEncBalance.data() + kCompressedEcPointLength, kCompressedEcPointLength); + if (!BEAST_EXPECTS(x == 1 && y == 1, "Failed to parse balance ciphertext")) + return; + + // Get Bob's private key + auto bobPrivKey = requireOptional(mptAlice.getPrivKey(bob), "Missing bob privkey"); + + // Generate the compact sigma proof for ConvertBack + // This verifies balance ownership and commitment linkage + x = secp256k1_compact_convertback_prove( + ctx, + sigmaProof.data(), + bobBalance, + bobPrivKey.data(), + pcBlindingFactor.data(), + &pkBob, + &b1, + &b2, + &pcBalance, + ctxHash.data()); + if (!BEAST_EXPECTS(x == 1, "Failed to generate convertback sigma proof")) + return; + + // Verify the sigma proof passes (it doesn't check range) + x = secp256k1_compact_convertback_verify( + ctx, sigmaProof.data(), &pkBob, &b1, &b2, &pcBalance, ctxHash.data()); + if (!BEAST_EXPECTS(x == 1, "Sigma verification failed")) + return; + + // Now forge the single bulletproof claiming the remaining balance is valid + // For ConvertBack, we need to prove: (balance - convertAmount) >= 0 + // We create a commitment to the remainder and generate a bulletproof for it + + // The bulletproof needs the blinding factor for the remainder commitment + // The ledger computes: C_remainder = C_balance - convertAmount*G + // So the blinding factor is just pcBlindingFactor (no randomness in convertAmount*G) + + auto const forgedBulletproof = + getForgedSingleBulletproof(bobRemaining, pcBlindingFactor, ctxHash); + + // Combine sigma proof + forged bulletproof + Buffer combinedProof(kEcConvertBackProofLength); + std::memcpy( + combinedProof.data(), sigmaProof.data(), SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE); + std::memcpy( + combinedProof.data() + SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE, + forgedBulletproof.data(), + kEcSingleBulletproofLength); + + // Direct verification of the full proof + x = mpt_verify_convert_back_proof( + combinedProof.data(), + bobPubKey.data(), + bobEncBalance.data(), + balanceCommit.data(), + convertAmount, + ctxHash.data()); + if (!BEAST_EXPECTS(x == errors.first, "Forged proof verification mismatch")) + return; + + // Attempt the transaction with forged proof + // Expected to FAIL with tecBAD_PROOF when convertAmount > bobBalance + mptAlice.convertBack({ + .account = bob, + .amt = convertAmount, + .proof = combinedProof, + .holderEncryptedAmt = bobEncAmt, + .issuerEncryptedAmt = issuerEncAmt, + .blindingFactor = blindingFactor, + .pedersenCommitment = balanceCommit, + .err = errors.second, + }); + + // Verify Bob's balance unchanged (attack prevented!) + { + auto const postBalance = requireOptional( + mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending), + "Missing post-attack balance"); + if (convertAmount > bobBalance) + { + BEAST_EXPECT(postBalance == bobBalance); + } + else + { + BEAST_EXPECT(postBalance < bobBalance); + } + } + } + void testConvertBackBulletproof(FeatureBitset features) { @@ -8143,6 +8566,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase testConvertBackWithAuditor(features); testConvertBackPedersenProof(features); testConvertBackBulletproof(features); + testConvertBackOverdraftBulletproof(features); // Homomorphic operation tests testSendHomomorphicOverflow(features); @@ -8177,6 +8601,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase testSendInvalidProofContextBinding(features); testSendForgedEqualityProof(features); testSendForgedRangeProof(features); + testSendOverdraftBulletproof(features); testSendNegativeValueMalleability(features); testSendFiatShamirBinding(features); testSendProofComponentReuse(features); diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index eaf1f2704c..ac6d8e068f 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -3374,6 +3374,41 @@ class Invariants_test : public beast::unit_test::Suite precloseXrp, TxAccount::A2); + // A negative loss unrealized must trip the invariant. ttLOAN_MANAGE is + // allowed to change loss unrealized, so it isolates this check from the + // "must not change loss unrealized" invariant. Gated behind + // fixCleanup3_4_0 (see below). + doInvariantCheck( + {"loss unrealized must not be negative"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { + sample.lossUnrealized = -1; + })); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + // Without fixCleanup3_4_0 the same state must NOT trip the invariant, + // preserving pre-amendment behavior (no fork risk). + doInvariantCheck( + makeEnv(defaultAmendments() - fixCleanup3_4_0), + {}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { + sample.lossUnrealized = -1; + })); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject& tx) {}}, + {tesSUCCESS, tesSUCCESS}, + precloseXrp, + TxAccount::A2); + doInvariantCheck( {"set assets outstanding must not exceed assets maximum"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { diff --git a/src/test/app/LedgerNodeHelpers_test.cpp b/src/test/app/LedgerNodeHelpers_test.cpp new file mode 100644 index 0000000000..a9e4e3ebfc --- /dev/null +++ b/src/test/app/LedgerNodeHelpers_test.cpp @@ -0,0 +1,260 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include + +namespace xrpl::tests { + +class LedgerNodeHelpers_test : public beast::unit_test::Suite +{ + static boost::intrusive_ptr + makeTestItem(std::uint32_t seed) + { + Serializer s; + s.add32(seed); + s.add32(seed + 1); + s.add32(seed + 2); + return makeShamapitem(s.getSHA512Half(), s.slice()); + } + + static std::string + serializeNode(SHAMapTreeNodePtr const& node) + { + Serializer s; + node->serializeForWire(s); + auto const slice = s.slice(); + return std::string(slice.begin(), slice.end()); + } + + void + testGetTreeNode() + { + testcase("getTreeNode"); + + // Valid: inner node. It must have at least one child for `serializeNode` to work. + { + auto const innerNode = intr_ptr::makeShared(1); + auto const childNode = intr_ptr::makeShared(1); + innerNode->setChild(0, childNode); + auto const innerData = serializeNode(innerNode); + auto const result = getTreeNode(innerData); + BEAST_EXPECT(result && result->isInner()); + } + + // Valid: leaf node. + { + auto const leafItem = makeTestItem(12345); + auto const leafNode = intr_ptr::makeShared(leafItem, 1); + auto const leafData = serializeNode(leafNode); + auto const result = getTreeNode(leafData); + BEAST_EXPECT(result && result->isLeaf()); + } + + // Invalid: empty data. + { + auto const result = getTreeNode(""); + BEAST_EXPECT(!result); + } + + // Invalid: garbage data. + { + auto const result = getTreeNode("invalid"); + BEAST_EXPECT(!result); + } + + // Invalid: truncated data. + { + auto const leafItem = makeTestItem(54321); + auto const leafNode = intr_ptr::makeShared(leafItem, 1); + // Truncate the data to trigger an exception in SHAMapTreeNode::makeAccountState when + // the data is used to deserialize the node. + uint256 const tag; + auto const leafData = serializeNode(leafNode).substr(0, tag.kBytes - 1); + auto const result = getTreeNode(leafData); + BEAST_EXPECT(!result); + } + } + + void + testGetSHAMapNodeID() + { + testcase("getSHAMapNodeID"); + + { + // Tests using inner nodes at various depths. + auto const innerNode = intr_ptr::makeShared(1); + auto const childNode = intr_ptr::makeShared(1); + innerNode->setChild(0, childNode); + auto const innerData = serializeNode(innerNode); + + // Valid: legacy `nodeid` field at arbitrary depth. + { + auto const innerDepth = 3; + auto const innerID = SHAMapNodeID::createID(innerDepth, uint256{}); + + protocol::TMLedgerNode ledgerNode; + ledgerNode.set_nodedata(innerData); + ledgerNode.set_nodeid(innerID.getRawString()); + auto const result = getSHAMapNodeID(ledgerNode, *innerNode); + BEAST_EXPECT(result == innerID); + } + + // Valid: new `id` field at minimum depth. + { + auto const innerDepth = 0; + auto const innerID = SHAMapNodeID::createID(innerDepth, uint256{}); + + protocol::TMLedgerNode ledgerNode; + ledgerNode.set_nodedata(innerData); + ledgerNode.set_id(innerID.getRawString()); + auto const result = getSHAMapNodeID(ledgerNode, *innerNode); + BEAST_EXPECT(result == innerID); + } + + // Invalid: new `depth` field should not be used for inner nodes. + { + protocol::TMLedgerNode ledgerNode; + ledgerNode.set_nodedata(innerData); + ledgerNode.set_depth(10); + auto const result = getSHAMapNodeID(ledgerNode, *innerNode); + BEAST_EXPECT(!result); + } + + // Invalid: both legacy `nodeid` and new `id` fields set for an inner node. + { + auto const innerDepth = 9; + auto const innerID = SHAMapNodeID::createID(innerDepth, uint256{}); + + protocol::TMLedgerNode ledgerNode; + ledgerNode.set_nodedata(innerData); + ledgerNode.set_nodeid(innerID.getRawString()); + ledgerNode.set_id(innerID.getRawString()); + auto const result = getSHAMapNodeID(ledgerNode, *innerNode); + BEAST_EXPECT(!result); + } + } + + { + // Tests using leaf nodes at various depths. + auto const leafItem = makeTestItem(12345); + auto const leafNode = intr_ptr::makeShared(leafItem, 1); + auto const leafData = serializeNode(leafNode); + auto const leafKey = leafItem->key(); + + // Valid: legacy `nodeid` field at arbitrary depth. + { + auto const kLeafDepth = 5; + auto const leafID = SHAMapNodeID::createID(kLeafDepth, leafKey); + + protocol::TMLedgerNode ledgerNode; + ledgerNode.set_nodedata(leafData); + ledgerNode.set_nodeid(leafID.getRawString()); + auto const result = getSHAMapNodeID(ledgerNode, *leafNode); + BEAST_EXPECT(result == leafID); + } + + // Invalid: new `id` field should not be used for leaf nodes. + { + auto const kLeafDepth = 5; + auto const leafID = SHAMapNodeID::createID(kLeafDepth, leafKey); + + protocol::TMLedgerNode ledgerNode; + ledgerNode.set_nodedata(leafData); + ledgerNode.set_id(leafID.getRawString()); + auto const result = getSHAMapNodeID(ledgerNode, *leafNode); + BEAST_EXPECT(!result); + } + + // Valid: new `depth` field at minimum depth. + { + auto const kLeafDepth = 0; + auto const leafID = SHAMapNodeID::createID(kLeafDepth, leafKey); + + protocol::TMLedgerNode ledgerNode; + ledgerNode.set_nodedata(leafData); + ledgerNode.set_depth(kLeafDepth); + auto const result = getSHAMapNodeID(ledgerNode, *leafNode); + BEAST_EXPECT(result == leafID); + } + + // Valid: new `depth` field at arbitrary depth between minimum and maximum. + { + auto const kLeafDepth = 10; + auto const leafID = SHAMapNodeID::createID(kLeafDepth, leafKey); + + protocol::TMLedgerNode ledgerNode; + ledgerNode.set_nodedata(leafData); + ledgerNode.set_depth(kLeafDepth); + auto const result = getSHAMapNodeID(ledgerNode, *leafNode); + BEAST_EXPECT(result == leafID); + } + + // Valid: new `depth` field at maximum depth. + // Note that we do not test a depth greater than the maximum depth, because the proto + // message is assumed to have been validated by the time the getSHAMapNodeID function is + // called. + { + auto const kLeafDepth = SHAMap::kLeafDepth; + auto const leafID = SHAMapNodeID::createID(kLeafDepth, leafKey); + + protocol::TMLedgerNode ledgerNode; + ledgerNode.set_nodedata(leafData); + ledgerNode.set_depth(kLeafDepth); + auto const result = getSHAMapNodeID(ledgerNode, *leafNode); + BEAST_EXPECT(result == leafID); + } + + // Invalid: legacy `nodeid` field where the node ID is inconsistent with the key. + { + auto const otherItem = makeTestItem(54321); + auto const otherNode = + intr_ptr::makeShared(otherItem, 1); + auto const otherData = serializeNode(otherNode); + auto const otherKey = otherItem->key(); + auto const otherDepth = 1; + auto const otherID = SHAMapNodeID::createID(otherDepth, otherKey); + + protocol::TMLedgerNode ledgerNode; + ledgerNode.set_nodedata(otherData); + ledgerNode.set_nodeid(otherID.getRawString()); + auto const result = getSHAMapNodeID(ledgerNode, *leafNode); + BEAST_EXPECT(!result); + } + } + + // Invalid: no field set. + { + auto const innerNode = intr_ptr::makeShared(1); + protocol::TMLedgerNode ledgerNode; + ledgerNode.set_nodedata("test_data"); + auto const result = getSHAMapNodeID(ledgerNode, *innerNode); + BEAST_EXPECT(!result); + } + } + +public: + void + run() override + { + testGetTreeNode(); + testGetSHAMapNodeID(); + } +}; + +BEAST_DEFINE_TESTSUITE(LedgerNodeHelpers, app, xrpl); + +} // namespace xrpl::tests diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp index ac8e0764fc..1235920fab 100644 --- a/src/test/app/LendingHelpers_test.cpp +++ b/src/test/app/LendingHelpers_test.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +20,7 @@ #include #include #include +#include #include namespace xrpl::test { @@ -1470,6 +1472,326 @@ class LendingHelpers_test : public beast::unit_test::Suite Number{-18304, -5})); } + void + testAccrualLoanOriginationDeltas() + { + using namespace xrpl::Accrual; + + struct TestCase + { + std::string name; + Number principalRequested; + Number interestDue; + }; + + auto const testCases = std::vector{ + {.name = "Zero interest", + .principalRequested = Number{1'000}, + .interestDue = Number{0}}, + {.name = "Nonzero interest", + .principalRequested = Number{1'000}, + .interestDue = Number{75}}, + }; + + for (auto const& tc : testCases) + { + testcase("Accrual::loanOriginationDeltas: " + tc.name); + + auto const deltas = loanOriginationDeltas(tc.principalRequested, tc.interestDue); + BEAST_EXPECTS( + deltas.assetsTotalDelta == tc.interestDue, + "assetsTotalDelta mismatch: expected " + to_string(tc.interestDue) + ", got " + + to_string(deltas.assetsTotalDelta)); + BEAST_EXPECTS( + deltas.debtTotalDelta == tc.principalRequested + tc.interestDue, + "debtTotalDelta mismatch: expected " + + to_string(tc.principalRequested + tc.interestDue) + ", got " + + to_string(deltas.debtTotalDelta)); + } + } + + void + testCashBasisLoanOriginationDeltas() + { + using namespace xrpl::CashBasis; + + testcase("CashBasis::loanOriginationDeltas: interestDue is ignored"); + + Number const principalRequested{1'000}; + Number const interestDue{75}; + + auto const deltas = loanOriginationDeltas(principalRequested); + BEAST_EXPECTS( + deltas.assetsTotalDelta == 0, + "assetsTotalDelta mismatch: expected 0, got " + to_string(deltas.assetsTotalDelta)); + BEAST_EXPECTS( + deltas.debtTotalDelta == principalRequested, + "debtTotalDelta mismatch: expected " + to_string(principalRequested) + ", got " + + to_string(deltas.debtTotalDelta)); + } + + void + testAccrualLoanOriginationExceedsVaultMaximum() + { + using namespace xrpl::Accrual; + + struct TestCase + { + std::string name; + Number vaultMaximum; + Number vaultTotal; + Number interestDue; + bool expected; + }; + + auto const testCases = std::vector{ + {.name = "No maximum configured", + .vaultMaximum = Number{0}, + .vaultTotal = Number{900}, + .interestDue = Number{1'000}, + .expected = false}, + {.name = "Interest fits under headroom", + .vaultMaximum = Number{1'000}, + .vaultTotal = Number{900}, + .interestDue = Number{50}, + .expected = false}, + {.name = "Interest exactly fills headroom", + .vaultMaximum = Number{1'000}, + .vaultTotal = Number{900}, + .interestDue = Number{100}, + .expected = false}, + {.name = "Interest exceeds headroom", + .vaultMaximum = Number{1'000}, + .vaultTotal = Number{900}, + .interestDue = Number{101}, + .expected = true}, + }; + + for (auto const& tc : testCases) + { + testcase("Accrual::loanOriginationExceedsVaultMaximum: " + tc.name); + BEAST_EXPECT( + loanOriginationExceedsVaultMaximum( + tc.vaultMaximum, tc.vaultTotal, tc.interestDue) == tc.expected); + } + } + + // Constructs a minimal ltLOAN SLE with just the fields needed by + // loanVaultExposure. Mirrors the bare-SLE pattern used by + // testCanApplyToBrokerCover for ltLOAN_BROKER. + static std::shared_ptr + makeLoanSle( + Number const& totalValueOutstanding, + Number const& principalOutstanding, + Number const& managementFeeOutstanding) + { + auto sle = std::make_shared(ltLOAN, uint256{1u}); + sle->at(sfTotalValueOutstanding) = totalValueOutstanding; + sle->at(sfPrincipalOutstanding) = principalOutstanding; + sle->at(sfManagementFeeOutstanding) = managementFeeOutstanding; + return sle; + } + + // Constructs a minimal ltVAULT SLE with just LEVersion set (or left + // absent), for exercising the dispatchers' per-Vault gating. + static std::shared_ptr + makeVaultSle( + std::optional leVersion = std::nullopt, + std::optional assetsMaximum = std::nullopt, + std::optional assetsTotal = std::nullopt) + { + auto sle = std::make_shared(ltVAULT, uint256{2u}); + if (leVersion) + sle->at(sfLEVersion) = std::to_underlying(*leVersion); + if (assetsMaximum) + sle->at(sfAssetsMaximum) = *assetsMaximum; + if (assetsTotal) + sle->at(sfAssetsTotal) = *assetsTotal; + return sle; + } + + void + testAccrualLoanVaultExposure() + { + testcase("Accrual::loanVaultExposure"); + + auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); + BEAST_EXPECT(xrpl::Accrual::loanVaultExposure(sle) == Number{950}); + } + + void + testCashBasisLoanVaultExposure() + { + testcase("CashBasis::loanVaultExposure"); + + auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); + BEAST_EXPECT(xrpl::CashBasis::loanVaultExposure(sle) == Number{800}); + } + + void + testLoanPaymentDeltas() + { + // principalPaid, interestPaid, feePaid, valueChange are all distinct + // and nonzero, with a nonzero valueChange simulating a late-payment + // penalty, so Accrual's formula is meaningfully exercised. + LoanPaymentParts const parts{ + .principalPaid = Number{100}, + .interestPaid = Number{20}, + .valueChange = Number{5}, + .feePaid = Number{3}}; + + { + testcase("Accrual::loanPaymentDeltas: nonzero valueChange"); + auto const deltas = xrpl::Accrual::loanPaymentDeltas(parts); + BEAST_EXPECT(deltas.assetsTotalDelta == parts.valueChange); + BEAST_EXPECT( + deltas.debtTotalDelta == + (parts.principalPaid + parts.interestPaid) - parts.valueChange); + } + + { + testcase("CashBasis::loanPaymentDeltas: nonzero valueChange ignored"); + auto const deltas = xrpl::CashBasis::loanPaymentDeltas(parts); + BEAST_EXPECT(deltas.assetsTotalDelta == parts.interestPaid); + BEAST_EXPECT(deltas.debtTotalDelta == parts.principalPaid); + } + } + + void + testLoanOriginationDeltasDispatcher() + { + using namespace jtx; + + Number const principalRequested{1'000}; + Number const interestDue{75}; + + auto const legacyVault = makeVaultSle(); + auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); + + { + testcase( + "loanOriginationDeltas dispatcher: amendment enabled, legacy vault picks " + "Accrual"); + Env const env{*this}; + auto const deltas = loanOriginationDeltas(legacyVault, principalRequested, interestDue); + auto const expected = + xrpl::Accrual::loanOriginationDeltas(principalRequested, interestDue); + BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); + BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); + } + + { + testcase( + "loanOriginationDeltas dispatcher: amendment enabled, LEVersion == " + "VaultVersion::CashBasis picks CashBasis"); + Env const env{*this}; + auto const deltas = + loanOriginationDeltas(cashBasisVault, principalRequested, interestDue); + auto const expected = xrpl::CashBasis::loanOriginationDeltas(principalRequested); + BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); + BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); + } + } + + void + testLoanOriginationExceedsVaultMaximumDispatcher() + { + using namespace jtx; + + Number const vaultMaximum{1'000}; + Number const vaultTotal{900}; + // Exceeds Accrual's headroom (100), but must never trip CashBasis. + Number const interestDue{101}; + + auto const legacyVault = makeVaultSle(std::nullopt, vaultMaximum, vaultTotal); + auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis, vaultMaximum, vaultTotal); + + { + testcase( + "loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, legacy vault " + "picks Accrual"); + Env const env{*this}; + BEAST_EXPECT( + loanOriginationExceedsVaultMaximum(legacyVault, vaultTotal, interestDue) == + xrpl::Accrual::loanOriginationExceedsVaultMaximum( + vaultMaximum, vaultTotal, interestDue)); + } + + { + testcase( + "loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, LEVersion == " + "VaultVersion::CashBasis picks CashBasis"); + Env const env{*this}; + BEAST_EXPECT( + loanOriginationExceedsVaultMaximum(cashBasisVault, vaultTotal, interestDue) == + false); + } + } + + void + testLoanVaultExposureDispatcher() + { + using namespace jtx; + + auto const legacyVault = makeVaultSle(); + auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); + + { + testcase("loanVaultExposure dispatcher: amendment enabled, legacy vault picks Accrual"); + Env const env{*this}; + auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); + BEAST_EXPECT( + loanVaultExposure(legacyVault, sle) == xrpl::Accrual::loanVaultExposure(sle)); + } + + { + testcase( + "loanVaultExposure dispatcher: amendment enabled, LEVersion == " + "VaultVersion::CashBasis " + "picks CashBasis"); + Env const env{*this}; + auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); + BEAST_EXPECT( + loanVaultExposure(cashBasisVault, sle) == xrpl::CashBasis::loanVaultExposure(sle)); + } + } + + void + testLoanPaymentDeltasDispatcher() + { + using namespace jtx; + + LoanPaymentParts const parts{ + .principalPaid = Number{100}, + .interestPaid = Number{20}, + .valueChange = Number{5}, + .feePaid = Number{3}}; + + auto const legacyVault = makeVaultSle(); + auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); + + { + testcase("loanPaymentDeltas dispatcher: amendment enabled, legacy vault picks Accrual"); + Env const env{*this}; + auto const deltas = loanPaymentDeltas(legacyVault, parts); + auto const expected = xrpl::Accrual::loanPaymentDeltas(parts); + BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); + BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); + } + + { + testcase( + "loanPaymentDeltas dispatcher: amendment enabled, LEVersion == " + "VaultVersion::CashBasis " + "picks CashBasis"); + Env const env{*this}; + auto const deltas = loanPaymentDeltas(cashBasisVault, parts); + auto const expected = xrpl::CashBasis::loanPaymentDeltas(parts); + BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); + BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); + } + } + public: void testCanApplyToBrokerCover() @@ -1573,6 +1895,17 @@ public: testComputeOverpaymentComponents(); testComputeInterestAndFeeParts(); testCanApplyToBrokerCover(); + + testAccrualLoanOriginationDeltas(); + testCashBasisLoanOriginationDeltas(); + testAccrualLoanOriginationExceedsVaultMaximum(); + testAccrualLoanVaultExposure(); + testCashBasisLoanVaultExposure(); + testLoanPaymentDeltas(); + testLoanOriginationDeltasDispatcher(); + testLoanOriginationExceedsVaultMaximumDispatcher(); + testLoanVaultExposureDispatcher(); + testLoanPaymentDeltasDispatcher(); } }; diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 231a3b405a..8a6f1669df 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -39,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -92,8 +93,13 @@ class Loan_test : public beast::unit_test::Suite protected: // Ensure that all the features needed for Lending Protocol are included, // even if they are set to unsupported. - - FeatureBitset const all_{jtx::testableAmendments()}; + // + // featureLendingProtocolV1_1 is excluded from the default set: it changes + // Vault/LoanBroker accounting (AssetsTotal/DebtTotal/LossUnrealized), and + // most of this file's tests assert whole-life-specific expected values + // for those fields. Tests that specifically exercise the amendment opt + // it back in explicitly (e.g. `all_ | featureLendingProtocolV1_1`). + FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1}; std::string const iouCurrency_{"IOU"}; void @@ -363,16 +369,21 @@ protected: { TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; auto const brokerDebt = brokerSle->at(sfDebtTotal); - auto const expectedDebt = principalOutstanding + interestOwed; - env.test.BEAST_EXPECT(brokerDebt == expectedDebt); - env.test.BEAST_EXPECT( - env.balance(pseudoAccount, broker.asset).number() == - brokerSle->at(sfCoverAvailable)); - env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount); if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); env.test.BEAST_EXPECT(vaultSle)) { + auto const expectedDebt = + env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis + ? principalOutstanding + : principalOutstanding + interestOwed; + env.test.BEAST_EXPECT(brokerDebt == expectedDebt); + env.test.BEAST_EXPECT( + env.balance(pseudoAccount, broker.asset).number() == + brokerSle->at(sfCoverAvailable)); + env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount); + Account const vaultPseudo{"vaultPseudoAccount", vaultSle->at(sfAccount)}; env.test.BEAST_EXPECT( vaultSle->at(sfAssetsAvailable) == @@ -468,7 +479,10 @@ protected: { env.test.BEAST_EXPECT( vaultSle->at(sfLossUnrealized) == - totalValue - managementFeeOutstanding); + (env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis + ? principalOutstanding + : totalValue - managementFeeOutstanding)); } else { @@ -635,8 +649,11 @@ protected: // log << vaultSle->getJson() << std::endl; auto const assetsUnavailable = vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable); - auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + state.totalValue - - state.managementFeeOutstanding; + auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + + (env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis + ? state.principalOutstanding + : state.totalValue - state.managementFeeOutstanding); if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable)) { @@ -8547,6 +8564,972 @@ protected: }); } + // LendingProtocolV1_1 ("cash-basis" accounting) dedicated coverage. + // + // Existing tests never enable featureLendingProtocolV1_1 (see `all_` + // above), so these are the only tests in this file that exercise the + // amendment. They are called once, directly, from + // runAmendmentIndependent() -- not looped through + // runAmendmentSensitive()/amendmentCombinations(), since doing so would + // require re-deriving whole-life-specific expected values for ~15 + // unrelated regression tests. + + // 1. LoanSet origination: Vault.AssetsTotal/LoanBroker.DebtTotal deltas, + // and the AssetsMaximum/DebtMaximum guards (which always check against + // principal + interestDue, regardless of the amendment). + void + testCashBasisLoanSetOrigination() + { + testcase("cash-basis: LoanSet origination"); + + using namespace jtx; + using namespace loan; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + + Number const principalRequest{10'000}; + TenthBips32 const interestRate{percentageToTenthBips(10)}; + std::uint32_t const paymentTotal = 2; + std::uint32_t const paymentInterval = 86400; + + // Creates a broker/vault, submits a single LoanSet with a nonzero + // interest rate, and returns the observed Vault.AssetsTotal / + // LoanBroker.DebtTotal deltas plus the loan's own computed + // interestDue and principalOutstanding. + auto runOrigination = [&](FeatureBitset features) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBefore && brokerBefore); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); + + auto const loanSequence = brokerBefore->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), + kCounterparty(lender), + kInterestRate(interestRate), + kPaymentTotal(paymentTotal), + kPaymentInterval(paymentInterval), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanSle->at(sfTotalValueOutstanding); + Number const interestDue = totalValueOutstanding - principalOutstanding; + BEAST_EXPECT(interestDue > beast::kZero); + BEAST_EXPECT(principalOutstanding == xrpAsset(principalRequest).value()); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + auto const brokerAfter = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfter && brokerAfter); + Number const assetsTotalDelta = + Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore; + Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore; + + return std::make_tuple( + assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding); + }; + + Number interestDueCash{}; + Number principalOutstandingCash{}; + { + auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] = + runOrigination(all_ | featureLendingProtocolV1_1); + interestDueCash = interestDue; + principalOutstandingCash = principalOutstanding; + + BEAST_EXPECTS( + assetsTotalDelta == beast::kZero, + "cash-basis origination must not change AssetsTotal; delta=" + + to_string(assetsTotalDelta)); + BEAST_EXPECTS( + debtTotalDelta == principalOutstanding, + "cash-basis origination must add principal-only to DebtTotal; delta=" + + to_string(debtTotalDelta) + " principal=" + to_string(principalOutstanding)); + } + + { + auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] = + runOrigination(all_); + + BEAST_EXPECTS( + assetsTotalDelta == interestDue, + "whole-life origination must add interestDue to AssetsTotal; delta=" + + to_string(assetsTotalDelta) + " interestDue=" + to_string(interestDue)); + BEAST_EXPECTS( + debtTotalDelta == principalOutstanding + interestDue, + "whole-life origination must add principal+interest to DebtTotal; delta=" + + to_string(debtTotalDelta)); + } + + // AssetsMaximum guard checks interestDue headroom only under + // whole-life accounting; DebtMaximum guard also varies by model. + auto runVaultGuard = [&](FeatureBitset features, Number const& slack, TER expected) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + Number const assetsTotalBefore = vaultSle->at(sfAssetsTotal); + + Vault const vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = assetsTotalBefore + slack; + env(tx); + env.close(); + + env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), + kCounterparty(lender), + kInterestRate(interestRate), + kPaymentTotal(paymentTotal), + kPaymentInterval(paymentInterval), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(expected)); + env.close(); + }; + + auto runBrokerGuard = [&](FeatureBitset features, Number const& debtMaximum, TER expected) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + env(loanBroker::set(lender, broker.vaultID), + loanBroker::kLoanBrokerId(broker.brokerID), + loanBroker::kDebtMaximum(debtMaximum), + Fee(env.current()->fees().base * 2)); + env.close(); + + env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), + kCounterparty(lender), + kInterestRate(interestRate), + kPaymentTotal(paymentTotal), + kPaymentInterval(paymentInterval), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(expected)); + env.close(); + }; + + Number const oneDrop = xrpAsset(1).value(); + { + testcase("whole-life: LoanSet AssetsMaximum guard checks interestDue headroom"); + // Guard rejects when there's not quite enough headroom for the + // interest. + runVaultGuard(all_, interestDueCash - oneDrop, tecLIMIT_EXCEEDED); + // Guard accepts at the exact boundary. + runVaultGuard(all_, interestDueCash, tesSUCCESS); + } + + { + testcase("cash-basis: LoanSet AssetsMaximum guard ignores interestDue headroom"); + // Even far less headroom than interestDue still succeeds, since + // cash-basis origination never adds interest to AssetsTotal. + runVaultGuard(all_ | featureLendingProtocolV1_1, oneDrop, tesSUCCESS); + } + + // DebtMaximum guard: cash-basis projects principal-only DebtTotal; + // whole-life projects principal + interestDue. + for (auto const cashBasis : {true, false}) + { + testcase( + std::string("LoanSet DebtMaximum guard (") + + (cashBasis ? "cash-basis)" : "whole-life)")); + auto const features = cashBasis ? all_ | featureLendingProtocolV1_1 : all_; + Number const newDebtTotal = + principalOutstandingCash + (cashBasis ? Number{} : interestDueCash); + runBrokerGuard(features, newDebtTotal - oneDrop, tecLIMIT_EXCEEDED); + runBrokerGuard(features, newDebtTotal, tesSUCCESS); + } + } + + // 2. LoanPay: regular, late, overpayment, and full-payment types. + // Assert Vault.AssetsTotal/LoanBroker.DebtTotal deltas match + // interestPaid/principalPaid under cash-basis, and cross-check the + // amendment-disabled run's deltas against the documented whole-life + // formula (AssetsTotal += valueChange; DebtTotal mirrors the loan's own + // TotalValueOutstanding delta exactly, since whole-life debt recognition + // tracks total loan value). + void + testCashBasisLoanPay() + { + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + using tp = NetClock::time_point; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + + Number const principalRequest{12'000}; + TenthBips32 const interestRate{percentageToTenthBips(12)}; + std::uint32_t const paymentTotal = 4; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 300; + + struct PaymentDeltas + { + Number principalPaid; + Number assetsTotalDelta; + Number debtTotalDelta; + Number totalValueDelta; + }; + + // Sets up a fresh broker + loan, advances time, submits a single + // payment of the given type/amount, and returns the observed deltas. + auto runPayment = [&](FeatureBitset features, + std::uint32_t loanSetFlags, + std::uint32_t payFlags, + std::function const& advanceTime, + std::function const& paymentAmount) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = interestRate, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + .flags = loanSetFlags, + }; + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(loanParams(env, broker)); + env.close(); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + + advanceTime(env, state.startDate); + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + auto const loanBefore = env.le(loanKeylet); + BEAST_EXPECT(vaultBefore && brokerBefore && loanBefore); + + Number const principalBefore = loanBefore->at(sfPrincipalOutstanding); + Number const totalValueBefore = loanBefore->at(sfTotalValueOutstanding); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); + + STAmount const amount = paymentAmount(state); + env(pay(borrower, loanKeylet.key, amount, payFlags), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + auto const brokerAfter = env.le(broker.brokerKeylet()); + auto const loanAfter = env.le(loanKeylet); + BEAST_EXPECT(vaultAfter && brokerAfter && loanAfter); + + Number const principalAfter = loanAfter->at(sfPrincipalOutstanding); + Number const totalValueAfter = loanAfter->at(sfTotalValueOutstanding); + Number const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); + Number const debtTotalAfter = brokerAfter->at(sfDebtTotal); + + return PaymentDeltas{ + .principalPaid = principalBefore - principalAfter, + .assetsTotalDelta = assetsTotalAfter - assetsTotalBefore, + .debtTotalDelta = debtTotalAfter - debtTotalBefore, + .totalValueDelta = totalValueAfter - totalValueBefore}; + }; + + // Compares the disabled (whole-life) and enabled (cash-basis) runs + // of the same payment scenario, and asserts the documented + // relationships between them. + auto checkScenario = [&](std::string const& label, + PaymentDeltas const& off, + PaymentDeltas const& on) { + testcase("cash-basis: LoanPay " + label); + + // The loan's own PrincipalOutstanding field is untouched by + // the amendment. + BEAST_EXPECTS( + off.principalPaid == on.principalPaid, + "principalPaid must be amendment-independent; off=" + to_string(off.principalPaid) + + " on=" + to_string(on.principalPaid)); + + // Whole-life structural invariant: DebtTotal (which + // recognizes a loan's full remaining value as debt) must + // change exactly as the loan's own TotalValueOutstanding + // does. + BEAST_EXPECTS( + off.debtTotalDelta == off.totalValueDelta, + "whole-life DebtTotal delta must mirror TotalValueOutstanding delta; " + "debtTotalDelta=" + + to_string(off.debtTotalDelta) + + " totalValueDelta=" + to_string(off.totalValueDelta)); + + // Derive interestPaid from the whole-life run's independent + // ledger deltas: + // assetsTotalDelta_off == valueChange + // debtTotalDelta_off == valueChange - (principalPaid + interestPaid) + // => interestPaid == assetsTotalDelta_off - debtTotalDelta_off - principalPaid + Number const interestPaid = + off.assetsTotalDelta - off.debtTotalDelta - off.principalPaid; + BEAST_EXPECTS( + interestPaid >= beast::kZero, + "derived interestPaid must be non-negative: " + to_string(interestPaid)); + + BEAST_EXPECTS( + on.assetsTotalDelta == interestPaid, + "cash-basis AssetsTotal delta must equal interestPaid; delta=" + + to_string(on.assetsTotalDelta) + " interestPaid=" + to_string(interestPaid)); + BEAST_EXPECTS( + on.debtTotalDelta == -on.principalPaid, + "cash-basis DebtTotal delta must equal -principalPaid; delta=" + + to_string(on.debtTotalDelta) + " principalPaid=" + to_string(on.principalPaid)); + }; + + // ---- Regular, on-time payment ---- + { + auto const noAdvance = [](Env& env, tp const&) { env.close(); }; + auto const regularAmount = [&](LoanState const& state) { + return STAmount{ + xrpAsset, + roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * + Number{3, -1} * 5}; // 1.5x, so only a single period is paid + }; + + auto const off = runPayment(all_, 0, 0, noAdvance, regularAmount); + auto const on = + runPayment(all_ | featureLendingProtocolV1_1, 0, 0, noAdvance, regularAmount); + + // Regular, on-time payments never change the loan's value beyond + // normal amortization (production asserts valueChange == 0), so + // AssetsTotal must be unaffected in the whole-life run. + BEAST_EXPECTS( + off.assetsTotalDelta == beast::kZero, + "regular on-time payment must not change AssetsTotal under whole-life; delta=" + + to_string(off.assetsTotalDelta)); + + checkScenario("regular payment", off, on); + } + + // ---- Late payment ---- + { + auto const advancePastDue = [&](Env& env, tp const& startDate) { + env.close(startDate + std::chrono::seconds(paymentInterval + 1)); + }; + auto const lateAmount = [&](LoanState const& state) { + return STAmount{ + xrpAsset, + roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * + Number{3}}; // generous; excess is not withdrawn + }; + + auto const off = runPayment(all_, 0, tfLoanLatePayment, advancePastDue, lateAmount); + auto const on = runPayment( + all_ | featureLendingProtocolV1_1, + 0, + tfLoanLatePayment, + advancePastDue, + lateAmount); + + checkScenario("late payment", off, on); + } + + // ---- Overpayment ---- + { + auto const noAdvance = [](Env& env, tp const&) { env.close(); }; + auto const overpayAmount = [&](LoanState const& state) { + // One regular period, plus a generous extra principal + // paydown. + return STAmount{ + xrpAsset, + roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) + + xrpAsset(2'000).value()}; + }; + + auto const off = + runPayment(all_, tfLoanOverpayment, tfLoanOverpayment, noAdvance, overpayAmount); + auto const on = runPayment( + all_ | featureLendingProtocolV1_1, + tfLoanOverpayment, + tfLoanOverpayment, + noAdvance, + overpayAmount); + + checkScenario("overpayment", off, on); + } + + // ---- Full payment ---- + { + auto const noAdvance = [](Env& env, tp const&) { env.close(); }; + auto const fullAmount = [&](LoanState const&) { + // Generously large: full payment only ever consumes exactly + // what's due (principal + accrued interest; close fee/ + // prepayment penalty are 0 here), excess is not withdrawn. + return STAmount{xrpAsset, xrpAsset(principalRequest).value() * Number{2}}; + }; + + auto const off = runPayment(all_, 0, tfLoanFullPayment, noAdvance, fullAmount); + auto const on = runPayment( + all_ | featureLendingProtocolV1_1, 0, tfLoanFullPayment, noAdvance, fullAmount); + + checkScenario("full payment", off, on); + } + } + + // 3. LoanManage: impair, unimpair, and default. + void + testCashBasisLoanManage() + { + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{percentageToTenthBips(10)}, + .coverDeposit = 5'000, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; + + Number const principalRequest{10'000}; + TenthBips32 const interestRate{percentageToTenthBips(12)}; + std::uint32_t const paymentTotal = 4; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 60; + + auto setupLoan = [&](Env& env) { + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = interestRate, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + }; + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(loanParams(env, broker)); + env.close(); + + return std::make_tuple(broker, loanKeylet, lender, borrower); + }; + + // ---- impair / unimpair ---- + auto runImpairUnimpair = [&](FeatureBitset features) { + Env env(*this, features); + auto const [broker, loanKeylet, lender, borrower] = setupLoan(env); + + auto const loanBefore = env.le(loanKeylet); + BEAST_EXPECT(loanBefore); + Number const principalOutstanding = loanBefore->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanBefore->at(sfTotalValueOutstanding); + Number const managementFeeOutstanding = loanBefore->at(sfManagementFeeOutstanding); + + Number const expectedExposure = + env.current()->rules().enabled(featureLendingProtocolV1_1) + ? principalOutstanding + : totalValueOutstanding - managementFeeOutstanding; + + auto const vaultBeforeImpair = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultBeforeImpair); + Number const lossBefore = vaultBeforeImpair->at(sfLossUnrealized); + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterImpair = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultAfterImpair); + Number const impairDelta = Number(vaultAfterImpair->at(sfLossUnrealized)) - lossBefore; + + env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterUnimpair = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultAfterUnimpair); + Number const netDelta = Number(vaultAfterUnimpair->at(sfLossUnrealized)) - lossBefore; + + return std::make_tuple(expectedExposure, impairDelta, netDelta); + }; + + for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) + { + testcase( + std::string("cash-basis: LoanManage impair/unimpair (") + + (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); + auto const [expectedExposure, impairDelta, netDelta] = runImpairUnimpair(features); + + BEAST_EXPECTS( + impairDelta == expectedExposure, + "impair must add loanVaultExposure to LossUnrealized; delta=" + + to_string(impairDelta) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + netDelta == beast::kZero, + "unimpair must be an exact reversal of impair; net=" + to_string(netDelta)); + } + + // ---- impair, then default ---- + auto runDefault = [&](FeatureBitset features) { + Env env(*this, features); + auto const [broker, loanKeylet, lender, borrower] = setupLoan(env); + + auto const loanBeforeImpair = env.le(loanKeylet); + BEAST_EXPECT(loanBeforeImpair); + Number const principalOutstanding = loanBeforeImpair->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanBeforeImpair->at(sfTotalValueOutstanding); + Number const managementFeeOutstanding = + loanBeforeImpair->at(sfManagementFeeOutstanding); + + Number const expectedExposure = + env.current()->rules().enabled(featureLendingProtocolV1_1) + ? principalOutstanding + : totalValueOutstanding - managementFeeOutstanding; + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + env.close( + state.startDate + std::chrono::seconds(paymentInterval) + + std::chrono::seconds(gracePeriod) + 60s); + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBefore && brokerBefore); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); + Number const lossBefore = vaultBefore->at(sfLossUnrealized); + Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + auto const brokerAfter = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfter && brokerAfter); + Number const assetsTotalDelta = + Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore; + Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore; + Number const lossDelta = Number(vaultAfter->at(sfLossUnrealized)) - lossBefore; + Number const coverAvailableDelta = + Number(brokerAfter->at(sfCoverAvailable)) - coverAvailableBefore; + + Number const defaultCovered = -coverAvailableDelta; + Number const vaultDefaultAmount = expectedExposure - defaultCovered; + + return std::make_tuple( + expectedExposure, assetsTotalDelta, debtTotalDelta, lossDelta, vaultDefaultAmount); + }; + + for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) + { + testcase( + std::string("cash-basis: LoanManage default (") + + (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); + auto const + [expectedExposure, + assetsTotalDelta, + debtTotalDelta, + lossDelta, + vaultDefaultAmount] = runDefault(features); + + BEAST_EXPECTS( + debtTotalDelta == -expectedExposure, + "default must reduce DebtTotal by the unified default amount; delta=" + + to_string(debtTotalDelta) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + lossDelta == -expectedExposure, + "default must reverse the earlier impair's LossUnrealized exactly; delta=" + + to_string(lossDelta) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + assetsTotalDelta == -vaultDefaultAmount, + "default must reduce AssetsTotal by (defaultAmount - defaultCovered); delta=" + + to_string(assetsTotalDelta) + " expected=" + to_string(-vaultDefaultAmount)); + } + } + + // 3b. LEVersion regression: a Vault created before featureLendingProtocolV1_1 + // activates (LEVersion absent) must keep whole-life (accrual) accounting + // forever, even after the amendment is later enabled -- the switch is + // per-Vault (LEVersion == VaultVersion::CashBasis), not a single global amendment + // flag. + void + testLegacyVaultKeepsAccrualAfterAmendmentEnabled() + { + testcase("LEVersion: legacy vault keeps accrual after amendment enabled"); + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{percentageToTenthBips(10)}, + .coverDeposit = 5'000, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; + + Number const principalRequest{10'000}; + TenthBips32 const interestRate{percentageToTenthBips(12)}; + std::uint32_t const paymentTotal = 4; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 60; + + // Amendment disabled at Vault creation time: LEVersion stays absent. + Env env(*this, all_); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + { + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); + } + + // Now enable the amendment -- production dispatch must still treat + // this specific Vault as accrual-basis, since its LEVersion is + // (and remains) absent. + env.enableFeature(featureLendingProtocolV1_1); + env.close(); + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = interestRate, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + }; + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + // ---- LoanSet origination: whole-life formulas expected ---- + auto const vaultBeforeSet = env.le(broker.vaultKeylet()); + auto const brokerBeforeSet = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBeforeSet && brokerBeforeSet); + Number const assetsTotalBeforeSet = vaultBeforeSet->at(sfAssetsTotal); + Number const debtTotalBeforeSet = brokerBeforeSet->at(sfDebtTotal); + + env(loanParams(env, broker)); + env.close(); + + auto const loanAfterSet = env.le(loanKeylet); + BEAST_EXPECT(loanAfterSet); + Number const principalOutstanding = loanAfterSet->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanAfterSet->at(sfTotalValueOutstanding); + Number const interestDue = totalValueOutstanding - principalOutstanding; + BEAST_EXPECT(interestDue > beast::kZero); + + auto const vaultAfterSet = env.le(broker.vaultKeylet()); + auto const brokerAfterSet = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfterSet && brokerAfterSet); + Number const assetsTotalDeltaSet = + Number(vaultAfterSet->at(sfAssetsTotal)) - assetsTotalBeforeSet; + Number const debtTotalDeltaSet = + Number(brokerAfterSet->at(sfDebtTotal)) - debtTotalBeforeSet; + + BEAST_EXPECTS( + assetsTotalDeltaSet == interestDue, + "legacy vault origination must still add interestDue to AssetsTotal; delta=" + + to_string(assetsTotalDeltaSet) + " interestDue=" + to_string(interestDue)); + BEAST_EXPECTS( + debtTotalDeltaSet == principalOutstanding + interestDue, + "legacy vault origination must still add principal+interest to DebtTotal; delta=" + + to_string(debtTotalDeltaSet)); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + env.close(); + + // ---- LoanPay: whole-life formulas expected ---- + auto const vaultBeforePay = env.le(broker.vaultKeylet()); + auto const brokerBeforePay = env.le(broker.brokerKeylet()); + auto const loanBeforePay = env.le(loanKeylet); + BEAST_EXPECT(vaultBeforePay && brokerBeforePay && loanBeforePay); + Number const totalValueBeforePay = loanBeforePay->at(sfTotalValueOutstanding); + Number const assetsTotalBeforePay = vaultBeforePay->at(sfAssetsTotal); + Number const debtTotalBeforePay = brokerBeforePay->at(sfDebtTotal); + + STAmount const paymentAmount{ + xrpAsset, roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale)}; + env(pay(borrower, loanKeylet.key, paymentAmount), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterPay = env.le(broker.vaultKeylet()); + auto const brokerAfterPay = env.le(broker.brokerKeylet()); + auto const loanAfterPay = env.le(loanKeylet); + BEAST_EXPECT(vaultAfterPay && brokerAfterPay && loanAfterPay); + Number const totalValueAfterPay = loanAfterPay->at(sfTotalValueOutstanding); + Number const assetsTotalDeltaPay = + Number(vaultAfterPay->at(sfAssetsTotal)) - assetsTotalBeforePay; + Number const debtTotalDeltaPay = + Number(brokerAfterPay->at(sfDebtTotal)) - debtTotalBeforePay; + Number const totalValueDeltaPay = totalValueAfterPay - totalValueBeforePay; + + // A regular, on-time payment has valueChange == 0, so whole-life + // AssetsTotal is untouched and DebtTotal mirrors TotalValueOutstanding. + BEAST_EXPECTS( + assetsTotalDeltaPay == beast::kZero, + "legacy vault regular payment must not change AssetsTotal; delta=" + + to_string(assetsTotalDeltaPay)); + BEAST_EXPECTS( + debtTotalDeltaPay == totalValueDeltaPay, + "legacy vault DebtTotal delta must mirror TotalValueOutstanding delta; " + "debtTotalDelta=" + + to_string(debtTotalDeltaPay) + " totalValueDelta=" + to_string(totalValueDeltaPay)); + + // ---- LoanManage: impair, then default -- whole-life exposure expected ---- + auto const loanBeforeImpair = env.le(loanKeylet); + BEAST_EXPECT(loanBeforeImpair); + Number const totalValueBeforeImpair = loanBeforeImpair->at(sfTotalValueOutstanding); + Number const managementFeeBeforeImpair = loanBeforeImpair->at(sfManagementFeeOutstanding); + Number const expectedExposure = totalValueBeforeImpair - managementFeeBeforeImpair; + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + LoanState const stateAtImpair = getCurrentState(env, broker, loanKeylet); + env.close( + stateAtImpair.startDate + std::chrono::seconds(paymentInterval) + + std::chrono::seconds(gracePeriod) + 60s); + + auto const vaultBeforeDefault = env.le(broker.vaultKeylet()); + auto const brokerBeforeDefault = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBeforeDefault && brokerBeforeDefault); + Number const debtTotalBeforeDefault = brokerBeforeDefault->at(sfDebtTotal); + Number const lossBeforeDefault = vaultBeforeDefault->at(sfLossUnrealized); + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterDefault = env.le(broker.vaultKeylet()); + auto const brokerAfterDefault = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfterDefault && brokerAfterDefault); + Number const debtTotalDeltaDefault = + Number(brokerAfterDefault->at(sfDebtTotal)) - debtTotalBeforeDefault; + Number const lossDeltaDefault = + Number(vaultAfterDefault->at(sfLossUnrealized)) - lossBeforeDefault; + + BEAST_EXPECTS( + debtTotalDeltaDefault == -expectedExposure, + "legacy vault default must reduce DebtTotal by whole-life exposure; delta=" + + to_string(debtTotalDeltaDefault) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + lossDeltaDefault == -expectedExposure, + "legacy vault default must reverse the earlier impair's LossUnrealized exactly; " + "delta=" + + to_string(lossDeltaDefault) + " expected=" + to_string(expectedExposure)); + + // Confirm the Vault's LEVersion truly never got set, throughout. + { + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); + BEAST_EXPECT(getVaultVersion(vaultSle) == VaultVersion::Legacy); + } + } + + // 4. End-to-end trajectory: LoanSet -> 2 LoanPays -> LoanManage(default), + // entirely under the amendment, with independently hand-computed + // expected AssetsTotal/DebtTotal/LossUnrealized/CoverAvailable values at + // each step. 0% interest keeps the arithmetic exact and tractable; the + // divergence from whole-life accounting is already covered directly by + // testCashBasisLoanSetOrigination/LoanPay/LoanManage above, so this test + // focuses purely on an independent, from-scratch trajectory check. + void + testCashBasisEndToEndTrajectory() + { + testcase("cash-basis: end-to-end trajectory"); + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, .managementFeeRate = TenthBips16{0}}; + + Env env(*this, all_ | featureLendingProtocolV1_1); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + // Hand computation (all values in XRP, drops == 1e-6 XRP): + // Vault: AssetsTotal starts at 100'000 (the deposit). + // Broker: DebtTotal starts at 0, CoverAvailable starts at 1'000 + // (BrokerParameters::defaults().coverDeposit). + auto const vaultKeylet = broker.vaultKeylet(); + auto const brokerKeylet = broker.brokerKeylet(); + + // All the "human XRP unit" constants below (e.g. `100'000`) are + // converted to raw native (drops) values via xrpAsset(...), since + // that's how the ledger fields are actually denominated. + auto const checkVaultBroker = [&](Number const& assetsTotalUnits, + Number const& debtTotalUnits, + Number const& lossUnrealizedUnits, + Number const& coverAvailableUnits, + char const* step) { + Number const assetsTotal = xrpAsset(assetsTotalUnits).value(); + Number const debtTotal = xrpAsset(debtTotalUnits).value(); + Number const lossUnrealized = xrpAsset(lossUnrealizedUnits).value(); + Number const coverAvailable = xrpAsset(coverAvailableUnits).value(); + + auto const vaultSle = env.le(vaultKeylet); + auto const brokerSle = env.le(brokerKeylet); + BEAST_EXPECT(vaultSle && brokerSle); + BEAST_EXPECTS( + vaultSle->at(sfAssetsTotal) == assetsTotal, + std::string(step) + ": AssetsTotal expected " + to_string(assetsTotal) + " got " + + to_string(Number(vaultSle->at(sfAssetsTotal)))); + BEAST_EXPECTS( + brokerSle->at(sfDebtTotal) == debtTotal, + std::string(step) + ": DebtTotal expected " + to_string(debtTotal) + " got " + + to_string(Number(brokerSle->at(sfDebtTotal)))); + BEAST_EXPECTS( + vaultSle->at(sfLossUnrealized) == lossUnrealized, + std::string(step) + ": LossUnrealized expected " + to_string(lossUnrealized) + + " got " + to_string(Number(vaultSle->at(sfLossUnrealized)))); + BEAST_EXPECTS( + brokerSle->at(sfCoverAvailable) == coverAvailable, + std::string(step) + ": CoverAvailable expected " + to_string(coverAvailable) + + " got " + to_string(Number(brokerSle->at(sfCoverAvailable)))); + }; + + checkVaultBroker(100'000, 0, 0, 1'000, "before LoanSet"); + + // Loan: principal=1200, 0% interest, 12 payments of 100 each, no fees. + Number const principalRequest{1'200}; + std::uint32_t const paymentTotal = 12; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 60; + + auto const brokerBeforeLoan = env.le(brokerKeylet); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = TenthBips32{0}, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + }; + env(loanParams(env, broker)); + env.close(); + + // Origination (cash-basis): AssetsTotal += 0, DebtTotal += principal. + checkVaultBroker(100'000, 1'200, 0, 1'000, "after LoanSet"); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + BEAST_EXPECT(state.periodicPayment == xrpAsset(100).value()); + + // Payment 1: principalPaid=100, interestPaid=0. + // AssetsTotal += 0; DebtTotal -= 100. + env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS)); + env.close(); + checkVaultBroker(100'000, 1'100, 0, 1'000, "after payment 1"); + + // Payment 2: same as above. + env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS)); + env.close(); + checkVaultBroker(100'000, 1'000, 0, 1'000, "after payment 2"); + + // Default (no impair): principalOutstanding remaining is 1'000. + // totalDefaultAmount (cash-basis) = PrincipalOutstanding = 1'000. + // minimumCover = DebtTotal(1'000) * coverRateMin(10%) = 100. + // covered = min(minimumCover * coverRateLiquidation(25%), totalDefaultAmount) + // = min(25, 1'000) = 25. + // defaultCovered = min(covered, CoverAvailable(1'000)) = 25. + // vaultDefaultAmount = 1'000 - 25 = 975. + // DebtTotal -= 1'000 -> 0. CoverAvailable -= 25 -> 975. + // AssetsTotal -= 975 -> 99'025. LossUnrealized unaffected (never impaired). + auto const loanBeforeDefault = env.le(loanKeylet); + BEAST_EXPECT(loanBeforeDefault); + BEAST_EXPECT( + Number(loanBeforeDefault->at(sfPrincipalOutstanding)) == xrpAsset(1'000).value()); + + env.close(state.startDate + std::chrono::seconds((3 * paymentInterval) + gracePeriod) + 1s); + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + checkVaultBroker(99'025, 0, 0, 975, "after LoanManage(default)"); + } + void runAmendmentIndependent() { @@ -8570,6 +9553,12 @@ protected: testBugInterestDueDeltaCrash(); testFullLifecycleVaultPnLNearZeroRate(); testLoanSetNearZeroInterestRateSucceeds(); + + testCashBasisLoanSetOrigination(); + testCashBasisLoanPay(); + testCashBasisLoanManage(); + testLegacyVaultKeepsAccrualAfterAmendmentEnabled(); + testCashBasisEndToEndTrajectory(); } // Tests run under each entry in amendmentCombinations(). diff --git a/src/test/app/Offer_test.cpp b/src/test/app/Offer_test.cpp index 7fc7161e36..33721e91d8 100644 --- a/src/test/app/Offer_test.cpp +++ b/src/test/app/Offer_test.cpp @@ -4324,6 +4324,165 @@ public: env.require(Balance(bob, gwUSD(10))); } + void + testDisallowIncomingTrustline(FeatureBitset features) + { + testcase("DisallowIncomingTrustline in OfferCreate"); + + // Test that asfDisallowIncomingTrustline flag prevents offer crossing + // when the taker doesn't have a trustline. + // + // 1. alice creates a trustline and sells USD/gw tokens. + // + // 2. gw sets asfDisallowIncomingTrustline flag. + // + // 3. An account without a trustline tries to create an offer for USD/gw. + // Without amendment: succeeds and crosses alice's offer (backward compatible). + // With amendment: fails with tecNO_LINE (new behavior). + // + // 4. An account WITH an existing trustline can create an offer. + // The offer succeeds and crosses alice's offer. + // + // Note: The DisallowIncomingTrustline flag also prevents NEW trustlines + // from being created via TrustSet (enforced by fixDisallowIncomingV1). + // So accounts must create trustlines BEFORE the issuer sets the flag. + + using namespace jtx; + auto const gw = Account("gw"); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const carol = Account("carol"); + auto const dan = Account("dan"); + auto const eve = Account("eve"); + auto const gwUSD = gw["USD"]; + + // Test without fixCleanup3_4_0 amendment + { + Env env{*this, features - fixCleanup3_4_0}; + + env.fund(XRP(400000), gw, alice, bob); + env.close(); + + // Alice creates trustline and gets some USD + env(trust(alice, gwUSD(100))); + env.close(); + env(pay(gw, alice, gwUSD(50))); + env.close(); + + // Alice creates sell offer + env(offer(alice, XRP(4000), gwUSD(40))); + env.close(); + env.require(offers(alice, 1)); + + // GW sets DisallowIncomingTrustline flag + env(fset(gw, asfDisallowIncomingTrustline)); + env.close(); + + // Without the amendment, bob can still create offer without trustline + // and the offer should cross (old behavior) + env(offer(bob, gwUSD(40), XRP(4000))); + env.close(); + + // Offer should have crossed + env.require(offers(alice, 0)); + env.require(offers(bob, 0)); + env.require(Balance(bob, gwUSD(40))); + } + + // Test with fixCleanup3_4_0 amendment + { + Env env{*this, features}; + + env.fund(XRP(400000), gw, alice, bob, carol, dan); + env.close(); + + // Alice creates trustline and gets some USD + env(trust(alice, gwUSD(100))); + env.close(); + env(pay(gw, alice, gwUSD(50))); + env.close(); + + // Bob and carol create trustlines BEFORE the flag is set + env(trust(bob, gwUSD(100))); + env.close(); + env(trust(carol, gwUSD(100))); + env.close(); + + // Alice creates sell offer + env(offer(alice, XRP(4000), gwUSD(40))); + env.close(); + env.require(offers(alice, 1)); + env.require(Balance(alice, gwUSD(50))); + + // GW sets DisallowIncomingTrustline flag + env(fset(gw, asfDisallowIncomingTrustline)); + env.close(); + + // Dan tries to create offer without trustline - should fail + env(offer(dan, gwUSD(40), XRP(4000)), Ter(tecNO_LINE)); + env.close(); + + // Alice's offer should still exist + env.require(offers(alice, 1)); + env.require(Balance(alice, gwUSD(50))); + + // Dan shouldn't have any offers or balance + env.require(offers(dan, 0)); + BEAST_EXPECT(env.le(keylet::trustLine(dan, gwUSD)) == nullptr); + + // Bob already has trustline, so his offer should succeed and cross + env(offer(bob, gwUSD(40), XRP(4000))); + env.close(); + + // Offer should have crossed + env.require(offers(alice, 0)); + env.require(offers(bob, 0)); + env.require(Balance(alice, gwUSD(10))); + env.require(Balance(bob, gwUSD(40))); + + // Test scenario where carol already has a trustline (created before flag was set) + // Carol should be able to create offer since trustline already exists + env(pay(gw, alice, gwUSD(50))); + env.close(); + env(offer(alice, XRP(1000), gwUSD(10))); + env.close(); + env.require(offers(alice, 1)); + + env(offer(carol, gwUSD(10), XRP(1000))); + env.close(); + + // Offer should have crossed + env.require(offers(alice, 0)); + env.require(offers(carol, 0)); + env.require(Balance(alice, gwUSD(50))); + env.require(Balance(carol, gwUSD(10))); + + // Test that gw can clear the flag + env(fclear(gw, asfDisallowIncomingTrustline)); + env.close(); + + // Create new account eve without trustline + env.fund(XRP(400000), eve); + env.close(); + + // Bob creates another sell offer + env(pay(gw, bob, gwUSD(50))); + env.close(); + env(offer(bob, XRP(5000), gwUSD(50))); + env.close(); + env.require(offers(bob, 1)); + + // Eve should now be able to create offer without trustline (flag is cleared) + env(offer(eve, gwUSD(50), XRP(5000))); + env.close(); + + // Offer should have crossed + env.require(offers(bob, 0)); + env.require(offers(eve, 0)); + env.require(Balance(eve, gwUSD(50))); + } + } + void testRCSmoketest(FeatureBitset features) { @@ -5167,6 +5326,7 @@ public: testSelfPayUnlimitedFunds(features); testRequireAuth(features); testMissingAuth(features); + testDisallowIncomingTrustline(features); testRCSmoketest(features); testSelfAuth(features); testDeletedOfferIssuer(features); diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 12ad7e6782..bd596d6149 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -7645,6 +7645,83 @@ class Vault_test : public beast::unit_test::Suite } } + void + testVaultCreateLEVersion() + { + using namespace test::jtx; + + Account const owner{"owner"}; + PrettyAsset const xrpAsset = xrpIssue(); + + { + testcase("VaultCreate LEVersion: featureLendingProtocolV1_1 disabled, field absent"); + Env env{*this}; + env.disableFeature(featureLendingProtocolV1_1); + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + env(tx, Ter(tesSUCCESS)); + env.close(); + + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault); + BEAST_EXPECT(!sleVault->isFieldPresent(sfLEVersion)); + } + + { + testcase( + "VaultCreate LEVersion: featureLendingProtocolV1_1 enabled, LEVersion == " + "VaultVersion::CashBasis"); + Env env{*this}; + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + env(tx, Ter(tesSUCCESS)); + env.close(); + + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault); + BEAST_EXPECT(sleVault->isFieldPresent(sfLEVersion)); + BEAST_EXPECT(sleVault->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis)); + } + + { + testcase("VaultCreate rejects LEVersion set in the transaction"); + Env env{*this}; + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + tx[sfLEVersion] = 2; + env(tx, Ter(temMALFORMED)); + env.close(); + + BEAST_EXPECT(!env.le(keylet)); + } + + { + testcase("VaultSet rejects LEVersion set in the transaction"); + Env env{*this}; + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + auto const [createTx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + env(createTx, Ter(tesSUCCESS)); + env.close(); + + auto setTx = vault.set({.owner = owner, .id = keylet.key}); + setTx[sfLEVersion] = 2; + env(setTx, Ter(temMALFORMED)); + env.close(); + } + } + void testVaultDepositFreezeIOU() { @@ -8317,6 +8394,7 @@ public: testVaultEscrowedMPT(); testAssetsMaximum(); testVaultDeleteMemoData(); + testVaultCreateLEVersion(); testBug6LimitBypassWithShares(); testRemoveEmptyHoldingLockedAmount(); testRemoveEmptyHoldingConfidentialBalances(); diff --git a/src/test/beast/LexicalCast_test.cpp b/src/test/beast/LexicalCast_test.cpp deleted file mode 100644 index b1d37daab8..0000000000 --- a/src/test/beast/LexicalCast_test.cpp +++ /dev/null @@ -1,280 +0,0 @@ -#include -#include -#include - -#include -#include -#include -#include - -namespace beast { - -class LexicalCast_test : public unit_test::Suite -{ -public: - template - static IntType - nextRandomInt(xor_shift_engine& r) - { - return static_cast(r()); - } - - template - void - testInteger(IntType in) - { - std::string s; - auto out = static_cast(~in); // Ensure out != in - - expect(lexicalCastChecked(s, in)); - expect(lexicalCastChecked(out, s)); - expect(out == in); - } - - template - void - testIntegers(xor_shift_engine& r) - { - { - std::stringstream ss; - ss << "random " << typeid(IntType).name(); - testcase(ss.str()); - - for (int i = 0; i < 1000; ++i) - { - auto const value = nextRandomInt(r); - testInteger(value); - } - } - - { - std::stringstream ss; - ss << "numeric_limits <" << typeid(IntType).name() << ">"; - testcase(ss.str()); - - testInteger(std::numeric_limits::min()); - testInteger(std::numeric_limits::max()); - } - } - - void - testPathologies() - { - testcase("pathologies"); - try - { - lexicalCastThrow("\xef\xbc\x91\xef\xbc\x90"); // utf-8 encoded - } - catch (BadLexicalCast const&) - { - pass(); - } - } - - template - void - tryBadConvert(std::string const& s) - { - T out; - expect(!lexicalCastChecked(out, s), s); - } - - void - testConversionOverflows() - { - testcase("conversion overflows"); - - tryBadConvert("99999999999999999999"); - tryBadConvert("4294967300"); - tryBadConvert("75821"); - } - - void - testConversionUnderflows() - { - testcase("conversion underflows"); - - tryBadConvert("-1"); - - tryBadConvert("-99999999999999999999"); - tryBadConvert("-4294967300"); - tryBadConvert("-75821"); - } - - template - bool - tryEdgeCase(std::string const& s) - { - T ret; - - bool const result = lexicalCastChecked(ret, s); - - if (!result) - return false; - - return s == std::to_string(ret); - } - - void - testEdgeCases() - { - testcase("conversion edge cases"); - - expect(tryEdgeCase("18446744073709551614")); - expect(tryEdgeCase("18446744073709551615")); - expect(!tryEdgeCase("18446744073709551616")); - - expect(tryEdgeCase("9223372036854775806")); - expect(tryEdgeCase("9223372036854775807")); - expect(!tryEdgeCase("9223372036854775808")); - - expect(tryEdgeCase("-9223372036854775807")); - expect(tryEdgeCase("-9223372036854775808")); - expect(!tryEdgeCase("-9223372036854775809")); - - expect(tryEdgeCase("4294967294")); - expect(tryEdgeCase("4294967295")); - expect(!tryEdgeCase("4294967296")); - - expect(tryEdgeCase("2147483646")); - expect(tryEdgeCase("2147483647")); - expect(!tryEdgeCase("2147483648")); - - expect(tryEdgeCase("-2147483647")); - expect(tryEdgeCase("-2147483648")); - expect(!tryEdgeCase("-2147483649")); - - expect(tryEdgeCase("65534")); - expect(tryEdgeCase("65535")); - expect(!tryEdgeCase("65536")); - - expect(tryEdgeCase("32766")); - expect(tryEdgeCase("32767")); - expect(!tryEdgeCase("32768")); - - expect(tryEdgeCase("-32767")); - expect(tryEdgeCase("-32768")); - expect(!tryEdgeCase("-32769")); - } - - template - void - testThrowConvert(std::string const& s, bool success) - { - bool result = !success; - T out; - - try - { - out = lexicalCastThrow(s); - result = true; - } - catch (BadLexicalCast const&) - { - result = false; - } - - expect(result == success, s); - } - - void - testThrowingConversions() - { - testcase("throwing conversion"); - - testThrowConvert("99999999999999999999", false); - testThrowConvert("9223372036854775806", true); - - testThrowConvert("4294967290", true); - testThrowConvert("42949672900", false); - testThrowConvert("429496729000", false); - testThrowConvert("4294967290000", false); - - testThrowConvert("5294967295", false); - testThrowConvert("-2147483644", true); - - testThrowConvert("66666", false); - testThrowConvert("-5711", true); - } - - void - testZero() - { - testcase("zero conversion"); - - { - std::int32_t out = 0; - - expect(lexicalCastChecked(out, "-0"), "0"); - expect(lexicalCastChecked(out, "0"), "0"); - expect(lexicalCastChecked(out, "+0"), "0"); - } - - { - std::uint32_t out = 0; - - expect(!lexicalCastChecked(out, "-0"), "0"); - expect(lexicalCastChecked(out, "0"), "0"); - expect(lexicalCastChecked(out, "+0"), "0"); - } - } - - void - testEntireRange() - { - testcase("entire range"); - - std::int32_t i = std::numeric_limits::min(); - std::string const empty; - - while (i <= std::numeric_limits::max()) - { - auto const j = static_cast(i); - - auto actual = std::to_string(j); - - auto result = lexicalCast(j, empty); - - expect(result == actual, actual + " (string to integer)"); - - if (result == actual) - { - auto number = lexicalCast(result); - - if (number != j) - expect(false, actual + " (integer to string)"); - } - - i++; - } - } - - void - run() override - { - std::int64_t const seedValue = 50; - - xor_shift_engine r(seedValue); - - testIntegers(r); - testIntegers(r); - testIntegers(r); - testIntegers(r); - testIntegers(r); - testIntegers(r); - testIntegers(r); - testIntegers(r); - - testPathologies(); - testConversionOverflows(); - testConversionUnderflows(); - testThrowingConversions(); - testZero(); - testEdgeCases(); - testEntireRange(); - } -}; - -BEAST_DEFINE_TESTSUITE(LexicalCast, beast, beast); - -} // namespace beast diff --git a/src/test/beast/SemanticVersion_test.cpp b/src/test/beast/SemanticVersion_test.cpp deleted file mode 100644 index d7079080c8..0000000000 --- a/src/test/beast/SemanticVersion_test.cpp +++ /dev/null @@ -1,266 +0,0 @@ -#include -#include - -#include - -namespace beast { - -class SemanticVersion_test : public unit_test::Suite -{ - using identifier_list = SemanticVersion::identifier_list; - -public: - void - checkPass(std::string const& input, bool shouldPass = true) - { - SemanticVersion v; - - if (shouldPass) - { - BEAST_EXPECT(v.parse(input)); - BEAST_EXPECT(v.print() == input); - } - else - { - BEAST_EXPECT(!v.parse(input)); - } - } - - void - checkFail(std::string const& input) - { - checkPass(input, false); - } - - // check input and input with appended metadata - void - checkMeta(std::string const& input, bool shouldPass) - { - checkPass(input, shouldPass); - - checkPass(input + "+a", shouldPass); - checkPass(input + "+1", shouldPass); - checkPass(input + "+a.b", shouldPass); - checkPass(input + "+ab.cd", shouldPass); - - checkFail(input + "!"); - checkFail(input + "+"); - checkFail(input + "++"); - checkFail(input + "+!"); - checkFail(input + "+."); - checkFail(input + "+a.!"); - } - - void - checkMetaFail(std::string const& input) - { - checkMeta(input, false); - } - - // check input, input with appended release data, - // input with appended metadata, and input with both - // appended release data and appended metadata - // - void - checkRelease(std::string const& input, bool shouldPass = true) - { - checkMeta(input, shouldPass); - - checkMeta(input + "-1", shouldPass); - checkMeta(input + "-a", shouldPass); - checkMeta(input + "-a1", shouldPass); - checkMeta(input + "-a1.b1", shouldPass); - checkMeta(input + "-ab.cd", shouldPass); - checkMeta(input + "--", shouldPass); - - checkMetaFail(input + "+"); - checkMetaFail(input + "!"); - checkMetaFail(input + "-"); - checkMetaFail(input + "-!"); - checkMetaFail(input + "-."); - checkMetaFail(input + "-a.!"); - checkMetaFail(input + "-0.a"); - } - - // Checks the major.minor.version string alone and with all - // possible combinations of release identifiers and metadata. - // - void - check(std::string const& input, bool shouldPass = true) - { - checkRelease(input, shouldPass); - } - - void - negcheck(std::string const& input) - { - check(input, false); - } - - void - testParse() - { - testcase("parsing"); - - check("0.0.0"); - check("1.2.3"); - check("2147483647.2147483647.2147483647"); // max int - - // negative values - negcheck("-1.2.3"); - negcheck("1.-2.3"); - negcheck("1.2.-3"); - - // missing parts - negcheck(""); - negcheck("1"); - negcheck("1."); - negcheck("1.2"); - negcheck("1.2."); - negcheck(".2.3"); - - // whitespace - negcheck(" 1.2.3"); - negcheck("1 .2.3"); - negcheck("1.2 .3"); - negcheck("1.2.3 "); - - // leading zeroes - negcheck("01.2.3"); - negcheck("1.02.3"); - negcheck("1.2.03"); - } - - static identifier_list - ids() - { - return identifier_list(); - } - - static identifier_list - ids(std::string const& s1) - { - identifier_list v; - v.push_back(s1); - return v; - } - - static identifier_list - ids(std::string const& s1, std::string const& s2) - { - identifier_list v; - v.push_back(s1); - v.push_back(s2); - return v; - } - - static identifier_list - ids(std::string const& s1, std::string const& s2, std::string const& s3) - { - identifier_list v; - v.push_back(s1); - v.push_back(s2); - v.push_back(s3); - return v; - } - - // Checks the decomposition of the input into appropriate values - void - checkValues( - std::string const& input, - int majorVersion, - int minorVersion, - int patchVersion, - identifier_list const& preReleaseIdentifiers = identifier_list(), - identifier_list const& metaData = identifier_list()) - { - SemanticVersion v; - - BEAST_EXPECT(v.parse(input)); - - BEAST_EXPECT(v.majorVersion == majorVersion); - BEAST_EXPECT(v.minorVersion == minorVersion); - BEAST_EXPECT(v.patchVersion == patchVersion); - - BEAST_EXPECT(v.preReleaseIdentifiers == preReleaseIdentifiers); - BEAST_EXPECT(v.metaData == metaData); - } - - void - testValues() - { - testcase("values"); - - checkValues("0.1.2", 0, 1, 2); - checkValues("1.2.3", 1, 2, 3); - checkValues("1.2.3-rc1", 1, 2, 3, ids("rc1")); - checkValues("1.2.3-rc1.debug", 1, 2, 3, ids("rc1", "debug")); - checkValues("1.2.3-rc1.debug.asm", 1, 2, 3, ids("rc1", "debug", "asm")); - checkValues("1.2.3+full", 1, 2, 3, ids(), ids("full")); - checkValues("1.2.3+full.prod", 1, 2, 3, ids(), ids("full", "prod")); - checkValues("1.2.3+full.prod.x86", 1, 2, 3, ids(), ids("full", "prod", "x86")); - checkValues( - "1.2.3-rc1.debug.asm+full.prod.x86", - 1, - 2, - 3, - ids("rc1", "debug", "asm"), - ids("full", "prod", "x86")); - } - - // makes sure the left version is less than the right - void - checkLessInternal(std::string const& lhs, std::string const& rhs) - { - SemanticVersion left; - SemanticVersion right; - - BEAST_EXPECT(left.parse(lhs)); - BEAST_EXPECT(right.parse(rhs)); - - BEAST_EXPECT(compare(left, left) == 0); - BEAST_EXPECT(compare(right, right) == 0); - BEAST_EXPECT(compare(left, right) < 0); - BEAST_EXPECT(compare(right, left) > 0); - - BEAST_EXPECT(left < right); - BEAST_EXPECT(right > left); - BEAST_EXPECT(left == left); - BEAST_EXPECT(right == right); - } - - void - checkLess(std::string const& lhs, std::string const& rhs) - { - checkLessInternal(lhs, rhs); - checkLessInternal(lhs + "+meta", rhs); - checkLessInternal(lhs, rhs + "+meta"); - checkLessInternal(lhs + "+meta", rhs + "+meta"); - } - - void - testCompare() - { - testcase("comparisons"); - - checkLess("1.0.0-alpha", "1.0.0-alpha.1"); - checkLess("1.0.0-alpha.1", "1.0.0-alpha.beta"); - checkLess("1.0.0-alpha.beta", "1.0.0-beta"); - checkLess("1.0.0-beta", "1.0.0-beta.2"); - checkLess("1.0.0-beta.2", "1.0.0-beta.11"); - checkLess("1.0.0-beta.11", "1.0.0-rc.1"); - checkLess("1.0.0-rc.1", "1.0.0"); - checkLess("0.9.9", "1.0.0"); - } - - void - run() override - { - testParse(); - testValues(); - testCompare(); - } -}; - -BEAST_DEFINE_TESTSUITE(SemanticVersion, beast, beast); -} // namespace beast diff --git a/src/test/beast/beast_Zero_test.cpp b/src/test/beast/beast_Zero_test.cpp deleted file mode 100644 index bb61844caa..0000000000 --- a/src/test/beast/beast_Zero_test.cpp +++ /dev/null @@ -1,115 +0,0 @@ -#include -#include - -namespace beast { - -struct AdlTester -{ -}; - -int -signum(AdlTester) -{ - return 0; -} - -namespace inner_adl_test { - -struct AdlTester2 -{ -}; - -int -signum(AdlTester2) -{ - return 0; -} - -} // namespace inner_adl_test - -class Zero_test : public beast::unit_test::Suite -{ -private: - struct IntegerWrapper - { - int value; - - IntegerWrapper(int v) : value(v) - { - } - - [[nodiscard]] int - signum() const - { - return value; - } - }; - -public: - void - expectSame(bool result, bool correct, char const* message) - { - expect(result == correct, message); - } - - void - testLhsZero(IntegerWrapper x) - { - expectSame(x >= kZero, x.signum() >= 0, "lhs greater-than-or-equal-to"); - expectSame(x > kZero, x.signum() > 0, "lhs greater than"); - expectSame(x == kZero, x.signum() == 0, "lhs equal to"); - expectSame(x != kZero, x.signum() != 0, "lhs not equal to"); - expectSame(x < kZero, x.signum() < 0, "lhs less than"); - expectSame(x <= kZero, x.signum() <= 0, "lhs less-than-or-equal-to"); - } - - void - testLhsZero() - { - testcase("lhs zero"); - - testLhsZero(-7); - testLhsZero(0); - testLhsZero(32); - } - - void - testRhsZero(IntegerWrapper x) - { - expectSame(kZero >= x, 0 >= x.signum(), "rhs greater-than-or-equal-to"); - expectSame(kZero > x, 0 > x.signum(), "rhs greater than"); - expectSame(kZero == x, 0 == x.signum(), "rhs equal to"); - expectSame(kZero != x, 0 != x.signum(), "rhs not equal to"); - expectSame(kZero < x, 0 < x.signum(), "rhs less than"); - expectSame(kZero <= x, 0 <= x.signum(), "rhs less-than-or-equal-to"); - } - - void - testRhsZero() - { - testcase("rhs zero"); - - testRhsZero(-4); - testRhsZero(0); - testRhsZero(64); - } - - void - testAdl() - { - expect(AdlTester{} == kZero, "ADL failure!"); - expect(inner_adl_test::AdlTester2{} == kZero, "ADL failure!"); - } - - void - run() override - { - testLhsZero(); - testRhsZero(); - testAdl(); - } -}; - -BEAST_DEFINE_TESTSUITE(Zero, beast, beast); - -} // namespace beast diff --git a/src/test/jtx/ConfidentialTransfer.h b/src/test/jtx/ConfidentialTransfer.h index 404ddbe31d..465bac03db 100644 --- a/src/test/jtx/ConfidentialTransfer.h +++ b/src/test/jtx/ConfidentialTransfer.h @@ -94,6 +94,36 @@ protected: return proof; } + // Generate a forged single bulletproof for a single value and blinding factor. + // Used to test ConvertBack overdraft prevention via bulletproof verification. + static Buffer + getForgedSingleBulletproof( + uint64_t value, + Buffer const& blindingFactor, + uint256 const& contextHash) + { + auto* const ctx = mpt_secp256k1_context(); + + secp256k1_pubkey h; + secp256k1_mpt_get_h_generator(ctx, &h); + + Buffer proof(kEcSingleBulletproofLength); + size_t proofLen = kEcSingleBulletproofLength; + + if (secp256k1_bulletproof_prove_agg( + ctx, + proof.data(), + &proofLen, + &value, + blindingFactor.data(), + 1, // m = 1 (single bulletproof) + &h, + contextHash.data()) == 0) + Throw("Failed to generate forged single bulletproof"); + + return proof; + } + // Get a bad ciphertext with valid structure but cryptographic invalid for // testing purposes. For preflight test purposes. static Buffer const& diff --git a/src/test/overlay/ProtocolVersion_test.cpp b/src/test/overlay/ProtocolVersion_test.cpp index 2fc8e4447d..e31a574502 100644 --- a/src/test/overlay/ProtocolVersion_test.cpp +++ b/src/test/overlay/ProtocolVersion_test.cpp @@ -63,8 +63,8 @@ public: negotiateProtocolVersion("RTXP/1.2, XRPL/2.0, XRPL/2.1") == makeProtocol(2, 1)); BEAST_EXPECT(negotiateProtocolVersion("XRPL/2.2") == makeProtocol(2, 2)); BEAST_EXPECT( - negotiateProtocolVersion("RTXP/1.2, XRPL/2.2, XRPL/2.3, XRPL/999.999") == - makeProtocol(2, 2)); + negotiateProtocolVersion("RTXP/1.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/protocol/ApiVersion_test.cpp b/src/test/protocol/ApiVersion_test.cpp deleted file mode 100644 index c41fa6f6c0..0000000000 --- a/src/test/protocol/ApiVersion_test.cpp +++ /dev/null @@ -1,41 +0,0 @@ -#include -#include - -namespace xrpl::test { -struct ApiVersion_test : beast::unit_test::Suite -{ - void - run() override - { - { - testcase("API versions invariants"); - - static_assert(RPC::kApiMinimumSupportedVersion <= RPC::kApiMaximumSupportedVersion); - static_assert(RPC::kApiMinimumSupportedVersion <= RPC::kApiMaximumValidVersion); - static_assert(RPC::kApiMaximumSupportedVersion <= RPC::kApiMaximumValidVersion); - static_assert(RPC::kApiBetaVersion <= RPC::kApiMaximumValidVersion); - - BEAST_EXPECT(true); - } - - { - // Update when we change versions - testcase("API versions"); - - static_assert(RPC::kApiMinimumSupportedVersion >= 1); - static_assert(RPC::kApiMinimumSupportedVersion < 2); - static_assert(RPC::kApiMaximumSupportedVersion >= 2); - static_assert(RPC::kApiMaximumSupportedVersion < 3); - static_assert(RPC::kApiMaximumValidVersion >= 3); - static_assert(RPC::kApiMaximumValidVersion < 4); - static_assert(RPC::kApiBetaVersion >= 3); - static_assert(RPC::kApiBetaVersion < 4); - - BEAST_EXPECT(true); - } - } -}; - -BEAST_DEFINE_TESTSUITE(ApiVersion, protocol, xrpl); - -} // namespace xrpl::test diff --git a/src/test/protocol/Serializer_test.cpp b/src/test/protocol/Serializer_test.cpp deleted file mode 100644 index b490e0476b..0000000000 --- a/src/test/protocol/Serializer_test.cpp +++ /dev/null @@ -1,52 +0,0 @@ -#include -#include - -#include -#include -#include - -namespace xrpl { - -struct Serializer_test : public beast::unit_test::Suite -{ - void - run() override - { - { - std::initializer_list const values = { - std::numeric_limits::min(), - -1, - 0, - 1, - std::numeric_limits::max()}; - for (std::int32_t const value : values) - { - Serializer s; - s.add32(value); - BEAST_EXPECT(s.size() == 4); - SerialIter sit(s.slice()); - BEAST_EXPECT(sit.geti32() == value); - } - } - { - std::initializer_list const values = { - std::numeric_limits::min(), - -1, - 0, - 1, - std::numeric_limits::max()}; - for (std::int64_t const value : values) - { - Serializer s; - s.add64(value); - BEAST_EXPECT(s.size() == 8); - SerialIter sit(s.slice()); - BEAST_EXPECT(sit.geti64() == value); - } - } - } -}; - -BEAST_DEFINE_TESTSUITE(Serializer, protocol, xrpl); - -} // namespace xrpl diff --git a/src/tests/libxrpl/CMakeLists.txt b/src/tests/libxrpl/CMakeLists.txt index 8e4ece1234..c45f55b5f2 100644 --- a/src/tests/libxrpl/CMakeLists.txt +++ b/src/tests/libxrpl/CMakeLists.txt @@ -27,15 +27,17 @@ target_link_libraries(xrpl_tests PRIVATE GTest::gtest GTest::gmock xrpl.libxrpl) # supported on Windows. set(test_modules basics + beast consensus crypto json + nodestore peerfinder + protocol resource shamap tx protocol_autogen - nodestore ) if(NOT WIN32) list(APPEND test_modules net) diff --git a/src/tests/libxrpl/basics/IntrusiveShared.cpp b/src/tests/libxrpl/basics/IntrusiveShared.cpp index e798cd1ccc..b9f8930b7b 100644 --- a/src/tests/libxrpl/basics/IntrusiveShared.cpp +++ b/src/tests/libxrpl/basics/IntrusiveShared.cpp @@ -50,11 +50,11 @@ struct Barrier { std::mutex mtx; std::condition_variable cv; - int count; - int const initial; + std::size_t count; + std::size_t const initial; std::size_t generation{0}; - explicit Barrier(int n) : count(n), initial(n) + explicit Barrier(std::size_t n) : count(n), initial(n) { } @@ -217,7 +217,7 @@ TEST(IntrusiveSharedTest, basics) auto id = b->id; EXPECT_EQ(TIBase::getState(id), Alive); EXPECT_EQ(b->useCount(), 1); - for (int i = 0; i < 10; ++i) + for (auto i = 0uz; i < 10; ++i) { strong.push_back(b); } @@ -232,7 +232,7 @@ TEST(IntrusiveSharedTest, basics) id = b->id; EXPECT_EQ(TIBase::getState(id), Alive); EXPECT_EQ(b->useCount(), 1); - for (int i = 0; i < 10; ++i) + for (auto i = 0uz; i < 10; ++i) { weak.emplace_back(b); EXPECT_EQ(b->useCount(), 1); @@ -280,17 +280,17 @@ TEST(IntrusiveSharedTest, basics) TIBase::ResetStatesGuard const rsg{true}; using enum TrackedState; - using swu = SharedWeakUnion; - swu b = makeSharedIntrusive(); + using SharedWeak = SharedWeakUnion; + SharedWeak b = makeSharedIntrusive(); EXPECT_TRUE(b.isStrong() && b.useCount() == 1); auto id = b.get()->id; EXPECT_EQ(TIBase::getState(id), Alive); - swu w = b; + SharedWeak w = b; EXPECT_TRUE(TIBase::getState(id) == Alive); EXPECT_TRUE(w.isStrong() && b.useCount() == 2); w.convertToWeak(); EXPECT_TRUE(w.isWeak() && b.useCount() == 1); - swu s = w; + SharedWeak s = w; EXPECT_TRUE(s.isWeak() && b.useCount() == 1); s.convertToStrong(); EXPECT_TRUE(s.isStrong() && b.useCount() == 2); @@ -380,43 +380,57 @@ TEST(IntrusiveSharedTest, partial_delete) std::atomic destructorRan{false}; std::atomic partialDeleteRan{false}; std::latch partialDeleteStartedSyncPoint{2}; + strong->tracingCallback = [&](TrackedState cur, std::optional next) { using enum TrackedState; - if (next == DeletedStarted) + if (!next) + return; + + switch (*next) { - // strong goes out of scope while weak is still in scope - // This checks that partialDelete has run to completion - // before the destructor is called. A sleep is inserted - // inside the partial delete to make sure the destructor is - // given an opportunity to run during partial delete. - EXPECT_EQ(cur, PartiallyDeleted); - } - if (next == PartiallyDeletedStarted) - { - partialDeleteStartedSyncPoint.arrive_and_wait(); - using namespace std::chrono_literals; - // Sleep and let the weak pointer go out of scope, - // potentially triggering a destructor while partial delete - // is running. The test is to make sure that doesn't happen. - std::this_thread::sleep_for(800ms); - } - if (next == PartiallyDeleted) - { - EXPECT_FALSE(partialDeleteRan.exchange(true) || destructorRan.load()); - } - if (next == Deleted) - { - EXPECT_FALSE(destructorRan.exchange(true)); + case DeletedStarted: + // strong goes out of scope while weak is still in scope + // This checks that partialDelete has run to completion + // before the destructor is called. A sleep is inserted + // inside the partial delete to make sure the destructor is + // given an opportunity to run during partial delete. + EXPECT_EQ(cur, PartiallyDeleted); + break; + + case PartiallyDeletedStarted: { + partialDeleteStartedSyncPoint.arrive_and_wait(); + using namespace std::chrono_literals; + // Sleep and let the weak pointer go out of scope, + // potentially triggering a destructor while partial delete + // is running. The test is to make sure that doesn't happen. + std::this_thread::sleep_for(800ms); + break; + } + + case PartiallyDeleted: + EXPECT_FALSE(partialDeleteRan.exchange(true) || destructorRan.load()); + break; + + case Deleted: + EXPECT_FALSE(destructorRan.exchange(true)); + break; + + case Uninitialized: + case Alive: + break; } }; + std::thread t1{[&] { partialDeleteStartedSyncPoint.arrive_and_wait(); weak.reset(); // Trigger a full delete as soon as the partial // delete starts }}; + std::thread t2{[&] { strong.reset(); // Trigger a partial delete }}; + t1.join(); t2.join(); @@ -444,13 +458,24 @@ TEST(IntrusiveSharedTest, destructor) std::latch weakResetSyncPoint{2}; strong->tracingCallback = [&](TrackedState cur, std::optional next) { using enum TrackedState; - if (next == PartiallyDeleted) + if (!next) + return; + + switch (*next) { - EXPECT_FALSE(partialDeleteRan.exchange(true) || destructorRan.load()); - } - if (next == Deleted) - { - EXPECT_FALSE(destructorRan.exchange(true)); + case PartiallyDeleted: + EXPECT_FALSE(partialDeleteRan.exchange(true) || destructorRan.load()); + break; + + case Deleted: + EXPECT_FALSE(destructorRan.exchange(true)); + break; + + case Uninitialized: + case Alive: + case PartiallyDeletedStarted: + case DeletedStarted: + break; } }; std::thread t1{[&] { @@ -492,25 +517,36 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_variant) auto tracingCallback = [&](TrackedState cur, std::optional next) { using enum TrackedState; auto [destructorRan, partialDeleteRan] = getDestructorState(); - if (next == PartiallyDeleted) + if (!next) + return; + + switch (*next) { - EXPECT_FALSE(partialDeleteRan || destructorRan); - setPartialDeleteRan(); - } - if (next == Deleted) - { - EXPECT_FALSE(destructorRan); - setDestructorRan(); + case PartiallyDeleted: + EXPECT_FALSE(partialDeleteRan || destructorRan); + setPartialDeleteRan(); + break; + + case Deleted: + EXPECT_FALSE(destructorRan); + setDestructorRan(); + break; + + case Uninitialized: + case Alive: + case PartiallyDeletedStarted: + case DeletedStarted: + break; } }; auto createVecOfPointers = [&](auto const& toClone, std::default_random_engine& eng) -> std::vector, WeakIntrusive>> { std::vector, WeakIntrusive>> result; - std::uniform_int_distribution<> toCreateDist(4, 64); + std::uniform_int_distribution toCreateDist(4, 64); std::uniform_int_distribution<> isStrongDist(0, 1); auto numToCreate = toCreateDist(eng); result.reserve(numToCreate); - for (int i = 0; i < numToCreate; ++i) + for (auto i = 0uz; i < numToCreate; ++i) { if (isStrongDist(eng)) { @@ -523,8 +559,8 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_variant) } return result; }; - constexpr int kLoopIters = 2 * 1024; - constexpr int kNumThreads = 16; + constexpr auto kLoopIters = 2uz * 1024; + constexpr auto kNumThreads = 16uz; std::vector> toClone; Barrier loopStartSyncPoint{kNumThreads}; Barrier postCreateToCloneSyncPoint{kNumThreads}; @@ -533,7 +569,7 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_variant) std::random_device rd; std::vector result; result.reserve(kNumThreads); - for (int i = 0; i < kNumThreads; ++i) + for (auto i = 0uz; i < kNumThreads; ++i) result.emplace_back(rd()); return result; }(); @@ -541,8 +577,8 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_variant) // cloneAndDestroy clones the strong pointer into a vector of mixed // strong and weak pointers and destroys them all at once. // threadId==0 is special. - auto cloneAndDestroy = [&](int threadId) { - for (int i = 0; i < kLoopIters; ++i) + auto cloneAndDestroy = [&](std::size_t threadId) { + for (auto i = 0uz; i < kLoopIters; ++i) { // ------ Sync Point ------ loopStartSyncPoint.arriveAndWait(); @@ -582,11 +618,11 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_variant) }; std::vector threads; threads.reserve(kNumThreads); - for (int i = 0; i < kNumThreads; ++i) + for (auto i = 0uz; i < kNumThreads; ++i) { threads.emplace_back(cloneAndDestroy, i); } - for (int i = 0; i < kNumThreads; ++i) + for (auto i = 0uz; i < kNumThreads; ++i) { threads[i].join(); } @@ -623,31 +659,42 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_union) auto tracingCallback = [&](TrackedState cur, std::optional next) { using enum TrackedState; auto [destructorRan, partialDeleteRan] = getDestructorState(); - if (next == PartiallyDeleted) + if (!next) + return; + + switch (*next) { - EXPECT_FALSE(partialDeleteRan || destructorRan); - setPartialDeleteRan(); - } - if (next == Deleted) - { - EXPECT_FALSE(destructorRan); - setDestructorRan(); + case PartiallyDeleted: + EXPECT_FALSE(partialDeleteRan || destructorRan); + setPartialDeleteRan(); + break; + + case Deleted: + EXPECT_FALSE(destructorRan); + setDestructorRan(); + break; + + case Uninitialized: + case Alive: + case PartiallyDeletedStarted: + case DeletedStarted: + break; } }; auto createVecOfPointers = [&](auto const& toClone, std::default_random_engine& eng) -> std::vector> { std::vector> result; - std::uniform_int_distribution<> toCreateDist(4, 64); + std::uniform_int_distribution toCreateDist(4, 64); auto numToCreate = toCreateDist(eng); result.reserve(numToCreate); - for (int i = 0; i < numToCreate; ++i) + for (auto i = 0uz; i < numToCreate; ++i) result.emplace_back(SharedIntrusive(toClone)); return result; }; - constexpr int kLoopIters = 2 * 1024; - constexpr int kFlipPointersLoopIters = 256; - constexpr int kNumThreads = 16; + constexpr auto kLoopIters = 2uz * 1024; + constexpr auto kFlipPointersLoopIters = 256uz; + constexpr auto kNumThreads = 16uz; std::vector> toClone; Barrier loopStartSyncPoint{kNumThreads}; Barrier postCreateToCloneSyncPoint{kNumThreads}; @@ -657,7 +704,7 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_union) std::random_device rd; std::vector result; result.reserve(kNumThreads); - for (int i = 0; i < kNumThreads; ++i) + for (auto i = 0uz; i < kNumThreads; ++i) result.emplace_back(rd()); return result; }(); @@ -666,8 +713,8 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_union) // mixed strong and weak pointers, runs a loop that randomly // changes strong pointers to weak pointers, and destroys them // all at once. - auto cloneAndDestroy = [&](int threadId) { - for (int i = 0; i < kLoopIters; ++i) + auto cloneAndDestroy = [&](std::size_t threadId) { + for (auto i = 0uz; i < kLoopIters; ++i) { // ------ Sync Point ------ loopStartSyncPoint.arriveAndWait(); @@ -702,7 +749,7 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_union) postCreateVecOfPointersSyncPoint.arriveAndWait(); std::uniform_int_distribution<> isStrongDist(0, 1); - for (int f = 0; f < kFlipPointersLoopIters; ++f) + for (auto f = 0uz; f < kFlipPointersLoopIters; ++f) { for (auto& p : v) { @@ -725,11 +772,11 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_union) }; std::vector threads; threads.reserve(kNumThreads); - for (int i = 0; i < kNumThreads; ++i) + for (auto i = 0uz; i < kNumThreads; ++i) { threads.emplace_back(cloneAndDestroy, i); } - for (int i = 0; i < kNumThreads; ++i) + for (auto i = 0uz; i < kNumThreads; ++i) { threads[i].join(); } @@ -761,21 +808,32 @@ TEST(IntrusiveSharedTest, multithreaded_locking_weak) auto tracingCallback = [&](TrackedState cur, std::optional next) { using enum TrackedState; auto [destructorRan, partialDeleteRan] = getDestructorState(); - if (next == PartiallyDeleted) + if (!next) + return; + + switch (*next) { - EXPECT_FALSE(partialDeleteRan || destructorRan); - setPartialDeleteRan(); - } - if (next == Deleted) - { - EXPECT_FALSE(destructorRan); - setDestructorRan(); + case PartiallyDeleted: + EXPECT_FALSE(partialDeleteRan || destructorRan); + setPartialDeleteRan(); + break; + + case Deleted: + EXPECT_FALSE(destructorRan); + setDestructorRan(); + break; + + case Uninitialized: + case Alive: + case PartiallyDeletedStarted: + case DeletedStarted: + break; } }; - constexpr int kLoopIters = 2 * 1024; - constexpr int kLockWeakLoopIters = 256; - constexpr int kNumThreads = 16; + constexpr auto kLoopIters = 2uz * 1024; + constexpr auto kLockWeakLoopIters = 256uz; + constexpr auto kNumThreads = 16uz; std::vector> toLock; Barrier loopStartSyncPoint{kNumThreads}; Barrier postCreateToLockSyncPoint{kNumThreads}; @@ -784,8 +842,8 @@ TEST(IntrusiveSharedTest, multithreaded_locking_weak) // lockAndDestroy creates weak pointers from the strong pointer // and runs a loop that locks the weak pointer. At the end of the loop // all the pointers are destroyed all at once. - auto lockAndDestroy = [&](int threadId) { - for (int i = 0; i < kLoopIters; ++i) + auto lockAndDestroy = [&](std::size_t threadId) { + for (auto i = 0uz; i < kLoopIters; ++i) { // ------ Sync Point ------ loopStartSyncPoint.arriveAndWait(); @@ -816,7 +874,7 @@ TEST(IntrusiveSharedTest, multithreaded_locking_weak) // Multiple threads all create a weak pointer from the same // strong pointer WeakIntrusive const weak{toLock[threadId]}; - for (int wi = 0; wi < kLockWeakLoopIters; ++wi) + for (auto wi = 0uz; wi < kLockWeakLoopIters; ++wi) { EXPECT_FALSE(weak.expired()); auto strong = weak.lock(); @@ -831,11 +889,11 @@ TEST(IntrusiveSharedTest, multithreaded_locking_weak) }; std::vector threads; threads.reserve(kNumThreads); - for (int i = 0; i < kNumThreads; ++i) + for (auto i = 0uz; i < kNumThreads; ++i) { threads.emplace_back(lockAndDestroy, i); } - for (int i = 0; i < kNumThreads; ++i) + for (auto i = 0uz; i < kNumThreads; ++i) { threads[i].join(); } diff --git a/src/tests/libxrpl/basics/MallocTrim.cpp b/src/tests/libxrpl/basics/MallocTrim.cpp index 6ac8957f0e..52151262b0 100644 --- a/src/tests/libxrpl/basics/MallocTrim.cpp +++ b/src/tests/libxrpl/basics/MallocTrim.cpp @@ -199,7 +199,7 @@ TEST(mallocTrim, repeated_calls) beast::Journal const journal{beast::Journal::getNullSink()}; // Call malloc_trim multiple times to ensure it's safe - for (int i = 0; i < 5; ++i) + for (auto i = 0uz; i < 5; ++i) { MallocTrimReport const report = mallocTrim("iteration_" + std::to_string(i), journal); diff --git a/src/tests/libxrpl/basics/Number.cpp b/src/tests/libxrpl/basics/Number.cpp index 36e1b4a700..32f93eb1f7 100644 --- a/src/tests/libxrpl/basics/Number.cpp +++ b/src/tests/libxrpl/basics/Number.cpp @@ -323,11 +323,11 @@ TEST(NumberTest, add) __LINE__, }, { - // Does not round. Mantissas are going to be > maxRep, so if + // Does not round. Mantissas are going to be > kMaxRep, so if // added together as uint64_t's, the result will overflow. // With addition using uint128_t, there's no problem. After // normalizing, the resulting mantissa ends up less than - // maxRep. + // kMaxRep. Number{false, 9'999'999'999'999'999'990ULL, 0, Number::Normalized{}}, Number{false, 9'999'999'999'999'999'990ULL, 0, Number::Normalized{}}, Number{false, 1'999'999'999'999'999'998ULL, 1, Number::Normalized{}}, @@ -1078,14 +1078,6 @@ TEST(NumberTest, root) EXPECT_EQ(result, z) << ss.str(); } }; - /* - auto tests = [&](auto const& cSmall, auto const& cLarge) { - test(cSmall); - if (scale != MantissaRange::mantissa_scale::small) - test(cLarge); - }; - */ - auto const cSmall = std::to_array( {{Number{2}, 2, Number{1414213562373095049, -18}}, {Number{2'000'000}, 2, Number{1414213562373095049, -15}}, @@ -1511,7 +1503,7 @@ TEST(NumberTest, to_string) NumberRoundModeGuard const mg(Number::RoundingMode::TowardsZero); auto const maxMantissa = Number::maxMantissa(); - EXPECT_EQ(maxMantissa, (9'999'999'999'999'999)); + EXPECT_EQ(maxMantissa, 9'999'999'999'999'999); test( Number{false, (maxMantissa * 1000) + 999, -3, Number::Normalized()}, "9999999999999999", @@ -1550,7 +1542,7 @@ TEST(NumberTest, to_string) NumberRoundModeGuard const mg(Number::RoundingMode::TowardsZero); auto const maxMantissa = Number::maxMantissa(); - EXPECT_EQ((maxMantissa), (9'999'999'999'999'999'999ULL)); + EXPECT_EQ(maxMantissa, 9'999'999'999'999'999'999ULL); test( Number{false, maxMantissa, 0, Number::Normalized{}}, "9999999999999999990", diff --git a/src/tests/libxrpl/basics/base58.cpp b/src/tests/libxrpl/basics/base58.cpp index d452453f76..d6b1d2c3f9 100644 --- a/src/tests/libxrpl/basics/base58.cpp +++ b/src/tests/libxrpl/basics/base58.cpp @@ -151,7 +151,7 @@ randomBigInt(std::uint8_t minSize = 1, std::uint8_t maxSize = 5) auto const numCoeff = numCoeffDist(eng); std::vector coeffs; coeffs.reserve(numCoeff); - for (int i = 0; i < numCoeff; ++i) + for (auto i = 0uz; i < numCoeff; ++i) { coeffs.push_back(dist(eng)); } @@ -167,7 +167,7 @@ TEST(Base58Test, multiprecision) auto eng = randEngine(); std::uniform_int_distribution dist; std::uniform_int_distribution dist1(1); - for (int i = 0; i < kIters; ++i) + for (auto i = 0uz; i < kIters; ++i) { std::uint64_t const d = dist(eng); if (d == 0u) @@ -185,7 +185,7 @@ TEST(Base58Test, multiprecision) EXPECT_EQ(refMod.convert_to(), mod); EXPECT_EQ(foundDiv, refDiv); } - for (int i = 0; i < kIters; ++i) + for (auto i = 0uz; i < kIters; ++i) { std::uint64_t const d = dist(eng); auto bigInt = multiprecision_utils::randomBigInt(/*minSize*/ 2); @@ -204,7 +204,7 @@ TEST(Base58Test, multiprecision) auto const foundAdd = multiprecision_utils::toBoostMP(bigInt); EXPECT_EQ(refAdd, foundAdd); } - for (int i = 0; i < kIters; ++i) + for (auto i = 0uz; i < kIters; ++i) { std::uint64_t const d = dist1(eng); // Force overflow @@ -221,7 +221,7 @@ TEST(Base58Test, multiprecision) auto const foundAdd = multiprecision_utils::toBoostMP(bigInt); EXPECT_NE(refAdd, foundAdd); } - for (int i = 0; i < kIters; ++i) + for (auto i = 0uz; i < kIters; ++i) { std::uint64_t const d = dist(eng); auto bigInt = multiprecision_utils::randomBigInt(/* minSize */ 2); @@ -239,7 +239,7 @@ TEST(Base58Test, multiprecision) auto const foundMul = multiprecision_utils::toBoostMP(bigInt); EXPECT_EQ(refMul, foundMul); } - for (int i = 0; i < kIters; ++i) + for (auto i = 0uz; i < kIters; ++i) { std::uint64_t const d = dist1(eng); // Force overflow @@ -265,7 +265,7 @@ TEST(Base58Test, fast_matches_ref) std::array b256ResultBuf[2]; std::array, 2> b256Result; - for (int i = 0; i < 2; ++i) + for (auto i = 0uz; i < 2; ++i) { std::span const outBuf{b58ResultBuf[i]}; if (i == 0) @@ -297,7 +297,7 @@ TEST(Base58Test, fast_matches_ref) } } - for (int i = 0; i < 2; ++i) + for (auto i = 0uz; i < 2; ++i) { std::span const outBuf{b256ResultBuf[i].data(), b256ResultBuf[i].size()}; if (i == 0) @@ -339,7 +339,7 @@ TEST(Base58Test, fast_matches_ref) std::array b256ResultBuf[2]; std::array, 2> b256Result; - for (int i = 0; i < 2; ++i) + for (auto i = 0uz; i < 2; ++i) { std::span const outBuf{b58ResultBuf[i].data(), b58ResultBuf[i].size()}; if (i == 0) @@ -370,7 +370,7 @@ TEST(Base58Test, fast_matches_ref) } } - for (int i = 0; i < 2; ++i) + for (auto i = 0uz; i < 2; ++i) { std::span const outBuf{b256ResultBuf[i].data(), b256ResultBuf[i].size()}; if (i == 0) @@ -425,7 +425,7 @@ TEST(Base58Test, fast_matches_ref) // test with random data constexpr std::size_t kIters = 100000; - for (int i = 0; i < kIters; ++i) + for (auto i = 0uz; i < kIters; ++i) { std::array b256DataBuf{}; auto const [tokType, b256Data] = randomB256TestData(b256DataBuf); diff --git a/src/tests/libxrpl/basics/base_uint.cpp b/src/tests/libxrpl/basics/base_uint.cpp index 174cc33aa0..10795f4563 100644 --- a/src/tests/libxrpl/basics/base_uint.cpp +++ b/src/tests/libxrpl/basics/base_uint.cpp @@ -59,65 +59,67 @@ struct BaseUintTest : public ::testing::Test static void testComparisons() { - { - static constexpr std::array, 6> kTestArgs{ - {{"0000000000000000", "0000000000000001"}, - {"0000000000000000", "ffffffffffffffff"}, - {"1234567812345678", "2345678923456789"}, - {"8000000000000000", "8000000000000001"}, - {"aaaaaaaaaaaaaaa9", "aaaaaaaaaaaaaaaa"}, - {"fffffffffffffffe", "ffffffffffffffff"}}}; + using HexPair = std::pair; - for (auto const& arg : kTestArgs) + { + static constexpr auto kTestArgs = std::to_array({ + {"0000000000000000", "0000000000000001"}, + {"0000000000000000", "ffffffffffffffff"}, + {"1234567812345678", "2345678923456789"}, + {"8000000000000000", "8000000000000001"}, + {"aaaaaaaaaaaaaaa9", "aaaaaaaaaaaaaaaa"}, + {"fffffffffffffffe", "ffffffffffffffff"}, + }); + + for (auto const& [smallerText, largerText] : kTestArgs) { - xrpl::BaseUInt<64> const u{arg.first}, v{arg.second}; + xrpl::BaseUInt<64> const smaller{smallerText}, larger{largerText}; // For code readability, we want to use general boolean // expectations instead of specific EXPECT_LT etc. - EXPECT_TRUE(u < v); - EXPECT_TRUE(u <= v); - EXPECT_TRUE(u != v); - EXPECT_FALSE(u == v); - EXPECT_FALSE(u > v); - EXPECT_FALSE(u >= v); - EXPECT_FALSE(v < u); - EXPECT_FALSE(v <= u); - EXPECT_TRUE(v != u); - EXPECT_FALSE(v == u); - EXPECT_TRUE(v > u); - EXPECT_TRUE(v >= u); - EXPECT_TRUE(u == u); - EXPECT_TRUE(v == v); + EXPECT_TRUE(smaller < larger); + EXPECT_TRUE(smaller <= larger); + EXPECT_TRUE(smaller != larger); + EXPECT_FALSE(smaller == larger); + EXPECT_FALSE(smaller > larger); + EXPECT_FALSE(smaller >= larger); + EXPECT_FALSE(larger < smaller); + EXPECT_FALSE(larger <= smaller); + EXPECT_TRUE(larger != smaller); + EXPECT_FALSE(larger == smaller); + EXPECT_TRUE(larger > smaller); + EXPECT_TRUE(larger >= smaller); + EXPECT_TRUE(smaller == smaller); + EXPECT_TRUE(larger == larger); } } { - static constexpr std::array, 6> kTestArgs{ - { - {"000000000000000000000000", "000000000000000000000001"}, - {"000000000000000000000000", "ffffffffffffffffffffffff"}, - {"0123456789ab0123456789ab", "123456789abc123456789abc"}, - {"555555555555555555555555", "55555555555a555555555555"}, - {"aaaaaaaaaaaaaaa9aaaaaaaa", "aaaaaaaaaaaaaaaaaaaaaaaa"}, - {"fffffffffffffffffffffffe", "ffffffffffffffffffffffff"}, - }}; + static constexpr auto kTestArgs = std::to_array({ + {"000000000000000000000000", "000000000000000000000001"}, + {"000000000000000000000000", "ffffffffffffffffffffffff"}, + {"0123456789ab0123456789ab", "123456789abc123456789abc"}, + {"555555555555555555555555", "55555555555a555555555555"}, + {"aaaaaaaaaaaaaaa9aaaaaaaa", "aaaaaaaaaaaaaaaaaaaaaaaa"}, + {"fffffffffffffffffffffffe", "ffffffffffffffffffffffff"}, + }); - for (auto const& arg : kTestArgs) + for (auto const& [smallerText, largerText] : kTestArgs) { - xrpl::BaseUInt<96> const u{arg.first}, v{arg.second}; - EXPECT_TRUE(u < v); - EXPECT_TRUE(u <= v); - EXPECT_TRUE(u != v); - EXPECT_FALSE(u == v); - EXPECT_FALSE(u > v); - EXPECT_FALSE(u >= v); - EXPECT_FALSE(v < u); - EXPECT_FALSE(v <= u); - EXPECT_TRUE(v != u); - EXPECT_FALSE(v == u); - EXPECT_TRUE(v > u); - EXPECT_TRUE(v >= u); - EXPECT_TRUE(u == u); - EXPECT_TRUE(v == v); + xrpl::BaseUInt<96> const smaller{smallerText}, larger{largerText}; + EXPECT_TRUE(smaller < larger); + EXPECT_TRUE(smaller <= larger); + EXPECT_TRUE(smaller != larger); + EXPECT_FALSE(smaller == larger); + EXPECT_FALSE(smaller > larger); + EXPECT_FALSE(smaller >= larger); + EXPECT_FALSE(larger < smaller); + EXPECT_FALSE(larger <= smaller); + EXPECT_TRUE(larger != smaller); + EXPECT_FALSE(larger == smaller); + EXPECT_TRUE(larger > smaller); + EXPECT_TRUE(larger >= smaller); + EXPECT_TRUE(smaller == smaller); + EXPECT_TRUE(larger == larger); } } } @@ -401,14 +403,14 @@ TEST_F(BaseUintTest, base_uint) { } }; - constexpr StrBaseUInt kTestCases[] = { + constexpr auto kTestCases = std::to_array({ "000000000000000000000000", "000000000000000000000001", "fedcba9876543210ABCDEF91", "19FEDCBA0123456789abcdef", "800000000000000000000000", "fFfFfFfFfFfFfFfFfFfFfFfF", - }; + }); for (StrBaseUInt const& t : kTestCases) { diff --git a/src/tests/libxrpl/basics/join.cpp b/src/tests/libxrpl/basics/join.cpp index 66c832678b..427f0b42bc 100644 --- a/src/tests/libxrpl/basics/join.cpp +++ b/src/tests/libxrpl/basics/join.cpp @@ -19,11 +19,11 @@ struct JoinTest : public ::testing::Test TEST_F(JoinTest, join) { - auto test = [](auto collectionanddelimiter, std::string expected) { + auto test = [](auto collectionAndDelimiter, std::string expected) { std::stringstream ss; // Put something else in the buffer before and after to ensure that // the << operator returns the stream correctly. - ss << "(" << collectionanddelimiter << ")"; + ss << "(" << collectionAndDelimiter << ")"; auto const str = ss.str(); EXPECT_EQ(str.substr(1, str.length() - 2), expected); EXPECT_EQ(str.front(), '('); diff --git a/src/tests/libxrpl/beast/LexicalCast.cpp b/src/tests/libxrpl/beast/LexicalCast.cpp new file mode 100644 index 0000000000..d18af4e1cd --- /dev/null +++ b/src/tests/libxrpl/beast/LexicalCast.cpp @@ -0,0 +1,339 @@ +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace beast { +namespace { + +template +[[nodiscard]] constexpr bool +parses(std::string_view text) +{ + T out{}; + return lexicalCastChecked(out, text); +} + +template +[[nodiscard]] constexpr T +parsed(std::string_view text) +{ + T out{}; + return lexicalCastChecked(out, text) ? out : T{}; +} + +template +constexpr T kMax = std::numeric_limits::max(); + +template +constexpr T kMin = std::numeric_limits::min(); + +template +constexpr T kUnderMax = kMax - 1; + +template +constexpr T kOverMin = kMin + 1; + +// Comfortably inside the range, not boundary values. +constexpr auto kNearMax32 = kMax - 5; +constexpr auto kNearMin32 = kMin + 4; +constexpr auto kUnderInt64Max = uint64_t{kMax} - 1; +constexpr auto kInRangeInt16 = int16_t{-5711}; + +// No wider integer type can hold these, so ToString cannot produce them. +constexpr auto kAboveUint64Max = "18446744073709551616"; +constexpr auto kBelowInt64Min = "-9223372036854775809"; + +// Out of range for every integer type we test. +constexpr auto kTwentyNines = "99999999999999999999"; +constexpr auto kNegativeTwentyNines = "-99999999999999999999"; + +// Arbitrary values chosen to sit well outside a type's range, not just over it. +constexpr auto kAboveUint16Max = "75821"; +constexpr auto kBelowInt16Min = "-75821"; +constexpr auto kAboveInt32Max = "5294967295"; +constexpr auto kAboveInt16Max = "66666"; + +constexpr auto kPositiveInt32 = int32_t{42}; +constexpr auto kNegativeInt32 = int32_t{-42}; + +constexpr auto kPositiveInt32Text = "+42"; +constexpr auto kNegativeInt32Text = "-42"; + +constexpr auto kNegativeOne = "-1"; +constexpr auto kNegativeZero = "-0"; +constexpr auto kBareZero = "0"; +constexpr auto kPositiveZero = "+0"; + +// Full-width digits one and zero, not ASCII ones. +constexpr std::string_view kFullWidthDigits = "\xef\xbc\x91\xef\xbc\x90"; + +// The decimal text of a value, usable in a constant expression. +template +struct ToString +{ + std::array buffer{}; + std::size_t length{}; + + constexpr explicit ToString(T value) + { + auto const result = std::to_chars(buffer.data(), buffer.data() + buffer.size(), value); + length = static_cast(result.ptr - buffer.data()); + } + + constexpr + operator std::string_view() const + { + return {buffer.data(), length}; + } +}; + +template +constexpr auto kMaxText = ToString{kMax}; + +template +constexpr auto kUnderMaxText = ToString{kUnderMax}; + +template +constexpr auto kOverMaxText = ToString{Wider{kMax} + 1}; + +template +constexpr auto kMinText = ToString{kMin}; + +template +constexpr auto kOverMinText = ToString{kOverMin}; + +template +constexpr auto kUnderMinText = ToString{Wider{kMin} - 1}; + +constexpr auto kOverUint32MaxText = ToString{uint64_t{kMax} + 5}; +constexpr auto kNegatedOverUint32MaxText = ToString{-(int64_t{kMax} + 5)}; + +// lexicalCastThrow deduces its input type, so the text has to be an explicit +// string_view rather than a ToString. +template +[[nodiscard]] constexpr T +castThrow(Value value) +{ + return lexicalCastThrow(std::string_view{ToString{value}}); +} + +template +[[nodiscard]] bool +roundTrips(std::string_view text) +{ + T out{}; + return lexicalCastChecked(out, text) && std::to_string(out) == text; +} + +template +void +expectRoundTrip(T value) +{ + SCOPED_TRACE(::testing::Message() << "value: " << value); + + auto const text = lexicalCast(value); + EXPECT_EQ(text, std::to_string(value)); + + auto decoded = static_cast(~value); // ensure decoded != value + EXPECT_TRUE(lexicalCastChecked(decoded, text)); + EXPECT_EQ(decoded, value); +} + +} // namespace + +// int/unsigned/short/unsigned short are covered by the list below — they are +// these exact types everywhere we build. +static_assert(std::is_same_v); +static_assert(std::is_same_v); +static_assert(std::is_same_v); +static_assert(std::is_same_v); + +using IntegerTypes = ::testing::Types< // + int16_t, + uint16_t, + int32_t, + uint32_t, + int64_t, + uint64_t>; + +struct IntegerTypeNames +{ + template + static std::string + // NOLINTNEXTLINE(readability-identifier-naming) - required by gtest + GetName(int) + { + return (std::is_signed_v ? "int" : "uint") + std::to_string(sizeof(T) * 8) + "_t"; + } +}; + +template +class LexicalCastIntegers : public ::testing::Test +{ +}; + +TYPED_TEST_SUITE(LexicalCastIntegers, IntegerTypes, IntegerTypeNames); + +TYPED_TEST(LexicalCastIntegers, round_trips_random_values) +{ + static constexpr auto kSampleCount = 1000uz; + + xor_shift_engine r{50}; // seeded per test so a failure reproduces on its own + + for (auto i = 0uz; i < kSampleCount; ++i) + expectRoundTrip(static_cast(r())); +} + +TYPED_TEST(LexicalCastIntegers, round_trips_numeric_limits) +{ + expectRoundTrip(std::numeric_limits::min()); + expectRoundTrip(std::numeric_limits::max()); +} + +TEST(LexicalCast, round_trips_every_int16_value) +{ + for (int32_t i = kMin; i <= kMax; ++i) + { + auto const value = static_cast(i); + + // ASSERT, or a broken cast reports all 65536 iterations. + auto const text = lexicalCast(value); + ASSERT_EQ(text, std::to_string(value)); + ASSERT_EQ(lexicalCast(text), value); + } +} + +TEST(LexicalCast, rejects_overflow) +{ + static_assert(not parses(kOverUint32MaxText)); + static_assert(not parses(kTwentyNines)); + static_assert(not parses(kAboveUint16Max)); +} + +TEST(LexicalCast, rejects_underflow) +{ + static_assert(not parses(kNegativeOne)); + static_assert(not parses(kNegatedOverUint32MaxText)); + static_assert(not parses(kNegativeTwentyNines)); + static_assert(not parses(kBelowInt16Min)); +} + +TEST(LexicalCast, accepts_up_to_the_maximum) +{ + static_assert(parsed(kUnderMaxText) == kUnderMax); + static_assert(parsed(kMaxText) == kMax); + static_assert(not parses(kOverMaxText)); + + static_assert(parsed(kUnderMaxText) == kUnderMax); + static_assert(parsed(kMaxText) == kMax); + static_assert(not parses(kOverMaxText)); + + static_assert(parsed(kUnderMaxText) == kUnderMax); + static_assert(parsed(kMaxText) == kMax); + static_assert(not parses(kOverMaxText)); + + static_assert(parsed(kUnderMaxText) == kUnderMax); + static_assert(parsed(kMaxText) == kMax); + static_assert(not parses(kOverMaxText)); + + static_assert(parsed(kUnderMaxText) == kUnderMax); + static_assert(parsed(kMaxText) == kMax); + static_assert(not parses(kOverMaxText)); + + static_assert(parsed(kUnderMaxText) == kUnderMax); + static_assert(parsed(kMaxText) == kMax); + static_assert(not parses(kAboveUint64Max)); +} + +TEST(LexicalCast, accepts_down_to_the_minimum) +{ + static_assert(parsed(kOverMinText) == kOverMin); + static_assert(parsed(kMinText) == kMin); + static_assert(not parses(kUnderMinText)); + + static_assert(parsed(kOverMinText) == kOverMin); + static_assert(parsed(kMinText) == kMin); + static_assert(not parses(kUnderMinText)); + + static_assert(parsed(kOverMinText) == kOverMin); + static_assert(parsed(kMinText) == kMin); + static_assert(not parses(kBelowInt64Min)); +} + +TEST(LexicalCast, limits_round_trip_through_to_string) +{ + EXPECT_TRUE(roundTrips(kMaxText)); + EXPECT_TRUE(roundTrips(kMaxText)); + EXPECT_TRUE(roundTrips(kMinText)); + EXPECT_TRUE(roundTrips(kMaxText)); + EXPECT_TRUE(roundTrips(kMinText)); + EXPECT_TRUE(roundTrips(kMaxText)); + EXPECT_TRUE(roundTrips(kMinText)); +} + +TEST(LexicalCast, accepts_signed_zero_in_every_form) +{ + static_assert(parsed(kNegativeZero) == 0); + static_assert(parsed(kBareZero) == 0); + static_assert(parsed(kPositiveZero) == 0); +} + +TEST(LexicalCast, rejects_negative_zero_when_unsigned) +{ + static_assert(not parses(kNegativeZero)); + static_assert(parsed(kBareZero) == 0); + static_assert(parsed(kPositiveZero) == 0); +} + +TEST(LexicalCast, accepts_char_pointer_and_std_string_input) +{ + int32_t fromLiteral = 0; + EXPECT_TRUE(lexicalCastChecked(fromLiteral, kPositiveInt32Text)); + EXPECT_EQ(fromLiteral, kPositiveInt32); + + int32_t fromString = 0; + EXPECT_TRUE(lexicalCastChecked(fromString, std::string{kNegativeInt32Text})); + EXPECT_EQ(fromString, kNegativeInt32); +} + +TEST(LexicalCast, throwing_cast_returns_in_range_values) +{ + static_assert(castThrow(kUnderInt64Max) == kUnderInt64Max); + static_assert(castThrow(kNearMax32) == kNearMax32); + static_assert(castThrow(kNearMin32) == kNearMin32); + static_assert(castThrow(kInRangeInt16) == kInRangeInt16); +} + +TEST(LexicalCast, throwing_cast_throws_on_out_of_range) +{ + EXPECT_THROW(lexicalCastThrow(kTwentyNines), BadLexicalCast); + + // kNearMax32 with digits appended, so each is further past uint32_t's range. + for (auto const scale : {10, 100, 1000}) + { + auto const tooBig = ToString{uint64_t{kNearMax32} * scale}; + EXPECT_THROW(lexicalCastThrow(std::string_view{tooBig}), BadLexicalCast); + } + + EXPECT_THROW(lexicalCastThrow(kAboveInt32Max), BadLexicalCast); + EXPECT_THROW(lexicalCastThrow(kAboveInt16Max), BadLexicalCast); +} + +// Full-width digits, not ASCII ones. +TEST(LexicalCast, throwing_cast_throws_on_utf8_digits) +{ + EXPECT_THROW(lexicalCastThrow(kFullWidthDigits), BadLexicalCast); +} + +} // namespace beast diff --git a/src/tests/libxrpl/beast/SemanticVersion.cpp b/src/tests/libxrpl/beast/SemanticVersion.cpp new file mode 100644 index 0000000000..21b33c9476 --- /dev/null +++ b/src/tests/libxrpl/beast/SemanticVersion.cpp @@ -0,0 +1,333 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace beast { +namespace { + +using IdentifierList = SemanticVersion::IdentifierList; + +// Version strings are not valid C++ identifiers, so squash their punctuation to +// turn one into a gtest parameter name. +std::string +identifierFor(std::string_view version) +{ + std::string name{version}; + std::ranges::replace_if( + name, [](char c) { return !std::isalnum(c, std::locale::classic()); }, '_'); + if (!name.empty() && std::isdigit(name.front(), std::locale::classic())) + name.insert(0, "v_"); + return name; +} + +// Pre-release and metadata suffixes, each applied to a "major.minor.patch" base. +// The valid ones leave a well-formed base well-formed; the invalid ones make any +// base malformed. +constexpr auto kValidPreRelease = + std::to_array({"", "-1", "-a", "-a1", "-a1.b1", "-ab.cd", "--"}); +constexpr auto kInvalidPreRelease = + std::to_array({"+", "!", "-", "-!", "-.", "-a.!", "-0.a"}); +constexpr auto kValidMetaData = std::to_array({"", "+a", "+1", "+a.b", "+ab.cd"}); +constexpr auto kInvalidMetaData = + std::to_array({"!", "+", "++", "+!", "+.", "+a.!"}); + +// Assembles base + preRelease + metaData and checks whether it parses. A version +// we accept must also round-trip through print(). +void +expectParse( + std::string_view base, + std::string_view preRelease, + std::string_view metaData, + bool shouldPass) +{ + auto const input = std::string{base}.append(preRelease).append(metaData); + SCOPED_TRACE(::testing::Message() << '"' << input << '"'); + + SemanticVersion v; + + if (shouldPass) + { + EXPECT_TRUE(v.parse(input)); + EXPECT_EQ(v.print(), input); + } + else + { + EXPECT_FALSE(v.parse(input)); + } +} + +struct ParseCase +{ + std::string_view testName; + std::string_view base; + bool shouldPass; +}; + +std::string +parseCaseName(::testing::TestParamInfo const& info) +{ + return std::string{info.param.testName}; +} + +constexpr auto kParseCases = std::to_array({ + {.testName = "zeroes", .base = "0.0.0", .shouldPass = true}, + {.testName = "simple", .base = "1.2.3", .shouldPass = true}, + {.testName = "max_int", .base = "2147483647.2147483647.2147483647", .shouldPass = true}, + + // negative values + {.testName = "negative_major", .base = "-1.2.3", .shouldPass = false}, + {.testName = "negative_minor", .base = "1.-2.3", .shouldPass = false}, + {.testName = "negative_patch", .base = "1.2.-3", .shouldPass = false}, + + // missing parts + {.testName = "empty", .base = "", .shouldPass = false}, + {.testName = "major_only", .base = "1", .shouldPass = false}, + {.testName = "major_then_dot", .base = "1.", .shouldPass = false}, + {.testName = "major_and_minor", .base = "1.2", .shouldPass = false}, + {.testName = "major_minor_then_dot", .base = "1.2.", .shouldPass = false}, + {.testName = "missing_major", .base = ".2.3", .shouldPass = false}, + + // whitespace + {.testName = "leading_space", .base = " 1.2.3", .shouldPass = false}, + {.testName = "space_after_major", .base = "1 .2.3", .shouldPass = false}, + {.testName = "space_after_minor", .base = "1.2 .3", .shouldPass = false}, + {.testName = "trailing_space", .base = "1.2.3 ", .shouldPass = false}, + + // leading zeroes + {.testName = "leading_zero_in_major", .base = "01.2.3", .shouldPass = false}, + {.testName = "leading_zero_in_minor", .base = "1.02.3", .shouldPass = false}, + {.testName = "leading_zero_in_patch", .base = "1.2.03", .shouldPass = false}, +}); + +struct ValuesCase +{ + std::string_view testName; + std::string_view input; + int majorVersion; + int minorVersion; + int patchVersion; + IdentifierList preReleaseIdentifiers{}; // NOLINT(readability-redundant-member-init) + IdentifierList metaData{}; // NOLINT(readability-redundant-member-init) +}; + +std::string +valuesCaseName(::testing::TestParamInfo const& info) +{ + return std::string{info.param.testName}; +} + +std::vector const kValuesCases{ + { + .testName = "zero_major", + .input = "0.1.2", + .majorVersion = 0, + .minorVersion = 1, + .patchVersion = 2, + }, + { + .testName = "simple", + .input = "1.2.3", + .majorVersion = 1, + .minorVersion = 2, + .patchVersion = 3, + }, + { + .testName = "one_pre_release_identifier", + .input = "1.2.3-rc1", + .majorVersion = 1, + .minorVersion = 2, + .patchVersion = 3, + .preReleaseIdentifiers = {"rc1"}, + }, + { + .testName = "two_pre_release_identifiers", + .input = "1.2.3-rc1.debug", + .majorVersion = 1, + .minorVersion = 2, + .patchVersion = 3, + .preReleaseIdentifiers = {"rc1", "debug"}, + }, + { + .testName = "three_pre_release_identifiers", + .input = "1.2.3-rc1.debug.asm", + .majorVersion = 1, + .minorVersion = 2, + .patchVersion = 3, + .preReleaseIdentifiers = {"rc1", "debug", "asm"}, + }, + { + .testName = "one_metadata_identifier", + .input = "1.2.3+full", + .majorVersion = 1, + .minorVersion = 2, + .patchVersion = 3, + .metaData = {"full"}, + }, + { + .testName = "two_metadata_identifiers", + .input = "1.2.3+full.prod", + .majorVersion = 1, + .minorVersion = 2, + .patchVersion = 3, + .metaData = {"full", "prod"}, + }, + { + .testName = "three_metadata_identifiers", + .input = "1.2.3+full.prod.x86", + .majorVersion = 1, + .minorVersion = 2, + .patchVersion = 3, + .metaData = {"full", "prod", "x86"}, + }, + { + .testName = "pre_release_and_metadata", + .input = "1.2.3-rc1.debug.asm+full.prod.x86", + .majorVersion = 1, + .minorVersion = 2, + .patchVersion = 3, + .preReleaseIdentifiers = {"rc1", "debug", "asm"}, + .metaData = {"full", "prod", "x86"}, + }, +}; + +struct OrderCase +{ + std::string_view lesser; + std::string_view greater; +}; + +std::string +orderCaseName(::testing::TestParamInfo const& info) +{ + return identifierFor(info.param.lesser) + "_below_" + identifierFor(info.param.greater); +} + +constexpr auto kOrderCases = std::to_array({ + {.lesser = "1.0.0-alpha", .greater = "1.0.0-alpha.1"}, + {.lesser = "1.0.0-alpha.1", .greater = "1.0.0-alpha.beta"}, + {.lesser = "1.0.0-alpha.beta", .greater = "1.0.0-beta"}, + {.lesser = "1.0.0-beta", .greater = "1.0.0-beta.2"}, + {.lesser = "1.0.0-beta.2", .greater = "1.0.0-beta.11"}, + {.lesser = "1.0.0-beta.11", .greater = "1.0.0-rc.1"}, + {.lesser = "1.0.0-rc.1", .greater = "1.0.0"}, + {.lesser = "0.9.9", .greater = "1.0.0"}, +}); + +} // namespace + +class SemanticVersionParse : public ::testing::TestWithParam +{ +}; + +// Exercises the base string on its own and with every combination of appended +// pre-release identifiers and metadata. +TEST_P(SemanticVersionParse, pre_release_and_metadata_combinations) +{ + auto const& [testName, base, shouldPass] = GetParam(); + + for (auto const preRelease : kValidPreRelease) + { + for (auto const metaData : kValidMetaData) + expectParse(base, preRelease, metaData, shouldPass); + + for (auto const metaData : kInvalidMetaData) + expectParse(base, preRelease, metaData, false); + } + + // A malformed pre-release section poisons the whole string, whatever + // metadata follows it. + for (auto const preRelease : kInvalidPreRelease) + { + for (auto const metaData : kValidMetaData) + expectParse(base, preRelease, metaData, false); + + for (auto const metaData : kInvalidMetaData) + expectParse(base, preRelease, metaData, false); + } +} + +INSTANTIATE_TEST_SUITE_P( + Inputs, + SemanticVersionParse, + ::testing::ValuesIn(kParseCases), + parseCaseName); + +class SemanticVersionValues : public ::testing::TestWithParam +{ +}; + +TEST_P(SemanticVersionValues, decomposes_into_components) +{ + auto const& expected = GetParam(); + + SemanticVersion v; + EXPECT_TRUE(v.parse(expected.input)); + + EXPECT_EQ(v.majorVersion, expected.majorVersion); + EXPECT_EQ(v.minorVersion, expected.minorVersion); + EXPECT_EQ(v.patchVersion, expected.patchVersion); + + EXPECT_EQ(v.preReleaseIdentifiers, expected.preReleaseIdentifiers); + EXPECT_EQ(v.metaData, expected.metaData); +} + +INSTANTIATE_TEST_SUITE_P( + Inputs, + SemanticVersionValues, + ::testing::ValuesIn(kValuesCases), + valuesCaseName); + +class SemanticVersionOrder : public ::testing::TestWithParam +{ +}; + +TEST_P(SemanticVersionOrder, lesser_precedes_greater) +{ + auto const& [lesser, greater] = GetParam(); + + // Metadata takes no part in precedence, so attaching it to either side must + // leave the ordering untouched. + static constexpr auto kMetaData = std::to_array({"", "+meta"}); + + for (auto const lesserMetaData : kMetaData) + { + for (auto const greaterMetaData : kMetaData) + { + auto const lesserInput = std::string{lesser}.append(lesserMetaData); + auto const greaterInput = std::string{greater}.append(greaterMetaData); + SCOPED_TRACE( + ::testing::Message() << '"' << lesserInput << "\" < \"" << greaterInput << '"'); + + SemanticVersion lesserVersion; + SemanticVersion greaterVersion; + EXPECT_TRUE(lesserVersion.parse(lesserInput)); + EXPECT_TRUE(greaterVersion.parse(greaterInput)); + + EXPECT_EQ(compare(lesserVersion, lesserVersion), 0); + EXPECT_EQ(compare(greaterVersion, greaterVersion), 0); + EXPECT_LT(compare(lesserVersion, greaterVersion), 0); + EXPECT_GT(compare(greaterVersion, lesserVersion), 0); + + EXPECT_LT(lesserVersion, greaterVersion); + EXPECT_GT(greaterVersion, lesserVersion); + EXPECT_EQ(lesserVersion, lesserVersion); + EXPECT_EQ(greaterVersion, greaterVersion); + } + } +} + +INSTANTIATE_TEST_SUITE_P( + Pairs, + SemanticVersionOrder, + ::testing::ValuesIn(kOrderCases), + orderCaseName); + +} // namespace beast diff --git a/src/tests/libxrpl/beast/Zero.cpp b/src/tests/libxrpl/beast/Zero.cpp new file mode 100644 index 0000000000..2ac725509a --- /dev/null +++ b/src/tests/libxrpl/beast/Zero.cpp @@ -0,0 +1,92 @@ +#include + +#include + +namespace beast { + +struct AdlTester +{ +}; + +int +signum(AdlTester) +{ + return 0; +} + +namespace inner_adl_test { + +struct AdlTester2 +{ +}; + +int +signum(AdlTester2) +{ + return 0; +} + +} // namespace inner_adl_test + +namespace { + +struct IntegerWrapper +{ + int value; + + IntegerWrapper(int v) : value(v) + { + } + + [[nodiscard]] int + signum() const + { + return value; + } +}; + +void +testLhsZero(IntegerWrapper x) +{ + EXPECT_EQ(x >= kZero, x.signum() >= 0); + EXPECT_EQ(x > kZero, x.signum() > 0); + EXPECT_EQ(x == kZero, x.signum() == 0); + EXPECT_EQ(x != kZero, x.signum() != 0); + EXPECT_EQ(x < kZero, x.signum() < 0); + EXPECT_EQ(x <= kZero, x.signum() <= 0); +} + +void +testRhsZero(IntegerWrapper x) +{ + EXPECT_EQ(kZero >= x, 0 >= x.signum()); + EXPECT_EQ(kZero > x, 0 > x.signum()); + EXPECT_EQ(kZero == x, 0 == x.signum()); + EXPECT_EQ(kZero != x, 0 != x.signum()); + EXPECT_EQ(kZero < x, 0 < x.signum()); + EXPECT_EQ(kZero <= x, 0 <= x.signum()); +} + +} // namespace + +TEST(Zero, lhs) +{ + testLhsZero(-7); + testLhsZero(0); + testLhsZero(32); +} + +TEST(Zero, rhs) +{ + testRhsZero(-4); + testRhsZero(0); + testRhsZero(64); +} + +TEST(Zero, adl) +{ + EXPECT_TRUE(AdlTester{} == kZero); + EXPECT_TRUE(inner_adl_test::AdlTester2{} == kZero); +} + +} // namespace beast diff --git a/src/tests/libxrpl/consensus/CensorshipDetector.cpp b/src/tests/libxrpl/consensus/CensorshipDetector.cpp index aa6b2d086b..2c6b6ec731 100644 --- a/src/tests/libxrpl/consensus/CensorshipDetector.cpp +++ b/src/tests/libxrpl/consensus/CensorshipDetector.cpp @@ -69,7 +69,7 @@ TEST(CensorshipDetectorTest, censorship_detector) runRound(cdet, ++round, {23, 24, 25, 26}, {25, 27}, {23, 26}, {24}); runRound(cdet, ++round, {23, 26, 28}, {26, 28}, {23}, {}); - for (int i = 0; i != 10; ++i) + for (auto i = 0uz; i != 10; ++i) runRound(cdet, ++round, {23}, {}, {23}, {}); runRound(cdet, ++round, {23, 29}, {29}, {23}, {}); diff --git a/src/tests/libxrpl/csf/TrustGraph.h b/src/tests/libxrpl/csf/TrustGraph.h index d010b954e0..8a804fcd5b 100644 --- a/src/tests/libxrpl/csf/TrustGraph.h +++ b/src/tests/libxrpl/csf/TrustGraph.h @@ -118,9 +118,9 @@ public: std::vector res; // Loop over all pairs of uniqueUNLs - for (int i = 0; i < uniqueUNLs.size(); ++i) + for (auto i = 0uz; i < uniqueUNLs.size(); ++i) { - for (int j = (i + 1); j < uniqueUNLs.size(); ++j) + for (auto j = i + 1; j < uniqueUNLs.size(); ++j) { auto const& unlA = uniqueUNLs[i]; auto const& unlB = uniqueUNLs[j]; diff --git a/src/tests/libxrpl/csf/random.h b/src/tests/libxrpl/csf/random.h index 007bdecb1b..56838bb280 100644 --- a/src/tests/libxrpl/csf/random.h +++ b/src/tests/libxrpl/csf/random.h @@ -24,11 +24,12 @@ randomWeightedShuffle(std::vector v, std::vector w, G& g) { using std::swap; - for (int i = 0; i < v.size() - 1; ++i) + for (auto i = 0uz; i + 1 < v.size(); ++i) { - // pick a random item weighted by w - std::discrete_distribution<> dd(w.begin() + i, w.end()); // NOLINT(misc-const-correctness) - auto idx = dd(g); + // Pick a random item from the unplaced tail, weighted by w. + // NOLINTNEXTLINE(misc-const-correctness) + std::discrete_distribution dd(w.begin() + i, w.end()); + auto const idx = i + dd(g); std::swap(v[i], v[idx]); std::swap(w[i], w[idx]); } diff --git a/src/tests/libxrpl/nodestore/Codec.cpp b/src/tests/libxrpl/nodestore/Codec.cpp new file mode 100644 index 0000000000..f31878f3c5 --- /dev/null +++ b/src/tests/libxrpl/nodestore/Codec.cpp @@ -0,0 +1,146 @@ +#include + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace xrpl; +using namespace xrpl::node_store; + +namespace { + +// v1 inner-node layout: 16 hashes of 32 bytes each +constexpr std::size_t kHashCount = 16; +constexpr std::size_t kHashSize = 32; + +std::vector +makeInnerNode(std::size_t nonEmptySlots) +{ + using namespace nudb::detail; + + static constexpr std::size_t kInnerNodeSize = 525; + + std::array hashes{}; + for (auto slot = 0uz; slot < nonEmptySlots; ++slot) + { + for (auto byte = 0uz; byte < kHashSize; ++byte) + { + std::size_t const offset = (slot * kHashSize) + byte; + hashes[offset] = static_cast((offset % 255) + 1); + } + } + + std::vector blob(kInnerNodeSize); + ostream os(blob.data(), blob.size()); + write(os, 0); // index + write(os, 0); // unused + write(os, static_cast(NodeObjectType::Unknown)); + write(os, static_cast(HashPrefix::InnerNode)); + write(os, hashes.data(), hashes.size()); + + return blob; +} + +std::uint8_t +codecType(std::pair const& compressed) +{ + return static_cast(compressed.first)[0]; +} + +} // namespace + +// All 16 hash slots populated - "full v1 inner node" +TEST(Codec, inner_node_full_roundtrip) +{ + static constexpr std::uint8_t kTypeInnerNodeFull = 3; + + auto const blob = makeInnerNode(kHashCount); + + nudb::detail::buffer compressBuf; + auto const compressed = nodeobjectCompress(blob.data(), blob.size(), compressBuf); + + EXPECT_EQ(codecType(compressed), kTypeInnerNodeFull); + EXPECT_EQ(compressed.second, sizeVarint(kTypeInnerNodeFull) + (kHashCount * kHashSize)); + + nudb::detail::buffer decompressBuf; + auto const restored = nodeobjectDecompress(compressed.first, compressed.second, decompressBuf); + + EXPECT_EQ(restored.second, blob.size()); + EXPECT_EQ(std::memcmp(restored.first, blob.data(), blob.size()), 0); +} + +// Some hash slots empty - "compressed v1 inner node" +TEST(Codec, inner_node_compressed_roundtrip) +{ + static constexpr std::uint8_t kTypeInnerNodeCompressed = 2; + static constexpr std::size_t kNonEmpty = 5; + auto const blob = makeInnerNode(kNonEmpty); + + nudb::detail::buffer compressBuf; + auto const compressed = nodeobjectCompress(blob.data(), blob.size(), compressBuf); + + EXPECT_EQ(codecType(compressed), kTypeInnerNodeCompressed); + EXPECT_EQ( + compressed.second, + sizeVarint(kTypeInnerNodeCompressed) + sizeof(std::uint16_t) + (kNonEmpty * kHashSize)); + EXPECT_LT(compressed.second, blob.size()); + + nudb::detail::buffer decompressBuf; + auto const restored = nodeobjectDecompress(compressed.first, compressed.second, decompressBuf); + + EXPECT_EQ(restored.second, blob.size()); + EXPECT_EQ(std::memcmp(restored.first, blob.data(), blob.size()), 0); +} + +// Anything that is not a v1 inner node - lz4 compressed +TEST(Codec, lz4_roundtrip) +{ + // A payload that is deliberately not a v1 inner node (any size other than 525), filled with a + // short repeating pattern so lz4 actually shrinks it. + static constexpr std::size_t kNonInnerNodeSize = 1000; + static constexpr std::size_t kBytePatternPeriod = 7; + static constexpr std::uint8_t kTypeLz4 = 1; + + std::vector blob(kNonInnerNodeSize); + for (auto i = 0uz; i < blob.size(); ++i) + blob[i] = static_cast(i % kBytePatternPeriod); + + nudb::detail::buffer compressBuf; + auto const compressed = nodeobjectCompress(blob.data(), blob.size(), compressBuf); + + EXPECT_EQ(codecType(compressed), kTypeLz4); + + nudb::detail::buffer decompressBuf; + auto const restored = nodeobjectDecompress(compressed.first, compressed.second, decompressBuf); + + EXPECT_EQ(restored.second, blob.size()); + EXPECT_EQ(std::memcmp(restored.first, blob.data(), blob.size()), 0); +} + +// An uncompressed blob is never produced by the compressor but must still decode: leading varint 0 +// followed by the raw payload. +TEST(Codec, uncompressed_passthrough) +{ + static constexpr std::uint8_t kTypeUncompressed = 0; + static constexpr auto payload = std::to_array({0xde, 0xad, 0xbe, 0xef, 0x2a}); + + std::vector blob; + blob.push_back(kTypeUncompressed); // leading varint type tag + blob.insert(blob.end(), payload.begin(), payload.end()); + + nudb::detail::buffer decompressBuf; + auto const restored = nodeobjectDecompress(blob.data(), blob.size(), decompressBuf); + + EXPECT_EQ(restored.second, payload.size()); + EXPECT_EQ(std::memcmp(restored.first, payload.data(), payload.size()), 0); +} diff --git a/src/tests/libxrpl/nodestore/Database.cpp b/src/tests/libxrpl/nodestore/Database.cpp index 23087a2f84..82012ed347 100644 --- a/src/tests/libxrpl/nodestore/Database.cpp +++ b/src/tests/libxrpl/nodestore/Database.cpp @@ -27,11 +27,11 @@ namespace { std::vector allBackends() { - std::vector types{"memory", "nudb"}; #if XRPL_ROCKSDB_AVAILABLE - types.emplace_back("rocksdb"); + return {"memory", "nudb", "rocksdb"}; +#else + return {"memory", "nudb"}; #endif - return types; } std::vector diff --git a/src/tests/libxrpl/nodestore/Varint.cpp b/src/tests/libxrpl/nodestore/Varint.cpp new file mode 100644 index 0000000000..3652fd5631 --- /dev/null +++ b/src/tests/libxrpl/nodestore/Varint.cpp @@ -0,0 +1,46 @@ +#include + +#include + +#include +#include +#include +#include + +using namespace xrpl::node_store; + +TEST(Varint, encode_decode) +{ + static constexpr auto kValues = std::to_array({ + 0, + 1, + 2, + 126, + 127, + 128, + 253, + 254, + 255, + 16127, + 16128, + 16129, + 0xff, + 0xffff, + 0xffffffff, + 0xffffffffffffUL, + std::numeric_limits::max(), + }); + + for (auto const value : kValues) + { + std::array::kMax> buffer{}; + auto const bytesWritten = writeVarint(buffer.data(), value); + EXPECT_GT(bytesWritten, 0u); + EXPECT_EQ(bytesWritten, sizeVarint(value)); + + std::size_t decoded = 0; + auto const bytesRead = readVarint(buffer.data(), bytesWritten, decoded); + EXPECT_EQ(bytesRead, bytesWritten); + EXPECT_EQ(value, decoded); + } +} diff --git a/src/tests/libxrpl/nodestore/varint.cpp b/src/tests/libxrpl/nodestore/varint.cpp deleted file mode 100644 index fee96f314b..0000000000 --- a/src/tests/libxrpl/nodestore/varint.cpp +++ /dev/null @@ -1,46 +0,0 @@ -#include - -#include - -#include -#include -#include -#include -#include - -using namespace xrpl::node_store; - -TEST(varint, encode_decode) -{ - std::vector const kVALUES = { - 0, - 1, - 2, - 126, - 127, - 128, - 253, - 254, - 255, - 16127, - 16128, - 16129, - 0xff, - 0xffff, - 0xffffffff, - 0xffffffffffffUL, - 0xffffffffffffffffUL}; - - for (auto const v : kVALUES) - { - SCOPED_TRACE("value=" + std::to_string(v)); - std::array::kMax> vi{}; - auto const n0 = writeVarint(vi.data(), v); - EXPECT_GT(n0, 0u) << "write error"; - EXPECT_EQ(n0, sizeVarint(v)) << "size error"; - std::size_t v1 = 0; - auto const n1 = readVarint(vi.data(), n0, v1); - EXPECT_EQ(n1, n0) << "read error"; - EXPECT_EQ(v1, v) << "wrong value"; - } -} diff --git a/src/tests/libxrpl/protocol/ApiVersion.cpp b/src/tests/libxrpl/protocol/ApiVersion.cpp new file mode 100644 index 0000000000..8af7787102 --- /dev/null +++ b/src/tests/libxrpl/protocol/ApiVersion.cpp @@ -0,0 +1,26 @@ +#include + +#include + +using namespace xrpl; + +TEST(ApiVersion, invariants) +{ + static_assert(RPC::kApiMinimumSupportedVersion <= RPC::kApiMaximumSupportedVersion); + static_assert(RPC::kApiMinimumSupportedVersion <= RPC::kApiMaximumValidVersion); + static_assert(RPC::kApiMaximumSupportedVersion <= RPC::kApiMaximumValidVersion); + static_assert(RPC::kApiBetaVersion <= RPC::kApiMaximumValidVersion); +} + +// Update when we change versions +TEST(ApiVersion, versions) +{ + static_assert(RPC::kApiMinimumSupportedVersion >= 1); + static_assert(RPC::kApiMinimumSupportedVersion < 2); + static_assert(RPC::kApiMaximumSupportedVersion >= 2); + static_assert(RPC::kApiMaximumSupportedVersion < 3); + static_assert(RPC::kApiMaximumValidVersion >= 3); + static_assert(RPC::kApiMaximumValidVersion < 4); + static_assert(RPC::kApiBetaVersion >= 3); + static_assert(RPC::kApiBetaVersion < 4); +} diff --git a/src/tests/libxrpl/protocol/Serializer.cpp b/src/tests/libxrpl/protocol/Serializer.cpp new file mode 100644 index 0000000000..fc5742444a --- /dev/null +++ b/src/tests/libxrpl/protocol/Serializer.cpp @@ -0,0 +1,49 @@ +#include + +#include + +#include +#include +#include + +using namespace xrpl; + +TEST(Serializer, add32_roundtrip) +{ + static constexpr auto kValues = std::to_array({ + std::numeric_limits::min(), + -1, + 0, + 1, + std::numeric_limits::max(), + }); + + for (std::int32_t const value : kValues) + { + Serializer s; + s.add32(value); + EXPECT_EQ(s.size(), 4); + SerialIter sit(s.slice()); + EXPECT_EQ(sit.geti32(), value); + } +} + +TEST(Serializer, add64_roundtrip) +{ + static constexpr auto kValues = std::to_array({ + std::numeric_limits::min(), + -1, + 0, + 1, + std::numeric_limits::max(), + }); + + for (std::int64_t const value : kValues) + { + Serializer s; + s.add64(value); + EXPECT_EQ(s.size(), 8); + SerialIter sit(s.slice()); + EXPECT_EQ(sit.geti64(), value); + } +} diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp index 2697924d37..f55d01f606 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp @@ -35,6 +35,7 @@ TEST(VaultTests, BuilderSettersRoundTrip) auto const shareMPTIDValue = canonical_UINT192(); auto const withdrawalPolicyValue = canonical_UINT8(); auto const scaleValue = canonical_UINT8(); + auto const lEVersionValue = canonical_UINT8(); VaultBuilder builder{ previousTxnIDValue, @@ -54,6 +55,7 @@ TEST(VaultTests, BuilderSettersRoundTrip) builder.setAssetsMaximum(assetsMaximumValue); builder.setLossUnrealized(lossUnrealizedValue); builder.setScale(scaleValue); + builder.setLEVersion(lEVersionValue); builder.setLedgerIndex(index); builder.setFlags(0x1u); @@ -166,6 +168,14 @@ TEST(VaultTests, BuilderSettersRoundTrip) EXPECT_TRUE(entry.hasScale()); } + { + auto const& expected = lEVersionValue; + auto const actualOpt = entry.getLEVersion(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfLEVersion"); + EXPECT_TRUE(entry.hasLEVersion()); + } + EXPECT_TRUE(entry.hasLedgerIndex()); auto const ledgerIndex = entry.getLedgerIndex(); ASSERT_TRUE(ledgerIndex.has_value()); @@ -194,6 +204,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip) auto const shareMPTIDValue = canonical_UINT192(); auto const withdrawalPolicyValue = canonical_UINT8(); auto const scaleValue = canonical_UINT8(); + auto const lEVersionValue = canonical_UINT8(); auto sle = std::make_shared(Vault::entryType, index); @@ -212,6 +223,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip) sle->at(sfShareMPTID) = shareMPTIDValue; sle->at(sfWithdrawalPolicy) = withdrawalPolicyValue; sle->at(sfScale) = scaleValue; + sle->at(sfLEVersion) = lEVersionValue; VaultBuilder builderFromSle{sle}; EXPECT_TRUE(builderFromSle.validate()); @@ -390,6 +402,19 @@ TEST(VaultTests, BuilderFromSleRoundTrip) expectEqualField(expected, *fromBuilderOpt, "sfScale"); } + { + auto const& expected = lEVersionValue; + + auto const fromSleOpt = entryFromSle.getLEVersion(); + auto const fromBuilderOpt = entryFromBuilder.getLEVersion(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfLEVersion"); + expectEqualField(expected, *fromBuilderOpt, "sfLEVersion"); + } + EXPECT_EQ(entryFromSle.getKey(), index); EXPECT_EQ(entryFromBuilder.getKey(), index); } @@ -472,5 +497,7 @@ TEST(VaultTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getLossUnrealized().has_value()); EXPECT_FALSE(entry.hasScale()); EXPECT_FALSE(entry.getScale().has_value()); + EXPECT_FALSE(entry.hasLEVersion()); + EXPECT_FALSE(entry.getLEVersion().has_value()); } } diff --git a/src/tests/libxrpl/resource/Logic.cpp b/src/tests/libxrpl/resource/Logic.cpp index 1f935ebf4b..a3362b4540 100644 --- a/src/tests/libxrpl/resource/Logic.cpp +++ b/src/tests/libxrpl/resource/Logic.cpp @@ -17,9 +17,12 @@ #include #include +#include #include #include +#include #include +#include namespace xrpl::Resource { @@ -54,9 +57,10 @@ protected: //-------------------------------------------------------------------------- - static void - populateGossip(Gossip& gossip) + static Gossip + makeGossip() { + Gossip gossip; std::uint8_t const v(10 + randInt(9)); std::uint8_t const n(10 + randInt(9)); gossip.items.reserve(n); @@ -71,8 +75,9 @@ protected: static_cast(v + i), }}; item.address = beast::IP::Endpoint{beast::IP::AddressV4{d}}; - gossip.items.push_back(item); + gossip.items.push_back(std::move(item)); } + return gossip; } }; @@ -87,8 +92,8 @@ TEST_F(ResourceManagerTest, limited_warn_drop) Consumer c{logic.newInboundEndpoint(addr)}; // Create load until we get a warning - int n = 10000; - bool warned = false; + auto n = 10000; + auto warned = false; while (--n >= 0) { @@ -97,7 +102,7 @@ TEST_F(ResourceManagerTest, limited_warn_drop) warned = true; break; } - ++logic.clock(); + logic.advance(); } ASSERT_TRUE(warned) << "Loop count exceeded without warning"; @@ -113,7 +118,7 @@ TEST_F(ResourceManagerTest, limited_warn_drop) EXPECT_TRUE(c.disconnect(j_)); break; } - ++logic.clock(); + logic.advance(); } ASSERT_TRUE(dropped) << "Loop count exceeded without dropping"; @@ -135,7 +140,7 @@ TEST_F(ResourceManagerTest, limited_warn_drop) auto n = kSecondsUntilExpiration + 1s; while (--n > 0s) { - ++logic.clock(); + logic.advance(); logic.periodicActivity(); Consumer const c{logic.newInboundEndpoint(addr)}; if (c.disposition() != Disposition::Drop) @@ -167,7 +172,7 @@ TEST_F(ResourceManagerTest, unlimited_warn_drop) warned = true; break; } - ++logic.clock(); + logic.advance(); } EXPECT_FALSE(warned) << "Should loop forever with no warning"; @@ -175,6 +180,8 @@ TEST_F(ResourceManagerTest, unlimited_warn_drop) TEST_F(ResourceManagerTest, charges) { + static constexpr auto kDecayTicks = 128uz; + TestLogic logic{j_}; { @@ -183,7 +190,7 @@ TEST_F(ResourceManagerTest, charges) Charge const fee{1000}; JLOG(j_.info()) << "Charging " << c.toString() << " " << fee << " per second"; c.charge(fee); - for (int i = 0; i < 128; ++i) + for (auto tick = 0uz; tick < kDecayTicks; ++tick) { JLOG(j_.info()) << "Time= " << logic.clock().now().time_since_epoch().count() << ", Balance = " << c.balance(); @@ -196,7 +203,7 @@ TEST_F(ResourceManagerTest, charges) Consumer c{logic.newInboundEndpoint(address)}; Charge const fee{1000}; JLOG(j_.info()) << "Charging " << c.toString() << " " << fee << " per second"; - for (int i = 0; i < 128; ++i) + for (auto tick = 0uz; tick < kDecayTicks; ++tick) { c.charge(fee); JLOG(j_.info()) << "Time= " << logic.clock().now().time_since_epoch().count() @@ -210,13 +217,10 @@ TEST_F(ResourceManagerTest, imports) { TestLogic logic{j_}; - Gossip g[5]; - - for (auto& i : g) - populateGossip(i); - - for (int i = 0; i < 5; ++i) - logic.importConsumers(std::to_string(i), g[i]); + static constexpr auto kGossipSources = 5uz; + std::ranges::for_each(std::views::iota(0uz, kGossipSources), [&](auto const i) { + logic.importConsumers(std::to_string(i), makeGossip()); + }); } TEST_F(ResourceManagerTest, import) @@ -233,7 +237,7 @@ TEST_F(ResourceManagerTest, import) 1, }}; item.address = beast::IP::Endpoint{beast::IP::AddressV4{d}}; - g.items.push_back(item); + g.items.push_back(std::move(item)); logic.importConsumers("g", g); } diff --git a/src/tests/libxrpl/shamap/SHAMap.cpp b/src/tests/libxrpl/shamap/SHAMap.cpp index 238c34bf9c..e662e16be4 100644 --- a/src/tests/libxrpl/shamap/SHAMap.cpp +++ b/src/tests/libxrpl/shamap/SHAMap.cpp @@ -257,12 +257,9 @@ TEST_P(SHAMapTest, add_traverse_snapshot_build_tear_and_iterate) map.invariants(); } - int h = 7; + auto keyIndex = kKeys.size(); for (auto const& k : map) - { - EXPECT_EQ(k.key(), kKeys[h]); - --h; - } + EXPECT_EQ(k.key(), kKeys[--keyIndex]); } } @@ -288,7 +285,11 @@ TEST_F(SHAMapPathProof, verify_proof_path) uint256 rootHash; std::vector goodPath; - for (unsigned char c = 1; c < 100; ++c) + static constexpr unsigned char kFirstKey = 1; + static constexpr unsigned char kKeyCount = 100; + static constexpr unsigned char kLastKey = kKeyCount - 1; + + for (unsigned char c = kFirstKey; c < kKeyCount; ++c) { uint256 k(c); map.addItem(SHAMapNodeType::TnAccountState, makeShamapitem(k, Slice{k.data(), k.size()})); @@ -304,7 +305,7 @@ TEST_F(SHAMapPathProof, verify_proof_path) auto& proofPath = *path; EXPECT_TRUE(map.verifyProofPath(root, k, proofPath)); - if (c == 1) + if (c == kFirstKey) { // extra node proofPath.insert(proofPath.begin(), proofPath.front()); @@ -313,7 +314,7 @@ TEST_F(SHAMapPathProof, verify_proof_path) uint256 const wrongKey(c + 1); EXPECT_FALSE(map.getProofPath(wrongKey)); } - if (c == 99) + if (c == kLastKey) { key = k; rootHash = root; diff --git a/src/tests/libxrpl/shamap/SHAMapSync.cpp b/src/tests/libxrpl/shamap/SHAMapSync.cpp index 5cefbae8a1..e4bcbd8970 100644 --- a/src/tests/libxrpl/shamap/SHAMapSync.cpp +++ b/src/tests/libxrpl/shamap/SHAMapSync.cpp @@ -1,4 +1,3 @@ -#include #include #include #include @@ -18,6 +17,7 @@ #include #include +#include #include #include #include @@ -34,15 +34,17 @@ protected: boost::intrusive_ptr makeRandomAS() { + static constexpr auto kWordsPerState = 3uz; + Serializer s; - for (int d = 0; d < 3; ++d) + for (auto word = 0uz; word < kWordsPerState; ++word) s.add32(randInt(eng_)); return makeShamapitem(s.getSHA512Half(), s.slice()); } bool - confuseMap(SHAMap& map, int count) + confuseMap(SHAMap& map, std::size_t count) { // add a bunch of random states to a map, then remove them // map should be the same @@ -50,7 +52,7 @@ protected: std::list items; - for (int i = 0; i < count; ++i) + for (auto i = 0uz; i < count; ++i) { auto item = makeRandomAS(); items.push_back(item->key()); @@ -87,39 +89,45 @@ TEST_F(SHAMapSyncTest, sync) SHAMap source{SHAMapType::FREE, f}; SHAMap destination{SHAMapType::FREE, f2}; - int const items = 10000; - for (int i = 0; i < items; ++i) + static constexpr auto kItemCount = 10000uz; + static constexpr auto kInvariantInterval = 100uz; + static constexpr auto kNodesToConfuse = 500uz; + static constexpr auto kMaxNodesPerRequest = 2048; + + for (auto i = 0uz; i < kItemCount; ++i) { source.addItem(SHAMapNodeType::TnAccountState, makeRandomAS()); - if (i % 100 == 0) + if (i % kInvariantInterval == 0) source.invariants(); } source.invariants(); - ASSERT_TRUE(confuseMap(source, 500)); + ASSERT_TRUE(confuseMap(source, kNodesToConfuse)); source.invariants(); source.setImmutable(); - int count = 0; + std::size_t count = 0; source.visitLeaves([&count]([[maybe_unused]] auto const& item) { ++count; }); - EXPECT_EQ(count, items); + EXPECT_EQ(count, kItemCount); std::vector missingNodes; - source.walkMap(missingNodes, 2048); + source.walkMap(missingNodes, kMaxNodesPerRequest); EXPECT_TRUE(missingNodes.empty()); destination.setSynching(); { - std::vector> a; + std::vector a; ASSERT_TRUE(source.getNodeFat(SHAMapNodeID(), a, randBool(eng_), randInt(eng_, 2))); ASSERT_FALSE(a.empty()) << "NodeSize"; - ASSERT_TRUE( - destination.addRootNode(source.getHash(), makeSlice(a[0].second), nullptr).isGood()); + auto node = SHAMapTreeNode::makeFromWire(makeSlice(a[0].data)); + if (!node) + FAIL() << "Could not create node"; + ASSERT_TRUE(destination.addRootNode(source.getHash(), std::move(node), nullptr).isGood()); } do @@ -127,13 +135,13 @@ TEST_F(SHAMapSyncTest, sync) f.clock().advance(std::chrono::seconds(1)); // get the list of nodes we know we need - auto nodesMissing = destination.getMissingNodes(2048, nullptr); + auto nodesMissing = destination.getMissingNodes(kMaxNodesPerRequest, nullptr); if (nodesMissing.empty()) break; // get as many nodes as possible based on this information - std::vector> b; + std::vector b; for (auto& it : nodesMissing) { @@ -155,7 +163,12 @@ TEST_F(SHAMapSyncTest, sync) // Keep failures fatal here because this loop is data-dependent. // non-deterministic number of times and the number of tests run // should be deterministic - if (!destination.addKnownNode(i.first, makeSlice(i.second), nullptr).isUseful()) + auto node = SHAMapTreeNode::makeFromWire(makeSlice(i.data)); + if (!node) + FAIL() << "Could not create node"; + if (i.isLeaf != node->isLeaf()) + FAIL() << "Node is not a leaf"; + if (!destination.addKnownNode(i.nodeID, std::move(node), nullptr).isUseful()) FAIL() << "Known node was not useful"; } } while (true); diff --git a/src/xrpld/app/ledger/InboundLedger.h b/src/xrpld/app/ledger/InboundLedger.h index 31ca4169ce..9a7ee510f6 100644 --- a/src/xrpld/app/ledger/InboundLedger.h +++ b/src/xrpld/app/ledger/InboundLedger.h @@ -6,7 +6,6 @@ #include #include -#include #include #include #include @@ -24,7 +23,7 @@ #include #include #include -#include +#include #include #include @@ -154,16 +153,19 @@ private: processData(std::shared_ptr peer, protocol::TMLedgerData const& data); bool - takeHeader(std::string const& data); + takeHeader(std::string_view data); void - receiveNode(protocol::TMLedgerData const& packet, SHAMapAddNode&); + receiveNode( + std::shared_ptr const& peer, + protocol::TMLedgerData const& packet, + SHAMapAddNode& san); bool - takeTxRootNode(Slice const& data, SHAMapAddNode&); + takeTxRootNode(std::string_view data, SHAMapAddNode& san); bool - takeAsRootNode(Slice const& data, SHAMapAddNode&); + takeAsRootNode(std::string_view data, SHAMapAddNode& san); std::vector neededTxHashes(int max, SHAMapSyncFilter const* filter) const; diff --git a/src/xrpld/app/ledger/LedgerNodeHelpers.h b/src/xrpld/app/ledger/LedgerNodeHelpers.h new file mode 100644 index 0000000000..9df9ab06c7 --- /dev/null +++ b/src/xrpld/app/ledger/LedgerNodeHelpers.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#include +#include + +namespace protocol { +class TMLedgerNode; +} // namespace protocol + +namespace xrpl { + +/** + * @brief Deserializes a SHAMapTreeNode from wire format data. + * + * This function attempts to create a SHAMapTreeNode from the provided data string. If the data is + * malformed or deserialization fails, the function returns a nullptr instead of throwing an + * exception. + * + * @param data The serialized node data in wire format. + * @return The deserialized tree node if successful, or a nullptr if deserialization fails. + */ +[[nodiscard]] SHAMapTreeNodePtr +getTreeNode(std::string_view data); + +/** + * @brief Extracts or reconstructs the SHAMapNodeID from a ledger node proto message. + * + * This function retrieves the SHAMapNodeID for a tree node, with behavior that depends on which + * field is set and the node type (inner vs. leaf). + * + * When the legacy `nodeid` field is set in the message: + * - For all nodes: Deserializes the node ID from the field. + * - For leaf nodes: Validates that the node ID is consistent with the leaf's key. + * + * When the new `id` or `depth` field is set in the message: + * - For inner nodes: Deserializes the node ID from the `id` field. + * - For leaf nodes: Reconstructs the node ID using both the depth from the `depth` field and the + * key from the leaf node's item. + * Note that root nodes may be inner nodes or leaf nodes. + * + * @param ledgerNode The validated protocol message containing the ledger node data. + * @param treeNode The deserialized tree node (inner or leaf node). + * @return An optional containing the node ID if extraction/reconstruction succeeds, or std::nullopt + * if the required fields are missing or validation fails. + */ +[[nodiscard]] std::optional +getSHAMapNodeID(protocol::TMLedgerNode const& ledgerNode, SHAMapTreeNode const& treeNode); + +} // namespace xrpl diff --git a/src/xrpld/app/ledger/detail/InboundLedger.cpp b/src/xrpld/app/ledger/detail/InboundLedger.cpp index 55a2a9d283..b3dafcf5e6 100644 --- a/src/xrpld/app/ledger/detail/InboundLedger.cpp +++ b/src/xrpld/app/ledger/detail/InboundLedger.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -44,8 +45,8 @@ #include #include #include -#include #include +#include #include #include #include @@ -779,7 +780,7 @@ InboundLedger::filterNodes( */ // data must not have hash prefix bool -InboundLedger::takeHeader(std::string const& data) +InboundLedger::takeHeader(std::string_view data) { // Return value: true=normal, false=bad data JLOG(journal_.trace()) << "got header acquiring ledger " << hash_; @@ -825,7 +826,10 @@ InboundLedger::takeHeader(std::string const& data) * Call with a lock */ void -InboundLedger::receiveNode(protocol::TMLedgerData const& packet, SHAMapAddNode& san) +InboundLedger::receiveNode( + std::shared_ptr const& peer, + protocol::TMLedgerData const& packet, + SHAMapAddNode& san) { if (!haveHeader_) { @@ -868,32 +872,47 @@ InboundLedger::receiveNode(protocol::TMLedgerData const& packet, SHAMapAddNode& { auto const f = filter.get(); - for (auto const& node : packet.nodes()) + for (auto const& ledgerNode : packet.nodes()) { - auto const nodeID = deserializeSHAMapNodeID(node.nodeid()); + auto treeNode = getTreeNode(ledgerNode.nodedata()); + if (!treeNode) + { + JLOG(journal_.warn()) + << "Got invalid node data for ledger " << hash_ << " from peer " << peer->id(); + peer->charge(Resource::kFeeInvalidData, "ledger_node.node_data invalid"); + san.incInvalid(); + return; + } + auto const nodeID = getSHAMapNodeID(ledgerNode, *treeNode); if (!nodeID) - throw std::runtime_error("data does not properly deserialize"); - - if (nodeID->isRoot()) { - san += map.addRootNode(rootHash, makeSlice(node.nodedata()), f); - } - else - { - san += map.addKnownNode(*nodeID, makeSlice(node.nodedata()), f); + JLOG(journal_.warn()) + << "Got invalid node id for ledger " << hash_ << " from peer " << peer->id(); + peer->charge(Resource::kFeeInvalidData, "ledger_node.node_id invalid"); + san.incInvalid(); + return; } - if (!san.isGood()) + auto const result = nodeID->isRoot() + ? map.addRootNode(rootHash, std::move(treeNode), f) + : map.addKnownNode(*nodeID, std::move(treeNode), f); + san += result; + + if (result.isInvalid()) { - JLOG(journal_.warn()) << "Received bad node data"; + JLOG(journal_.warn()) << "Got invalid node " << *nodeID << " for ledger " << hash_ + << " from peer " << peer->id(); + peer->charge(Resource::kFeeInvalidData, "ledger_node invalid"); return; } } } catch (std::exception const& e) { - JLOG(journal_.error()) << "Received bad node data: " << e.what(); + // If we get here it is not necessarily because the node was bad, so don't charge the peer. + JLOG(journal_.error()) << "Could not process node for ledger " << hash_ << " from peer " + << peer->id() << ": " << e.what(); san.incInvalid(); return; } @@ -922,7 +941,7 @@ InboundLedger::receiveNode(protocol::TMLedgerData const& packet, SHAMapAddNode& * Call with a lock */ bool -InboundLedger::takeAsRootNode(Slice const& data, SHAMapAddNode& san) +InboundLedger::takeAsRootNode(std::string_view data, SHAMapAddNode& san) { if (failed_ || haveState_) { @@ -938,10 +957,19 @@ InboundLedger::takeAsRootNode(Slice const& data, SHAMapAddNode& san) // LCOV_EXCL_STOP } + auto treeNode = getTreeNode(data); + if (!treeNode) + { + JLOG(journal_.warn()) << "Got invalid AS root node data for ledger " << hash_; + san.incInvalid(); + return false; + } + AccountStateSF filter(ledger_->stateMap().family().db(), app_.getLedgerMaster()); - san += - ledger_->stateMap().addRootNode(SHAMapHash{ledger_->header().accountHash}, data, &filter); - return san.isGood(); + auto const result = ledger_->stateMap().addRootNode( + SHAMapHash{ledger_->header().accountHash}, std::move(treeNode), &filter); + san += result; + return !result.isInvalid(); } /** @@ -949,7 +977,7 @@ InboundLedger::takeAsRootNode(Slice const& data, SHAMapAddNode& san) * Call with a lock */ bool -InboundLedger::takeTxRootNode(Slice const& data, SHAMapAddNode& san) +InboundLedger::takeTxRootNode(std::string_view data, SHAMapAddNode& san) { if (failed_ || haveTransactions_) { @@ -965,9 +993,19 @@ InboundLedger::takeTxRootNode(Slice const& data, SHAMapAddNode& san) // LCOV_EXCL_STOP } + auto treeNode = getTreeNode(data); + if (!treeNode) + { + JLOG(journal_.warn()) << "Got invalid TX root node data for ledger " << hash_; + san.incInvalid(); + return false; + } + TransactionStateSF filter(ledger_->txMap().family().db(), app_.getLedgerMaster()); - san += ledger_->txMap().addRootNode(SHAMapHash{ledger_->header().txHash}, data, &filter); - return san.isGood(); + auto const result = ledger_->txMap().addRootNode( + SHAMapHash{ledger_->header().txHash}, std::move(treeNode), &filter); + san += result; + return !result.isInvalid(); } std::vector @@ -1065,20 +1103,33 @@ InboundLedger::processData(std::shared_ptr peer, protocol::TMLedgerData co } if (!haveState_ && (packet.nodes().size() > 1) && - !takeAsRootNode(makeSlice(packet.nodes(1).nodedata()), san)) + !takeAsRootNode(packet.nodes(1).nodedata(), san)) { - JLOG(journal_.warn()) << "Included AS root invalid"; + JLOG(journal_.warn()) << "Included AS root invalid for ledger " << hash_ + << " from peer " << peer->id(); + if (san.isInvalid()) + { + peer->charge(Resource::kFeeInvalidData, "ledger_data invalid AS root"); + return -1; + } } if (!haveTransactions_ && (packet.nodes().size() > 2) && - !takeTxRootNode(makeSlice(packet.nodes(2).nodedata()), san)) + !takeTxRootNode(packet.nodes(2).nodedata(), san)) { - JLOG(journal_.warn()) << "Included TX root invalid"; + JLOG(journal_.warn()) << "Included TX root invalid for ledger " << hash_ + << " from peer " << peer->id(); + if (san.isInvalid()) + { + peer->charge(Resource::kFeeInvalidData, "ledger_data invalid TX root"); + return -1; + } } } catch (std::exception const& ex) { - JLOG(journal_.warn()) << "Included AS/TX root invalid: " << ex.what(); + JLOG(journal_.warn()) << "Included AS/TX root invalid for ledger " << hash_ + << " from peer " << peer->id() << ": " << ex.what(); using namespace std::string_literals; peer->charge(Resource::kFeeInvalidData, "ledger_data "s + ex.what()); return -1; @@ -1102,24 +1153,18 @@ InboundLedger::processData(std::shared_ptr peer, protocol::TMLedgerData co ScopedLockType const sl(mtx_); - // Verify node IDs and data are complete - for (auto const& node : packet.nodes()) - { - if (!node.has_nodeid() || !node.has_nodedata()) - { - JLOG(journal_.warn()) << "Got bad node"; - peer->charge(Resource::kFeeMalformedRequest, "ledger_data bad node"); - return -1; - } - } - SHAMapAddNode san; - receiveNode(packet, san); + receiveNode(peer, packet, san); JLOG(journal_.debug()) << "Ledger " << ((packet.type() == protocol::liTX_NODE) ? "TX" : "AS") << " node stats: " << san.get(); + // `san` accumulates across the whole packet, so `isInvalid()` (bad_ > 0) does not mean the + // packet had no useful nodes: credit whatever good/useful nodes were sent rather than + // discarding everything because one node in an otherwise-good packet was bad. + // Note: Peer charges for invalid/malformed data are issued from within receiveNode at the + // exact failure site, so the peer is only charged for problems they are responsible for. if (san.isUseful()) progress_ = true; diff --git a/src/xrpld/app/ledger/detail/InboundLedgers.cpp b/src/xrpld/app/ledger/detail/InboundLedgers.cpp index dc361694cf..4d565ca674 100644 --- a/src/xrpld/app/ledger/detail/InboundLedgers.cpp +++ b/src/xrpld/app/ledger/detail/InboundLedgers.cpp @@ -2,13 +2,13 @@ #include #include +#include #include #include #include #include #include -#include #include #include #include @@ -252,23 +252,17 @@ public: Serializer s; try { - for (int i = 0; i < packetPtr->nodes().size(); ++i) + for (auto const& ledgerNode : packetPtr->nodes()) { - auto const& node = packetPtr->nodes(i); - - if (!node.has_nodeid() || !node.has_nodedata()) - return; - - auto newNode = SHAMapTreeNode::makeFromWire(makeSlice(node.nodedata())); - - if (!newNode) + auto const treeNode = getTreeNode(ledgerNode.nodedata()); + if (!treeNode) return; s.erase(); - newNode->serializeWithPrefix(s); + treeNode->serializeWithPrefix(s); app_.getLedgerMaster().addFetchPack( - newNode->getHash().asUInt256(), std::make_shared(s.begin(), s.end())); + treeNode->getHash().asUInt256(), std::make_shared(s.begin(), s.end())); } } catch (std::exception const&) // NOLINT(bugprone-empty-catch) diff --git a/src/xrpld/app/ledger/detail/InboundTransactions.cpp b/src/xrpld/app/ledger/detail/InboundTransactions.cpp index 9b50a1584f..d735a97d28 100644 --- a/src/xrpld/app/ledger/detail/InboundTransactions.cpp +++ b/src/xrpld/app/ledger/detail/InboundTransactions.cpp @@ -1,11 +1,11 @@ #include +#include #include #include #include #include -#include #include #include #include @@ -14,6 +14,7 @@ #include #include #include +#include #include @@ -137,34 +138,45 @@ public: if (ta == nullptr) { - peer->charge(Resource::kFeeUselessData, "ledger_data"); + peer->charge(Resource::kFeeUselessData, "ledger_data useless"); return; } - std::vector> data; + std::vector> data; data.reserve(packet.nodes().size()); - for (auto const& node : packet.nodes()) + for (auto const& ledgerNode : packet.nodes()) { - if (!node.has_nodeid() || !node.has_nodedata()) + auto treeNode = getTreeNode(ledgerNode.nodedata()); + if (!treeNode) { - peer->charge(Resource::kFeeMalformedRequest, "ledger_data"); + JLOG(j_.warn()) << "Got invalid node data for TX set " << hash << " from peer " + << peer->id(); + peer->charge(Resource::kFeeInvalidData, "ledger_node.node_data invalid"); return; } - auto const id = deserializeSHAMapNodeID(node.nodeid()); - - if (!id) + auto const nodeID = getSHAMapNodeID(ledgerNode, *treeNode); + if (!nodeID) { - peer->charge(Resource::kFeeInvalidData, "ledger_data"); + JLOG(j_.warn()) << "Got invalid node id for TX set " << hash << " from peer " + << peer->id(); + peer->charge(Resource::kFeeInvalidData, "ledger_node.node_id invalid"); return; } - data.emplace_back(*id, makeSlice(node.nodedata())); + data.emplace_back(*nodeID, std::move(treeNode)); } - if (!ta->takeNodes(data, peer).isUseful()) - peer->charge(Resource::kFeeUselessData, "ledger_data not useful"); + auto const san = ta->takeNodes(std::move(data), peer); + if (san.isInvalid()) + { + peer->charge(Resource::kFeeInvalidData, "ledger_data invalid"); + } + else if (!san.isUseful()) + { + peer->charge(Resource::kFeeUselessData, "ledger_data useless"); + } } void diff --git a/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp b/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp new file mode 100644 index 0000000000..531dba59f9 --- /dev/null +++ b/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp @@ -0,0 +1,89 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace xrpl { + +SHAMapTreeNodePtr +getTreeNode(std::string_view data) +{ + auto const slice = makeSlice(data); + try + { + return SHAMapTreeNode::makeFromWire(slice); + } + catch (std::exception const&) + { + return {}; + } +} + +std::optional +getSHAMapNodeID(protocol::TMLedgerNode const& ledgerNode, SHAMapTreeNode const& treeNode) +{ + if (ledgerNode.has_id() || ledgerNode.has_depth()) + { + // Reject ambiguous messages that mix the legacy and new reference fields. + if (ledgerNode.has_nodeid()) + return std::nullopt; + + if (treeNode.isInner()) + { + if (!ledgerNode.has_id()) + return std::nullopt; + + REACHABLE("xrpl::getSHAMapNodeID : inner node ID from id field"); + return deserializeSHAMapNodeID(ledgerNode.id()); + } + + if (treeNode.isLeaf()) + { + SOMETIMES( + ledgerNode.has_depth() && ledgerNode.depth() > SHAMap::kLeafDepth, + "xrpl::getSHAMapNodeID : leaf depth exceeds max"); + if (!ledgerNode.has_depth() || ledgerNode.depth() > SHAMap::kLeafDepth) + return std::nullopt; + + auto const key = leafKey(treeNode); + REACHABLE("xrpl::getSHAMapNodeID : leaf node ID reconstructed from depth"); + return SHAMapNodeID::createID(ledgerNode.depth(), key); + } + // LCOV_EXCL_START + UNREACHABLE("xrpl::getSHAMapNodeID : tree node is neither inner nor leaf"); + return std::nullopt; + // LCOV_EXCL_STOP + } + + if (!ledgerNode.has_nodeid()) + return std::nullopt; + + auto nodeID = deserializeSHAMapNodeID(ledgerNode.nodeid()); + if (!nodeID.has_value()) + return std::nullopt; + + if (treeNode.isLeaf()) + { + auto const key = leafKey(treeNode); + auto const expectedID = SHAMapNodeID::createID(static_cast(nodeID->getDepth()), key); + SOMETIMES( + nodeID->getNodeID() != expectedID.getNodeID(), + "xrpl::getSHAMapNodeID : legacy leaf ID inconsistent with key"); + if (nodeID->getNodeID() != expectedID.getNodeID()) + return std::nullopt; + } + + return nodeID; +} + +} // namespace xrpl diff --git a/src/xrpld/app/ledger/detail/TransactionAcquire.cpp b/src/xrpld/app/ledger/detail/TransactionAcquire.cpp index 62312b04d2..db99299fd6 100644 --- a/src/xrpld/app/ledger/detail/TransactionAcquire.cpp +++ b/src/xrpld/app/ledger/detail/TransactionAcquire.cpp @@ -7,13 +7,13 @@ #include #include -#include #include #include #include #include #include #include +#include #include @@ -171,7 +171,7 @@ TransactionAcquire::trigger(std::shared_ptr const& peer) SHAMapAddNode TransactionAcquire::takeNodes( - std::vector> const& data, + std::vector> data, std::shared_ptr const& peer) { ScopedLockType const sl(mtx_); @@ -195,7 +195,7 @@ TransactionAcquire::takeNodes( ConsensusTransSetSF sf(app_, app_.getTempNodeCache()); - for (auto const& d : data) + for (auto& d : data) { if (d.first.isRoot()) { @@ -203,18 +203,22 @@ TransactionAcquire::takeNodes( { JLOG(journal_.debug()) << "Got root TXS node, already have it"; } - else if (!map_->addRootNode(SHAMapHash{hash_}, d.second, nullptr).isGood()) + else if (!map_->addRootNode(SHAMapHash{hash_}, std::move(d.second), nullptr) + .isGood()) { - JLOG(journal_.warn()) << "TX acquire got bad root node"; + JLOG(journal_.warn()) << "TX acquire got bad root node for TX set " << hash_ + << " from peer " << peer->id(); + return SHAMapAddNode::invalid(); } else { haveRoot_ = true; } } - else if (!map_->addKnownNode(d.first, d.second, &sf).isGood()) + else if (!map_->addKnownNode(d.first, std::move(d.second), &sf).isGood()) { - JLOG(journal_.warn()) << "TX acquire got bad non-root node"; + JLOG(journal_.warn()) << "TX acquire got bad non-root node " << d.first + << " for TX set " << hash_ << " from peer " << peer->id(); return SHAMapAddNode::invalid(); } } diff --git a/src/xrpld/app/ledger/detail/TransactionAcquire.h b/src/xrpld/app/ledger/detail/TransactionAcquire.h index 5b33066390..2faf74b557 100644 --- a/src/xrpld/app/ledger/detail/TransactionAcquire.h +++ b/src/xrpld/app/ledger/detail/TransactionAcquire.h @@ -6,10 +6,10 @@ #include #include -#include #include #include #include +#include #include #include @@ -32,8 +32,8 @@ public: SHAMapAddNode takeNodes( - std::vector> const& data, - std::shared_ptr const&); + std::vector> data, + std::shared_ptr const& peer); void init(int startPeers); diff --git a/src/xrpld/overlay/Peer.h b/src/xrpld/overlay/Peer.h index 23a45dc512..20a8730cf1 100644 --- a/src/xrpld/overlay/Peer.h +++ b/src/xrpld/overlay/Peer.h @@ -23,6 +23,7 @@ enum class ProtocolFeature { ValidatorListPropagation, ValidatorList2Propagation, LedgerReplay, + LedgerNodeDepth, }; /** diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index d7a9a9e449..688d0ac314 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -61,6 +62,7 @@ #include #include #include +#include #include #include @@ -543,6 +545,8 @@ PeerImp::supportsFeature(ProtocolFeature f) const return protocol_ >= makeProtocol(2, 1); case ProtocolFeature::ValidatorList2Propagation: return protocol_ >= makeProtocol(2, 2); + case ProtocolFeature::LedgerNodeDepth: + return protocol_ >= makeProtocol(2, 3); case ProtocolFeature::LedgerReplay: return ledgerReplayEnabled_; } @@ -1477,23 +1481,12 @@ PeerImp::onMessage(std::shared_ptr const& m) } } - // Verify ledger node IDs - if (itype != protocol::liBASE) + // Verify ledger node counts. Full parsing of the node IDs is deferred to the job, so the I/O + // thread is not burdened with SHAMapNodeID deserialization for every TMGetLedger message. + if (itype != protocol::liBASE && m->nodeids_size() <= 0) { - if (m->nodeids_size() <= 0) - { - badData("Invalid ledger node IDs"); - return; - } - - for (auto const& nodeId : m->nodeids()) - { - if (deserializeSHAMapNodeID(nodeId) == std::nullopt) - { - badData("Invalid SHAMap node ID"); - return; - } - } + badData("Invalid ledger node IDs"); + return; } // Verify query type @@ -1513,11 +1506,57 @@ PeerImp::onMessage(std::shared_ptr const& m) } } - // Queue a job to process the request + // Queue a job to process the request. std::weak_ptr const weak = shared_from_this(); - app_.getJobQueue().addJob(JtLedgerReq, "RcvGetLedger", [weak, m]() { - if (auto peer = weak.lock()) - peer->processLedgerRequest(m); + app_.getJobQueue().addJob(JtLedgerReq, "RcvGetLedger", [weak, m, itype]() { + auto peer = weak.lock(); + if (!peer) + return; + + std::vector nodeIDs; + bool tooManyNodeIds = false; + if (itype != protocol::liBASE) + { + nodeIDs.reserve(std::min(m->nodeids_size(), Tuning::kSoftMaxReplyNodes)); + for (auto const& nodeId : m->nodeids()) + { + if (nodeIDs.size() >= Tuning::kSoftMaxReplyNodes) + { + // The peer requested too many node IDs. Continue processing the received node + // IDs up to the limit. If the request is legitimate then at least they will get + // a response and won't have to resend these nodes in their next request. + tooManyNodeIds = true; + break; + } + auto parsed = deserializeSHAMapNodeID(nodeId); + if (!parsed) + { + peer->charge(Resource::kFeeInvalidData, "TMGetLedger: Invalid node ID"); + return; + } + nodeIDs.push_back(std::move(*parsed)); + } + } + + // These are two distinct infractions and are charged independently: requesting too many + // node IDs is charged even for a relay response, while the base "get ledger request" charge + // below is skipped for relay responses. + if (tooManyNodeIds) + { + peer->charge(Resource::kFeeModerateBurdenPeer, "TMGetLedger: too many node IDs"); + + // Truncate the request to what was actually parsed and charged for, so that if this + // request ends up being relayed to another peer, we don't forward the oversized list. + m->mutable_nodeids()->DeleteSubrange( + static_cast(nodeIDs.size()), + m->nodeids_size() - static_cast(nodeIDs.size())); + } + if (!m->has_requestcookie()) + { + peer->charge(Resource::kFeeModerateBurdenPeer, "TMGetLedger: get ledger request"); + } + + peer->processLedgerRequest(m, std::move(nodeIDs)); }); } @@ -1682,12 +1721,119 @@ PeerImp::onMessage(std::shared_ptr const& m) return; } - // If there is a request cookie, attempt to relay the message + // If there is a request cookie, attempt to relay the message. if (m->has_requestcookie()) { if (auto peer = overlay_.findPeerByShortID(m->requestcookie())) { m->clear_requestcookie(); + + // If the original requester doesn't support the new depth-based format, rewrite any + // nodes that use it back to the legacy nodeid format before relaying. Once all nodes + // have upgraded, the old protocol version and this code can be removed. Make sure that + // the format of the nodes is consistent - either all use the legacy format or the new + // format, unless it is liBASE data in which case none of these fields should be set. + auto const peerSupportsNodeDepth = + peer->supportsFeature(ProtocolFeature::LedgerNodeDepth); + enum class MessageType { Unknown, Base, Legacy, Depth }; + MessageType messageType = MessageType::Unknown; + for (int i = 0; i < m->nodes_size(); ++i) + { + auto* ledgerNode = m->mutable_nodes(i); + + // All nodes should have non-empty data. The field is required so we don't need to + // check for presence first. + if (ledgerNode->nodedata().empty()) + { + badData( + "Received node with empty data while relaying ledger data for " + + to_string(uint256::fromRaw(m->ledgerhash())) + " to peer " + + std::to_string(peer->id())); + return; + } + + MessageType msgType = MessageType::Unknown; + if (m->type() == protocol::liBASE) + { + if (ledgerNode->has_nodeid() || ledgerNode->has_id() || ledgerNode->has_depth()) + { + badData( + "Received liBASE message with node reference while relaying ledger " + "data for " + + to_string(uint256::fromRaw(m->ledgerhash())) + " to peer " + + std::to_string(peer->id())); + return; + } + msgType = MessageType::Base; + } + else + { + msgType = ledgerNode->has_nodeid() ? MessageType::Legacy : MessageType::Depth; + } + if (messageType != MessageType::Unknown && messageType != msgType) + { + badData( + "Received mixed mode message while relaying ledger data for " + + to_string(uint256::fromRaw(m->ledgerhash())) + " to peer " + + std::to_string(peer->id())); + return; + } + messageType = msgType; + + if (peerSupportsNodeDepth || msgType != MessageType::Depth) + continue; + + SOMETIMES( + !peerSupportsNodeDepth, + "xrpl::PeerImp : relaying depth-format ledger data to pre-2.3 peer"); + switch (ledgerNode->reference_case()) + { + case protocol::TMLedgerNode::kId: { + // We can directly copy the `id` field, because it uses the same wire format + // as the legacy `nodeid` field. + REACHABLE("xrpl::PeerImp : relay downgrade id to nodeid"); + ledgerNode->set_nodeid(ledgerNode->id()); + ledgerNode->clear_id(); + break; + } + case protocol::TMLedgerNode::kDepth: { + // We need to regenerate the node ID from the node data and depth. + auto treeNode = getTreeNode(ledgerNode->nodedata()); + if (!treeNode) + { + badData( + "Unable to get tree node while relaying ledger data for " + + to_string(uint256::fromRaw(m->ledgerhash())) + " to peer " + + std::to_string(peer->id())); + return; + } + + auto const nodeID = getSHAMapNodeID(*ledgerNode, *treeNode); + if (!nodeID) + { + badData( + "Unable to get node ID while relaying ledger data for " + + to_string(uint256::fromRaw(m->ledgerhash())) + " to peer " + + std::to_string(peer->id())); + return; + } + + REACHABLE("xrpl::PeerImp : relay downgrade depth to nodeid"); + ledgerNode->set_nodeid(nodeID->getRawString()); + ledgerNode->clear_depth(); + break; + } + default: { + SOMETIMES(true, "xrpl::PeerImp : relay node has empty reference"); + badData( + "Empty node reference while relaying ledger data for " + + to_string(uint256::fromRaw(m->ledgerhash())) + " to peer " + + std::to_string(peer->id())); + return; + } + } + } + peer->send(std::make_shared(*m, protocol::mtLEDGER_DATA)); } else @@ -3287,12 +3433,10 @@ PeerImp::getTxSet(std::shared_ptr const& m) const } void -PeerImp::processLedgerRequest(std::shared_ptr const& m) +PeerImp::processLedgerRequest( + std::shared_ptr const& m, + std::vector nodeIDs) { - // Do not resource charge a peer responding to a relay - if (!m->has_requestcookie()) - charge(Resource::kFeeModerateBurdenPeer, "received a get ledger request"); - std::shared_ptr ledger; std::shared_ptr sharedMap; SHAMap const* map{nullptr}; @@ -3372,26 +3516,25 @@ PeerImp::processLedgerRequest(std::shared_ptr const& m) } // Add requested node data to reply - if (m->nodeids_size() > 0) + if (!nodeIDs.empty()) { std::uint32_t const defaultDepth = isHighLatency() ? 2 : 1; auto const queryDepth{m->has_querydepth() ? m->querydepth() : defaultDepth}; - std::vector> data; + std::vector data; + data.reserve(Tuning::kSoftMaxReplyNodes); + auto const useLedgerNodeDepth = supportsFeature(ProtocolFeature::LedgerNodeDepth); - for (int i = 0; - i < m->nodeids_size() && ledgerData.nodes_size() < Tuning::kSoftMaxReplyNodes; - ++i) + for (auto const& nodeID : nodeIDs) { - auto const shaMapNodeId{deserializeSHAMapNodeID(m->nodeids(i))}; + if (ledgerData.nodes_size() >= Tuning::kSoftMaxReplyNodes) + break; data.clear(); - data.reserve(Tuning::kSoftMaxReplyNodes); try { - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) nodeids checked in onGetLedger - if (map->getNodeFat(*shaMapNodeId, data, fatLeaves, queryDepth)) + if (map->getNodeFat(nodeID, data, fatLeaves, queryDepth)) { JLOG(pJournal_.trace()) << "processLedgerRequest: getNodeFat got " << data.size() << " nodes"; @@ -3400,9 +3543,27 @@ PeerImp::processLedgerRequest(std::shared_ptr const& m) { if (ledgerData.nodes_size() >= Tuning::kHardMaxReplyNodes) break; + protocol::TMLedgerNode* node{ledgerData.add_nodes()}; - node->set_nodeid(d.first.getRawString()); - node->set_nodedata(d.second.data(), d.second.size()); + node->set_nodedata(d.data.data(), d.data.size()); + + // When the LedgerNodeDepth protocol feature is not supported by the peer, + // we always set the `nodeid` field. However, when it is supported then we + // set the `id` field for inner nodes and the `depth` field for leaf nodes. + if (!useLedgerNodeDepth) + { + node->set_nodeid(d.nodeID.getRawString()); + } + else if (d.isLeaf) + { + REACHABLE("xrpl::PeerImp : emit leaf depth in reply"); + node->set_depth(d.nodeID.getDepth()); + } + else + { + REACHABLE("xrpl::PeerImp : emit inner id in reply"); + node->set_id(d.nodeID.getRawString()); + } } } else @@ -3441,13 +3602,13 @@ PeerImp::processLedgerRequest(std::shared_ptr const& m) info += ", no hash specified"; JLOG(pJournal_.warn()) - << "processLedgerRequest: getNodeFat with nodeId " << *shaMapNodeId + << "processLedgerRequest: getNodeFat with nodeId " << nodeID << " and ledger info type " << info << " throws exception: " << e.what(); } } JLOG(pJournal_.info()) << "processLedgerRequest: Got request for " << m->nodeids_size() - << " nodes at depth " << queryDepth << ", return " + << " node IDs at depth " << queryDepth << ", return " << ledgerData.nodes_size() << " nodes"; } diff --git a/src/xrpld/overlay/detail/PeerImp.h b/src/xrpld/overlay/detail/PeerImp.h index 90f8a917f4..de90e60955 100644 --- a/src/xrpld/overlay/detail/PeerImp.h +++ b/src/xrpld/overlay/detail/PeerImp.h @@ -32,6 +32,7 @@ #include #include #include +#include #include #include @@ -679,7 +680,9 @@ private: getTxSet(std::shared_ptr const& m) const; void - processLedgerRequest(std::shared_ptr const& m); + processLedgerRequest( + std::shared_ptr const& m, + std::vector nodeIDs); protected: // Kept `protected` so test subclasses (see diff --git a/src/xrpld/overlay/detail/ProtocolVersion.cpp b/src/xrpld/overlay/detail/ProtocolVersion.cpp index 347e59accb..2d5d0a56f7 100644 --- a/src/xrpld/overlay/detail/ProtocolVersion.cpp +++ b/src/xrpld/overlay/detail/ProtocolVersion.cpp @@ -29,6 +29,7 @@ namespace xrpl { constexpr ProtocolVersion const kSupportedProtocolList[]{ {2, 1}, {2, 2}, + {2, 3}, }; // This ugly construct ensures that supportedProtocolList is sorted in strictly diff --git a/src/xrpld/rpc/handlers/account/AccountObjects.cpp b/src/xrpld/rpc/handlers/account/AccountObjects.cpp index 4a34ff02fc..ee2595bf94 100644 --- a/src/xrpld/rpc/handlers/account/AccountObjects.cpp +++ b/src/xrpld/rpc/handlers/account/AccountObjects.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -191,6 +192,13 @@ getAccountObjects( for (; entryIter != dirEntries.end(); ++entryIter) { auto const sleNode = ledger.read(keylet::child(*entryIter)); + if (!sleNode) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::doAccountObjects : null SLE"); + continue; + // LCOV_EXCL_STOP + } bool canAppend = true;