diff --git a/.github/scripts/levelization/results/ordering.txt b/.github/scripts/levelization/results/ordering.txt index d2a1894585..c2000d1768 100644 --- a/.github/scripts/levelization/results/ordering.txt +++ b/.github/scripts/levelization/results/ordering.txt @@ -188,10 +188,16 @@ test.toplevel > xrpl.json test.unit_test > xrpl.basics test.unit_test > xrpl.protocol tests.libxrpl > xrpl.basics +tests.libxrpl > xrpl.core tests.libxrpl > xrpl.json +tests.libxrpl > xrpl.ledger tests.libxrpl > xrpl.net +tests.libxrpl > xrpl.nodestore tests.libxrpl > xrpl.protocol tests.libxrpl > xrpl.protocol_autogen +tests.libxrpl > xrpl.server +tests.libxrpl > xrpl.shamap +tests.libxrpl > xrpl.tx xrpl.conditions > xrpl.basics xrpl.conditions > xrpl.protocol xrpl.core > xrpl.basics diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py index 4784142b7b..dec41a2610 100755 --- a/.github/scripts/strategy-matrix/generate.py +++ b/.github/scripts/strategy-matrix/generate.py @@ -51,20 +51,21 @@ def generate_strategy_matrix(all: bool, config: Config) -> list: # Only generate a subset of configurations in PRs. if not all: # Debian: - # - Bookworm using GCC 13: Release on linux/amd64, set the reference - # fee to 500. - # - Bookworm using GCC 15: Debug on linux/amd64, enable code - # coverage (which will be done below). + # - Bookworm using GCC 13: Debug on linux/amd64, set the reference + # fee to 500 and enable code coverage (which will be done below). + # - Bookworm using GCC 15: Debug on linux/amd64, enable Address and + # UB sanitizers (which will be done below). # - Bookworm using Clang 16: Debug on linux/amd64, enable voidstar. # - Bookworm using Clang 17: Release on linux/amd64, set the # reference fee to 1000. - # - Bookworm using Clang 20: Debug on linux/amd64. + # - Bookworm using Clang 20: Debug on linux/amd64, enable Address + # and UB sanitizers (which will be done below). if os["distro_name"] == "debian": skip = True if os["distro_version"] == "bookworm": if ( f"{os['compiler_name']}-{os['compiler_version']}" == "gcc-13" - and build_type == "Release" + and build_type == "Debug" and architecture["platform"] == "linux/amd64" ): cmake_args = f"-DUNIT_TEST_REFERENCE_FEE=500 {cmake_args}" @@ -193,11 +194,11 @@ def generate_strategy_matrix(all: bool, config: Config) -> list: ): continue - # Enable code coverage for Debian Bookworm using GCC 15 in Debug on - # linux/amd64 + # Enable code coverage for Debian Bookworm using GCC 13 in Debug on + # linux/amd64. if ( f"{os['distro_name']}-{os['distro_version']}" == "debian-bookworm" - and f"{os['compiler_name']}-{os['compiler_version']}" == "gcc-15" + and f"{os['compiler_name']}-{os['compiler_version']}" == "gcc-13" and build_type == "Debug" and architecture["platform"] == "linux/amd64" ): @@ -234,23 +235,39 @@ def generate_strategy_matrix(all: bool, config: Config) -> list: # Add the configuration to the list, with the most unique fields first, # so that they are easier to identify in the GitHub Actions UI, as long # names get truncated. - # Add Address and Thread (both coupled with UB) sanitizers for specific bookworm distros. + # Add Address and UB sanitizers as separate configurations for specific + # bookworm distros. Thread sanitizer is currently disabled (see below). # GCC-Asan xrpld-embedded tests are failing because of https://github.com/google/sanitizers/issues/856 - if ( - os["distro_version"] == "bookworm" - and f"{os['compiler_name']}-{os['compiler_version']}" == "clang-20" - ): - # Add ASAN + UBSAN configuration. + if os[ + "distro_version" + ] == "bookworm" and f"{os['compiler_name']}-{os['compiler_version']}" in [ + "gcc-15", + "clang-20", + ]: + # Add ASAN configuration. configurations.append( { - "config_name": config_name + "-asan-ubsan", + "config_name": config_name + "-asan", "cmake_args": cmake_args, "cmake_target": cmake_target, "build_only": build_only, "build_type": build_type, "os": os, "architecture": architecture, - "sanitizers": "address,undefinedbehavior", + "sanitizers": "address", + } + ) + # Add UBSAN configuration. + configurations.append( + { + "config_name": config_name + "-ubsan", + "cmake_args": cmake_args, + "cmake_target": cmake_target, + "build_only": build_only, + "build_type": build_type, + "os": os, + "architecture": architecture, + "sanitizers": "undefinedbehavior", } ) # TSAN is deactivated due to seg faults with latest compilers. diff --git a/include/xrpl/basics/DecayingSample.h b/include/xrpl/basics/DecayingSample.h index d4c7388046..8f6e729acf 100644 --- a/include/xrpl/basics/DecayingSample.h +++ b/include/xrpl/basics/DecayingSample.h @@ -67,8 +67,10 @@ private: } else { - while ((elapsed--) != 0u) + for (; elapsed > 0; --elapsed) + { m_value -= (m_value + Window - 1) / Window; + } } } diff --git a/include/xrpl/beast/test/yield_to.h b/include/xrpl/beast/test/yield_to.h index e8db9e5864..4723fb49b8 100644 --- a/include/xrpl/beast/test/yield_to.h +++ b/include/xrpl/beast/test/yield_to.h @@ -43,8 +43,10 @@ public: : work_(boost::asio::make_work_guard(ios_)) { threads_.reserve(concurrency); - while ((concurrency--) != 0u) + for (std::size_t i = 0; i < concurrency; ++i) + { threads_.emplace_back([&] { ios_.run(); }); + } } ~enable_yield_to() diff --git a/include/xrpl/nodestore/detail/varint.h b/include/xrpl/nodestore/detail/varint.h index e63944c63b..7c2a3756ff 100644 --- a/include/xrpl/nodestore/detail/varint.h +++ b/include/xrpl/nodestore/detail/varint.h @@ -54,8 +54,9 @@ read_varint(void const* buf, std::size_t buflen, std::size_t& t) return 1; } auto const used = n; - while (n--) + while (n > 0) { + --n; auto const d = p[n]; auto const t0 = t; t *= 127; diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index b7dd3760da..5b9798fa74 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -16,6 +16,7 @@ // Keep it sorted in reverse chronological order. XRPL_FEATURE(DefragDirectories, Supported::no, VoteBehavior::DefaultNo) +XRPL_FIX (Cleanup3_2_0, Supported::no, VoteBehavior::DefaultNo) XRPL_FEATURE(MPTokensV2, Supported::no, VoteBehavior::DefaultNo) XRPL_FIX (Security3_1_3, Supported::no, VoteBehavior::DefaultNo) XRPL_FIX (PermissionedDomainInvariant, Supported::yes, VoteBehavior::DefaultNo) diff --git a/include/xrpl/protocol_autogen/LedgerEntryBase.h b/include/xrpl/protocol_autogen/LedgerEntryBase.h index 0c5b367391..ad513992c7 100644 --- a/include/xrpl/protocol_autogen/LedgerEntryBase.h +++ b/include/xrpl/protocol_autogen/LedgerEntryBase.h @@ -130,6 +130,19 @@ public: return sle_->at(sfFlags); } + /** + * @brief Check if a specific flag is set. + * + * @param f The flag bitmask to check + * @return true if all bits in f are set in the flags field + */ + [[nodiscard]] + bool + isFlag(std::uint32_t f) const + { + return sle_->isFlag(f); + } + /** * @brief Get the underlying SLE object. * diff --git a/sanitizers/suppressions/ubsan.supp b/sanitizers/suppressions/ubsan.supp index 1e07065ebd..88d8e82e33 100644 --- a/sanitizers/suppressions/ubsan.supp +++ b/sanitizers/suppressions/ubsan.supp @@ -11,7 +11,6 @@ float-cast-overflow:external float-divide-by-zero:external function:external implicit-integer-sign-change:external -implicit-signed-integer-truncation::external implicit-signed-integer-truncation:external implicit-unsigned-integer-truncation:external integer-divide-by-zero:external @@ -71,145 +70,15 @@ vla-bound:boost vptr_check:boost vptr:boost -# Google protobuf +# Google protobuf - intentional overflows in hash functions undefined:protobuf - -# Suppress UBSan errors in xrpld code by source file path -undefined:src/libxrpl/basics/base64.cpp -undefined:src/libxrpl/basics/Number.cpp -undefined:src/libxrpl/beast/utility/beast_Journal.cpp -undefined:src/libxrpl/crypto/RFC1751.cpp -undefined:src/libxrpl/ledger/ApplyView.cpp -undefined:src/libxrpl/ledger/View.cpp -undefined:src/libxrpl/protocol/Permissions.cpp -undefined:src/libxrpl/protocol/STAmount.cpp -undefined:src/libxrpl/protocol/STPathSet.cpp -undefined:src/libxrpl/protocol/tokens.cpp -undefined:src/libxrpl/shamap/SHAMap.cpp -undefined:src/test/app/Batch_test.cpp -undefined:src/test/app/Invariants_test.cpp -undefined:src/test/app/NFToken_test.cpp -undefined:src/test/app/Offer_test.cpp -undefined:src/test/app/Path_test.cpp -undefined:src/test/basics/XRPAmount_test.cpp -undefined:src/test/beast/LexicalCast_test.cpp -undefined:src/test/jtx/impl/acctdelete.cpp -undefined:src/test/ledger/SkipList_test.cpp -undefined:src/test/rpc/Subscribe_test.cpp -undefined:src/tests/libxrpl/basics/RangeSet.cpp -undefined:src/xrpld/app/main/BasicApp.cpp -undefined:src/xrpld/app/main/BasicApp.cpp -undefined:src/xrpld/app/misc/detail/AmendmentTable.cpp -undefined:src/xrpld/app/misc/NetworkOPs.cpp -undefined:src/libxrpl/json/json_value.cpp -undefined:src/xrpld/app/paths/detail/StrandFlow.h -undefined:src/xrpld/app/tx/detail/NFTokenMint.cpp -undefined:src/xrpld/app/tx/detail/OracleSet.cpp -undefined:src/xrpld/core/detail/JobQueue.cpp -undefined:src/xrpld/core/detail/Workers.cpp -undefined:src/xrpld/rpc/detail/Role.cpp -undefined:src/xrpld/rpc/handlers/GetAggregatePrice.cpp -undefined:xrpl/basics/base_uint.h -undefined:xrpl/basics/DecayingSample.h -undefined:xrpl/beast/test/yield_to.h -undefined:xrpl/beast/xor_shift_engine.h -undefined:xrpl/nodestore/detail/varint.h -undefined:xrpl/peerfinder/detail/Counts.h -undefined:xrpl/protocol/nft.h - -# basic_string.h:483:51: runtime error: unsigned integer overflow -unsigned-integer-overflow:basic_string.h -unsigned-integer-overflow:bits/chrono.h -unsigned-integer-overflow:bits/random.h -unsigned-integer-overflow:bits/random.tcc -unsigned-integer-overflow:bits/stl_algobase.h -unsigned-integer-overflow:bits/uniform_int_dist.h -unsigned-integer-overflow:string_view - -# runtime error: unsigned integer overflow: 0 - 1 cannot be represented in type 'std::size_t' (aka 'unsigned long') -unsigned-integer-overflow:src/libxrpl/basics/base64.cpp -unsigned-integer-overflow:src/libxrpl/basics/Number.cpp -unsigned-integer-overflow:src/libxrpl/crypto/RFC1751.cpp -unsigned-integer-overflow:rc/libxrpl/json/json_value.cpp -unsigned-integer-overflow:src/libxrpl/ledger/ApplyView.cpp -unsigned-integer-overflow:src/libxrpl/ledger/View.cpp -unsigned-integer-overflow:src/libxrpl/protocol/Permissions.cpp -unsigned-integer-overflow:src/libxrpl/protocol/STAmount.cpp -unsigned-integer-overflow:src/libxrpl/protocol/STPathSet.cpp -unsigned-integer-overflow:src/libxrpl/protocol/tokens.cpp -unsigned-integer-overflow:src/libxrpl/shamap/SHAMap.cpp -unsigned-integer-overflow:src/test/app/Batch_test.cpp -unsigned-integer-overflow:src/test/app/Invariants_test.cpp -unsigned-integer-overflow:src/test/app/NFToken_test.cpp -unsigned-integer-overflow:src/test/app/Offer_test.cpp -unsigned-integer-overflow:src/test/app/Path_test.cpp -unsigned-integer-overflow:src/test/basics/XRPAmount_test.cpp -unsigned-integer-overflow:src/test/beast/LexicalCast_test.cpp -unsigned-integer-overflow:src/test/jtx/impl/acctdelete.cpp -unsigned-integer-overflow:src/test/ledger/SkipList_test.cpp -unsigned-integer-overflow:src/test/rpc/Subscribe_test.cpp -unsigned-integer-overflow:src/tests/libxrpl/basics/RangeSet.cpp -unsigned-integer-overflow:src/xrpld/app/main/BasicApp.cpp -unsigned-integer-overflow:src/xrpld/app/misc/detail/AmendmentTable.cpp -unsigned-integer-overflow:src/xrpld/app/misc/NetworkOPs.cpp -unsigned-integer-overflow:src/xrpld/app/paths/detail/StrandFlow.h -unsigned-integer-overflow:src/xrpld/app/tx/detail/NFTokenMint.cpp -unsigned-integer-overflow:src/xrpld/app/tx/detail/OracleSet.cpp -unsigned-integer-overflow:src/xrpld/rpc/detail/Role.cpp -unsigned-integer-overflow:src/xrpld/rpc/handlers/GetAggregatePrice.cpp -unsigned-integer-overflow:xrpl/basics/base_uint.h -unsigned-integer-overflow:xrpl/basics/DecayingSample.h -unsigned-integer-overflow:xrpl/beast/test/yield_to.h -unsigned-integer-overflow:xrpl/beast/xor_shift_engine.h -unsigned-integer-overflow:xrpl/nodestore/detail/varint.h -unsigned-integer-overflow:xrpl/peerfinder/detail/Counts.h -unsigned-integer-overflow:xrpl/protocol/nft.h - -# Xrpld intentional overflows and operations -# STAmount uses intentional negation of INT64_MIN and overflow in arithmetic -signed-integer-overflow:src/libxrpl/protocol/STAmount.cpp -unsigned-integer-overflow:src/libxrpl/protocol/STAmount.cpp - -# XRPAmount test intentional overflows -signed-integer-overflow:src/test/basics/XRPAmount_test.cpp - -# Peerfinder intentional overflow in counter arithmetic -unsigned-integer-overflow:src/xrpld/peerfinder/detail/Counts.h - -# Signed integer overflow suppressions -signed-integer-overflow:src/test/beast/LexicalCast_test.cpp - -# External library suppressions -unsigned-integer-overflow:nudb/detail/xxhash.hpp - -# Loan_test.cpp intentional underflow in test arithmetic -unsigned-integer-overflow:src/test/app/Loan_test.cpp -undefined:src/test/app/Loan_test.cpp - -# Source tree restructured paths (libxrpl/tx/transactors/) -# These duplicate the xrpld/app/tx/detail entries above for the new layout -unsigned-integer-overflow:src/libxrpl/tx/transactors/oracle/OracleSet.cpp -undefined:src/libxrpl/tx/transactors/oracle/OracleSet.cpp -unsigned-integer-overflow:src/libxrpl/tx/transactors/nft/NFTokenMint.cpp -undefined:src/libxrpl/tx/transactors/nft/NFTokenMint.cpp - -# Protobuf intentional overflows in hash functions -# Protobuf uses intentional unsigned overflow for hash computation (stringpiece.h:393) unsigned-integer-overflow:google/protobuf/stubs/stringpiece.h -# gRPC intentional overflows -# gRPC uses intentional overflow in timer calculations +# gRPC intentional overflows in timer calculations unsigned-integer-overflow:grpc unsigned-integer-overflow:timer_manager.cc -# Standard library intentional overflows -# These are intentional overflows in random number generation and character conversion -unsigned-integer-overflow:__random/seed_seq.h -unsigned-integer-overflow:__charconv/traits.h - - -# Suppress errors in RocksDB -# RocksDB uses intentional unsigned integer overflows in hash functions and CRC calculations +# RocksDB intentional unsigned integer overflows in hash functions and CRC calculations unsigned-integer-overflow:rocks*/*/util/xxhash.h unsigned-integer-overflow:rocks*/*/util/xxph3.h unsigned-integer-overflow:rocks*/*/util/hash.cc @@ -221,13 +90,14 @@ unsigned-integer-overflow:rocks*/*/table/format.cc unsigned-integer-overflow:rocks*/*/table/block_based/block_based_table_builder.cc unsigned-integer-overflow:rocks*/*/table/block_based/reader_common.cc unsigned-integer-overflow:rocks*/*/db/version_set.cc - -# RocksDB misaligned loads (intentional for performance on ARM64) alignment:rocks*/*/util/crc32c_arm64.cc +undefined:rocks*/*/util/crc32c_arm64.cc +undefined:rocks*/*/util/xxhash.h # nudb intentional overflows in hash functions unsigned-integer-overflow:nudb/detail/xxhash.hpp alignment:nudb/detail/xxhash.hpp +undefined:nudb # Snappy compression library intentional overflows unsigned-integer-overflow:snappy.cc @@ -239,10 +109,40 @@ unsigned-integer-overflow:absl/base/internal/low_level_alloc.cc unsigned-integer-overflow:absl/hash/internal/hash.h unsigned-integer-overflow:absl/container/internal/raw_hash_set.h -# Standard library intentional overflows in chrono duration arithmetic +# Standard library intentional overflows +unsigned-integer-overflow:basic_string.h +unsigned-integer-overflow:bits/chrono.h +unsigned-integer-overflow:bits/random.h +unsigned-integer-overflow:bits/random.tcc +unsigned-integer-overflow:bits/stl_algobase.h +unsigned-integer-overflow:bits/uniform_int_dist.h +unsigned-integer-overflow:string_view +unsigned-integer-overflow:__random/seed_seq.h +unsigned-integer-overflow:__charconv/traits.h unsigned-integer-overflow:__chrono/duration.h -# Suppress undefined errors in RocksDB and nudb -undefined:rocks.*/*/util/crc32c_arm64.cc -undefined:rocks.*/*/util/xxhash.h -undefined:nudb +# ============================================================================= +# Rippled code suppressions +# ============================================================================= + +# Signed integer negation (-value) in amount types. +# INT64_MIN cannot occur in practice due to domain invariants (mantissa ranges +# are well within int64_t bounds), but UBSan flags the pattern as potential +# signed overflow. Narrowed to operator- to avoid suppressing unrelated +# overflows anywhere in a stack trace containing these type names. +signed-integer-overflow:operator-*IOUAmount* +signed-integer-overflow:operator-*XRPAmount* +signed-integer-overflow:operator-*MPTAmount* +signed-integer-overflow:operator-*STAmount* + +# STAmount::operator+ signed addition — operands are bounded by total supply +# (~10^17 for XRP, ~10^18 for MPT) so overflow cannot occur in practice. +signed-integer-overflow:operator+*STAmount* + +# STAmount::getRate uses unsigned shift and addition +unsigned-integer-overflow:*STAmount*getRate* +# STAmount::serialize uses unsigned bitwise operations +unsigned-integer-overflow:*STAmount*serialize* + +# nft::cipheredTaxon uses intentional uint32 wraparound (LCG permutation) +unsigned-integer-overflow:cipheredTaxon diff --git a/src/libxrpl/basics/base64.cpp b/src/libxrpl/basics/base64.cpp index cf6af3db70..f266d93194 100644 --- a/src/libxrpl/basics/base64.cpp +++ b/src/libxrpl/basics/base64.cpp @@ -107,7 +107,7 @@ encode(void* dest, void const* src, std::size_t len) char const* in = static_cast(src); auto const tab = base64::get_alphabet(); - for (auto n = len / 3; n != 0u; --n) + for (auto n = len / 3; n > 0; --n) { *out++ = tab[(in[0] & 0xfc) >> 2]; *out++ = tab[((in[0] & 0x03) << 4) + ((in[1] & 0xf0) >> 4)]; diff --git a/src/test/beast/LexicalCast_test.cpp b/src/test/beast/LexicalCast_test.cpp index 410358111e..12c2c2a464 100644 --- a/src/test/beast/LexicalCast_test.cpp +++ b/src/test/beast/LexicalCast_test.cpp @@ -24,7 +24,7 @@ public: testInteger(IntType in) { std::string s; - IntType out(in + 1); + IntType out = static_cast(~in); // Ensure out != in expect(lexicalCastChecked(s, in)); expect(lexicalCastChecked(out, s)); diff --git a/src/tests/libxrpl/CMakeLists.txt b/src/tests/libxrpl/CMakeLists.txt index 0b666441d1..ee07698519 100644 --- a/src/tests/libxrpl/CMakeLists.txt +++ b/src/tests/libxrpl/CMakeLists.txt @@ -8,9 +8,12 @@ add_custom_target(xrpl.tests) # Test helpers add_library(xrpl.helpers.test STATIC) -target_sources(xrpl.helpers.test PRIVATE helpers/TestSink.cpp) +target_sources( + xrpl.helpers.test + PRIVATE helpers/Account.cpp helpers/TestSink.cpp helpers/TxTest.cpp +) target_include_directories(xrpl.helpers.test PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(xrpl.helpers.test PRIVATE xrpl.libxrpl) +target_link_libraries(xrpl.helpers.test PUBLIC xrpl.libxrpl gtest::gtest) # Common library dependencies for the rest of the tests. add_library(xrpl.imports.test INTERFACE) @@ -32,6 +35,10 @@ xrpl_add_test(json) target_link_libraries(xrpl.test.json PRIVATE xrpl.imports.test) add_dependencies(xrpl.tests xrpl.test.json) +xrpl_add_test(tx) +target_link_libraries(xrpl.test.tx PRIVATE xrpl.imports.test) +add_dependencies(xrpl.tests xrpl.test.tx) + xrpl_add_test(protocol_autogen) target_link_libraries(xrpl.test.protocol_autogen PRIVATE xrpl.imports.test) add_dependencies(xrpl.tests xrpl.test.protocol_autogen) diff --git a/src/tests/libxrpl/helpers/Account.cpp b/src/tests/libxrpl/helpers/Account.cpp new file mode 100644 index 0000000000..736ae0a24b --- /dev/null +++ b/src/tests/libxrpl/helpers/Account.cpp @@ -0,0 +1,19 @@ +#include + +#include +#include +#include +#include + +namespace xrpl::test { + +Account const Account::master{"masterpassphrase"}; + +Account::Account(std::string_view name, KeyType type) + : name_(name) + , keyPair_(generateKeyPair(type, generateSeed(name_))) + , id_(calcAccountID(keyPair_.first)) +{ +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/helpers/Account.h b/src/tests/libxrpl/helpers/Account.h new file mode 100644 index 0000000000..9c3ad19bbb --- /dev/null +++ b/src/tests/libxrpl/helpers/Account.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +/** + * @brief A test account with cryptographic keys. + * + * Generates keys deterministically from a name, making tests reproducible. + * The same name always produces the same AccountID and keys. + */ +class Account +{ +public: + /** + * @brief The master account that holds all XRP in genesis. + * + * This account is created in the genesis ledger with all 100 billion XRP. + * It uses the well-known seed "masterpassphrase". + */ + static Account const master; + + /** + * @brief Create an account from a name. + * + * Keys are derived deterministically from the name. + * + * @param name Human-readable name for the account. + * @param type Key type to use (defaults to secp256k1). + */ + explicit Account(std::string_view name, KeyType type = KeyType::secp256k1); + + /** @brief Return the human-readable name. */ + std::string const& + name() const noexcept + { + return name_; + } + + /** @brief Return the AccountID. */ + AccountID const& + id() const noexcept + { + return id_; + } + + /** @brief Return the public key. */ + PublicKey const& + pk() const noexcept + { + return keyPair_.first; + } + + /** @brief Return the secret key. */ + SecretKey const& + sk() const noexcept + { + return keyPair_.second; + } + + /** @brief Implicit conversion to AccountID. */ + operator AccountID const&() const noexcept + { + return id_; + } + +private: + std::string name_; + std::pair keyPair_; + AccountID id_; +}; + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/helpers/IOU.h b/src/tests/libxrpl/helpers/IOU.h new file mode 100644 index 0000000000..1e845f5ae6 --- /dev/null +++ b/src/tests/libxrpl/helpers/IOU.h @@ -0,0 +1,132 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace xrpl::test { + +/** + * @brief Represents an IOU (issued currency) for testing. + * + * Provides a clear, explicit API for creating currencies issued by an account. + * This replaces the cryptic `Account::operator[]` from the jtx framework. + * + * @code + * Account gw("gateway"); + * IOU USD("USD", gw); + * + * auto issue = USD.issue(); // Get the Issue + * auto asset = USD.asset(); // Get the Asset + * auto amt = USD.amount(100); // Get STAmount of 100 USD + * @endcode + */ +class IOU +{ +public: + /** + * @brief Construct an IOU from a currency code and issuing account. + * @param currencyCode A 3-character ISO currency code (e.g., "USD"). + * @param issuer The account that issues this currency. + */ + IOU(std::string_view currencyCode, Account const& issuer) + : currency_(to_currency(std::string(currencyCode))), issuer_(issuer.id()) + { + XRPL_ASSERT(!isXRP(currency_), "IOU: currency code must not resolve to XRP"); + } + + /** + * @brief Construct an IOU from a Currency and issuing account. + * @param currency The Currency object. + * @param issuer The account that issues this currency. + */ + IOU(Currency currency, Account const& issuer) + : currency_(std::move(currency)), issuer_(issuer.id()) + { + XRPL_ASSERT(!isXRP(currency_), "IOU: currency code must not resolve to XRP"); + } + + /** + * @brief Get the Issue (currency + issuer pair). + * @return An Issue object representing this IOU. + */ + [[nodiscard]] Issue + issue() const + { + return Issue{currency_, issuer_}; + } + + /** + * @brief Get the Asset. + * @return An Asset object representing this IOU. + */ + [[nodiscard]] Asset + asset() const + { + return Asset{issue()}; + } + + /** + * @brief Create an STAmount of this IOU. + * + * Works with any arithmetic type (int, double, etc.) by converting + * to string and parsing. This matches the jtx IOU behaviour. + * + * @tparam T An arithmetic type. + * @param value The amount as any arithmetic type. + * @return An STAmount representing value units of this IOU. + */ + template + requires std::is_arithmetic_v + [[nodiscard]] STAmount + amount(T value) const + { + return amountFromString(issue(), to_string(value)); + } + + /** + * @brief Create an STAmount of this IOU from a Number. + * @param value The amount as a Number. + * @return An STAmount representing value units of this IOU. + */ + [[nodiscard]] STAmount + amount(Number const& value) const + { + return STAmount{issue(), value}; + } + + /** + * @brief Get the currency. + * @return The currency. + */ + [[nodiscard]] Currency const& + currency() const + { + return currency_; + } + + /** + * @brief Get the issuer account ID. + * @return The issuer's AccountID. + */ + [[nodiscard]] AccountID const& + issuer() const + { + return issuer_; + } + +private: + Currency currency_; + AccountID issuer_; +}; + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/helpers/TestFamily.h b/src/tests/libxrpl/helpers/TestFamily.h new file mode 100644 index 0000000000..98c5a379e4 --- /dev/null +++ b/src/tests/libxrpl/helpers/TestFamily.h @@ -0,0 +1,111 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace xrpl { +namespace test { + +/** Test implementation of Family for unit tests. + + Uses an in-memory NodeStore database and simple caches. + The missingNode methods throw since tests shouldn't encounter missing nodes. +*/ +class TestFamily : public Family +{ +private: + std::unique_ptr db_; + TestStopwatch clock_; + std::shared_ptr fbCache_; + std::shared_ptr tnCache_; + NodeStore::DummyScheduler scheduler_; + beast::Journal j_; + +public: + explicit TestFamily(beast::Journal j) + : fbCache_(std::make_shared("TestFamily full below cache", clock_, j)) + , tnCache_( + std::make_shared( + "TestFamily tree node cache", + 65536, + std::chrono::minutes{1}, + clock_, + j)) + , j_(j) + { + Section config; + config.set("type", "memory"); + config.set("path", "TestFamily"); + db_ = NodeStore::Manager::instance().make_Database(megabytes(4), scheduler_, 1, config, j); + } + + NodeStore::Database& + db() override + { + return *db_; + } + + NodeStore::Database const& + db() const override + { + return *db_; + } + + beast::Journal const& + journal() override + { + return j_; + } + + std::shared_ptr + getFullBelowCache() override + { + return fbCache_; + } + + std::shared_ptr + getTreeNodeCache() override + { + return tnCache_; + } + + void + sweep() override + { + fbCache_->sweep(); + tnCache_->sweep(); + } + + void + missingNodeAcquireBySeq(std::uint32_t refNum, uint256 const& nodeHash) override + { + Throw("TestFamily: missing node (by seq)"); + } + + void + missingNodeAcquireByHash(uint256 const& refHash, std::uint32_t refNum) override + { + Throw("TestFamily: missing node (by hash)"); + } + + void + reset() override + { + fbCache_->reset(); + tnCache_->reset(); + } + + /** Access the test clock for time manipulation in tests. */ + TestStopwatch& + clock() + { + return clock_; + } +}; + +} // namespace test +} // namespace xrpl diff --git a/src/tests/libxrpl/helpers/TestServiceRegistry.h b/src/tests/libxrpl/helpers/TestServiceRegistry.h new file mode 100644 index 0000000000..4f39124087 --- /dev/null +++ b/src/tests/libxrpl/helpers/TestServiceRegistry.h @@ -0,0 +1,378 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include +#include + +namespace xrpl { +namespace test { + +/** Logs implementation that creates TestSink instances. */ +class TestLogs : public Logs +{ +public: + explicit TestLogs(beast::severities::Severity level = beast::severities::kWarning) : Logs(level) + { + } + + std::unique_ptr + makeSink(std::string const&, beast::severities::Severity threshold) override + { + return std::make_unique(threshold); + } +}; + +/** Simple NetworkIDService implementation for tests. */ +class TestNetworkIDService final : public NetworkIDService +{ +public: + explicit TestNetworkIDService(std::uint32_t networkID = 0) : networkID_(networkID) + { + } + + [[nodiscard]] std::uint32_t + getNetworkID() const noexcept override + { + return networkID_; + } + +private: + std::uint32_t networkID_; +}; + +/** Test implementation of ServiceRegistry for unit tests. + + This class provides real implementations for services that can be + instantiated from libxrpl (such as Logs, io_context, caches), and + throws std::logic_error for services that require the full Application. + + Tests can subclass this to provide additional services they need. +*/ +class TestServiceRegistry : public ServiceRegistry +{ + TestLogs logs_{beast::severities::kWarning}; + boost::asio::io_context io_context_; + TestFamily family_{logs_.journal("TestFamily")}; + LoadFeeTrack feeTrack_{logs_.journal("LoadFeeTrack")}; + TestNetworkIDService networkIDService_; + HashRouter hashRouter_{HashRouter::Setup{}, stopwatch()}; + NodeCache tempNodeCache_{ + "TempNodeCache", + 16384, + std::chrono::minutes{1}, + stopwatch(), + logs_.journal("TaggedCache")}; + CachedSLEs cachedSLEs_{ + "CachedSLEs", + 16384, + std::chrono::minutes{1}, + stopwatch(), + logs_.journal("TaggedCache")}; + PendingSaves pendingSaves_; + std::optional trapTxID_; + +public: + TestServiceRegistry() = default; + ~TestServiceRegistry() override = default; + + // Core infrastructure services + CollectorManager& + getCollectorManager() override + { + throw std::logic_error("TestServiceRegistry::getCollectorManager() not implemented"); + } + + Family& + getNodeFamily() override + { + return family_; + } + + TimeKeeper& + getTimeKeeper() override + { + throw std::logic_error("TestServiceRegistry::timeKeeper() not implemented"); + } + + JobQueue& + getJobQueue() override + { + throw std::logic_error("TestServiceRegistry::getJobQueue() not implemented"); + } + + NodeCache& + getTempNodeCache() override + { + return tempNodeCache_; + } + + CachedSLEs& + getCachedSLEs() override + { + return cachedSLEs_; + } + + NetworkIDService& + getNetworkIDService() override + { + return networkIDService_; + } + + // Protocol and validation services + AmendmentTable& + getAmendmentTable() override + { + throw std::logic_error("TestServiceRegistry::getAmendmentTable() not implemented"); + } + + HashRouter& + getHashRouter() override + { + return hashRouter_; + } + + LoadFeeTrack& + getFeeTrack() override + { + return feeTrack_; + } + + LoadManager& + getLoadManager() override + { + throw std::logic_error("TestServiceRegistry::getLoadManager() not implemented"); + } + + RCLValidations& + getValidations() override + { + throw std::logic_error("TestServiceRegistry::getValidations() not implemented"); + } + + ValidatorList& + getValidators() override + { + throw std::logic_error("TestServiceRegistry::validators() not implemented"); + } + + ValidatorSite& + getValidatorSites() override + { + throw std::logic_error("TestServiceRegistry::validatorSites() not implemented"); + } + + ManifestCache& + getValidatorManifests() override + { + throw std::logic_error("TestServiceRegistry::validatorManifests() not implemented"); + } + + ManifestCache& + getPublisherManifests() override + { + throw std::logic_error("TestServiceRegistry::publisherManifests() not implemented"); + } + + // Network services + Overlay& + getOverlay() override + { + throw std::logic_error("TestServiceRegistry::overlay() not implemented"); + } + + Cluster& + getCluster() override + { + throw std::logic_error("TestServiceRegistry::cluster() not implemented"); + } + + PeerReservationTable& + getPeerReservations() override + { + throw std::logic_error("TestServiceRegistry::peerReservations() not implemented"); + } + + Resource::Manager& + getResourceManager() override + { + throw std::logic_error("TestServiceRegistry::getResourceManager() not implemented"); + } + + // Storage services + NodeStore::Database& + getNodeStore() override + { + throw std::logic_error("TestServiceRegistry::getNodeStore() not implemented"); + } + + SHAMapStore& + getSHAMapStore() override + { + throw std::logic_error("TestServiceRegistry::getSHAMapStore() not implemented"); + } + + RelationalDatabase& + getRelationalDatabase() override + { + throw std::logic_error("TestServiceRegistry::getRelationalDatabase() not implemented"); + } + + // Ledger services + InboundLedgers& + getInboundLedgers() override + { + throw std::logic_error("TestServiceRegistry::getInboundLedgers() not implemented"); + } + + InboundTransactions& + getInboundTransactions() override + { + throw std::logic_error("TestServiceRegistry::getInboundTransactions() not implemented"); + } + + TaggedCache& + getAcceptedLedgerCache() override + { + throw std::logic_error("TestServiceRegistry::getAcceptedLedgerCache() not implemented"); + } + + LedgerMaster& + getLedgerMaster() override + { + throw std::logic_error("TestServiceRegistry::getLedgerMaster() not implemented"); + } + + LedgerCleaner& + getLedgerCleaner() override + { + throw std::logic_error("TestServiceRegistry::getLedgerCleaner() not implemented"); + } + + LedgerReplayer& + getLedgerReplayer() override + { + throw std::logic_error("TestServiceRegistry::getLedgerReplayer() not implemented"); + } + + PendingSaves& + getPendingSaves() override + { + return pendingSaves_; + } + + OpenLedger& + getOpenLedger() override + { + throw std::logic_error("TestServiceRegistry::openLedger() not implemented"); + } + + OpenLedger const& + getOpenLedger() const override + { + throw std::logic_error("TestServiceRegistry::openLedger() const not implemented"); + } + + // Transaction and operation services + NetworkOPs& + getOPs() override + { + throw std::logic_error("TestServiceRegistry::getOPs() not implemented"); + } + + OrderBookDB& + getOrderBookDB() override + { + throw std::logic_error("TestServiceRegistry::getOrderBookDB() not implemented"); + } + + TransactionMaster& + getMasterTransaction() override + { + throw std::logic_error("TestServiceRegistry::getMasterTransaction() not implemented"); + } + + TxQ& + getTxQ() override + { + throw std::logic_error("TestServiceRegistry::getTxQ() not implemented"); + } + + PathRequestManager& + getPathRequestManager() override + { + throw std::logic_error("TestServiceRegistry::getPathRequestManager() not implemented"); + } + + // Server services + ServerHandler& + getServerHandler() override + { + throw std::logic_error("TestServiceRegistry::getServerHandler() not implemented"); + } + + perf::PerfLog& + getPerfLog() override + { + throw std::logic_error("TestServiceRegistry::getPerfLog() not implemented"); + } + + // Configuration and state + bool + isStopping() const override + { + return false; + } + + beast::Journal + getJournal(std::string const& name) override + { + return logs_.journal(name); + } + + boost::asio::io_context& + getIOContext() override + { + return io_context_; + } + + Logs& + getLogs() override + { + return logs_; + } + + std::optional const& + getTrapTxID() const override + { + return trapTxID_; + } + + DatabaseCon& + getWalletDB() override + { + throw std::logic_error("TestServiceRegistry::getWalletDB() not implemented"); + } + + // Temporary: Get the underlying Application + Application& + getApp() override + { + throw std::logic_error( + "TestServiceRegistry::app() not implemented - no Application available in tests"); + } +}; + +} // namespace test +} // namespace xrpl diff --git a/src/tests/libxrpl/helpers/TxTest.cpp b/src/tests/libxrpl/helpers/TxTest.cpp new file mode 100644 index 0000000000..d88f700356 --- /dev/null +++ b/src/tests/libxrpl/helpers/TxTest.cpp @@ -0,0 +1,252 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +//------------------------------------------------------------------------------ +// Feature helpers +//------------------------------------------------------------------------------ + +FeatureBitset +allFeatures() +{ + static FeatureBitset const features = [] { + auto const& sa = allAmendments(); + std::vector feats; + feats.reserve(sa.size()); + for ([[maybe_unused]] auto const& [name, _] : sa) + { + if (auto const f = getRegisteredFeature(name); f.has_value()) + feats.push_back(*f); + } + return FeatureBitset(feats); + }(); + return features; +} + +//------------------------------------------------------------------------------ +// TxTest +//------------------------------------------------------------------------------ + +TxTest::TxTest(std::optional features) +{ + // Convert FeatureBitset to unordered_set for Rules constructor + auto const featureBits = features.value_or(allFeatures()); + foreachFeature(featureBits, [&](uint256 const& f) { featureSet_.insert(f); }); + + // Create rules with the specified features + rules_.emplace(featureSet_); + + // Default fees for testing + Fees const fees{XRPAmount{10}, XRPAmount{10000000}, XRPAmount{2000000}}; + + // Create a genesis ledger as the base + closedLedger_ = std::make_shared( + create_genesis, + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + *rules_, + fees, + std::vector{featureSet_.begin(), featureSet_.end()}, + registry_.getNodeFamily()); + + // Initialize time from the genesis ledger + now_ = closedLedger_->header().closeTime; + + // Create an open view on top of the genesis ledger + openLedger_ = + std::make_shared(open_ledger, closedLedger_.get(), *rules_, closedLedger_); +} + +bool +TxTest::isEnabled(uint256 const& feature) const +{ + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + return rules_->enabled(feature); +} + +Rules const& +TxTest::getRules() const +{ + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + return *rules_; +} + +[[nodiscard]] TxResult +TxTest::submit(std::shared_ptr stx) +{ + auto result = apply(registry_, *openLedger_, *stx, tapNONE, registry_.getJournal("apply")); + + // Track successfully applied transactions for canonical reordering on close + // We make a copy since the TransactionBase doesn't own the STTx + if (result.applied) + pendingTxs_.push_back(stx); + + return TxResult{ + .ter = result.ter, + .applied = result.applied, + .metadata = std::move(result).metadata, + .tx = std::move(stx)}; +} + +void +TxTest::createAccount(Account const& account, XRPAmount xrp, uint32_t accountFlags) +{ + auto const paymentTer = + submit(transactions::PaymentBuilder{Account::master, account, xrp}, Account::master).ter; + + if (paymentTer != tesSUCCESS) + { + throw std::runtime_error("TxTest::createAccount: failed to create account"); + } + + close(); + + if (accountFlags != 0) + { + auto const accountSetTer = + submit(transactions::AccountSetBuilder{account}.setSetFlag(accountFlags), account).ter; + if (accountSetTer != tesSUCCESS) + { + throw std::runtime_error("TxTest::createAccount: failed to set account flags"); + } + close(); + } +} + +ledger_entries::AccountRoot +TxTest::getAccountRoot(AccountID const& id) const +{ + auto const sle = getOpenLedger().read(keylet::account(id)); + if (!sle) + Throw("TxTest::getAccountRoot: account not found"); + return ledger_entries::AccountRoot{std::const_pointer_cast(sle)}; +} + +OpenView& +TxTest::getOpenLedger() +{ + return *openLedger_; +} + +OpenView const& +TxTest::getOpenLedger() const +{ + return *openLedger_; +} + +ReadView const& +TxTest::getClosedLedger() const +{ + return *closedLedger_; +} + +void +TxTest::close() +{ + // Build a new closed ledger from the previous closed ledger, + // similar to how buildLedgerImpl works: + // 1. Create a new Ledger from the previous closed ledger + // 2. Re-apply transactions in canonical order + // 3. Mark it as accepted/immutable + + auto const& prevLedger = *closedLedger_; + + auto const ledgerCloseTime = now_ + prevLedger.header().closeTimeResolution; + + now_ = ledgerCloseTime; + + auto newLedger = std::make_shared(prevLedger, ledgerCloseTime); + + CanonicalTXSet txSet(prevLedger.header().hash); + for (auto const& tx : pendingTxs_) + txSet.insert(tx); + + { + OpenView accum(&*newLedger); + for (auto const& [key, tx] : txSet) + { + auto result = apply(registry_, accum, *tx, tapNONE, registry_.getJournal("apply")); + if (!result.applied) + { + throw std::runtime_error("TxTest::close: failed to apply transaction"); + } + } + accum.apply(*newLedger); + } + + newLedger->setAccepted(ledgerCloseTime, newLedger->header().closeTimeResolution, true); + + closedLedger_ = newLedger; + + pendingTxs_.clear(); + + openLedger_ = + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + std::make_shared(open_ledger, closedLedger_.get(), *rules_, closedLedger_); +} + +void +TxTest::advanceTime(NetClock::duration duration) +{ + now_ += duration; +} + +NetClock::time_point +TxTest::getCloseTime() const +{ + return now_; +} + +STAmount +TxTest::getBalance(AccountID const& account, IOU const& iou) const +{ + auto const sle = openLedger_->read(keylet::line(account, iou.issue())); + if (!sle) + return STAmount{iou.issue(), 0}; + + auto const rippleState = ledger_entries::RippleState{sle}; + + auto balance = rippleState.getBalance(); + if (iou.issue().account == account) + { + throw std::logic_error("TxTest::getBalance: account is issuer"); + } + + balance.get().account = iou.issue().account; + if (account > iou.issue().account) + balance.negate(); + return balance; +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/helpers/TxTest.h b/src/tests/libxrpl/helpers/TxTest.h new file mode 100644 index 0000000000..864b19c399 --- /dev/null +++ b/src/tests/libxrpl/helpers/TxTest.h @@ -0,0 +1,364 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +//------------------------------------------------------------------------------ +// Amount helpers +//------------------------------------------------------------------------------ + +/** + * @brief Convert XRP to drops (integral types). + * @param xrp The amount in XRP. + * @return The equivalent amount in drops as XRPAmount. + */ +template +constexpr XRPAmount +XRP(T xrp) +{ + return XRPAmount{static_cast(xrp) * DROPS_PER_XRP.drops()}; +} + +/** + * @brief Convert XRP to drops (floating point types). + * @param xrp The amount in XRP (may be fractional). + * @return The equivalent amount in drops as XRPAmount. + */ +template +XRPAmount +XRP(T xrp) +{ + return XRPAmount{static_cast(std::round(xrp * DROPS_PER_XRP.drops()))}; +} + +/** + * @brief Convert XRP to drops (Number type). + * @param xrp The amount in XRP as a Number. + * @return The equivalent amount in drops as XRPAmount. + */ +inline XRPAmount +XRP(Number const& xrp) +{ + return XRPAmount{static_cast(xrp * DROPS_PER_XRP.drops())}; +} + +//------------------------------------------------------------------------------ +// Flag helpers +//------------------------------------------------------------------------------ + +/** + * @brief Convert AccountSet flag (asf) to LedgerState flag (lsf). + * @param asf The AccountSet flag value. + * @return The corresponding LedgerState flag. + * @throws std::runtime_error if the flag is not supported. + * + * Supported flags: + * asfRequireDest, asfRequireAuth, asfDisallowXRP, asfDisableMaster, + * asfNoFreeze, asfGlobalFreeze, asfDefaultRipple, asfDepositAuth, + * asfAllowTrustLineClawback, asfDisallowIncomingCheck, + * asfDisallowIncomingNFTokenOffer, asfDisallowIncomingPayChan, + * asfDisallowIncomingTrustline, asfAllowTrustLineLocking + */ +constexpr std::uint32_t +asfToLsf(std::uint32_t asf) +{ + switch (asf) + { + case asfRequireDest: + return lsfRequireDestTag; + case asfRequireAuth: + return lsfRequireAuth; + case asfDisallowXRP: + return lsfDisallowXRP; + case asfDisableMaster: + return lsfDisableMaster; + case asfNoFreeze: + return lsfNoFreeze; + case asfGlobalFreeze: + return lsfGlobalFreeze; + case asfDefaultRipple: + return lsfDefaultRipple; + case asfDepositAuth: + return lsfDepositAuth; + case asfAllowTrustLineClawback: + return lsfAllowTrustLineClawback; + case asfDisallowIncomingCheck: + return lsfDisallowIncomingCheck; + case asfDisallowIncomingNFTokenOffer: + return lsfDisallowIncomingNFTokenOffer; + case asfDisallowIncomingPayChan: + return lsfDisallowIncomingPayChan; + case asfDisallowIncomingTrustline: + return lsfDisallowIncomingTrustline; + case asfAllowTrustLineLocking: + return lsfAllowTrustLineLocking; + default: + throw std::runtime_error("Unknown asf flag"); + } +} + +//------------------------------------------------------------------------------ +// Feature helpers +//------------------------------------------------------------------------------ + +/** + * @brief Returns all testable amendments. + * @note This is similar to jtx::testable_amendments() but for the TxTest framework. + */ +FeatureBitset +allFeatures(); + +//------------------------------------------------------------------------------ +// TxResult +//------------------------------------------------------------------------------ + +/** + * @brief Result of a transaction submission in TxTest. + * + * Contains the TER code, whether the transaction was applied, + * optional metadata, and a reference to the submitted transaction. + * Use standard gtest macros (EXPECT_EQ, EXPECT_TRUE, etc.) to verify results. + */ +struct TxResult +{ + TER ter; /**< The transaction engine result code. */ + bool applied; /**< Whether the transaction was applied to the ledger. */ + std::optional metadata; /**< Transaction metadata, if available. */ + std::shared_ptr tx; /**< Pointer to the submitted transaction. */ +}; + +/** + * @brief A lightweight transaction testing harness. + * + * Unlike the JTx framework which requires a full Application and RPC layer, + * TxTest applies transactions directly to an OpenView using the transactor + * pipeline (preflight -> preclaim -> doApply). + * + * This makes it suitable for: + * - Unit testing individual transactors + * - Testing transaction validation logic + * - Fast, focused tests without full server infrastructure + * + * @code + * TxTest env; + * env.submit(paymentTx).expectSuccess(); + * env.submit(badTx).expectTer(tecNO_ENTRY); + * @endcode + */ +class TxTest +{ +public: + /** + * @brief Construct a TxTest environment. + * + * Creates a genesis ledger and an open view on top of it. + * + * @param features Optional set of features to enable. If not specified, + * uses all testable amendments. + */ + explicit TxTest(std::optional features = std::nullopt); + + /** + * @brief Check if a feature is enabled. + * @param feature The feature to check. + * @return True if the feature is enabled. + */ + [[nodiscard]] bool + isEnabled(uint256 const& feature) const; + + /** + * @brief Get the current rules. + * @return The current consensus rules. + */ + [[nodiscard]] Rules const& + getRules() const; + + /** + * @brief Submit a transaction from a builder. + * + * Convenience overload that accepts transaction builders. + * Automatically sets sequence and fee before submission. + * + * @tparam T A type derived from TransactionBuilderBase. + * @param builder The transaction builder. + * @param signer The account to sign with. + * @return TxResult containing the result code, applied status, and metadata. + */ + template + requires std:: + derived_from, transactions::TransactionBuilderBase>> + [[nodiscard]] TxResult + submit(T&& builder, Account const& signer) + { + auto const& obj = builder.getSTObject(); + auto accountId = obj[sfAccount]; + // Only set sequence if not using a ticket (ticket sets sequence to 0) + if (!obj.isFieldPresent(sfTicketSequence)) + { + builder.setSequence(getAccountRoot(accountId).getSequence()); + } + else + { + builder.setSequence(0); + } + builder.setFee(XRPAmount(10)); + return submit(builder.build(signer.pk(), signer.sk()).getSTTx()); + } + + /** + * @brief Submit a transaction to the open ledger. + * + * Applies the transaction through the full transactor pipeline: + * preflight -> preclaim -> doApply -> invariant checks + * + * Invariant checks are automatically run after doApply. If any + * invariant fails, the result will be tecINVARIANT_FAILED. + * + * @param stx The transaction to submit. + * @return TxResult containing the result code, applied status, and metadata. + */ + [[nodiscard]] TxResult + submit(std::shared_ptr stx); + + /** + * @brief Create a new account in the ledger. + * + * Sends a Payment from the master account to create and fund the account. + * Closes the ledger after creation. If accountFlags is non-zero, submits + * an AccountSet transaction and closes again. + * + * @param account The account to create. + * @param xrp The initial XRP balance. + * @param accountFlags Optional account flags to set. Defaults to 0 + * (no flags). + */ + void + createAccount(Account const& account, XRPAmount xrp, uint32_t accountFlags = 0); + + /** + * @brief Get the account root object from the current open ledger. + * @param id The account ID. + * @return The AccountRoot ledger entry. + * @throws std::runtime_error if the account does not exist. + * @todo Once we make keylet strongly typed, we can ditch this method. + */ + [[nodiscard]] ledger_entries::AccountRoot + getAccountRoot(AccountID const& id) const; + + /** + * @brief Get the current open ledger view. + * @return A mutable reference to the open ledger. + */ + [[nodiscard]] OpenView& + getOpenLedger(); + + /** + * @brief Get the current open ledger view (const). + * @return A const reference to the open ledger. + */ + [[nodiscard]] OpenView const& + getOpenLedger() const; + + /** + * @brief Get the closed (base) ledger view. + * @return A const reference to the closed ledger. + */ + [[nodiscard]] ReadView const& + getClosedLedger() const; + + /** + * @brief Close the current ledger. + * + * Creates a new closed ledger from the current open ledger. + * All pending transactions are re-applied in canonical order. + */ + void + close(); + + /** + * @brief Advance time without closing the ledger. + * + * Useful for testing time-dependent features like escrow release + * times or offer expirations. + * + * @param duration The amount of time to advance. + */ + void + advanceTime(NetClock::duration duration); + + /** + * @brief Get the current ledger close time. + * @return The current close time. + */ + [[nodiscard]] NetClock::time_point + getCloseTime() const; + + /** + * @brief Get the balance of an IOU for an account. + * + * Returns the balance from the perspective of the specified account. + * If the trust line doesn't exist, returns zero. + * + * @param account The account to check. + * @param iou The IOU to check the balance for. + * @return The balance as an STAmount. + * @todo Once we make keylet strongly typed, we can ditch this method. + */ + [[nodiscard]] STAmount + getBalance(AccountID const& account, IOU const& iou) const; + + /** + * @brief Get the service registry. + * @return A reference to the service registry. + */ + ServiceRegistry& + getServiceRegistry() + { + return registry_; + } + +private: + TestServiceRegistry registry_; + std::unordered_set> featureSet_; + std::optional rules_; + std::shared_ptr closedLedger_; + std::shared_ptr openLedger_; + + /** Transactions submitted to the open ledger, for canonical reordering on close. */ + std::vector> pendingTxs_; + + /** Current time (can be advanced arbitrarily for testing). */ + NetClock::time_point now_; +}; + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/AccountSet.cpp b/src/tests/libxrpl/tx/AccountSet.cpp new file mode 100644 index 0000000000..3dbe7a4903 --- /dev/null +++ b/src/tests/libxrpl/tx/AccountSet.cpp @@ -0,0 +1,804 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +TEST(AccountSet, NullAccountSet) +{ + TxTest env; + + Account const alice("alice"); + env.createAccount(alice, XRP(10)); + + auto& view = env.getOpenLedger(); + + // ask for the ledger entry - account root, to check its flags + auto sle = view.read(keylet::account(alice)); + + EXPECT_NE(sle, nullptr); + ledger_entries::AccountRoot const accountRoot(sle); + EXPECT_EQ(accountRoot.getFlags(), 0); +} + +TEST(AccountSet, MostFlags) +{ + Account const alice("alice"); + + TxTest env; + env.createAccount(alice, XRP(10000)); + + // Give alice a regular key so she can legally set and clear + // her asfDisableMaster flag. + Account const aliceRegularKey{"aliceRegularKey", KeyType::secp256k1}; + + env.createAccount(aliceRegularKey, XRP(10000)); + env.close(); + + EXPECT_EQ( + env.submit(transactions::SetRegularKeyBuilder{alice}.setRegularKey(aliceRegularKey), alice) + .ter, + tesSUCCESS); + env.close(); + + auto testFlags = [&alice, &aliceRegularKey, &env]( + std::initializer_list goodFlags) { + std::uint32_t const orig_flags = env.getAccountRoot(alice).getFlags(); + for (std::uint32_t flag{1u}; flag < std::numeric_limits::digits; ++flag) + { + if (flag == asfNoFreeze) + { + // The asfNoFreeze flag can't be cleared. It is tested + // elsewhere. + continue; + } + if (flag == asfAuthorizedNFTokenMinter) + { + // The asfAuthorizedNFTokenMinter flag requires the + // presence or absence of the sfNFTokenMinter field in + // the transaction. It is tested elsewhere. + continue; + } + + if (flag == asfDisallowIncomingCheck || flag == asfDisallowIncomingPayChan || + flag == asfDisallowIncomingNFTokenOffer || flag == asfDisallowIncomingTrustline) + { + // These flags are part of the DisallowIncoming amendment + // and are tested elsewhere + continue; + } + if (flag == asfAllowTrustLineClawback) + { + // The asfAllowTrustLineClawback flag can't be cleared. It + // is tested elsewhere. + continue; + } + if (flag == asfAllowTrustLineLocking) + { + // These flags are part of the AllowTokenLocking amendment + // and are tested elsewhere + continue; + } + if (std::ranges::find(goodFlags, flag) != goodFlags.end()) + { + // Good flag + EXPECT_FALSE(env.getAccountRoot(alice).isFlag(asfToLsf(flag))); + + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setSetFlag(flag), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_TRUE(env.getAccountRoot(alice).isFlag(asfToLsf(flag))); + + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice}.setClearFlag(flag), + aliceRegularKey) + .ter, + tesSUCCESS); + env.close(); + + EXPECT_FALSE(env.getAccountRoot(alice).isFlag(asfToLsf(flag))); + + std::uint32_t const now_flags = env.getAccountRoot(alice).getFlags(); + EXPECT_EQ(now_flags, orig_flags); + } + else + { + // Bad flag + EXPECT_EQ(env.getAccountRoot(alice).getFlags(), orig_flags); + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setSetFlag(flag), alice).ter, + tesSUCCESS); + env.close(); + EXPECT_EQ(env.getAccountRoot(alice).getFlags(), orig_flags); + + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice}.setClearFlag(flag), + aliceRegularKey) + .ter, + tesSUCCESS); + env.close(); + EXPECT_EQ(env.getAccountRoot(alice).getFlags(), orig_flags); + } + } + }; + testFlags({ + asfRequireDest, + asfRequireAuth, + asfDisallowXRP, + asfGlobalFreeze, + asfDisableMaster, + asfDefaultRipple, + asfDepositAuth, + }); +} + +TEST(AccountSet, SetAndResetAccountTxnID) +{ + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + + std::uint32_t const orig_flags = env.getAccountRoot(alice).getFlags(); + + // asfAccountTxnID is special and not actually set as a flag, + // so we check the field presence instead + EXPECT_FALSE(env.getAccountRoot(alice).hasAccountTxnID()); + + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setSetFlag(asfAccountTxnID), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_TRUE(env.getAccountRoot(alice).hasAccountTxnID()); + + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setClearFlag(asfAccountTxnID), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_FALSE(env.getAccountRoot(alice).hasAccountTxnID()); + + std::uint32_t const now_flags = env.getAccountRoot(alice).getFlags(); + EXPECT_EQ(now_flags, orig_flags); +} + +TEST(AccountSet, SetNoFreeze) +{ + TxTest env; + Account const alice("alice"); + Account const eric("eric"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + // Set eric as alice's regular key (eric doesn't need to be funded) + EXPECT_EQ( + env.submit(transactions::SetRegularKeyBuilder{alice}.setRegularKey(eric), alice).ter, + tesSUCCESS); + env.close(); + + // Verify alice doesn't have NoFreeze flag + EXPECT_FALSE(env.getAccountRoot(alice).isFlag(lsfNoFreeze)); + + // Setting NoFreeze with regular key should fail - requires master key + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setSetFlag(asfNoFreeze), eric).ter, + tecNEED_MASTER_KEY); + env.close(); + + // Setting NoFreeze with master key should succeed + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setSetFlag(asfNoFreeze), alice).ter, + tesSUCCESS); + env.close(); + + // Verify alice now has NoFreeze flag + EXPECT_TRUE(env.getAccountRoot(alice).isFlag(lsfNoFreeze)); + + // Try to clear NoFreeze - transaction succeeds but flag remains set + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setClearFlag(asfNoFreeze), alice).ter, + tesSUCCESS); + env.close(); + + // Verify flag is still set (NoFreeze cannot be cleared once set) + EXPECT_TRUE(env.getAccountRoot(alice).isFlag(lsfNoFreeze)); +} + +TEST(AccountSet, Domain) +{ + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + // The Domain field is represented as the hex string of the lowercase + // ASCII of the domain. For example, the domain example.com would be + // represented as "6578616d706c652e636f6d". + // + // To remove the Domain field from an account, send an AccountSet with + // the Domain set to an empty string. + std::string const domain = "example.com"; + + // Set domain + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setDomain(makeSlice(domain)), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_TRUE(env.getAccountRoot(alice).hasDomain()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ(*env.getAccountRoot(alice).getDomain(), makeSlice(domain)); + + // Clear domain by setting empty + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setDomain(Slice{}), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_FALSE(env.getAccountRoot(alice).hasDomain()); + + // The upper limit on the length is 256 bytes + // (defined as DOMAIN_BYTES_MAX in SetAccount) + // test the edge cases: 255, 256, 257. + std::size_t const maxLength = 256; + for (std::size_t len = maxLength - 1; len <= maxLength + 1; ++len) + { + std::string const domain2 = std::string(len - domain.length() - 1, 'a') + "." + domain; + + EXPECT_EQ(domain2.length(), len); + + if (len <= maxLength) + { + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice}.setDomain(makeSlice(domain2)), alice) + .ter, + tesSUCCESS); + env.close(); + + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ(*env.getAccountRoot(alice).getDomain(), makeSlice(domain2)); + } + else + { + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice}.setDomain(makeSlice(domain2)), alice) + .ter, + telBAD_DOMAIN); + env.close(); + } + } +} + +TEST(AccountSet, MessageKey) +{ + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + // Generate a random ed25519 key pair for the message key + auto const rkp = randomKeyPair(KeyType::ed25519); + + // Set the message key + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setMessageKey(rkp.first.slice()), alice) + .ter, + tesSUCCESS); + env.close(); + + EXPECT_TRUE(env.getAccountRoot(alice).hasMessageKey()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ(*env.getAccountRoot(alice).getMessageKey(), rkp.first.slice()); + + // Clear the message key by setting to empty + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setMessageKey(Slice{}), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_FALSE(env.getAccountRoot(alice).hasMessageKey()); + + // Try to set an invalid public key - should fail + using namespace std::string_literals; + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice}.setMessageKey( + makeSlice("NOT_REALLY_A_PUBKEY"s)), + alice) + .ter, + telBAD_PUBLIC_KEY); +} + +TEST(AccountSet, WalletID) +{ + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + std::string_view const locator = + "9633EC8AF54F16B5286DB1D7B519EF49EEFC050C0C8AC4384F1D88ACD1BFDF05"; + uint256 locatorHash{}; + EXPECT_TRUE(locatorHash.parseHex(locator)); + + // Set the wallet locator + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setWalletLocator(locatorHash), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_TRUE(env.getAccountRoot(alice).hasWalletLocator()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ(*env.getAccountRoot(alice).getWalletLocator(), locatorHash); + + // Clear the wallet locator by setting to zero + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setWalletLocator(beast::zero), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_FALSE(env.getAccountRoot(alice).hasWalletLocator()); +} + +TEST(AccountSet, EmailHash) +{ + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + std::string_view const mh = "5F31A79367DC3137FADA860C05742EE6"; + uint128 emailHash{}; + EXPECT_TRUE(emailHash.parseHex(mh)); + + // Set the email hash + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setEmailHash(emailHash), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_TRUE(env.getAccountRoot(alice).hasEmailHash()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ(*env.getAccountRoot(alice).getEmailHash(), emailHash); + + // Clear the email hash by setting to zero + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setEmailHash(beast::zero), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_FALSE(env.getAccountRoot(alice).hasEmailHash()); +} + +TEST(AccountSet, TransferRate) +{ + struct TestCase + { + double set; + TER code; + double get; + }; + + // Test data: {rate to set, expected TER, expected stored rate} + std::vector const testData = { + {1.0, tesSUCCESS, 1.0}, + {1.1, tesSUCCESS, 1.1}, + {2.0, tesSUCCESS, 2.0}, + {2.1, temBAD_TRANSFER_RATE, 2.0}, // > 2.0 is invalid + {0.0, tesSUCCESS, 1.0}, // 0 clears the rate (default = 1.0) + {2.0, tesSUCCESS, 2.0}, + {0.9, temBAD_TRANSFER_RATE, 2.0}, // < 1.0 is invalid + }; + + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + for (auto const& r : testData) + { + auto const rateValue = static_cast(QUALITY_ONE * r.set); + + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setTransferRate(rateValue), alice) + .ter, + r.code); + env.close(); + + // If the field is not present, expect the default value (1.0) + if (!env.getAccountRoot(alice).hasTransferRate()) + { + EXPECT_EQ(r.get, 1.0); + } + else + { + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ( + *env.getAccountRoot(alice).getTransferRate(), + static_cast(r.get * QUALITY_ONE)); + } + } +} + +TEST(AccountSet, BadInputs) +{ + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + // Setting and clearing the same flag is invalid + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice} + .setSetFlag(asfDisallowXRP) + .setClearFlag(asfDisallowXRP), + alice) + .ter, + temINVALID_FLAG); + + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice} + .setSetFlag(asfRequireAuth) + .setClearFlag(asfRequireAuth), + alice) + .ter, + temINVALID_FLAG); + + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice} + .setSetFlag(asfRequireDest) + .setClearFlag(asfRequireDest), + alice) + .ter, + temINVALID_FLAG); + + // Setting asf flag while also using corresponding tf flag is invalid + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice} + .setSetFlag(asfDisallowXRP) + .setFlags(tfAllowXRP), + alice) + .ter, + temINVALID_FLAG); + + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice} + .setSetFlag(asfRequireAuth) + .setFlags(tfOptionalAuth), + alice) + .ter, + temINVALID_FLAG); + + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice} + .setSetFlag(asfRequireDest) + .setFlags(tfOptionalDestTag), + alice) + .ter, + temINVALID_FLAG); + + // Using invalid flags (mask) is invalid + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice} + .setSetFlag(asfRequireDest) + .setFlags(tfAccountSetMask), + alice) + .ter, + temINVALID_FLAG); + + // Disabling master key without an alternative key is invalid + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setSetFlag(asfDisableMaster), alice).ter, + tecNO_ALTERNATIVE_KEY); +} + +TEST(AccountSet, RequireAuthWithDir) +{ + TxTest env; + Account const alice("alice"); + Account const bob("bob"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + // alice should have an empty directory + EXPECT_TRUE(dirIsEmpty(env.getClosedLedger(), keylet::ownerDir(alice.id()))); + + // Give alice a signer list, then there will be stuff in the directory + // Build the SignerEntries array + STArray signerEntries(1); + { + signerEntries.push_back(STObject::makeInnerObject(sfSignerEntry)); + STObject& entry = signerEntries.back(); + entry[sfAccount] = bob.id(); + entry[sfSignerWeight] = std::uint16_t{1}; + } + + EXPECT_EQ( + env.submit( + transactions::SignerListSetBuilder{alice, 1}.setSignerEntries(signerEntries), alice) + .ter, + tesSUCCESS); + env.close(); + + EXPECT_FALSE(dirIsEmpty(env.getClosedLedger(), keylet::ownerDir(alice.id()))); + + // Setting RequireAuth should fail because alice has owner objects + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setSetFlag(asfRequireAuth), alice).ter, + tecOWNERS); + + // Remove the signer list (quorum = 0, no entries) + EXPECT_EQ(env.submit(transactions::SignerListSetBuilder{alice, 0}, alice).ter, tesSUCCESS); + env.close(); + + EXPECT_TRUE(dirIsEmpty(env.getClosedLedger(), keylet::ownerDir(alice.id()))); + + // Now setting RequireAuth should succeed + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setSetFlag(asfRequireAuth), alice).ter, + tesSUCCESS); +} + +TEST(AccountSet, Ticket) +{ + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + // Get alice's current sequence - the ticket will be created at seq + 1 + std::uint32_t const aliceSeqBefore = env.getAccountRoot(alice.id()).getSequence(); + std::uint32_t const ticketSeq = aliceSeqBefore + 1; + + // Create a ticket + EXPECT_EQ(env.submit(transactions::TicketCreateBuilder{alice, 1}, alice).ter, tesSUCCESS); + env.close(); + + // Verify alice has 1 owner object (the ticket) + EXPECT_EQ(env.getAccountRoot(alice.id()).getOwnerCount(), 1u); + // Verify ticket exists + EXPECT_TRUE(env.getClosedLedger().exists(keylet::ticket(alice.id(), ticketSeq))); + + // Try using a ticket that alice doesn't have + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq + 1), alice) + .ter, + terPRE_TICKET); + env.close(); + + // Verify ticket still exists + EXPECT_TRUE(env.getClosedLedger().exists(keylet::ticket(alice.id(), ticketSeq))); + + // Get alice's sequence before using the ticket + std::uint32_t const aliceSeq = env.getAccountRoot(alice.id()).getSequence(); + + // Actually use alice's ticket (noop AccountSet) + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq), alice).ter, + tesSUCCESS); + env.close(); + + // Verify ticket is consumed (no owner objects) + EXPECT_EQ(env.getAccountRoot(alice.id()).getOwnerCount(), 0u); + EXPECT_FALSE(env.getClosedLedger().exists(keylet::ticket(alice.id(), ticketSeq))); + + // Verify alice's sequence did NOT advance (ticket use doesn't increment seq) + EXPECT_EQ(env.getAccountRoot(alice.id()).getSequence(), aliceSeq); + + // Try re-using a ticket that alice already used + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq), alice).ter, + tefNO_TICKET); +} + +TEST(AccountSet, BadSigningKey) +{ + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + // Build a valid transaction first, then corrupt the signing key + auto stx = transactions::AccountSetBuilder{alice} + .setSequence(env.getAccountRoot(alice.id()).getSequence()) + .setFee(XRPAmount{10}) + .build(alice.pk(), alice.sk()) + .getSTTx(); + + // Create a copy with a bad signing key + STObject obj = *stx; + obj.setFieldVL(sfSigningPubKey, makeSlice(std::string("badkey"))); + + auto result = env.submit(std::make_shared(std::move(obj))); + EXPECT_EQ(result.ter, temBAD_SIGNATURE); + EXPECT_FALSE(result.applied); +} + +TEST(AccountSet, Gateway) +{ + Account const alice("alice"); + Account const bob("bob"); + Account const gw("gateway"); + IOU const USD("USD", gw); + + // Test gateway with a variety of allowed transfer rates + for (double transferRate = 1.0; transferRate <= 2.0; transferRate += 0.03125) + { + TxTest env; + + env.createAccount(gw, XRP(10000), asfDefaultRipple); + env.createAccount(alice, XRP(10000), asfDefaultRipple); + env.createAccount(bob, XRP(10000), asfDefaultRipple); + env.close(); + + // Set up trust lines: alice and bob trust gw for USD + EXPECT_EQ( + env.submit(transactions::TrustSetBuilder{alice}.setLimitAmount(USD.amount(10)), alice) + .ter, + tesSUCCESS); + EXPECT_EQ( + env.submit(transactions::TrustSetBuilder{bob}.setLimitAmount(USD.amount(10)), bob).ter, + tesSUCCESS); + env.close(); + + // Set transfer rate on the gateway + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{gw}.setTransferRate( + static_cast(transferRate * QUALITY_ONE)), + gw) + .ter, + tesSUCCESS); + env.close(); + + // Calculate the amount with transfer rate applied + auto const amount = USD.amount(1); + Rate const rate(static_cast(transferRate * QUALITY_ONE)); + auto const amountWithRate = multiply(amount, rate); + + // Gateway pays alice 10 USD + EXPECT_EQ( + env.submit(transactions::PaymentBuilder{gw, alice, USD.amount(10)}, gw).ter, + tesSUCCESS); + env.close(); + + // Alice pays bob 1 USD (with sendmax to cover transfer fee) + EXPECT_EQ( + env.submit( + transactions::PaymentBuilder{alice, bob, USD.amount(1)}.setSendMax( + USD.amount(10)), + alice) + .ter, + tesSUCCESS); + env.close(); + + // Check balances + EXPECT_EQ(env.getBalance(alice.id(), USD), USD.amount(10) - amountWithRate); + EXPECT_EQ(env.getBalance(bob.id(), USD), USD.amount(1)); + } + + // Test out-of-bounds legacy transfer rates (4.0 and 4.294967295) + // These require direct ledger modification since the transactor blocks them + for (std::uint32_t const transferRate : {4000000000U, 4294967295U}) + { + TxTest env; + env.createAccount(gw, XRP(10000), asfDefaultRipple); + env.createAccount(alice, XRP(10000), asfDefaultRipple); + env.createAccount(bob, XRP(10000), asfDefaultRipple); + env.close(); + + // Set up trust lines + EXPECT_EQ( + env.submit(transactions::TrustSetBuilder{alice}.setLimitAmount(USD.amount(10)), alice) + .ter, + tesSUCCESS); + EXPECT_EQ( + env.submit(transactions::TrustSetBuilder{bob}.setLimitAmount(USD.amount(10)), bob).ter, + tesSUCCESS); + env.close(); + + // Set an acceptable transfer rate first (we'll hack it later) + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{gw}.setTransferRate( + static_cast(2.0 * QUALITY_ONE)), + gw) + .ter, + tesSUCCESS); + env.close(); + + // Directly modify the ledger to set an out-of-bounds transfer rate + // This bypasses the transactor's validation + auto& view = env.getOpenLedger(); + auto slePtr = view.read(keylet::account(gw.id())); + ASSERT_NE(slePtr, nullptr); + auto sleCopy = std::make_shared(*slePtr); + (*sleCopy)[sfTransferRate] = transferRate; + view.rawReplace(sleCopy); + + // Calculate the amount with the legacy transfer rate + auto const amount = USD.amount(1); + auto const amountWithRate = multiply(amount, Rate(transferRate)); + + // Gateway pays alice 10 USD + EXPECT_EQ( + env.submit(transactions::PaymentBuilder{gw, alice, USD.amount(10)}, gw).ter, + tesSUCCESS); + + // Alice pays bob 1 USD + EXPECT_EQ( + env.submit( + transactions::PaymentBuilder{alice, bob, amount}.setSendMax(USD.amount(10)), + alice) + .ter, + tesSUCCESS); + + // Check balances + EXPECT_EQ(env.getBalance(alice.id(), USD), USD.amount(10) - amountWithRate); + EXPECT_EQ(env.getBalance(bob.id(), USD), amount); + } +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/main.cpp b/src/tests/libxrpl/tx/main.cpp new file mode 100644 index 0000000000..5142bbe08a --- /dev/null +++ b/src/tests/libxrpl/tx/main.cpp @@ -0,0 +1,8 @@ +#include + +int +main(int argc, char** argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/src/xrpld/app/main/BasicApp.cpp b/src/xrpld/app/main/BasicApp.cpp index 9de7dc53d3..71138c6517 100644 --- a/src/xrpld/app/main/BasicApp.cpp +++ b/src/xrpld/app/main/BasicApp.cpp @@ -12,10 +12,10 @@ BasicApp::BasicApp(std::size_t numberOfThreads) work_.emplace(boost::asio::make_work_guard(io_context_)); threads_.reserve(numberOfThreads); - while ((numberOfThreads--) != 0u) + for (std::size_t i = 0; i < numberOfThreads; ++i) { - threads_.emplace_back([this, numberOfThreads]() { - beast::setCurrentThreadName("io svc #" + std::to_string(numberOfThreads)); + threads_.emplace_back([this, i]() { + beast::setCurrentThreadName("io svc #" + std::to_string(i)); this->io_context_.run(); }); } diff --git a/src/xrpld/peerfinder/detail/Counts.h b/src/xrpld/peerfinder/detail/Counts.h index 811758b0b3..6d1221d331 100644 --- a/src/xrpld/peerfinder/detail/Counts.h +++ b/src/xrpld/peerfinder/detail/Counts.h @@ -8,6 +8,9 @@ namespace xrpl::PeerFinder { +/** Direction of a slot count adjustment. */ +enum class CountAdjustment : int { Decrement = -1, Increment = 1 }; + /** Manages the count of available connections for the various slots. */ class Counts { @@ -16,14 +19,14 @@ public: void add(Slot const& s) { - adjust(s, 1); + adjust(s, CountAdjustment::Increment); } /** Removes the slot state and properties from the slot counts. */ void remove(Slot const& s) { - adjust(s, -1); + adjust(s, CountAdjustment::Decrement); } /** Returns `true` if the slot can become active. */ @@ -207,21 +210,40 @@ public: //-------------------------------------------------------------------------- private: + /** Increments or decrements a counter based on the adjustment direction. */ + template + static void + adjustCounter(T& counter, CountAdjustment dir) + { + switch (dir) + { + case CountAdjustment::Increment: + ++counter; + break; + case CountAdjustment::Decrement: + --counter; + break; + } + } + // Adjusts counts based on the specified slot, in the direction indicated. + // Using ++/-- instead of += on std::size_t counters avoids UBSan + // unsigned-integer-overflow from implicit conversion of -1 to SIZE_MAX. + // A decrement on a zero counter is a real bug that UBSan should catch. void - adjust(Slot const& s, int const n) + adjust(Slot const& s, CountAdjustment const dir) { if (s.fixed()) - m_fixed += n; + adjustCounter(m_fixed, dir); if (s.reserved()) - m_reserved += n; + adjustCounter(m_reserved, dir); switch (s.state()) { case Slot::accept: XRPL_ASSERT(s.inbound(), "xrpl::PeerFinder::Counts::adjust : input is inbound"); - m_acceptCount += n; + adjustCounter(m_acceptCount, dir); break; case Slot::connect: @@ -230,28 +252,28 @@ private: !s.inbound(), "xrpl::PeerFinder::Counts::adjust : input is not " "inbound"); - m_attempts += n; + adjustCounter(m_attempts, dir); break; case Slot::active: if (s.fixed()) - m_fixed_active += n; + adjustCounter(m_fixed_active, dir); if (!s.fixed() && !s.reserved()) { if (s.inbound()) { - m_in_active += n; + adjustCounter(m_in_active, dir); } else { - m_out_active += n; + adjustCounter(m_out_active, dir); } } - m_active += n; + adjustCounter(m_active, dir); break; case Slot::closing: - m_closingCount += n; + adjustCounter(m_closingCount, dir); break; // LCOV_EXCL_START