diff --git a/.cspell.config.yaml b/.cspell.config.yaml index cbd77dbf06..f437f169fe 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -366,6 +366,7 @@ words: - xchain - ximinez - XMACRO + - xored - xrpkuwait - xrpl - xrpld diff --git a/.envrc b/.envrc index cecf4b4767..ec38b75f5c 100644 --- a/.envrc +++ b/.envrc @@ -1,3 +1,7 @@ watch_file nix/*.nix +# The dev shell derivation includes all of conan/ (see nix/devshell.nix), so any +# change in there has to invalidate direnv's cached environment. +watch_dir conan + use flake diff --git a/API-CHANGELOG.md b/API-CHANGELOG.md index 79fb8ff522..c853cfb07c 100644 --- a/API-CHANGELOG.md +++ b/API-CHANGELOG.md @@ -54,6 +54,8 @@ This section contains changes targeting a future version. - `submit`: The `fail_hard` field now returns an error if the value is not a boolean. [#6529](https://github.com/XRPLF/rippled/pull/6529) - `subscribe`: The `taker` field in the `books` array now returns `actMalformed` instead of `badIssuer` if the value is not a valid account. [#6529](https://github.com/XRPLF/rippled/pull/6529) - Fixed a bug in `Forwarded` HTTP header parsing where the extracted IP address could be incorrect when no comma or semicolon delimiter follows the address. This could cause the server to misidentify a client's IP address when operating behind a reverse proxy. [#6529](https://github.com/XRPLF/rippled/pull/6529) +- `gateway_balances`: The `account` and `ident` fields now return an `invalidParams` error if the value is not a string, instead of an `internal` error. [#7655](https://github.com/XRPLF/rippled/pull/7655) +- `account_lines`: The `peer` field now returns an error if the value is not a string. [#7728](https://github.com/XRPLF/rippled/pull/7728) ## XRP Ledger server version 3.1.0 diff --git a/BUILD.md b/BUILD.md index 238c10e17c..ae2e69bb97 100644 --- a/BUILD.md +++ b/BUILD.md @@ -4,34 +4,14 @@ ## Minimum Requirements -See [System Requirements](https://xrpl.org/system-requirements.html). +For the hardware needed to run a node, see +[System Requirements](https://xrpl.org/system-requirements.html). -Building xrpld generally requires Git, Python, Conan, CMake, and a C++ -compiler. - -- [Python](https://www.python.org/downloads/) -- [Conan](https://conan.io/downloads.html) -- [CMake](https://cmake.org/download/) - -You can verify that the required tools are installed and runnable with: - -```bash -./bin/check-tools.sh -``` - -`xrpld` is written in the C++23 dialect. The [tested compiler versions][cpp23-support] are: - -| Compiler | Version | -| ----------- | --------------- | -| GCC | 15.2 | -| Clang | 22 | -| Apple Clang | 21 | -| MSVC | 19.44[^windows] | +For the software needed to build xrpld, see the +[environment setup guide](./docs/build/environment.md). ## Operating Systems -Please see the [environment setup guide](./docs/build/environment.md) for detailed instructions for all platforms. - ### Linux The Ubuntu Linux distribution has received the highest level of quality @@ -47,9 +27,8 @@ CI testing is done in macOS 26 (Tahoe), but the build defaults `CMAKE_OSX_DEPLOY ### Windows -Windows is used by some engineers for development only. - -[^windows]: Windows is not recommended for production use. +Windows is used by some engineers for development only, and is not recommended +for production use. ## Steps @@ -74,37 +53,25 @@ releases](https://github.com/XRPLF/rippled/releases). ### Set Up Conan -After you have a [C++ development environment](./docs/build/environment.md) ready with Git, Python, -Conan, CMake, and a C++ compiler, you may need to set up your Conan profile. - -These instructions assume a basic familiarity with Conan and CMake. If you are -unfamiliar with Conan, then please read [this crash course](./docs/build/conan.md) or the official -[Getting Started][conan-getting-started] walkthrough. - -#### Profiles - -We recommend that you install our Conan profiles: +Once your [development environment](./docs/build/environment.md) is ready, set +Conan up for this repository: ```bash -conan config install conan/profiles/ -tf $(conan config home)/profiles/ +./conan/init.sh ``` -You can check your Conan profile by running: +That installs our [`global.conf`](./conan/global.conf), our Conan +[profiles](./conan/profiles), and the `xrplf` remote that hosts some of our +dependencies. It honours `CONAN_HOME` and never deletes an existing Conan home, +so it is safe to re-run — it only overwrites the files it manages. -```bash -conan profile show -``` +> [!TIP] +> In the [Nix development shell](./docs/build/nix.md#conan-configuration) this is +> already done for you: the script runs on entry. -If the default profile is not suitable for your environment, you can create a custom profile and pass it to Conan. -More information on customizing Conan can be found in the [Advanced Conan configuration](./docs/build/advanced_conan.md). - -#### Add xrplf remote - -Run the following command to add the `xrplf` remote, which hosts some of our dependencies: - -```bash -conan remote add --index 0 --force xrplf https://conan.xrplf.org/repository/conan/ -``` +You can inspect the resulting profile with `conan profile show`. If it is not +suitable for your environment, create a custom profile and pass it to Conan — see +[Advanced Conan configuration](./docs/build/advanced_conan.md). ### Set Up Ccache @@ -269,10 +236,14 @@ which is only enabled when the `coverage` option is set, e.g. with Prerequisites for the coverage report: - [gcovr tool][gcovr] (can be installed e.g. with [pip][python-pip]) -- `gcov` for GCC (installed with the compiler by default) or -- `llvm-cov` for Clang (installed with the compiler by default) +- `gcov` for GCC or `llvm-cov` for Clang, usually installed with the compiler - `Debug` build type +> [!NOTE] +> Clang coverage is not available in the [Nix development shell](./docs/build/nix.md#building-xrpld-in-the-nix-shell): +> its `clang` shells do not ship `llvm-cov`. Use a `gcc` shell instead (`.#gcc`, +> or `.#gcc-plain` on Linux), which provides a `gcov` matching its compiler. + A coverage report is created when the following steps are completed, in order: 1. `xrpld` binary built with instrumentation data, enabled by the `coverage` @@ -389,10 +360,14 @@ After any updates or changes to dependencies, you may need to do the following: 4. [Regenerate lockfile](./docs/build/advanced_conan.md#conan-lockfile). 5. Re-run [conan install](#build-and-test). +If you are using the Nix development shell, whether prebuilt Conan binaries apply +depends on your platform — see +[Prebuilt packages](./docs/build/nix.md#prebuilt-packages). + #### ERROR: Package not resolved If you're seeing an error like `ERROR: Package 'snappy/1.1.10' not resolved: Unable to find 'snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1756234314.246' in remotes.`, -please [add `xrplf` remote](#add-xrplf-remote) or re-run `conan export` for [patched recipes](./docs/build/advanced_conan.md#patched-recipes). +please [set Conan up](#set-up-conan) so the `xrplf` remote is configured, or re-run `conan export` for [patched recipes](./docs/build/advanced_conan.md#patched-recipes). ### `protobuf/port_def.inc` file not found @@ -412,7 +387,6 @@ For example, if you want to build Debug: 1. For conan install, pass `--settings build_type=Debug` 2. For cmake, pass `-DCMAKE_BUILD_TYPE=Debug` -[cpp23-support]: https://en.cppreference.com/w/cpp/compiler_support/23 [conan-getting-started]: https://docs.conan.io/en/latest/getting_started.html [unity-build]: https://en.wikipedia.org/wiki/Unity_build [gcovr]: https://gcovr.com/en/stable/getting-started.html diff --git a/cmake/XrplCompiler.cmake b/cmake/XrplCompiler.cmake index e262acf1c9..2b46739d97 100644 --- a/cmake/XrplCompiler.cmake +++ b/cmake/XrplCompiler.cmake @@ -266,10 +266,50 @@ elseif(use_lld) ) if("${LD_VERSION}" MATCHES "LLD") target_link_libraries(common INTERFACE -fuse-ld=lld) + # remembered for the linker flag probe below + set(fuse_ld_flag "-fuse-ld=lld") endif() unset(LD_VERSION) endif() +# Linker warnings are errors where we control the toolchain and the dependencies: CI and the Nix dev shell. +# On non-Nix macOS we suppress the deployment target warning: an old Conan profile may not pin os.version. +# Only the new Apple linker understands the flag, so probe the actual linker (lld may be selected above). +if(is_macos OR is_linux) + if(is_ci OR is_nix_compiler) + if(is_macos) + set(fatal_warnings_flag "-Wl,-fatal_warnings") + else() + set(fatal_warnings_flag "-Wl,--fatal-warnings") + endif() + message( + STATUS + "Treating all linker warnings as errors (${fatal_warnings_flag})" + ) + target_link_options(common INTERFACE "${fatal_warnings_flag}") + unset(fatal_warnings_flag) + elseif(is_macos) + set(silence_flag "-Wl,-deployment_target_mismatches,suppress") + set(probe_flags ${fuse_ld_flag} "${silence_flag}") + include(CheckLinkerFlag) + check_linker_flag( + CXX + "${probe_flags}" + have_deployment_target_mismatches + ) + if(have_deployment_target_mismatches) + message( + STATUS + "Silencing macOS deployment target mismatch warnings (${silence_flag})" + ) + target_link_options(common INTERFACE "${silence_flag}") + endif() + unset(probe_flags) + unset(silence_flag) + endif() +endif() +unset(fuse_ld_flag) + if(assert) foreach(var_ CMAKE_C_FLAGS_RELEASE CMAKE_CXX_FLAGS_RELEASE) string(REGEX REPLACE "[-/]DNDEBUG" "" ${var_} "${${var_}}") diff --git a/cmake/XrplCov.cmake b/cmake/XrplCov.cmake index 86ba534a88..05d9ed3806 100644 --- a/cmake/XrplCov.cmake +++ b/cmake/XrplCov.cmake @@ -44,6 +44,7 @@ setup_target_for_coverage_gcovr( EXCLUDE "src/test" "src/tests" + "src/benchmarks" "include/xrpl/beast/test" "include/xrpl/beast/unit_test" "${CMAKE_BINARY_DIR}/pb-xrpl.libpb" diff --git a/conan/init.sh b/conan/init.sh new file mode 100755 index 0000000000..287ee83001 --- /dev/null +++ b/conan/init.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Install our Conan configuration, profiles and the xrplf remote into CONAN_HOME. +# Safe to re-run; never deletes the Conan home. + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +CONAN_DIR="$(conan config home)" + +echo "Installing Conan configuration into ${CONAN_DIR}" +conan config install "${SCRIPT_DIR}/global.conf" +conan config install "${SCRIPT_DIR}/profiles" -tf "${CONAN_DIR}/profiles" +# This script manages these files, so make them read-only - Conan does not +# preserve the source mode. Only the files: the directories must stay writable +# for `conan config install` to replace them. +chmod a-w "${CONAN_DIR}/global.conf" +find "${CONAN_DIR}/profiles" -type f -exec chmod a-w {} + + +echo "Adding the xrplf Conan remote" +# --index 0: our patched recipes must win over Conan Center. +conan remote add --index 0 --force xrplf https://conan.xrplf.org/repository/conan/ diff --git a/conan/profiles/default b/conan/profiles/default index f2d93213ac..1b7eaff980 100644 --- a/conan/profiles/default +++ b/conan/profiles/default @@ -1,10 +1,7 @@ {% set os = detect_api.detect_os() %} {% set arch = detect_api.detect_arch() %} {% set compiler, version, compiler_exe = detect_api.detect_default_compiler() %} -{% set compiler_version = version %} -{% if os == "Linux" %} {% set compiler_version = detect_api.default_compiler_version(compiler, version) %} -{% endif %} {% if os == "Macos" %} {# Minimum macOS the dependencies target. #} {# Without this, Conan builds each dependency against the (possibly newer) host SDK, so the #} diff --git a/docs/NodeStoreRefactoringCaseStudy.pdf b/docs/NodeStoreRefactoringCaseStudy.pdf deleted file mode 100644 index 6cde8a2eed..0000000000 Binary files a/docs/NodeStoreRefactoringCaseStudy.pdf and /dev/null differ diff --git a/docs/build/environment.md b/docs/build/environment.md index e639ed2d5f..5616f32f37 100644 --- a/docs/build/environment.md +++ b/docs/build/environment.md @@ -6,22 +6,52 @@ This document explains how to set one up. ## Tested compiler versions -`xrpld` is built in the **C++23** dialect by default. -Make sure your toolchain is recent enough — the compiler versions currently tested in CI are: +`xrpld` is built in the **C++23** dialect by default, so your toolchain has to +support it — see [compiler support for C++23][cpp23-support]. +The versions currently tested in CI are: -| Compiler | Version | -| ----------- | ------- | -| GCC | 15.2 | -| Clang | 22 | -| Apple Clang | 17 | -| MSVC | 19.44 | +| Compiler | Version | +| ----------- | ------------------ | +| GCC | 15.2 | +| Clang | 22 | +| Apple Clang | 21 | +| MSVC | Visual Studio 2026 | LLVM tools (`clang-tidy` and `clang-format`) are also pinned to version 22. +### Older compilers + Older compilers may fail to build the latest `develop` code: the codebase now relies on C++23 features and has been adjusted for `clang-tidy`. If the latest code doesn't build for you, update your build toolchain first. +If updating isn't an option for you, we do accept pull requests that fix builds +on older compilers, as long as the change is small and doesn't make the code +harder to read. What we can't promise is that older compilers will keep working: +only the versions in the table above are tested in CI, and we won't hold back +the use of C++23 features or add invasive workarounds to keep an untested +compiler building. Treat support for anything outside the table as best-effort. + +## Required tools + +Besides a compiler, building `xrpld` requires: + +| Tool | Minimum version | +| ------------------------------------------- | --------------- | +| [Git](https://git-scm.com/downloads) | any recent | +| [Python](https://www.python.org/downloads/) | 3.11 | +| [Conan](https://conan.io/downloads.html) | 2.17 | +| [CMake](https://cmake.org/download/) | 3.16 | + +On Linux and macOS, the [Nix development shell](./nix.md) provides all of them +(see below). On Windows they have to be installed manually. + +Once they are in place, verify that everything is installed and runnable with: + +```bash +./bin/check-tools.sh +``` + ## Linux and macOS The **recommended way** to get a development environment on Linux and macOS is @@ -39,20 +69,15 @@ Clang. If you instead opt to use your system-wide Apple Clang (via below). See [Using the Nix development shell](./nix.md) for installation and usage -details, including how to select a different compiler. - -> [!NOTE] -> Using Nix is not mandatory. Any custom environment (Homebrew packages or -> anything else) will continue to work, but then it is up to you to keep it in -> sync with the environment used in CI. Nix unifies the development environment -> for everyone and synchronizes updates, which is why we recommend it. +details, including how to select a different compiler and why we recommend Nix +over a hand-maintained environment. ### macOS: managing the Apple Clang version If you use your system-wide Apple Clang on macOS (via `nix develop .#apple-clang`), the compiler version is whatever your installed Xcode (or Command Line Tools) provides. The following command should return a version greater than or equal to -the [minimum required](#tested-compiler-versions): +the [tested one](#tested-compiler-versions): ```bash clang --version @@ -89,23 +114,23 @@ building xrpld. You may want to install and pin a specific version of Xcode: Nix is not available on Windows, so the required tools have to be installed manually: -- [Visual Studio 2022](https://visualstudio.microsoft.com/) with the +- [Visual Studio 2026](https://visualstudio.microsoft.com/) with the **"Desktop development with C++"** workload — this provides MSVC and the - "x64 Native Tools Command Prompt". + "x64 Native Tools Command Prompt". CI configures CMake with the + `Visual Studio 18 2026` generator. - [Git for Windows](https://git-scm.com/download/win) -- [Python 3.11](https://www.python.org/downloads/), or higher -- [Conan 2.17](https://conan.io/downloads.html), or higher -- [CMake 3.22](https://cmake.org/download/), or higher - -> [!NOTE] -> Windows is used for development only and is not recommended for production. +- Python, Conan, and CMake, at the versions listed in + [Required tools](#required-tools). ## Clang-tidy `clang-tidy` is required to run static analysis checks locally (see [CONTRIBUTING.md](../../CONTRIBUTING.md)). It is not required to build the -project. This project currently uses `clang-tidy` version 22. +project. The version this project uses is listed in +[Tested compiler versions](#tested-compiler-versions). -On Linux and macOS, the [Nix development shell](./nix.md) provides `clang-tidy` -22 out of the box — run it via `run-clang-tidy`. No separate installation is -needed. +On Linux and macOS, the [Nix development shell](./nix.md) provides that exact +version out of the box — run it via `run-clang-tidy`. No separate installation +is needed. + +[cpp23-support]: https://en.cppreference.com/w/cpp/compiler_support/23 diff --git a/docs/build/nix.md b/docs/build/nix.md index d0001294e3..d1e40fcc89 100644 --- a/docs/build/nix.md +++ b/docs/build/nix.md @@ -120,7 +120,7 @@ nix develop -c "$SHELL" > > If it doesn't, either adjust your shell configuration so it doesn't override `$PATH`, or use [direnv](#automatic-activation-with-direnv) (below), which loads the environment _after_ your shell config and so takes precedence regardless of the shell you use. -## Building xrpld with Nix +## Building xrpld in the Nix shell Once inside the Nix development shell, follow the standard [build instructions](../../BUILD.md#steps). The Nix shell provides all necessary tools (CMake, Ninja, Conan, etc.). @@ -128,6 +128,28 @@ Coverage builds (`-Dcoverage=ON`) work in the `gcc` shell (and `gcc-plain` on Li each ships a `gcov` matching its compiler, since Nix's cc-wrapper does not expose one. The `clang` shells do not include `llvm-cov`, so use a `gcc` shell for coverage. +## Conan configuration + +The shell runs [`conan/init.sh`](../../conan/init.sh) on entry, so +[Set Up Conan](../../BUILD.md#set-up-conan) is already done for you. It installs +into the shell's own Conan home: `CONAN_HOME=~/.conan2-nix`. + +### Prebuilt packages + +On **Linux**, the binaries on the `xrplf` remote are built in this same Nix +environment — CI runs in Docker images that bundle the dev shell's toolchain (see +[`nix/docker`](../../nix/docker)) — so `.#gcc` and `.#clang` can reuse them. The +`-plain` shells do not match that toolchain's glibc, so binaries from the remote +are not a reliable match there. + +On **macOS**, CI builds with Apple Clang, so the remote holds nothing for the Nix +`clang` toolchain and dependencies are compiled locally. We do not publish +Nix-built macOS binaries because a Conan package ID records the compiler version +but not the nixpkgs revision. + +To compile everything from source, add `--build '*'` to the `conan install` +command. + ## Automatic Activation with direnv [direnv](https://direnv.net/) or [nix-direnv](https://github.com/nix-community/nix-direnv) can automatically activate the Nix development shell when you enter the repository directory. @@ -142,14 +164,6 @@ The repository already ships an `.envrc` at its root that activates the Nix flak > [!NOTE] > direnv only caches the `.direnv` directory (already listed in `.gitignore`); no other repository files are affected. -## Conan and Prebuilt Packages - -Please note that there is no guarantee that binaries from conan cache will work when using nix. If you encounter any errors, please use `--build '*'` to force conan to compile everything from source: - -```bash -conan install .. --output-folder . --build '*' --settings build_type=Release -``` - ## Updating `flake.lock` file To update `flake.lock` to the latest revision use `nix flake update` command. diff --git a/docs/sample_chart.doc b/docs/sample_chart.doc deleted file mode 100644 index 631c0554b2..0000000000 --- a/docs/sample_chart.doc +++ /dev/null @@ -1,24 +0,0 @@ -/*! - \page somestatechart Example state diagram - - \startuml SomeState "my state diagram" - scale 600 width - - [*] -> State1 - State1 --> State2 : Succeeded - State1 --> [*] : Aborted - State2 --> State3 : Succeeded - State2 --> [*] : Aborted - state State3 { - state "Accumulate Enough Data\nLong State Name" as long1 - long1 : Just a test - [*] --> long1 - long1 --> long1 : New Data - long1 --> ProcessData : Enough Data - } - State3 --> State3 : Failed - State3 --> [*] : Succeeded / Save Result - State3 --> [*] : Aborted - - \enduml -*/ diff --git a/include/xrpl/basics/Archive.h b/include/xrpl/basics/Archive.h index 66d6a019af..67261352e9 100644 --- a/include/xrpl/basics/Archive.h +++ b/include/xrpl/basics/Archive.h @@ -1,6 +1,6 @@ #pragma once -#include +#include namespace xrpl { @@ -13,6 +13,6 @@ namespace xrpl { * @throws runtime_error */ void -extractTarLz4(boost::filesystem::path const& src, boost::filesystem::path const& dst); +extractTarLz4(std::filesystem::path const& src, std::filesystem::path const& dst); } // namespace xrpl diff --git a/include/xrpl/basics/Buffer.h b/include/xrpl/basics/Buffer.h index 05af6c409a..00a6b7ecf9 100644 --- a/include/xrpl/basics/Buffer.h +++ b/include/xrpl/basics/Buffer.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -156,6 +157,19 @@ public: } /** @} */ + /** + * Set every byte in the buffer to the given value. + * + * The size is unchanged, and this is a no-op on an empty buffer. + * + * @param value the byte to write to every position. + */ + void + fill(std::uint8_t value) noexcept + { + std::fill_n(p_.get(), size_, value); + } + /** * Reset the buffer. * All memory is deallocated. The resulting size is 0. @@ -226,10 +240,4 @@ operator==(Buffer const& lhs, Buffer const& rhs) noexcept return std::memcmp(lhs.data(), rhs.data(), lhs.size()) == 0; } -inline bool -operator!=(Buffer const& lhs, Buffer const& rhs) noexcept -{ - return !(lhs == rhs); -} - } // namespace xrpl diff --git a/include/xrpl/basics/FileUtilities.h b/include/xrpl/basics/FileUtilities.h index c7a427b8a9..ca3435be03 100644 --- a/include/xrpl/basics/FileUtilities.h +++ b/include/xrpl/basics/FileUtilities.h @@ -1,24 +1,79 @@ #pragma once -#include -#include - #include +#include #include #include +#include namespace xrpl { std::string getFileContents( - boost::system::error_code& ec, - boost::filesystem::path const& sourcePath, + std::error_code& ec, + std::filesystem::path const& sourcePath, std::optional maxSize = std::nullopt); void writeFileContents( - boost::system::error_code& ec, - boost::filesystem::path const& destPath, + std::error_code& ec, + std::filesystem::path const& destPath, std::string const& contents); +/** + * Generate a unique, non-existing path under @p base whose filename starts with + * @p prefix and ends with a random hex suffix. + * + * Attempts up to @p maxAttempts paths. Throws `std::runtime_error` if a unique + * path cannot be found or if the filesystem returns an error while checking for + * existence. + */ +std::filesystem::path +uniqueRandomPath( + std::filesystem::path const& base, + std::string const& prefix = "", + std::size_t maxAttempts = 100); + +/** + * RAII temporary directory. + * + * The directory and all its contents are deleted when + * the instance of `TempDir` is destroyed. + */ +class TempDir +{ + std::filesystem::path path_; + +public: +#if !GENERATING_DOCS + TempDir(TempDir const&) = delete; + TempDir& + operator=(TempDir const&) = delete; +#endif + + /** + * Construct a temporary directory. + */ + TempDir(); + + /** + * Destroy a temporary directory. + */ + ~TempDir(); + + /** + * Get the native path for the temporary directory. + */ + [[nodiscard]] std::string + path() const; + + /** + * Get the native path for a file. + * + * The file does not need to exist. + */ + [[nodiscard]] std::string + file(std::string const& name) const; +}; + } // namespace xrpl diff --git a/include/xrpl/basics/IntrusivePointer.h b/include/xrpl/basics/IntrusivePointer.h index 59853ad4d0..b978016860 100644 --- a/include/xrpl/basics/IntrusivePointer.h +++ b/include/xrpl/basics/IntrusivePointer.h @@ -96,9 +96,6 @@ public: SharedIntrusive& operator=(SharedIntrusive const& rhs); - bool - operator!=(std::nullptr_t) const; - bool operator==(std::nullptr_t) const; diff --git a/include/xrpl/basics/IntrusivePointer.ipp b/include/xrpl/basics/IntrusivePointer.ipp index 67d43b05d6..6c2a71f7eb 100644 --- a/include/xrpl/basics/IntrusivePointer.ipp +++ b/include/xrpl/basics/IntrusivePointer.ipp @@ -111,13 +111,6 @@ SharedIntrusive::operator=(SharedIntrusive&& rhs) return *this; } -template -bool -SharedIntrusive::operator!=(std::nullptr_t) const -{ - return this->get() != nullptr; -} - template bool SharedIntrusive::operator==(std::nullptr_t) const diff --git a/include/xrpl/basics/Log.h b/include/xrpl/basics/Log.h index 945dc1b4ec..3aceac5f4a 100644 --- a/include/xrpl/basics/Log.h +++ b/include/xrpl/basics/Log.h @@ -3,8 +3,8 @@ #include #include -#include +#include #include #include #include @@ -84,7 +84,7 @@ private: * @return `true` if the file was opened. */ bool - open(boost::filesystem::path const& path); + open(std::filesystem::path const& path); /** * Close and re-open the system file associated with the log @@ -133,7 +133,7 @@ private: private: std::unique_ptr stream_; - boost::filesystem::path path_; + std::filesystem::path path_; }; std::mutex mutable mutex_; @@ -152,7 +152,7 @@ public: virtual ~Logs() = default; bool - open(boost::filesystem::path const& pathToLogFile); + open(std::filesystem::path const& pathToLogFile); beast::Journal::Sink& get(std::string const& name); diff --git a/include/xrpl/basics/Number.h b/include/xrpl/basics/Number.h index f90800c715..f6ce0b300d 100644 --- a/include/xrpl/basics/Number.h +++ b/include/xrpl/basics/Number.h @@ -304,7 +304,7 @@ concept Integral64 = std::is_same_v || std::is_same_v inline std::size_t extract(SHAMapHash const& key) diff --git a/include/xrpl/basics/Slice.h b/include/xrpl/basics/Slice.h index 75c9b8c7bd..92b777ab98 100644 --- a/include/xrpl/basics/Slice.h +++ b/include/xrpl/basics/Slice.h @@ -208,12 +208,6 @@ operator==(Slice const& lhs, Slice const& rhs) noexcept return std::memcmp(lhs.data(), rhs.data(), lhs.size()) == 0; } -inline bool -operator!=(Slice const& lhs, Slice const& rhs) noexcept -{ - return !(lhs == rhs); -} - inline bool operator<(Slice const& lhs, Slice const& rhs) noexcept { diff --git a/include/xrpl/basics/StringUtilities.h b/include/xrpl/basics/StringUtilities.h index 2b360d2fda..d606613c65 100644 --- a/include/xrpl/basics/StringUtilities.h +++ b/include/xrpl/basics/StringUtilities.h @@ -125,9 +125,31 @@ struct ParsedUrl bool parseUrl(ParsedUrl& pUrl, std::string const& strUrl); +/** + * Remove leading and trailing ASCII whitespace. + * + * Whitespace is the fixed set " \t\n\v\f\r"; the current locale is not + * consulted, so the result depends only on the input. + * + * @param str The string to trim. + * @return @p str without leading or trailing whitespace. + */ std::string trimWhitespace(std::string str); +/** + * Fold ASCII upper case letters to lower case. + * + * Only 'A' through 'Z' are remapped; every other byte is left alone and the + * current locale is not consulted, so the result depends only on the input. + * + * @param str The string to fold. + * @return @p str with each ASCII upper case letter replaced by its lower case + * equivalent. + */ +std::string +toLower(std::string str); + std::optional toUInt64(std::string const& s); diff --git a/include/xrpl/basics/partitioned_unordered_map.h b/include/xrpl/basics/partitioned_unordered_map.h index e78043e252..c6b0107b93 100644 --- a/include/xrpl/basics/partitioned_unordered_map.h +++ b/include/xrpl/basics/partitioned_unordered_map.h @@ -116,12 +116,6 @@ public: { return lhs.map == rhs.map && lhs.ait == rhs.ait && lhs.mit == rhs.mit; } - - friend bool - operator!=(Iterator const& lhs, Iterator const& rhs) - { - return !(lhs == rhs); - } }; struct ConstIterator @@ -189,12 +183,6 @@ public: { return lhs.map == rhs.map && lhs.ait == rhs.ait && lhs.mit == rhs.mit; } - - friend bool - operator!=(ConstIterator const& lhs, ConstIterator const& rhs) - { - return !(lhs == rhs); - } }; private: diff --git a/include/xrpl/beast/container/detail/aged_ordered_container.h b/include/xrpl/beast/container/detail/aged_ordered_container.h index 5b60ef7e6d..9dd83d466b 100644 --- a/include/xrpl/beast/container/detail/aged_ordered_container.h +++ b/include/xrpl/beast/container/detail/aged_ordered_container.h @@ -1038,25 +1038,6 @@ public: Compare, OtherAllocator> const& other) const; - template < - bool OtherIsMulti, - bool OtherIsMap, - class OtherT, - class OtherDuration, - class OtherAllocator> - bool - operator!=(AgedOrderedContainer< - OtherIsMulti, - OtherIsMap, - Key, - OtherT, - OtherDuration, - Compare, - OtherAllocator> const& other) const - { - return !(this->operator==(other)); - } - template < bool OtherIsMulti, bool OtherIsMap, diff --git a/include/xrpl/beast/container/detail/aged_unordered_container.h b/include/xrpl/beast/container/detail/aged_unordered_container.h index c4287b1ca1..ea271feed0 100644 --- a/include/xrpl/beast/container/detail/aged_unordered_container.h +++ b/include/xrpl/beast/container/detail/aged_unordered_container.h @@ -1340,28 +1340,6 @@ public: OtherAllocator> const& other) const requires MaybeMulti; - template < - bool OtherIsMulti, - bool OtherIsMap, - class OtherKey, - class OtherT, - class OtherDuration, - class OtherHash, - class OtherAllocator> - bool - operator!=(AgedUnorderedContainer< - OtherIsMulti, - OtherIsMap, - OtherKey, - OtherT, - OtherDuration, - OtherHash, - KeyEqual, - OtherAllocator> const& other) const - { - return !(this->operator==(other)); - } - private: bool wouldExceed(size_type additional) const diff --git a/include/xrpl/beast/core/List.h b/include/xrpl/beast/core/List.h index b9b6829d31..076ac3028b 100644 --- a/include/xrpl/beast/core/List.h +++ b/include/xrpl/beast/core/List.h @@ -82,13 +82,6 @@ public: return node_ == other.node_; } - template - bool - operator!=(ListIterator const& other) const noexcept - { - return !((*this) == other); - } - reference operator*() const noexcept { diff --git a/include/xrpl/beast/net/IPEndpoint.h b/include/xrpl/beast/net/IPEndpoint.h index d4d3b2ab12..a5fb5b4318 100644 --- a/include/xrpl/beast/net/IPEndpoint.h +++ b/include/xrpl/beast/net/IPEndpoint.h @@ -110,12 +110,6 @@ public: operator==(Endpoint const& lhs, Endpoint const& rhs); friend bool operator<(Endpoint const& lhs, Endpoint const& rhs); - - friend bool - operator!=(Endpoint const& lhs, Endpoint const& rhs) - { - return !(lhs == rhs); - } friend bool operator>(Endpoint const& lhs, Endpoint const& rhs) { diff --git a/include/xrpl/beast/rfc2616.h b/include/xrpl/beast/rfc2616.h index 1986568553..0e061845fb 100644 --- a/include/xrpl/beast/rfc2616.h +++ b/include/xrpl/beast/rfc2616.h @@ -229,12 +229,6 @@ public: return other.it_ == it_ && other.end_ == end_ && other.value_.size() == value_.size(); } - bool - operator!=(ListIterator const& other) const - { - return !(*this == other); - } - reference operator*() const { diff --git a/include/xrpl/beast/unit_test/reporter.h b/include/xrpl/beast/unit_test/reporter.h index 0fe77a7862..cbd1c7e70d 100644 --- a/include/xrpl/beast/unit_test/reporter.h +++ b/include/xrpl/beast/unit_test/reporter.h @@ -8,7 +8,6 @@ #include #include -#include #include #include @@ -188,7 +187,7 @@ Reporter::fmtdur(clock_type::duration const& d) using namespace std::chrono; auto const ms = duration_cast(d); if (ms < seconds{1}) - return boost::lexical_cast(ms.count()) + "ms"; + return std::to_string(ms.count()) + "ms"; std::stringstream ss; ss << std::fixed << std::setprecision(1) << (ms.count() / 1000.) << "s"; return ss.str(); diff --git a/include/xrpl/beast/unit_test/suite.h b/include/xrpl/beast/unit_test/suite.h index e24904a87b..2b06fb4e05 100644 --- a/include/xrpl/beast/unit_test/suite.h +++ b/include/xrpl/beast/unit_test/suite.h @@ -6,11 +6,10 @@ #include -#include -#include #include #include +#include #include #include #include @@ -27,10 +26,10 @@ makeReason(String const& reason, char const* file, int line) std::string s(reason); if (!s.empty()) s.append(": "); - namespace fs = boost::filesystem; + namespace fs = std::filesystem; s.append(fs::path{file}.filename().string()); s.append("("); - s.append(boost::lexical_cast(line)); + s.append(std::to_string(line)); s.append(")"); return s; } diff --git a/include/xrpl/beast/utility/temp_dir.h b/include/xrpl/beast/utility/temp_dir.h deleted file mode 100644 index a0ff1e6940..0000000000 --- a/include/xrpl/beast/utility/temp_dir.h +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include - -#include - -namespace beast { - -/** - * RAII temporary directory. - * - * The directory and all its contents are deleted when - * the instance of `temp_dir` is destroyed. - */ -class TempDir -{ - boost::filesystem::path path_; - -public: -#if !GENERATING_DOCS - TempDir(TempDir const&) = delete; - TempDir& - operator=(TempDir const&) = delete; -#endif - - /** - * Construct a temporary directory. - */ - TempDir() - { - auto const dir = boost::filesystem::temp_directory_path(); - do - { - path_ = dir / boost::filesystem::unique_path(); - } while (boost::filesystem::exists(path_)); - boost::filesystem::create_directory(path_); - } - - /** - * Destroy a temporary directory. - */ - ~TempDir() - { - // use non-throwing calls in the destructor - boost::system::error_code ec; - boost::filesystem::remove_all(path_, ec); - // TODO: warn/notify if ec set ? - } - - /** - * Get the native path for the temporary directory - */ - [[nodiscard]] std::string - path() const - { - return path_.string(); - } - - /** - * Get the native path for the a file. - * - * The file does not need to exist. - */ - [[nodiscard]] std::string - file(std::string const& name) const - { - return (path_ / name).string(); - } -}; - -} // namespace beast diff --git a/include/xrpl/conditions/Condition.h b/include/xrpl/conditions/Condition.h index 365a41a087..04e571a028 100644 --- a/include/xrpl/conditions/Condition.h +++ b/include/xrpl/conditions/Condition.h @@ -92,10 +92,4 @@ operator==(Condition const& lhs, Condition const& rhs) lhs.fingerprint == rhs.fingerprint; } -inline bool -operator!=(Condition const& lhs, Condition const& rhs) -{ - return !(lhs == rhs); -} - } // namespace xrpl::cryptoconditions diff --git a/include/xrpl/conditions/Fulfillment.h b/include/xrpl/conditions/Fulfillment.h index 11f3165a58..6fd75aa5a3 100644 --- a/include/xrpl/conditions/Fulfillment.h +++ b/include/xrpl/conditions/Fulfillment.h @@ -93,12 +93,6 @@ operator==(Fulfillment const& lhs, Fulfillment const& rhs) lhs.fingerprint() == rhs.fingerprint(); } -inline bool -operator!=(Fulfillment const& lhs, Fulfillment const& rhs) -{ - return !(lhs == rhs); -} - /** * Determine whether the given fulfillment and condition match */ diff --git a/include/xrpl/core/PerfLog.h b/include/xrpl/core/PerfLog.h index f09665e291..dd78a8f9a6 100644 --- a/include/xrpl/core/PerfLog.h +++ b/include/xrpl/core/PerfLog.h @@ -4,10 +4,9 @@ #include #include -#include - #include #include +#include #include #include #include @@ -44,7 +43,7 @@ public: */ struct Setup { - boost::filesystem::path perfLog; + std::filesystem::path perfLog; // log_interval is in milliseconds to support faster testing. milliseconds logInterval{seconds(1)}; }; @@ -149,7 +148,7 @@ public: }; PerfLog::Setup -setupPerfLog(Section const& section, boost::filesystem::path const& configDir); +setupPerfLog(Section const& section, std::filesystem::path const& configDir); std::unique_ptr makePerfLog( diff --git a/include/xrpl/json/json_value.h b/include/xrpl/json/json_value.h index 260917face..57936a774f 100644 --- a/include/xrpl/json/json_value.h +++ b/include/xrpl/json/json_value.h @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -72,36 +73,18 @@ operator==(StaticString x, StaticString y) return strcmp(x.cStr(), y.cStr()) == 0; } -inline bool -operator!=(StaticString x, StaticString y) -{ - return !(x == y); -} - inline bool operator==(std::string const& x, StaticString y) { return strcmp(x.c_str(), y.cStr()) == 0; } -inline bool -operator!=(std::string const& x, StaticString y) -{ - return !(x == y); -} - inline bool operator==(StaticString x, std::string const& y) { return y == x; } -inline bool -operator!=(StaticString x, std::string const& y) -{ - return !(y == x); -} - /** * @brief Represents a JSON value. * @@ -489,12 +472,6 @@ toJson(xrpl::Number const& number) bool operator==(Value const&, Value const&); -inline bool -operator!=(Value const& x, Value const& y) -{ - return !(x == y); -} - bool operator<(Value const&, Value const&); @@ -548,6 +525,7 @@ public: class ValueIteratorBase { public: + using iterator_category = std::bidirectional_iterator_tag; using size_t = unsigned int; using difference_type = int; using SelfType = ValueIteratorBase; @@ -562,12 +540,6 @@ public: return isEqual(other); } - bool - operator!=(SelfType const& other) const - { - return !isEqual(other); - } - /** * Return either the index or the member name of the referenced value as a * Value. diff --git a/include/xrpl/ledger/BookDirs.h b/include/xrpl/ledger/BookDirs.h index dc4361136d..b9aa87ae52 100644 --- a/include/xrpl/ledger/BookDirs.h +++ b/include/xrpl/ledger/BookDirs.h @@ -49,12 +49,6 @@ public: bool operator==(const_iterator const& other) const; - bool - operator!=(const_iterator const& other) const - { - return !(*this == other); - } - reference operator*() const; diff --git a/include/xrpl/ledger/CanonicalTXSet.h b/include/xrpl/ledger/CanonicalTXSet.h index 11aadf4e92..3fe17d6eef 100644 --- a/include/xrpl/ledger/CanonicalTXSet.h +++ b/include/xrpl/ledger/CanonicalTXSet.h @@ -59,12 +59,6 @@ private: return lhs.txId_ == rhs.txId_; } - friend bool - operator!=(Key const& lhs, Key const& rhs) - { - return !(lhs == rhs); - } - [[nodiscard]] uint256 const& getAccount() const { diff --git a/include/xrpl/ledger/Dir.h b/include/xrpl/ledger/Dir.h index 233719cdeb..eb70b3b6a3 100644 --- a/include/xrpl/ledger/Dir.h +++ b/include/xrpl/ledger/Dir.h @@ -59,12 +59,6 @@ public: bool operator==(ConstIterator const& other) const; - bool - operator!=(ConstIterator const& other) const - { - return !(*this == other); - } - reference operator*() const; diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 768e518008..e8b4a932d0 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -35,6 +35,11 @@ enum class SkipEntry : bool { No = false, Yes }; // //------------------------------------------------------------------------------ +/** + * Whether an expiration check should be inclusive or exclusive. + */ +enum class ExpiryComparison { Inclusive, Exclusive }; + /** * Determines whether the given expiration time has passed. * @@ -54,11 +59,16 @@ enum class SkipEntry : bool { No = false, Yes }; * * @param view The ledger whose parent time is used as the clock. * @param exp The optional expiration time we want to check. + * @param comparison Whether the boundary is inclusive (`now >= exp`, the + * default) or exclusive (`now > exp`). * * @return `true` if `exp` is in the past; `false` otherwise. */ [[nodiscard]] bool -hasExpired(ReadView const& view, std::optional const& exp); +hasExpired( + ReadView const& view, + std::optional const& exp, + ExpiryComparison comparison = ExpiryComparison::Inclusive); // Note, depth parameter is used to limit the recursion depth [[nodiscard]] bool diff --git a/include/xrpl/ledger/detail/ReadViewFwdRange.h b/include/xrpl/ledger/detail/ReadViewFwdRange.h index 19ac0698c2..bfa2527bbd 100644 --- a/include/xrpl/ledger/detail/ReadViewFwdRange.h +++ b/include/xrpl/ledger/detail/ReadViewFwdRange.h @@ -85,9 +85,6 @@ public: bool operator==(Iterator const& other) const; - bool - operator!=(Iterator const& other) const; - // Can throw reference operator*() const; diff --git a/include/xrpl/ledger/detail/ReadViewFwdRange.ipp b/include/xrpl/ledger/detail/ReadViewFwdRange.ipp index c7cbc5ee61..2003280ea6 100644 --- a/include/xrpl/ledger/detail/ReadViewFwdRange.ipp +++ b/include/xrpl/ledger/detail/ReadViewFwdRange.ipp @@ -64,13 +64,6 @@ ReadViewFwdRange::Iterator::operator==(Iterator const& other) const return impl_ == other.impl_; } -template -bool -ReadViewFwdRange::Iterator::operator!=(Iterator const& other) const -{ - return !(*this == other); -} - template auto ReadViewFwdRange::Iterator::operator*() const -> reference diff --git a/include/xrpl/ledger/helpers/AMMHelpers.h b/include/xrpl/ledger/helpers/AMMHelpers.h index 7d41bfce81..a68171c426 100644 --- a/include/xrpl/ledger/helpers/AMMHelpers.h +++ b/include/xrpl/ledger/helpers/AMMHelpers.h @@ -226,7 +226,7 @@ getAMMOfferStartWithTakerGets( auto getAmounts = [&pool, &tfee](Number const& nTakerGetsProposed) { // Round downward to minimize the offer and to maximize the quality. - // This has the most impact when takerGets is XRP. + // This has the most impact when takerGets is integral. auto const takerGets = toAmount(getAsset(pool.out), nTakerGetsProposed, Number::RoundingMode::Downward); return TAmounts{swapAssetOut(pool, takerGets, tfee), takerGets}; @@ -294,7 +294,7 @@ getAMMOfferStartWithTakerPays( auto getAmounts = [&pool, &tfee](Number const& nTakerPaysProposed) { // Round downward to minimize the offer and to maximize the quality. - // This has the most impact when takerPays is XRP. + // This has the most impact when takerPays is integral. auto const takerPays = toAmount(getAsset(pool.in), nTakerPaysProposed, Number::RoundingMode::Downward); return TAmounts{takerPays, swapAssetIn(pool, takerPays, tfee)}; @@ -313,11 +313,11 @@ getAMMOfferStartWithTakerPays( * is equal to LOB quality (in this case AMM offer quality is * better than LOB quality) or AMM offer is equal to LOB quality * (in this case SPQ is better than LOB quality). - * Pre-amendment code calculates takerPays first. If takerGets is XRP, - * it is rounded down, which results in worse offer quality than - * LOB quality, and the offer might fail to generate. - * Post-amendment code calculates the XRP offer side first. The result - * is rounded down, which makes the offer quality better. + * Pre-amendment code calculates takerPays first. If takerGets is the + * economically coarser integral side, it is rounded down, which results in + * worse offer quality than LOB quality, and the offer might fail to generate. + * Post-amendment code calculates the economically coarser integral offer side + * first. The result is rounded down, which makes the offer quality better. * It might not be possible to match either SPQ or AMM offer to LOB * quality. This generally happens at higher fees. * @param pool AMM pool balances @@ -396,10 +396,18 @@ changeSpotPriceQuality( return std::nullopt; } - // Generate the offer starting with XRP side. Return seated offer amounts - // if the offer can be generated, otherwise nullopt. auto amounts = [&]() { - if (isXRP(getAsset(pool.out))) + bool const inIntegral = getAsset(pool.in).integral(); + bool const outIntegral = getAsset(pool.out).integral(); + + // Preserve historical behavior for fractional pairs and XRP/IOU-style + // one-integral-side pairs. For two integral assets, pick the side whose + // minimum unit is economically coarser at this quality. + // + // Quality::rate() is input units per output unit, so one output unit is + // coarser when it costs at least one input unit. Ties use takerGets, + // matching the historical XRP-output behavior. + if (outIntegral && (!inIntegral || Number(quality.rate()) >= 1)) return getAMMOfferStartWithTakerGets(pool, quality, tfee); return getAMMOfferStartWithTakerPays(pool, quality, tfee); }(); diff --git a/include/xrpl/ledger/helpers/CredentialHelpers.h b/include/xrpl/ledger/helpers/CredentialHelpers.h index 66be6646da..76c2786cde 100644 --- a/include/xrpl/ledger/helpers/CredentialHelpers.h +++ b/include/xrpl/ledger/helpers/CredentialHelpers.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -58,7 +59,7 @@ deletePseudoAccountCredentials( // Amendment and parameters checks for sfCredentialIDs field NotTEC -checkFields(STTx const& tx, beast::Journal j); +checkFields(STTx const& tx, Rules const& rules, beast::Journal j); // Accessing the ledger to check if provided credentials are valid. Do not use // in doApply (only in preclaim) since it does not remove expired credentials. diff --git a/include/xrpl/ledger/helpers/MPTokenHelpers.h b/include/xrpl/ledger/helpers/MPTokenHelpers.h index 5418e5b26a..7babefd196 100644 --- a/include/xrpl/ledger/helpers/MPTokenHelpers.h +++ b/include/xrpl/ledger/helpers/MPTokenHelpers.h @@ -261,6 +261,14 @@ checkCreateMPT( xrpl::MPTIssue const& mptIssue, xrpl::AccountID const& holder, SLE::ref sponsorSle, + std::uint32_t flags, + beast::Journal j); + +TER +checkCreateMPT( + xrpl::ApplyView& view, + xrpl::MPTIssue const& mptIssue, + xrpl::AccountID const& holder, beast::Journal j); //------------------------------------------------------------------------------ diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index 5681cc57e8..acbf2c3ac0 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -6,10 +6,13 @@ #include #include +#include #include namespace xrpl { +class STTx; + /** * From the perspective of a vault, return the number of shares to give * depositor when they offer a fixed amount of assets. Note, since shares are @@ -123,4 +126,82 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref [[nodiscard]] VaultVersion getVaultVersion(SLE::const_ref vault); +/** + * Resolves the VaultKind of a vault SLE. Returns VaultKind::ClosedEnded when + * sfVaultKind is present and equal to that value; anything else (including an + * absent field or an unrecognised value) is treated as VaultKind::OpenEnded. + * + * @param vault The vault SLE. + */ +[[nodiscard]] VaultKind +getVaultKind(SLE::const_ref vault); + +/** + * Reads sfVaultKind from a transaction. An absent field resolves to + * VaultKind::OpenEnded (matching the on-ledger default); any unrecognised + * value is also treated as VaultKind::OpenEnded, mirroring the SLE overload. + * Callers that need to reject out-of-range values (e.g. preflight) should + * gate on isValidVaultKind() first. + * + * @param tx The transaction. + */ +[[nodiscard]] VaultKind +getVaultKind(STTx const& tx); + +/** + * Returns true iff sfVaultKind is either absent from @p tx or is present and + * equal to a recognised VaultKind enumerator. Intended for use in preflight + * to reject malformed transactions before decoding with getVaultKind(). + * + * @param tx The transaction. + */ +[[nodiscard]] bool +isValidVaultKind(STTx const& tx); + +/** + * Returns true iff the (SubscriptionDate, RedemptionDate) gap of a + * closed-ended vault satisfies + * kMinInvestmentPeriod <= (red - sub) < kMaxInvestmentPeriod. The arithmetic + * is performed in std::int64_t so that @p sub near UINT32_MAX does not + * overflow. Shared by VaultCreate::preflight and the ValidVault invariant. + * + * @param sub The value of sfSubscriptionDate. + * @param red The value of sfRedemptionDate. + */ +[[nodiscard]] bool +isValidClosedEndedGap(std::uint32_t sub, std::uint32_t red); + +/** + * Returns the current lifecycle phase of a vault. Open-ended + * vaults are always NoPhase. For closed-ended vaults the phase is derived + * from the parent ledger close time and the vault's immutable + * SubscriptionDate and RedemptionDate. + * + * @param view The ledger view whose parent close time is used as the clock. + * @param vault The vault SLE. + */ +[[nodiscard]] VaultPhase +getVaultPhase(ReadView const& view, SLE::const_ref vault); + +/** + * Raw-fields overload of getVaultPhase. Derives the phase from an already + * decomposed vault snapshot: an absent or non-ClosedEnded @p vaultKind + * resolves to VaultPhase::NoPhase; otherwise the phase is computed from + * @p subscriptionDate and @p redemptionDate against the view's parent + * close time using the same boundary semantics as the SLE overload + * (Subscription is inclusive of now == SubscriptionDate; Investment starts + * strictly after). + * + * @param view The ledger view whose parent close time is used as the clock. + * @param vaultKind The value of sfVaultKind, or nullopt if absent. + * @param subscriptionDate The value of sfSubscriptionDate, or nullopt if absent. + * @param redemptionDate The value of sfRedemptionDate, or nullopt if absent. + */ +[[nodiscard]] VaultPhase +getVaultPhase( + ReadView const& view, + std::optional vaultKind, + std::optional subscriptionDate, + std::optional redemptionDate); + } // namespace xrpl diff --git a/include/xrpl/protocol/AMMCore.h b/include/xrpl/protocol/AMMCore.h index a3666c7960..1e11f6cd8b 100644 --- a/include/xrpl/protocol/AMMCore.h +++ b/include/xrpl/protocol/AMMCore.h @@ -47,7 +47,7 @@ ammLPTIssue(Asset const& asset1, Asset const& asset2, AccountID const& ammAccoun /** * Validate the amount. - * If validZero is false and amount is beast::zero then invalid amount. + * If validZero is false and amount is beast::kZero then invalid amount. * Return error code if invalid amount. * If pair then validate amount's issue matches one of the pair's issue. */ diff --git a/include/xrpl/protocol/AmountConversions.h b/include/xrpl/protocol/AmountConversions.h index 3bcd80e827..ed68be62fe 100644 --- a/include/xrpl/protocol/AmountConversions.h +++ b/include/xrpl/protocol/AmountConversions.h @@ -154,7 +154,7 @@ T toAmount(Asset const& asset, Number const& n, Number::RoundingMode mode = Number::getround()) { SaveNumberRoundMode const rm(Number::getround()); - if (isXRP(asset)) + if (asset.integral()) Number::setround(mode); if constexpr (std::is_same_v) diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 5c5eeffd16..46f74b905a 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -327,6 +328,36 @@ enum class VaultVersion : uint8_t { CashBasis, }; +/** + * Vault kind. Distinguishes closed-ended vaults from the default open-ended + * kind. Persisted as sfVaultKind (UINT8); absent means OpenEnded. + */ +enum class VaultKind : std::uint8_t { + OpenEnded = 0, + ClosedEnded = 1, +}; + +/** + * Lifecycle phase of a vault. Open-ended vaults are always NoPhase; the other + * three values are the phases of a closed-ended vault. + */ +enum class VaultPhase : std::uint8_t { + NoPhase = 0, + Subscription, + Investment, + Redemption, +}; + +/** + * Bounds on the length of a closed-ended vault's Investment phase + * (RedemptionDate - SubscriptionDate). At vault creation the gap must satisfy + * kMinInvestmentPeriod <= gap < kMaxInvestmentPeriod. + */ +constexpr std::uint32_t kMinInvestmentPeriod = + std::chrono::seconds{std::chrono::minutes{1}}.count(); +// This is 946708560 seconds which 30 x 365.2425 days (the average length of a Gregorian year). +constexpr std::uint32_t kMaxInvestmentPeriod = std::chrono::seconds{std::chrono::years{30}}.count(); + /** * Maximum recursion depth for vault shares being put as an asset inside * another vault; counted from 0 diff --git a/include/xrpl/protocol/Quality.h b/include/xrpl/protocol/Quality.h index 3475efa977..d0d0f10cd2 100644 --- a/include/xrpl/protocol/Quality.h +++ b/include/xrpl/protocol/Quality.h @@ -75,13 +75,6 @@ operator==(TAmounts const& lhs, TAmounts const& rhs) noexcept return lhs.in == rhs.in && lhs.out == rhs.out; } -template -bool -operator!=(TAmounts const& lhs, TAmounts const& rhs) noexcept -{ - return !(lhs == rhs); -} - //------------------------------------------------------------------------------ // XRPL specific constant used for parsing qualities and other things @@ -271,12 +264,6 @@ public: return lhs.value_ == rhs.value_; } - friend bool - operator!=(Quality const& lhs, Quality const& rhs) noexcept - { - return !(lhs == rhs); - } - friend std::ostream& operator<<(std::ostream& os, Quality const& quality) { diff --git a/include/xrpl/protocol/QualityFunction.h b/include/xrpl/protocol/QualityFunction.h index 128b37ce12..4fcc730c42 100644 --- a/include/xrpl/protocol/QualityFunction.h +++ b/include/xrpl/protocol/QualityFunction.h @@ -60,6 +60,15 @@ public: std::optional outFromAvgQ(Quality const& quality); + /** + * Return whether `out` produces at least the requested + * average quality. + * @param quality requested average quality (quality limit) + * @param out output amount to test + */ + [[nodiscard]] bool + satisfiesAvgQ(Quality const& quality, Number const& out) const; + /** * Return true if the quality function is constant */ diff --git a/include/xrpl/protocol/Rules.h b/include/xrpl/protocol/Rules.h index 2c2136b6e8..d67e0d8654 100644 --- a/include/xrpl/protocol/Rules.h +++ b/include/xrpl/protocol/Rules.h @@ -98,9 +98,6 @@ public: */ bool operator==(Rules const&) const; - - bool - operator!=(Rules const& other) const; }; std::optional const& diff --git a/include/xrpl/protocol/STAmount.h b/include/xrpl/protocol/STAmount.h index cc80481582..4b2f1cc9fb 100644 --- a/include/xrpl/protocol/STAmount.h +++ b/include/xrpl/protocol/STAmount.h @@ -642,12 +642,6 @@ operator==(STAmount const& lhs, STAmount const& rhs); bool operator<(STAmount const& lhs, STAmount const& rhs); -inline bool -operator!=(STAmount const& lhs, STAmount const& rhs) -{ - return !(lhs == rhs); -} - inline bool operator>(STAmount const& lhs, STAmount const& rhs) { diff --git a/include/xrpl/protocol/STArray.h b/include/xrpl/protocol/STArray.h index 573bb6dad8..e88563fb1a 100644 --- a/include/xrpl/protocol/STArray.h +++ b/include/xrpl/protocol/STArray.h @@ -133,9 +133,6 @@ public: bool operator==(STArray const& s) const; - bool - operator!=(STArray const& s) const; - iterator erase(iterator pos); @@ -283,12 +280,6 @@ STArray::operator==(STArray const& s) const return v_ == s.v_; } -inline bool -STArray::operator!=(STArray const& s) const -{ - return v_ != s.v_; -} - inline STArray::iterator STArray::erase(iterator pos) { diff --git a/include/xrpl/protocol/STBase.h b/include/xrpl/protocol/STBase.h index acc5500a57..a8bda8f614 100644 --- a/include/xrpl/protocol/STBase.h +++ b/include/xrpl/protocol/STBase.h @@ -140,8 +140,6 @@ public: bool operator==(STBase const& t) const; - bool - operator!=(STBase const& t) const; template D& diff --git a/include/xrpl/protocol/STCurrency.h b/include/xrpl/protocol/STCurrency.h index 18642b20cf..933abaedb8 100644 --- a/include/xrpl/protocol/STCurrency.h +++ b/include/xrpl/protocol/STCurrency.h @@ -93,12 +93,6 @@ operator==(STCurrency const& lhs, STCurrency const& rhs) return lhs.currency() == rhs.currency(); } -inline bool -operator!=(STCurrency const& lhs, STCurrency const& rhs) -{ - return !operator==(lhs, rhs); -} - inline bool operator<(STCurrency const& lhs, STCurrency const& rhs) { diff --git a/include/xrpl/protocol/STObject.h b/include/xrpl/protocol/STObject.h index c7fc4fa796..dcbd08170e 100644 --- a/include/xrpl/protocol/STObject.h +++ b/include/xrpl/protocol/STObject.h @@ -432,8 +432,6 @@ public: bool operator==(STObject const& o) const; - bool - operator!=(STObject const& o) const; class FieldErr; @@ -667,36 +665,6 @@ public: return !lhs.engaged() || *lhs == *rhs; } - friend bool - operator!=(OptionalProxy const& lhs, std::nullopt_t) noexcept - { - return !(lhs == std::nullopt); - } - - friend bool - operator!=(std::nullopt_t, OptionalProxy const& rhs) noexcept - { - return !(rhs == std::nullopt); - } - - friend bool - operator!=(OptionalProxy const& lhs, optional_type const& rhs) noexcept - { - return !(lhs == rhs); - } - - friend bool - operator!=(optional_type const& lhs, OptionalProxy const& rhs) noexcept - { - return !(lhs == rhs); - } - - friend bool - operator!=(OptionalProxy const& lhs, OptionalProxy const& rhs) noexcept - { - return !(lhs == rhs); - } - // Emulate std::optional::value_or [[nodiscard]] value_type valueOr(value_type val) const; @@ -1202,12 +1170,6 @@ STObject::setFieldH160(SField const& field, BaseUInt<160, Tag> const& v) } } -inline bool -STObject::operator!=(STObject const& o) const -{ - return !(*this == o); -} - template V STObject::getFieldByValue(SField const& field) const diff --git a/include/xrpl/protocol/STPathSet.h b/include/xrpl/protocol/STPathSet.h index d527e2479f..5768721111 100644 --- a/include/xrpl/protocol/STPathSet.h +++ b/include/xrpl/protocol/STPathSet.h @@ -115,9 +115,6 @@ public: bool operator==(STPathElement const& t) const; - bool - operator!=(STPathElement const& t) const; - private: static std::size_t getHash(STPathElement const& element); @@ -432,12 +429,6 @@ STPathElement::operator==(STPathElement const& t) const accountID_ == t.accountID_ && assetID_ == t.assetID_ && issuerID_ == t.issuerID_; } -inline bool -STPathElement::operator!=(STPathElement const& t) const -{ - return !operator==(t); -} - // ------------ STPath ------------ inline STPath::STPath(std::vector p) : path_(std::move(p)) diff --git a/include/xrpl/protocol/SeqProxy.h b/include/xrpl/protocol/SeqProxy.h index fa72914591..3686d123d6 100644 --- a/include/xrpl/protocol/SeqProxy.h +++ b/include/xrpl/protocol/SeqProxy.h @@ -123,12 +123,6 @@ public: return (lhs.value() == rhs.value()); } - friend constexpr bool - operator!=(SeqProxy lhs, SeqProxy rhs) - { - return !(lhs == rhs); - } - friend constexpr bool operator<(SeqProxy lhs, SeqProxy rhs) { diff --git a/include/xrpl/protocol/Serializer.h b/include/xrpl/protocol/Serializer.h index 73bd9c8289..c1ea5c16ba 100644 --- a/include/xrpl/protocol/Serializer.h +++ b/include/xrpl/protocol/Serializer.h @@ -265,20 +265,10 @@ public: return v == data_; } bool - operator!=(Blob const& v) const - { - return v != data_; - } - bool operator==(Serializer const& v) const { return v.data_ == data_; } - bool - operator!=(Serializer const& v) const - { - return v.data_ != data_; - } static int decodeLengthLength(int b1); diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index 14bc0571e9..40edf2239b 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -425,8 +425,7 @@ inline constexpr FlagValue tfDepositSubTx = ASF_FLAG(asfDefaultRipple, 8) \ ASF_FLAG(asfDepositAuth, 9) \ ASF_FLAG(asfAuthorizedNFTokenMinter, 10) \ - /* 11 is reserved for Hooks amendment */ \ - /* ASF_FLAG(asfTshCollect, 11) */ \ + /* 11 is unused */ \ ASF_FLAG(asfDisallowIncomingNFTokenOffer, 12) \ ASF_FLAG(asfDisallowIncomingCheck, 13) \ ASF_FLAG(asfDisallowIncomingPayChan, 14) \ diff --git a/include/xrpl/protocol/Units.h b/include/xrpl/protocol/Units.h index 169ee2c543..94afd72f53 100644 --- a/include/xrpl/protocol/Units.h +++ b/include/xrpl/protocol/Units.h @@ -258,13 +258,6 @@ public: return value_ == other; } - template Other> - constexpr bool - operator!=(ValueUnit const& other) const - { - return !operator==(other); - } - constexpr bool operator<(ValueUnit const& other) const { diff --git a/include/xrpl/protocol/detail/STVar.h b/include/xrpl/protocol/detail/STVar.h index 12026f3d09..56f868b665 100644 --- a/include/xrpl/protocol/detail/STVar.h +++ b/include/xrpl/protocol/detail/STVar.h @@ -152,10 +152,4 @@ operator==(STVar const& lhs, STVar const& rhs) return lhs.get().isEquivalent(rhs.get()); } -inline bool -operator!=(STVar const& lhs, STVar const& rhs) -{ - return !(lhs == rhs); -} - } // namespace xrpl::detail diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index ffcd025f01..f166473d7f 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -506,6 +506,9 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ {sfWithdrawalPolicy, SoeRequired}, {sfScale, SoeDefault}, {sfLEVersion, SoeDefault}, + {sfVaultKind, SoeDefault}, + {sfSubscriptionDate, SoeOptional}, + {sfRedemptionDate, SoeOptional}, // no SharesTotal ever (use MPTIssuance.sfOutstandingAmount) // no PermissionedDomainID ever (use MPTIssuance.sfDomainID) })) diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index cff075e738..ec05804253 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -23,9 +23,11 @@ TYPED_SFIELD(sfLEVersion, UINT8, 6) // 8-bit integers (uncommon) TYPED_SFIELD(sfTickSize, UINT8, 16) TYPED_SFIELD(sfUNLModifyDisabling, UINT8, 17) -TYPED_SFIELD(sfHookResult, UINT8, 18) +// 18 unused TYPED_SFIELD(sfWasLockingChainSend, UINT8, 19) TYPED_SFIELD(sfWithdrawalPolicy, UINT8, 20) +TYPED_SFIELD(sfContractResult, UINT8, 21) +TYPED_SFIELD(sfVaultKind, UINT8, 22) // 16-bit integers (common) TYPED_SFIELD(sfLedgerEntryType, UINT16, 1, SField::kSmdNever) @@ -37,10 +39,7 @@ TYPED_SFIELD(sfDiscountedFee, UINT16, 6) // 16-bit integers (uncommon) TYPED_SFIELD(sfVersion, UINT16, 16) -TYPED_SFIELD(sfHookStateChangeCount, UINT16, 17) -TYPED_SFIELD(sfHookEmitCount, UINT16, 18) -TYPED_SFIELD(sfHookExecutionIndex, UINT16, 19) -TYPED_SFIELD(sfHookApiVersion, UINT16, 20) +// 17 to 20 unused TYPED_SFIELD(sfLedgerFixType, UINT16, 21) TYPED_SFIELD(sfManagementFeeRate, UINT16, 22) // 1/10 basis points (bips) @@ -91,9 +90,7 @@ TYPED_SFIELD(sfTicketSequence, UINT32, 41) TYPED_SFIELD(sfNFTokenTaxon, UINT32, 42) TYPED_SFIELD(sfMintedNFTokens, UINT32, 43) TYPED_SFIELD(sfBurnedNFTokens, UINT32, 44) -TYPED_SFIELD(sfHookStateCount, UINT32, 45) -TYPED_SFIELD(sfEmitGeneration, UINT32, 46) -// 47 reserved for Hooks +// 45 to 47 unused TYPED_SFIELD(sfVoteWeight, UINT32, 48) TYPED_SFIELD(sfFirstNFTokenSequence, UINT32, 50) TYPED_SFIELD(sfOracleDocumentID, UINT32, 51) @@ -120,6 +117,8 @@ TYPED_SFIELD(sfSponsoringOwnerCount, UINT32, 71) TYPED_SFIELD(sfSponsoringAccountCount, UINT32, 72) TYPED_SFIELD(sfRemainingOwnerCount, UINT32, 73) TYPED_SFIELD(sfSponsorFlags, UINT32, 74) +TYPED_SFIELD(sfSubscriptionDate, UINT32, 75) +TYPED_SFIELD(sfRedemptionDate, UINT32, 76) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) @@ -137,9 +136,7 @@ TYPED_SFIELD(sfNFTokenOfferNode, UINT64, 12) TYPED_SFIELD(sfEmitBurden, UINT64, 13) // 64-bit integers (uncommon) -TYPED_SFIELD(sfHookOn, UINT64, 16) -TYPED_SFIELD(sfHookInstructionCount, UINT64, 17) -TYPED_SFIELD(sfHookReturnCode, UINT64, 18) +// 16 to 18 unused TYPED_SFIELD(sfReferenceCount, UINT64, 19) TYPED_SFIELD(sfXChainClaimID, UINT64, 20) TYPED_SFIELD(sfXChainAccountCreateCount, UINT64, 21) @@ -203,10 +200,7 @@ TYPED_SFIELD(sfPreviousPageMin, UINT256, 26) TYPED_SFIELD(sfNextPageMin, UINT256, 27) TYPED_SFIELD(sfNFTokenBuyOffer, UINT256, 28) TYPED_SFIELD(sfNFTokenSellOffer, UINT256, 29) -TYPED_SFIELD(sfHookStateKey, UINT256, 30) -TYPED_SFIELD(sfHookHash, UINT256, 31) -TYPED_SFIELD(sfHookNamespace, UINT256, 32) -TYPED_SFIELD(sfHookSetTxnID, UINT256, 33) +// 30 to 33 unused TYPED_SFIELD(sfDomainID, UINT256, 34) TYPED_SFIELD(sfVaultID, UINT256, 35, SField::kSmdPseudoAccount | SField::kSmdDefault) @@ -237,7 +231,7 @@ TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15, SField::kSmdNeedsAsset TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16) TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17, SField::kSmdNeedsAsset | SField::kSmdDefault) -// int32 +// 32-bit signed (common) TYPED_SFIELD(sfLoanScale, INT32, 1) TYPED_SFIELD(sfRemainingOwnerCountDelta, INT32, 2) @@ -261,15 +255,13 @@ TYPED_SFIELD(sfMinimumOffer, AMOUNT, 16) TYPED_SFIELD(sfRippleEscrow, AMOUNT, 17) TYPED_SFIELD(sfDeliveredAmount, AMOUNT, 18) TYPED_SFIELD(sfNFTokenBrokerFee, AMOUNT, 19) - -// Reserve 20 & 21 for Hooks. - +// 20 to 21 unused // currency amount (fees) TYPED_SFIELD(sfBaseFeeDrops, AMOUNT, 22) TYPED_SFIELD(sfReserveBaseDrops, AMOUNT, 23) TYPED_SFIELD(sfReserveIncrementDrops, AMOUNT, 24) -// currency amount (AMM) +// currency amount (more) TYPED_SFIELD(sfLPTokenOut, AMOUNT, 25) TYPED_SFIELD(sfLPTokenIn, AMOUNT, 26) TYPED_SFIELD(sfEPrice, AMOUNT, 27) @@ -304,10 +296,7 @@ TYPED_SFIELD(sfMasterSignature, VL, 18, SField::kSmdDefault, SFi TYPED_SFIELD(sfUNLModifyValidator, VL, 19) TYPED_SFIELD(sfValidatorToDisable, VL, 20) TYPED_SFIELD(sfValidatorToReEnable, VL, 21) -TYPED_SFIELD(sfHookStateData, VL, 22) -TYPED_SFIELD(sfHookReturnString, VL, 23) -TYPED_SFIELD(sfHookParameterName, VL, 24) -TYPED_SFIELD(sfHookParameterValue, VL, 25) +// 22 to 25 unused TYPED_SFIELD(sfDIDDocument, VL, 26) TYPED_SFIELD(sfData, VL, 27) TYPED_SFIELD(sfAssetClass, VL, 28) @@ -345,7 +334,7 @@ TYPED_SFIELD(sfHolder, ACCOUNT, 11) TYPED_SFIELD(sfDelegate, ACCOUNT, 12) // account (uncommon) -TYPED_SFIELD(sfHookAccount, ACCOUNT, 16) +// 16 unused TYPED_SFIELD(sfOtherChainSource, ACCOUNT, 18) TYPED_SFIELD(sfOtherChainDestination, ACCOUNT, 19) TYPED_SFIELD(sfAttestationSignerAccount, ACCOUNT, 20) @@ -398,7 +387,7 @@ UNTYPED_SFIELD(sfMemo, OBJECT, 10) UNTYPED_SFIELD(sfSignerEntry, OBJECT, 11) UNTYPED_SFIELD(sfNFToken, OBJECT, 12) UNTYPED_SFIELD(sfEmitDetails, OBJECT, 13) -UNTYPED_SFIELD(sfHook, OBJECT, 14) +// 14 unused UNTYPED_SFIELD(sfPermission, OBJECT, 15) // inner object (uncommon) @@ -406,11 +395,7 @@ UNTYPED_SFIELD(sfSigner, OBJECT, 16) // 17 unused UNTYPED_SFIELD(sfMajority, OBJECT, 18) UNTYPED_SFIELD(sfDisabledValidator, OBJECT, 19) -UNTYPED_SFIELD(sfEmittedTxn, OBJECT, 20) -UNTYPED_SFIELD(sfHookExecution, OBJECT, 21) -UNTYPED_SFIELD(sfHookDefinition, OBJECT, 22) -UNTYPED_SFIELD(sfHookParameter, OBJECT, 23) -UNTYPED_SFIELD(sfHookGrant, OBJECT, 24) +// 20 to 24 unused UNTYPED_SFIELD(sfVoteEntry, OBJECT, 25) UNTYPED_SFIELD(sfAuctionSlot, OBJECT, 26) UNTYPED_SFIELD(sfAuthAccount, OBJECT, 27) @@ -438,16 +423,14 @@ UNTYPED_SFIELD(sfSufficient, ARRAY, 7) UNTYPED_SFIELD(sfAffectedNodes, ARRAY, 8) UNTYPED_SFIELD(sfMemos, ARRAY, 9) UNTYPED_SFIELD(sfNFTokens, ARRAY, 10) -UNTYPED_SFIELD(sfHooks, ARRAY, 11) +// 11 unused UNTYPED_SFIELD(sfVoteSlots, ARRAY, 12) UNTYPED_SFIELD(sfAdditionalBooks, ARRAY, 13) // array of objects (uncommon) UNTYPED_SFIELD(sfMajorities, ARRAY, 16) UNTYPED_SFIELD(sfDisabledValidators, ARRAY, 17) -UNTYPED_SFIELD(sfHookExecutions, ARRAY, 18) -UNTYPED_SFIELD(sfHookParameters, ARRAY, 19) -UNTYPED_SFIELD(sfHookGrants, ARRAY, 20) +// 18 to 20 unused UNTYPED_SFIELD(sfXChainClaimAttestations, ARRAY, 21) UNTYPED_SFIELD(sfXChainCreateAccountAttestations, ARRAY, 22) // 23 unused diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 1f9603dbae..f8676d3b63 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -862,6 +862,9 @@ TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, {sfWithdrawalPolicy, SoeOptional}, {sfData, SoeOptional}, {sfScale, SoeOptional}, + {sfVaultKind, SoeOptional}, + {sfSubscriptionDate, SoeOptional}, + {sfRedemptionDate, SoeOptional}, })) /** This transaction updates a single asset vault. */ diff --git a/include/xrpl/protocol_autogen/ledger_entries/Vault.h b/include/xrpl/protocol_autogen/ledger_entries/Vault.h index a6ab54cb0a..389ffb4c46 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Vault.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Vault.h @@ -311,6 +311,78 @@ public: { return this->sle_->isFieldPresent(sfLEVersion); } + + /** + * @brief Get sfVaultKind (SoeDefault) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getVaultKind() const + { + if (hasVaultKind()) + return this->sle_->at(sfVaultKind); + return std::nullopt; + } + + /** + * @brief Check if sfVaultKind is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasVaultKind() const + { + return this->sle_->isFieldPresent(sfVaultKind); + } + + /** + * @brief Get sfSubscriptionDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getSubscriptionDate() const + { + if (hasSubscriptionDate()) + return this->sle_->at(sfSubscriptionDate); + return std::nullopt; + } + + /** + * @brief Check if sfSubscriptionDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasSubscriptionDate() const + { + return this->sle_->isFieldPresent(sfSubscriptionDate); + } + + /** + * @brief Get sfRedemptionDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getRedemptionDate() const + { + if (hasRedemptionDate()) + return this->sle_->at(sfRedemptionDate); + return std::nullopt; + } + + /** + * @brief Check if sfRedemptionDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasRedemptionDate() const + { + return this->sle_->isFieldPresent(sfRedemptionDate); + } }; /** @@ -543,6 +615,39 @@ public: return *this; } + /** + * @brief Set sfVaultKind (SoeDefault) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setVaultKind(std::decay_t const& value) + { + object_[sfVaultKind] = value; + return *this; + } + + /** + * @brief Set sfSubscriptionDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setSubscriptionDate(std::decay_t const& value) + { + object_[sfSubscriptionDate] = value; + return *this; + } + + /** + * @brief Set sfRedemptionDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setRedemptionDate(std::decay_t const& value) + { + object_[sfRedemptionDate] = value; + return *this; + } + /** * @brief Build and return the completed Vault wrapper. * @param index The ledger entry index. diff --git a/include/xrpl/protocol_autogen/transactions/VaultCreate.h b/include/xrpl/protocol_autogen/transactions/VaultCreate.h index b7e1527754..e206925e02 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultCreate.h +++ b/include/xrpl/protocol_autogen/transactions/VaultCreate.h @@ -214,6 +214,84 @@ public: { return this->tx_->isFieldPresent(sfScale); } + + /** + * @brief Get sfVaultKind (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getVaultKind() const + { + if (hasVaultKind()) + { + return this->tx_->at(sfVaultKind); + } + return std::nullopt; + } + + /** + * @brief Check if sfVaultKind is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasVaultKind() const + { + return this->tx_->isFieldPresent(sfVaultKind); + } + + /** + * @brief Get sfSubscriptionDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getSubscriptionDate() const + { + if (hasSubscriptionDate()) + { + return this->tx_->at(sfSubscriptionDate); + } + return std::nullopt; + } + + /** + * @brief Check if sfSubscriptionDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasSubscriptionDate() const + { + return this->tx_->isFieldPresent(sfSubscriptionDate); + } + + /** + * @brief Get sfRedemptionDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getRedemptionDate() const + { + if (hasRedemptionDate()) + { + return this->tx_->at(sfRedemptionDate); + } + return std::nullopt; + } + + /** + * @brief Check if sfRedemptionDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasRedemptionDate() const + { + return this->tx_->isFieldPresent(sfRedemptionDate); + } }; /** @@ -338,6 +416,39 @@ public: return *this; } + /** + * @brief Set sfVaultKind (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultCreateBuilder& + setVaultKind(std::decay_t const& value) + { + object_[sfVaultKind] = value; + return *this; + } + + /** + * @brief Set sfSubscriptionDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultCreateBuilder& + setSubscriptionDate(std::decay_t const& value) + { + object_[sfSubscriptionDate] = value; + return *this; + } + + /** + * @brief Set sfRedemptionDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultCreateBuilder& + setRedemptionDate(std::decay_t const& value) + { + object_[sfRedemptionDate] = value; + return *this; + } + /** * @brief Build and return the VaultCreate wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/rdb/DatabaseCon.h b/include/xrpl/rdb/DatabaseCon.h index 90aed04337..5c20f65784 100644 --- a/include/xrpl/rdb/DatabaseCon.h +++ b/include/xrpl/rdb/DatabaseCon.h @@ -6,13 +6,12 @@ #include #include -#include - #include #include #include #include +#include #include #include #include @@ -80,7 +79,7 @@ public: StartUpType startUp = StartUpType::Normal; bool standAlone = false; - boost::filesystem::path dataDir; + std::filesystem::path dataDir; // Indicates whether or not to return the `globalPragma` // from commonPragma() bool useGlobalPragma = false; @@ -143,7 +142,7 @@ public: template DatabaseCon( - boost::filesystem::path const& dataDir, + std::filesystem::path const& dataDir, std::string const& dbName, std::array const& pragma, std::array const& initSQL, @@ -155,7 +154,7 @@ public: // Use this constructor to setup checkpointing template DatabaseCon( - boost::filesystem::path const& dataDir, + std::filesystem::path const& dataDir, std::string const& dbName, std::array const& pragma, std::array const& initSQL, @@ -190,7 +189,7 @@ private: template DatabaseCon( - boost::filesystem::path const& pPath, + std::filesystem::path const& pPath, std::vector const* commonPragma, std::array const& pragma, std::array const& initSQL, diff --git a/include/xrpl/rdb/RelationalDatabase.h b/include/xrpl/rdb/RelationalDatabase.h index e5784c7418..e858f578f8 100644 --- a/include/xrpl/rdb/RelationalDatabase.h +++ b/include/xrpl/rdb/RelationalDatabase.h @@ -14,7 +14,6 @@ #include #include -#include #include #include diff --git a/include/xrpl/server/Manifest.h b/include/xrpl/server/Manifest.h index 786967b057..1b726f2c0c 100644 --- a/include/xrpl/server/Manifest.h +++ b/include/xrpl/server/Manifest.h @@ -306,12 +306,6 @@ operator==(Manifest const& lhs, Manifest const& rhs) lhs.serialized == rhs.serialized; } -inline bool -operator!=(Manifest const& lhs, Manifest const& rhs) -{ - return !(lhs == rhs); -} - struct ValidatorToken { std::string manifest; diff --git a/include/xrpl/server/State.h b/include/xrpl/server/State.h index 8590f6e18f..b79253c12c 100644 --- a/include/xrpl/server/State.h +++ b/include/xrpl/server/State.h @@ -4,8 +4,6 @@ #include #include -#include - #include namespace xrpl { diff --git a/include/xrpl/shamap/SHAMap.h b/include/xrpl/shamap/SHAMap.h index e198c472fa..97ab2e9f7a 100644 --- a/include/xrpl/shamap/SHAMap.h +++ b/include/xrpl/shamap/SHAMap.h @@ -789,12 +789,6 @@ operator==(SHAMap::ConstIterator const& x, SHAMap::ConstIterator const& y) return x.item_ == y.item_; } -inline bool -operator!=(SHAMap::ConstIterator const& x, SHAMap::ConstIterator const& y) -{ - return !(x == y); -} - inline SHAMap::ConstIterator SHAMap::begin() const { diff --git a/include/xrpl/shamap/SHAMapNodeID.h b/include/xrpl/shamap/SHAMapNodeID.h index 6094892091..1189304aa7 100644 --- a/include/xrpl/shamap/SHAMapNodeID.h +++ b/include/xrpl/shamap/SHAMapNodeID.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -65,45 +66,32 @@ public: static SHAMapNodeID createID(int depth, uint256 const& key); - // FIXME-C++20: use spaceship and operator synthesis /** * Comparison operators + * + * <, >, <= and >= are synthesized from the spaceship. It is written out + * rather than defaulted because the ordering is by depth first, and the + * members are not declared in that order. */ - bool - operator<(SHAMapNodeID const& n) const + std::strong_ordering + operator<=>(SHAMapNodeID const& n) const { - return std::tie(depth_, id_) < std::tie(n.depth_, n.id_); - } - - bool - operator>(SHAMapNodeID const& n) const - { - return n < *this; - } - - bool - operator<=(SHAMapNodeID const& n) const - { - return !(n < *this); - } - - bool - operator>=(SHAMapNodeID const& n) const - { - return !(*this < n); + return std::tie(depth_, id_) <=> std::tie(n.depth_, n.id_); } + /** + * Equality, which the spaceship above does not provide. + * + * Only a *defaulted* operator<=> implicitly declares a defaulted + * operator==; the one above is user-provided, so == has to be written. + * It cannot be defaulted either, because a defaulted == would also compare + * the CountedObject base, which is not equality comparable. + */ bool operator==(SHAMapNodeID const& n) const { return (depth_ == n.depth_) && (id_ == n.id_); } - - bool - operator!=(SHAMapNodeID const& n) const - { - return !(*this == n); - } }; inline std::string diff --git a/include/xrpl/tx/invariants/FreezeInvariant.h b/include/xrpl/tx/invariants/FreezeInvariant.h index 4b3e9beec4..c66e002872 100644 --- a/include/xrpl/tx/invariants/FreezeInvariant.h +++ b/include/xrpl/tx/invariants/FreezeInvariant.h @@ -69,7 +69,8 @@ private: IssuerChanges const& changes, STTx const& tx, beast::Journal const& j, - bool enforce); + bool enforce, + bool fixOverrideFreeze); static bool validateFrozenState( @@ -78,7 +79,8 @@ private: STTx const& tx, beast::Journal const& j, bool enforce, - bool globalFreeze); + bool globalFreeze, + bool fixOverrideFreeze); }; } // namespace xrpl diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index 1239305e79..e8dafbd301 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -198,7 +198,7 @@ public: /** * @brief Invariant: An account XRP balance must be in XRP and take a value - * between 0 and INITIAL_XRP drops, inclusive. + * between 0 and kInitialXRP drops, inclusive. * * We iterate all account roots modified by the transaction and ensure that * their XRP balances are reasonable. @@ -290,7 +290,7 @@ public: /** * @brief Invariant: an escrow entry must take a value between 0 and - * INITIAL_XRP drops exclusive. + * kInitialXRP drops exclusive. */ class NoZeroEscrow { diff --git a/include/xrpl/tx/invariants/LoanInvariant.h b/include/xrpl/tx/invariants/LoanInvariant.h index 0648881423..fc72b8d420 100644 --- a/include/xrpl/tx/invariants/LoanInvariant.h +++ b/include/xrpl/tx/invariants/LoanInvariant.h @@ -16,6 +16,8 @@ namespace xrpl { * @brief Invariants: Loans are internally consistent * * 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0` + * 2. A newly-created Loan against a closed-ended vault must satisfy + * `StartDate + PaymentInterval * PaymentRemaining < Vault.RedemptionDate`. * */ class ValidLoan diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index 136c6c4a25..2ba42f0ab4 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -38,7 +38,17 @@ namespace xrpl { * - vault set must not alter the vault assets or shares balance * - no vault transaction can change loss unrealized (it's updated by loan * transactions) + * - a created closed-ended vault must satisfy + * MIN_INVESTMENT_PERIOD <= RedemptionDate - SubscriptionDate < + * MAX_INVESTMENT_PERIOD + * - vault deposit may only succeed when the vault phase is NoPhase or + * Subscription + * - vault withdrawal may not succeed when the vault phase is Investment + * - closed-ended loan origination (ttLOAN_SET) may only succeed when the + * vault phase is Investment * + * Immutability of VaultKind, SubscriptionDate and RedemptionDate is enforced + * by NoModifiedUnmodifiableFields (see InvariantCheck.cpp). */ class ValidVault { @@ -55,6 +65,9 @@ class ValidVault Number assetsAvailable = 0; Number assetsMaximum = 0; Number lossUnrealized = 0; + std::optional vaultKind; + std::optional subscriptionDate; + std::optional redemptionDate; Vault static make(SLE const&); }; @@ -153,6 +166,17 @@ private: [[nodiscard]] static bool isVaultEmpty(Vault const& vault); + /** + * @brief Invariant check for @c ttLOAN_SET. + * + * For a closed-ended vault, a loan may only be originated while the vault is in the Investment + * phase (strictly past @c SubscriptionDate and before @c RedemptionDate). Open-ended vaults (@c + * NoPhase) are unaffected. The complementary maturity bound (final payment strictly precedes @c + * RedemptionDate) is enforced by @c ValidLoan. + */ + [[nodiscard]] bool + finalizeLoanSet(ReadView const& view, beast::Journal const& j) const; + public: // Compute the coarsest scale required to represent all numbers [[nodiscard]] static std::int32_t diff --git a/include/xrpl/tx/paths/detail/Steps.h b/include/xrpl/tx/paths/detail/Steps.h index 8ee37c026c..1d68860adc 100644 --- a/include/xrpl/tx/paths/detail/Steps.h +++ b/include/xrpl/tx/paths/detail/Steps.h @@ -274,19 +274,6 @@ public: return lhs.equal(rhs); } - /** - * Return true if lhs != rhs. - * - * @param lhs Step to compare. - * @param rhs Step to compare. - * @return true if lhs != rhs. - */ - friend bool - operator!=(Step const& lhs, Step const& rhs) - { - return !(lhs == rhs); - } - /** * Streaming operator for a Step. */ diff --git a/include/xrpl/tx/paths/detail/StrandFlow.h b/include/xrpl/tx/paths/detail/StrandFlow.h index c932c49cca..fcca97ecfc 100644 --- a/include/xrpl/tx/paths/detail/StrandFlow.h +++ b/include/xrpl/tx/paths/detail/StrandFlow.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -373,7 +374,7 @@ qualityUpperBound(ReadView const& v, Strand const& strand) * increases quality of AMM steps, increasing the strand's composite * quality as the result. */ -template +template inline TOutAmt limitOut( ReadView const& v, @@ -411,21 +412,29 @@ limitOut( auto const out = qf->outFromAvgQ(limitQuality); if (!out) return remainingOut; - if constexpr (std::is_same_v) + if constexpr (std::is_same_v || std::is_same_v) { - return XRPAmount{*out}; + auto const roundedOut = TOutAmt{*out}; + // Integral outputs that round above the continuous target can + // realize worse average quality than the requested limit. Keep the + // default rounded value when it still satisfies the limit, since it + // is the largest matching offer; otherwise round down. + if (v.rules().enabled(featureMPTokensV2) && roundedOut > *out && + !qf->satisfiesAvgQ(limitQuality, roundedOut)) + { + NumberRoundModeGuard const g(Number::RoundingMode::Downward); + return TOutAmt{*out}; + } + return roundedOut; } else if constexpr (std::is_same_v) { return IOUAmount{*out}; } - else if constexpr (std::is_same_v) - { - return MPTAmount{*out}; - } else { - return STAmount{remainingOut.asset(), out->mantissa(), out->exponent()}; + static constexpr bool kAlwaysFalse = !std::is_same_v; + static_assert(kAlwaysFalse, "Unhandled StepAmount type"); } }(); // A tiny difference could be due to the round off diff --git a/include/xrpl/tx/transactors/dex/AMMWithdraw.h b/include/xrpl/tx/transactors/dex/AMMWithdraw.h index 7004dd57c1..6861fa7bc4 100644 --- a/include/xrpl/tx/transactors/dex/AMMWithdraw.h +++ b/include/xrpl/tx/transactors/dex/AMMWithdraw.h @@ -118,6 +118,7 @@ public: Sandbox& view, SLE const& ammSle, AccountID const account, + std::optional const& clawbackIssuer, AccountID const& ammAccount, STAmount const& amountBalance, STAmount const& amount2Balance, @@ -138,6 +139,11 @@ public: * @param view * @param ammSle AMM ledger entry * @param ammAccount AMM account + * @param clawbackIssuer when set (AMMClawback path), the issuer performing + * the clawback. A recreated MPToken is only auto-authorized when the + * asset's issuer matches this account, so a clawback cannot grant + * authorization on behalf of a different (paired-asset) issuer. + * @param account LP account * @param amountBalance current LP asset1 balance * @param amountWithdraw asset1 withdraw amount * @param amount2Withdraw asset2 withdraw amount @@ -153,6 +159,7 @@ public: Sandbox& view, SLE const& ammSle, AccountID const& ammAccount, + std::optional const& clawbackIssuer, AccountID const& account, STAmount const& amountBalance, STAmount const& amountWithdraw, diff --git a/nix/devshell.nix b/nix/devshell.nix index cb4a99c76a..ac0b84e169 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -30,10 +30,29 @@ let }; customGccGcov = if pkgs.stdenv.isLinux then customCompilers.customGcov else plainGcov; + # Whole directory: init.sh locates the profiles relative to itself. + conanDir = ../conan; + + # Own Conan home, so Nix-built packages never share a cache with a system + # Conan. The stamp holds a content-addressed store path, so init.sh re-runs + # only when something in conan/ changes. + conanHook = '' + export CONAN_HOME=~/.conan2-nix + _xrpl_conan_stamp="$CONAN_HOME/.xrpld-devshell" + if [ "$(cat "$_xrpl_conan_stamp" 2>/dev/null)" != "${conanDir}" ]; then + if ${conanDir}/init.sh; then + printf '%s' "${conanDir}" >"$_xrpl_conan_stamp" + else + echo "⚠️ Conan setup failed - run ./conan/init.sh from the repository root to retry." + fi + fi + unset _xrpl_conan_stamp + ''; + # Shown when entering a *-plain shell. These exist only on Linux (see below), # where the stock toolchain diverges from CI. plainWarningHook = '' - echo "⚠️ WARNING: this is the stock nixpkgs toolchain and does not match CI's glibc. Prefer 'nix develop .#gcc' / '.#clang' unless you need to skip the custom-glibc build." + echo "⚠️ WARNING: this is the stock nixpkgs toolchain and does not match CI's glibc. Prefer 'nix develop .#gcc' / '.#clang' unless you need to skip the custom-glibc build." ''; # Tools to expose under version-suffixed names (see mkVersionedToolLinks). @@ -87,6 +106,7 @@ let shellHook = '' echo "Welcome to xrpld development shell"; ${compilerVersionHook} + ${conanHook} ${warningHook} ''; } diff --git a/nix/packages.nix b/nix/packages.nix index 01ab2ecf9a..0623ff51b9 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -129,15 +129,6 @@ in perl # needed for openssl pkg-config pre-commit - # protoc generates the Go gRPC bindings and embeds its own version string into every committed - # .pb.go file. To allow CI to verify those files with a plain `git diff`, we pin the version to - # `protobuf_34` rather than the rolling `protobuf` to keep regeneration reproducible across the - # Nix frequently changing unstable channel. The protoc-gen-go* plugins have no versioned - # attributes in nixpkgs; protoc-gen-go's version is in turn constrained by the go.mod require - # on google.golang.org/protobuf. - protobuf_34 # provides protoc - protoc-gen-go # protoc plugin for the Go message bindings - protoc-gen-go-grpc # protoc plugin for the Go gRPC service stubs python3 runClangTidy vim @@ -146,7 +137,6 @@ in cargo-audit cargo-llvm-cov cargo-nextest - corrosion rustToolchain ]; } diff --git a/src/benchmarks/libxrpl/nodestore/Backend.cpp b/src/benchmarks/libxrpl/nodestore/Backend.cpp index cd3e15bd65..9d5937f869 100644 --- a/src/benchmarks/libxrpl/nodestore/Backend.cpp +++ b/src/benchmarks/libxrpl/nodestore/Backend.cpp @@ -41,10 +41,11 @@ struct RunState release() { harness.reset(); - Batch{}.swap(present); - Batch{}.swap(recent); - std::vector{}.swap(missing); - std::vector{}.swap(shuffle); + present = Batch{}; + recent = Batch{}; + missing = std::vector{}; + shuffle = std::vector{}; + avgPayload = 0; } }; @@ -239,9 +240,13 @@ registerWorkload(BackendConfig const& bc, Workload const& w) if (!w.pinToPool) { auto rs = std::make_shared(); - auto* b = benchmark::RegisterBenchmark(name, makeRunner(w, cfg, rs)); - b->RangeMultiplier(10)->Range(kPoolSizes.front(), kPoolSizes.back()); - b->Threads(1)->Threads(4)->Threads(8)->UseRealTime(); + benchmark::RegisterBenchmark(name, makeRunner(w, cfg, rs)) + ->RangeMultiplier(10) + ->Range(kPoolSizes.front(), kPoolSizes.back()) + ->Threads(1) + ->Threads(4) + ->Threads(8) + ->UseRealTime(); return; } diff --git a/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h b/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h index debdc5d47a..a90207f26a 100644 --- a/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h +++ b/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h @@ -2,10 +2,10 @@ #include #include +#include #include #include #include -#include #include #include #include @@ -227,7 +227,7 @@ sliceFixedBatches(Batch const& pool, std::size_t batchSize) */ struct BackendHarness { - beast::TempDir tempDir; ///< Declared first so it is destroyed last + TempDir tempDir; ///< Declared first so it is destroyed last DummyScheduler scheduler; beast::Journal journal{beast::Journal::getNullSink()}; std::unique_ptr backend; @@ -257,7 +257,7 @@ struct BackendHarness */ struct DatabaseHarness { - beast::TempDir tempDir; + TempDir tempDir; DummyScheduler scheduler; beast::Journal journal{beast::Journal::getNullSink()}; std::unique_ptr db; @@ -297,12 +297,11 @@ struct BackendConfig inline std::vector const& backendConfigs() { + // Use factory settings for each DB static std::vector const kConfigs = { {.name = "nudb", .config = "type=nudb"}, #if XRPL_ROCKSDB_AVAILABLE - {.name = "rocksdb", - .config = "type=rocksdb,open_files=2000,filter_bits=12,cache_mb=256," - "file_size_mb=8,file_size_mult=2"}, + {.name = "rocksdb", .config = "type=rocksdb"}, #endif }; return kConfigs; diff --git a/src/libxrpl/basics/Archive.cpp b/src/libxrpl/basics/Archive.cpp index bba144ed04..5ab0d88c1d 100644 --- a/src/libxrpl/basics/Archive.cpp +++ b/src/libxrpl/basics/Archive.cpp @@ -2,22 +2,20 @@ #include -#include -#include - #include #include #include +#include #include #include namespace xrpl { void -extractTarLz4(boost::filesystem::path const& src, boost::filesystem::path const& dst) +extractTarLz4(std::filesystem::path const& src, std::filesystem::path const& dst) { - if (!is_regular_file(src)) + if (!std::filesystem::is_regular_file(src)) Throw("Invalid source file"); using archive_ptr = std::unique_ptr; diff --git a/src/libxrpl/basics/FileUtilities.cpp b/src/libxrpl/basics/FileUtilities.cpp index 1a6e604724..bed2b756ac 100644 --- a/src/libxrpl/basics/FileUtilities.cpp +++ b/src/libxrpl/basics/FileUtilities.cpp @@ -1,29 +1,31 @@ #include -#include -#include -#include -#include -#include +#include #include #include +#include #include +#include #include +#include #include #include +#include +#include +#include #include +#include namespace xrpl { std::string getFileContents( - boost::system::error_code& ec, - boost::filesystem::path const& sourcePath, + std::error_code& ec, + std::filesystem::path const& sourcePath, std::optional maxSize) { - using namespace boost::filesystem; - using namespace boost::system::errc; + using namespace std::filesystem; path const fullPath{canonical(sourcePath, ec)}; if (ec) @@ -32,15 +34,15 @@ getFileContents( if (maxSize && (file_size(fullPath, ec) > *maxSize || ec)) { if (!ec) - ec = make_error_code(file_too_large); + ec = make_error_code(std::errc::file_too_large); return {}; } - std::ifstream fileStream(fullPath.string(), std::ios::in); + std::ifstream fileStream(fullPath, std::ios::in); if (!fileStream) { - ec = make_error_code(static_cast(errno)); + ec.assign(errno, std::generic_category()); return {}; } @@ -49,7 +51,7 @@ getFileContents( if (fileStream.bad()) { - ec = make_error_code(static_cast(errno)); + ec.assign(errno, std::generic_category()); return {}; } @@ -58,18 +60,15 @@ getFileContents( void writeFileContents( - boost::system::error_code& ec, - boost::filesystem::path const& destPath, + std::error_code& ec, + std::filesystem::path const& destPath, std::string const& contents) { - using namespace boost::filesystem; - using namespace boost::system::errc; - - std::ofstream fileStream(destPath.string(), std::ios::out | std::ios::trunc); + std::ofstream fileStream(destPath, std::ios::out | std::ios::trunc); if (!fileStream) { - ec = make_error_code(static_cast(errno)); + ec.assign(errno, std::generic_category()); return; } @@ -77,9 +76,64 @@ writeFileContents( if (fileStream.bad()) { - ec = make_error_code(static_cast(errno)); + ec.assign(errno, std::generic_category()); return; } } +std::filesystem::path +uniqueRandomPath( + std::filesystem::path const& base, + std::string const& prefix, + std::size_t maxAttempts) +{ + std::random_device rd; + for (std::size_t attempt = 0; attempt < maxAttempts; ++attempt) + { + std::ostringstream oss; + oss << prefix << std::hex << std::setfill('0') << std::setw(8) << rd() << std::setw(8) + << rd(); + auto candidate = base / oss.str(); + std::error_code ec; + bool const exists = std::filesystem::exists(candidate, ec); + if (ec) + { + Throw( + "Unable to check path '" + candidate.string() + "': " + ec.message()); + } + if (!exists) + return candidate; + } + Throw("Unable to generate a unique path under '" + base.string() + "'"); +} + +TempDir::TempDir() : path_(uniqueRandomPath(std::filesystem::temp_directory_path())) +{ + std::filesystem::create_directory(path_); +} + +TempDir::~TempDir() +{ + // use non-throwing calls in the destructor + std::error_code ec; + std::filesystem::remove_all(path_, ec); + if (ec) + { + std::cerr << "Unable to remove temporary directory '" << path_.string() + << "': " << ec.message() << '\n'; + } +} + +std::string +TempDir::path() const +{ + return path_.string(); +} + +std::string +TempDir::file(std::string const& name) const +{ + return (path_ / name).string(); +} + } // namespace xrpl diff --git a/src/libxrpl/basics/Log.cpp b/src/libxrpl/basics/Log.cpp index d1e54a515f..68525f5a65 100644 --- a/src/libxrpl/basics/Log.cpp +++ b/src/libxrpl/basics/Log.cpp @@ -5,10 +5,10 @@ #include #include -#include #include #include +#include #include #include #include @@ -54,7 +54,7 @@ Logs::File::isOpen() const noexcept } bool -Logs::File::open(boost::filesystem::path const& path) +Logs::File::open(std::filesystem::path const& path) { close(); @@ -114,7 +114,7 @@ Logs::Logs(beast::Severity thresh) : thresh_(thresh) // default severity } bool -Logs::open(boost::filesystem::path const& pathToLogFile) +Logs::open(std::filesystem::path const& pathToLogFile) { return file_.open(pathToLogFile); } diff --git a/src/libxrpl/basics/StringUtilities.cpp b/src/libxrpl/basics/StringUtilities.cpp index 2b7deecb8e..9eb1bff995 100644 --- a/src/libxrpl/basics/StringUtilities.cpp +++ b/src/libxrpl/basics/StringUtilities.cpp @@ -5,15 +5,15 @@ #include #include -#include -#include #include #include #include +#include #include #include #include +#include #include #include @@ -67,7 +67,7 @@ parseUrl(ParsedUrl& pUrl, std::string const& strUrl) } pUrl.scheme = smMatch[1]; - boost::algorithm::to_lower(pUrl.scheme); + pUrl.scheme = toLower(pUrl.scheme); pUrl.username = smMatch[2]; pUrl.password = smMatch[3]; std::string const domain = smMatch[4]; @@ -93,10 +93,42 @@ parseUrl(ParsedUrl& pUrl, std::string const& strUrl) return true; } +namespace { + +// Deliberately not std::isspace / std::tolower: those consult the current C +// locale, so the same input could trim or fold differently depending on +// process-wide state set by something else entirely. Everything these helpers +// are used on (config keys and values, URL schemes, hex digests) is ASCII, and +// the callers want a fixed answer, so spell the ASCII rules out. + +constexpr bool +isAsciiSpace(char c) +{ + return c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '\f' || c == '\r'; +} + +constexpr char +toAsciiLower(char c) +{ + return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; +} + +} // namespace + std::string trimWhitespace(std::string str) { - boost::trim(str); + auto const end = std::ranges::find_if_not(str | std::views::reverse, isAsciiSpace).base(); + str.erase(end, str.end()); + str.erase(str.begin(), std::ranges::find_if_not(str, isAsciiSpace)); + + return str; +} + +std::string +toLower(std::string str) +{ + std::ranges::transform(str, str.begin(), toAsciiLower); return str; } diff --git a/src/libxrpl/crypto/RFC1751.cpp b/src/libxrpl/crypto/RFC1751.cpp index 4b17e1443c..f6342928ab 100644 --- a/src/libxrpl/crypto/RFC1751.cpp +++ b/src/libxrpl/crypto/RFC1751.cpp @@ -1,11 +1,11 @@ #include +#include #include #include #include #include -#include #include #include @@ -397,7 +397,7 @@ RFC1751::getKeyFromEnglish(std::string& strKey, std::string const& strHuman) std::string strTrimmed(strHuman); - boost::algorithm::trim(strTrimmed); + strTrimmed = trimWhitespace(strTrimmed); boost::algorithm::split( vWords, strTrimmed, boost::algorithm::is_space(), boost::algorithm::token_compress_on); diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index 8116f4f641..2dd70e2950 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -45,12 +45,20 @@ namespace xrpl { //------------------------------------------------------------------------------ bool -hasExpired(ReadView const& view, std::optional const& exp) +hasExpired( + ReadView const& view, + std::optional const& exp, + ExpiryComparison comparison) { using d = NetClock::duration; using tp = NetClock::time_point; - return exp && (view.parentCloseTime() >= tp{d{*exp}}); + if (!exp) + return false; + auto const boundary = tp{d{*exp}}; + return comparison == ExpiryComparison::Inclusive // + ? view.parentCloseTime() >= boundary + : view.parentCloseTime() > boundary; } bool diff --git a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp index 3a9621bb88..9c3ca4ec78 100644 --- a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp +++ b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include #include @@ -54,6 +55,9 @@ removeExpired(ApplyView& view, STVector256 const& arr, beast::Journal const j) for (auto const& h : arr) { // Credentials already checked in preclaim. Look only for expired here. + if (view.rules().enabled(fixCleanup3_4_0) && h.isZero()) + return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE + auto const k = keylet::credential(h); auto const sleCred = view.peek(k); @@ -156,7 +160,7 @@ deletePseudoAccountCredentials( } NotTEC -checkFields(STTx const& tx, beast::Journal j) +checkFields(STTx const& tx, Rules const& rules, beast::Journal j) { if (!tx.isFieldPresent(sfCredentialIDs)) return tesSUCCESS; @@ -169,6 +173,13 @@ checkFields(STTx const& tx, beast::Journal j) return temMALFORMED; } + if (rules.enabled(fixCleanup3_4_0) && + std::ranges::any_of(credentials, [](uint256 const& id) { return id.isZero(); })) + { + JLOG(j.trace()) << "Malformed transaction: zero credential ID."; + return temMALFORMED; + } + std::unordered_set duplicates; for (auto const& cred : credentials) { @@ -192,6 +203,14 @@ valid(STTx const& tx, ReadView const& view, AccountID const& src, beast::Journal auto const& credIDs(tx.getFieldV256(sfCredentialIDs)); for (auto const& h : credIDs) { + if (view.rules().enabled(fixCleanup3_4_0) && h.isZero()) + { + // LCOV_EXCL_START + JLOG(j.trace()) << "Zero credential ID."; + return tecINTERNAL; + // LCOV_EXCL_STOP + } + auto const sleCred = view.read(keylet::credential(h)); if (!sleCred) { @@ -266,6 +285,9 @@ authorizedDepositPreauth(ReadView const& view, STVector256 const& credIDs, Accou lifeExtender.reserve(credIDs.size()); for (auto const& h : credIDs) { + if (view.rules().enabled(fixCleanup3_4_0) && h.isZero()) + return tefINTERNAL; // LCOV_EXCL_LINE + auto sleCred = view.read(keylet::credential(h)); if (!sleCred) // already checked in preclaim return tefINTERNAL; // LCOV_EXCL_LINE diff --git a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp index 6fe7328fa7..b239d0d3d1 100644 --- a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp @@ -952,6 +952,7 @@ checkCreateMPT( xrpl::MPTIssue const& mptIssue, xrpl::AccountID const& holder, SLE::ref sponsorSle, + std::uint32_t flags, beast::Journal j) { if (mptIssue.getIssuer() == holder) @@ -961,7 +962,7 @@ checkCreateMPT( auto const mptokenID = keylet::mptoken(mptIssuanceID.key, holder); if (!view.exists(mptokenID)) { - if (auto const err = createMPToken(view, mptIssue.getMptID(), holder, sponsorSle, 0); + if (auto const err = createMPToken(view, mptIssue.getMptID(), holder, sponsorSle, flags); !isTesSuccess(err)) { return err; @@ -977,6 +978,16 @@ checkCreateMPT( return tesSUCCESS; } +TER +checkCreateMPT( + xrpl::ApplyView& view, + xrpl::MPTIssue const& mptIssue, + xrpl::AccountID const& holder, + beast::Journal j) +{ + return checkCreateMPT(view, mptIssue, holder, {}, 0, j); +} + std::int64_t maxMPTAmount(SLE const& sleIssuance) { diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index 78f64d2077..67e0262e14 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include // IWYU pragma: keep @@ -11,6 +12,7 @@ #include #include #include // IWYU pragma: keep +#include #include #include @@ -157,4 +159,74 @@ getVaultVersion(SLE::const_ref vault) return static_cast(version); } +namespace { + +[[nodiscard]] VaultKind +decodeVaultKind(std::optional vaultKind) +{ + if (vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded)) + return VaultKind::ClosedEnded; + return VaultKind::OpenEnded; +} + +} // namespace + +[[nodiscard]] VaultKind +getVaultKind(SLE::const_ref vault) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultKind : valid Vault sle"); + return decodeVaultKind(vault->at(~sfVaultKind)); +} + +[[nodiscard]] VaultKind +getVaultKind(STTx const& tx) +{ + return decodeVaultKind(tx[~sfVaultKind]); +} + +[[nodiscard]] bool +isValidVaultKind(STTx const& tx) +{ + auto const kindField = tx[~sfVaultKind]; + if (!kindField) + return true; + return *kindField == std::to_underlying(VaultKind::OpenEnded) || + *kindField == std::to_underlying(VaultKind::ClosedEnded); +} + +[[nodiscard]] bool +isValidClosedEndedGap(std::uint32_t sub, std::uint32_t red) +{ + auto const s = static_cast(sub); + auto const r = static_cast(red); + return r >= s + kMinInvestmentPeriod && r < s + kMaxInvestmentPeriod; +} + +[[nodiscard]] VaultPhase +getVaultPhase(ReadView const& view, SLE::const_ref vault) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultPhase : valid Vault sle"); + return getVaultPhase( + view, (*vault)[~sfVaultKind], (*vault)[~sfSubscriptionDate], (*vault)[~sfRedemptionDate]); +} + +[[nodiscard]] VaultPhase +getVaultPhase( + ReadView const& view, + std::optional vaultKind, + std::optional subscriptionDate, + std::optional redemptionDate) +{ + if (!vaultKind || *vaultKind != std::to_underlying(VaultKind::ClosedEnded)) + return VaultPhase::NoPhase; + + // Subscription includes now == SubscriptionDate; Investment starts + // strictly after SubscriptionDate. + if (!hasExpired(view, subscriptionDate, ExpiryComparison::Exclusive)) + return VaultPhase::Subscription; + if (!hasExpired(view, redemptionDate)) + return VaultPhase::Investment; + return VaultPhase::Redemption; +} + } // namespace xrpl diff --git a/src/libxrpl/nodestore/backend/NuDBFactory.cpp b/src/libxrpl/nodestore/backend/NuDBFactory.cpp index bbf37f3edf..98173858e8 100644 --- a/src/libxrpl/nodestore/backend/NuDBFactory.cpp +++ b/src/libxrpl/nodestore/backend/NuDBFactory.cpp @@ -16,8 +16,6 @@ #include #include -#include -#include #include #include @@ -36,12 +34,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include namespace xrpl::node_store { @@ -131,7 +131,7 @@ public: void open(bool createIfMissing, uint64_t appType, uint64_t uid, uint64_t salt) override { - using namespace boost::filesystem; + using namespace std::filesystem; if (db.is_open()) { // LCOV_EXCL_START @@ -194,11 +194,12 @@ public: if (deletePath) { - boost::filesystem::remove_all(name, ec); - if (ec) + std::error_code fsec; + std::filesystem::remove_all(name, fsec); + if (fsec) { - JLOG(j.fatal()) - << "Filesystem remove_all of " << name << " failed with: " << ec.message(); + JLOG(j.fatal()) << "Filesystem remove_all of " << name + << " failed with: " << fsec.message(); } } } @@ -352,7 +353,7 @@ private: static std::size_t parseBlockSize(std::string const& name, Section const& keyValues, beast::Journal journal) { - using namespace boost::filesystem; + using namespace std::filesystem; auto const folder = path(name); auto const kp = (folder / "nudb.key").string(); diff --git a/src/libxrpl/nodestore/backend/RocksDBFactory.cpp b/src/libxrpl/nodestore/backend/RocksDBFactory.cpp index 4b7a1171fe..6f00b762b2 100644 --- a/src/libxrpl/nodestore/backend/RocksDBFactory.cpp +++ b/src/libxrpl/nodestore/backend/RocksDBFactory.cpp @@ -19,9 +19,6 @@ #include #include -#include -#include - #include #include #include @@ -37,6 +34,7 @@ #include #include +#include #include #include #include @@ -262,8 +260,8 @@ public: db.reset(); if (deletePath_) { - boost::filesystem::path const dir = name; - boost::filesystem::remove_all(dir); + std::filesystem::path const dir = name; + std::filesystem::remove_all(dir); } } } diff --git a/src/libxrpl/protocol/ConfidentialTransfer.cpp b/src/libxrpl/protocol/ConfidentialTransfer.cpp index fe8a08c2ef..ecd4832928 100644 --- a/src/libxrpl/protocol/ConfidentialTransfer.cpp +++ b/src/libxrpl/protocol/ConfidentialTransfer.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -124,7 +125,12 @@ std::optional makeEcPair(Slice const& buffer) { if (buffer.length() != 2 * kEcCiphertextComponentLength) - return std::nullopt; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::makeEcPair : callers must pre-validate ciphertext length"); + return std::nullopt; + // LCOV_EXCL_STOP + } auto parsePubKey = [](Slice const& slice, secp256k1_pubkey& out) { return secp256k1_ec_pubkey_parse(secp256k1Context(), &out, slice.data(), slice.length()); @@ -266,7 +272,13 @@ std::optional encryptCanonicalZeroAmount(Slice const& pubKeySlice, AccountID const& account, MPTID const& mptId) { if (pubKeySlice.size() != kEcPubKeyLength) - return std::nullopt; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::encryptCanonicalZeroAmount : callers must pre-validate public key length"); + return std::nullopt; + // LCOV_EXCL_STOP + } EcPair pair{}; secp256k1_pubkey pubKey; @@ -274,14 +286,24 @@ encryptCanonicalZeroAmount(Slice const& pubKeySlice, AccountID const& account, M secp256k1Context(), &pubKey, pubKeySlice.data(), kEcPubKeyLength); res != 1) { - return std::nullopt; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::encryptCanonicalZeroAmount : public key read from the ledger must already be " + "valid"); + return std::nullopt; + // LCOV_EXCL_STOP } if (auto res = generate_canonical_encrypted_zero( secp256k1Context(), &pair.c1, &pair.c2, &pubKey, account.data(), mptId.data()); res != 1) { - return std::nullopt; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::encryptCanonicalZeroAmount : canonical zero generation cannot fail for a " + "valid public key"); + return std::nullopt; + // LCOV_EXCL_STOP } return serializeEcPair(pair); @@ -301,7 +323,11 @@ verifyRevealedAmount( issuer.publicKey.size() != kEcPubKeyLength || issuer.encryptedAmount.size() != kEcGamalEncryptedTotalLength) { - return tecINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::verifyRevealedAmount : callers must pre-validate holder/issuer field lengths"); + return tecINTERNAL; + // LCOV_EXCL_STOP } auto const holderP = toParticipant(holder); @@ -313,7 +339,11 @@ verifyRevealedAmount( if (auditor->publicKey.size() != kEcPubKeyLength || auditor->encryptedAmount.size() != kEcGamalEncryptedTotalLength) { - return tecINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::verifyRevealedAmount : callers must pre-validate auditor field lengths"); + return tecINTERNAL; + // LCOV_EXCL_STOP } auditorP = toParticipant(*auditor); auditorPtr = &auditorP; @@ -337,7 +367,12 @@ checkEncryptedAmountFormat(STObject const& object) if (!object.isFieldPresent(sfHolderEncryptedAmount) || !object.isFieldPresent(sfIssuerEncryptedAmount)) { - return temMALFORMED; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::checkEncryptedAmountFormat : callers already enforce that these fields are " + "present"); + return temMALFORMED; + // LCOV_EXCL_STOP } if (object[sfHolderEncryptedAmount].length() != kEcGamalEncryptedTotalLength || @@ -366,7 +401,12 @@ TER verifySchnorrProof(Slice const& pubKeySlice, Slice const& proofSlice, uint256 const& contextHash) { if (proofSlice.size() != kEcSchnorrProofLength || pubKeySlice.size() != kEcPubKeyLength) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::verifySchnorrProof : callers must pre-validate proof/public key length"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } if (mpt_verify_convert_proof(proofSlice.data(), pubKeySlice.data(), contextHash.data()) != 0) return tecBAD_PROOF; @@ -385,7 +425,12 @@ verifyClawbackProof( if (ciphertext.size() != kEcGamalEncryptedTotalLength || pubKeySlice.size() != kEcPubKeyLength || proof.size() != kEcClawbackProofLength) { - return tecINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::verifyClawbackProof : callers must pre-validate ciphertext/public " + "key/proof length"); + return tecINTERNAL; + // LCOV_EXCL_STOP } if (mpt_verify_clawback_proof( @@ -420,7 +465,12 @@ verifySendProof( amountCommitment.size() != kEcPedersenCommitmentLength || balanceCommitment.size() != kEcPedersenCommitmentLength) { - return tecINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::verifySendProof : callers must pre-validate proof/participant/commitment " + "lengths"); + return tecINTERNAL; + // LCOV_EXCL_STOP } std::vector participants; @@ -433,12 +483,22 @@ verifySendProof( if (auditor->publicKey.size() != kEcPubKeyLength || auditor->encryptedAmount.size() != kEcGamalEncryptedTotalLength) { - return tecINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE("xrpl::verifySendProof : callers must pre-validate auditor field lengths"); + return tecINTERNAL; + // LCOV_EXCL_STOP } participants.push_back(toParticipant(*auditor)); } if (participants.size() != recipientCount) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::verifySendProof : participant count must match the requested recipient " + "count"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } if (mpt_verify_send_proof( proof.data(), @@ -468,7 +528,12 @@ verifyConvertBackProof( spendingBalance.size() != kEcGamalEncryptedTotalLength || balanceCommitment.size() != kEcPedersenCommitmentLength) { - return tecINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::verifyConvertBackProof : callers must pre-validate proof/public " + "key/balance/commitment lengths"); + return tecINTERNAL; + // LCOV_EXCL_STOP } if (mpt_verify_convert_back_proof( diff --git a/src/libxrpl/protocol/ErrorCodes.cpp b/src/libxrpl/protocol/ErrorCodes.cpp index e81f975844..802bae100d 100644 --- a/src/libxrpl/protocol/ErrorCodes.cpp +++ b/src/libxrpl/protocol/ErrorCodes.cpp @@ -105,10 +105,9 @@ static constexpr ErrorInfo kUnorderedErrorInfos[]{ }; // clang-format on -// Sort and validate unorderedErrorInfos at compile time. Should be -// converted to consteval when get to C++20. +// Sort and validate unorderedErrorInfos at compile time. template -constexpr auto +consteval auto sortErrorInfos(ErrorInfo const (&unordered)[N]) -> std::array { std::array ret = {}; diff --git a/src/libxrpl/protocol/InnerObjectFormats.cpp b/src/libxrpl/protocol/InnerObjectFormats.cpp index 0bdb217771..5cb7d166e9 100644 --- a/src/libxrpl/protocol/InnerObjectFormats.cpp +++ b/src/libxrpl/protocol/InnerObjectFormats.cpp @@ -137,9 +137,9 @@ InnerObjectFormats::InnerObjectFormats() {sfCredentialType, SoeRequired}, }); - add(sfPermission.jsonName.cStr(), sfPermission.getCode(), {{sfPermissionValue, SoeRequired}}); + add(sfPermission.jsonName, sfPermission.getCode(), {{sfPermissionValue, SoeRequired}}); - add(sfBatchSigner.jsonName.cStr(), + add(sfBatchSigner.jsonName, sfBatchSigner.getCode(), {{sfAccount, SoeRequired}, {sfSigningPubKey, SoeOptional}, @@ -161,7 +161,7 @@ InnerObjectFormats::InnerObjectFormats() {sfSigners, SoeOptional}, }); - add(sfSponsorSignature.jsonName.cStr(), + add(sfSponsorSignature.jsonName, sfSponsorSignature.getCode(), { {sfSigningPubKey, SoeOptional}, diff --git a/src/libxrpl/protocol/QualityFunction.cpp b/src/libxrpl/protocol/QualityFunction.cpp index e862770406..ffe583b7e1 100644 --- a/src/libxrpl/protocol/QualityFunction.cpp +++ b/src/libxrpl/protocol/QualityFunction.cpp @@ -38,7 +38,22 @@ QualityFunction::outFromAvgQ(Quality const& quality) return std::nullopt; return out; } - return std::nullopt; + // The sole caller (StrandFlow::limitOut) only invokes this on a non-const + // quality function, so m_ != 0 here, and a real payment/offer never yields + // a zero-rate limit quality (it would divide by zero above). This fallback + // is therefore unreachable in practice. + return std::nullopt; // LCOV_EXCL_LINE +} + +bool +QualityFunction::satisfiesAvgQ(Quality const& quality, Number const& out) const +{ + // satisfiesAvgQ is only reached from StrandFlow::limitOut *after* + // outFromAvgQ returned a value, which requires a non-zero rate. So a + // zero-rate quality never reaches here; this guard is defensive. + if (quality.rate() == beast::kZero) + return false; // LCOV_EXCL_LINE + return m_ * out + b_ >= 1 / quality.rate(); } } // namespace xrpl diff --git a/src/libxrpl/protocol/Rules.cpp b/src/libxrpl/protocol/Rules.cpp index 197139027a..cb71133d8f 100644 --- a/src/libxrpl/protocol/Rules.cpp +++ b/src/libxrpl/protocol/Rules.cpp @@ -193,12 +193,6 @@ Rules::operator==(Rules const& other) const return *impl_ == *other.impl_; } -bool -Rules::operator!=(Rules const& other) const -{ - return !(*this == other); -} - bool isFeatureEnabled(uint256 const& feature, bool resultIfNoRules) { diff --git a/src/libxrpl/protocol/STAmount.cpp b/src/libxrpl/protocol/STAmount.cpp index 212c34322b..83b2983756 100644 --- a/src/libxrpl/protocol/STAmount.cpp +++ b/src/libxrpl/protocol/STAmount.cpp @@ -1445,6 +1445,59 @@ public: operator=(DontAffectNumberRoundMode const&) = delete; }; +Number::RoundingMode +roundMode(bool const resultNegative, bool const roundUp) +{ + using enum Number::RoundingMode; + // STAmount roundUp means "away from zero". The legacy scaled-mantissa + // multiply and divide paths reach that result with slightly different + // mechanics, including a final TowardsZero materialization in multiply. + // + // The MPT/V2 Number path already performs the operation under the directed + // mode below. Use the same mode again when converting back to STAmount so a + // fractional integral result stays consistently rounded after Number + // arithmetic, independent of whether the operation was multiply or divide. + return roundUp ^ resultNegative ? Upward : Downward; +} + +STAmount +roundNumberResult( + Asset const& asset, + bool const resultNegative, + bool const roundUp, + Number const& number) +{ + // MPT/V2 Number arithmetic uses directed rounding both for the operation + // and for materializing the final integral amount. + NumberRoundModeGuard const finalRound(roundMode(resultNegative, roundUp)); + auto result = STAmount{asset, number}; + [[maybe_unused]] bool const nonzeroPositiveRoundUp = + roundUp && !resultNegative && number != beast::kZero; + ALWAYS( + !nonzeroPositiveRoundUp || result != beast::kZero, + "xrpl::roundNumberResult : positive rounded-up MPT result is representable"); + + if (roundUp && !resultNegative && !result) + { + // Intended to preserve existing mulRound/divRound behavior for a + // positive result too small to represent in the target asset. + // + // Unreachable in practice: when roundUp is set, roundMode() above + // selects Upward, and materializing a Number into an STAmount honors + // that mode (Number::operator rep()), so any positive value rounds up + // to at least the smallest representable unit. Hence, a positive result + // is never !result here; the only zero case is a zero operand, which + // the mulRound/divRound callers handle before reaching this function. + // LCOV_EXCL_START + if (asset.integral()) + return STAmount{asset, 1}; + return STAmount{asset, STAmount::kMinValue, STAmount::kMinOffset, false}; + // LCOV_EXCL_STOP + } + + return result; +} + } // anonymous namespace // Pass the canonicalizeRound function pointer as a template parameter. @@ -1486,6 +1539,22 @@ mulRoundImpl(STAmount const& v1, STAmount const& v2, Asset const& asset, bool ro return STAmount(asset, minV * maxV); } + bool const resultNegative = v1.negative() != v2.negative(); + + if (asset.holds() && isFeatureEnabled(featureMPTokensV2, false)) + { + // MPT DEX can combine 63-bit MPT amounts with IOU-shaped transfer + // rates. Use Number arithmetic under MPTokensV2 so the rounded + // operation is not limited by the legacy uint64_t scaled mantissa. + Number result; + { + NumberRoundModeGuard const operationRound(roundMode(resultNegative, roundUp)); + result = Number{v1} * Number{v2}; + } + + return roundNumberResult(asset, resultNegative, roundUp, result); + } + std::uint64_t value1 = v1.mantissa(), value2 = v2.mantissa(); int offset1 = v1.exponent(), offset2 = v2.exponent(); @@ -1506,9 +1575,6 @@ mulRoundImpl(STAmount const& v1, STAmount const& v2, Asset const& asset, bool ro --offset2; } } - - bool const resultNegative = v1.negative() != v2.negative(); - // We multiply the two mantissas (each is between 10^15 // and 10^16), so their product is in the 10^30 to 10^32 // range. Dividing their product by 10^14 maintains the @@ -1575,6 +1641,22 @@ divRoundImpl(STAmount const& num, STAmount const& den, Asset const& asset, bool if (num == beast::kZero) return {asset}; + bool const resultNegative = (num.negative() != den.negative()); + + if (asset.holds() && isFeatureEnabled(featureMPTokensV2, false)) + { + // Match the multiply path above: Number performs the rounded + // operation, then STAmount materializes the final MPT amount using the + // same final rounding mode as the legacy path below. + Number result; + { + NumberRoundModeGuard const operationRound(roundMode(resultNegative, roundUp)); + result = Number{num} / Number{den}; + } + + return roundNumberResult(asset, resultNegative, roundUp, result); + } + std::uint64_t numVal = num.mantissa(), denVal = den.mantissa(); int numOffset = num.exponent(), denOffset = den.exponent(); @@ -1596,8 +1678,6 @@ divRoundImpl(STAmount const& num, STAmount const& den, Asset const& asset, bool } } - bool const resultNegative = (num.negative() != den.negative()); - // We divide the two mantissas (each is between 10^15 // and 10^16). To maintain precision, we multiply the // numerator by 10^17 (the product is in the range of diff --git a/src/libxrpl/protocol/STBase.cpp b/src/libxrpl/protocol/STBase.cpp index f029f10e75..1e56897e30 100644 --- a/src/libxrpl/protocol/STBase.cpp +++ b/src/libxrpl/protocol/STBase.cpp @@ -38,12 +38,6 @@ STBase::operator==(STBase const& t) const return (getSType() == t.getSType()) && isEquivalent(t); } -bool -STBase::operator!=(STBase const& t) const -{ - return (getSType() != t.getSType()) || !isEquivalent(t); -} - STBase* STBase::copy(std::size_t n, void* buf) const { diff --git a/src/libxrpl/rdb/SociDB.cpp b/src/libxrpl/rdb/SociDB.cpp index 2c3fb1bde1..84006acbe7 100644 --- a/src/libxrpl/rdb/SociDB.cpp +++ b/src/libxrpl/rdb/SociDB.cpp @@ -5,13 +5,11 @@ #include #include -#include -#include - #include #include #include +#include #include #include #include @@ -45,8 +43,8 @@ getSociSqliteInit(std::string const& name, std::string const& dir, std::string c Throw( "Sqlite databases must specify a dir and a name. Name: " + name + " Dir: " + dir); } - boost::filesystem::path file(dir); - if (is_directory(file)) + std::filesystem::path file(dir); + if (std::filesystem::is_directory(file)) file /= name + ext; return file.string(); } diff --git a/src/libxrpl/server/Manifest.cpp b/src/libxrpl/server/Manifest.cpp index 0760196a3b..c85c8445f0 100644 --- a/src/libxrpl/server/Manifest.cpp +++ b/src/libxrpl/server/Manifest.cpp @@ -23,8 +23,6 @@ #include #include -#include - #include #include #include @@ -277,7 +275,7 @@ loadValidatorToken(std::vector const& blob, beast::Journal journal) [](std::size_t init, std::string const& s) { return init + s.size(); })); for (auto const& line : blob) - tokenStr += boost::algorithm::trim_copy(line); + tokenStr += trimWhitespace(line); tokenStr = base64Decode(tokenStr); @@ -653,7 +651,7 @@ ManifestCache::load( [](std::size_t init, std::string const& s) { return init + s.size(); })); for (auto const& line : configRevocation) - revocationStr += boost::algorithm::trim_copy(line); + revocationStr += trimWhitespace(line); auto mo = deserializeManifest(base64Decode(revocationStr)); diff --git a/src/libxrpl/server/Port.cpp b/src/libxrpl/server/Port.cpp index 694d4448d5..a7892bc0e8 100644 --- a/src/libxrpl/server/Port.cpp +++ b/src/libxrpl/server/Port.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -9,7 +10,6 @@ #include #include -#include #include #include #include @@ -98,7 +98,7 @@ populate( while (std::getline(ss, ip, ',')) { - boost::algorithm::trim(ip); + ip = trimWhitespace(ip); bool v4 = false; boost::asio::ip::network_v4 v4Net; boost::asio::ip::network_v6 v6Net; diff --git a/src/libxrpl/server/Vacuum.cpp b/src/libxrpl/server/Vacuum.cpp index 63d40af156..df768d509a 100644 --- a/src/libxrpl/server/Vacuum.cpp +++ b/src/libxrpl/server/Vacuum.cpp @@ -5,13 +5,12 @@ #include #include -#include -#include #include // IWYU pragma: keep #include #include +#include #include #include @@ -20,12 +19,12 @@ namespace xrpl { bool doVacuumDB(DatabaseCon::Setup const& setup, beast::Journal j) { - boost::filesystem::path const dbPath = setup.dataDir / kTxDbName; + std::filesystem::path const dbPath = setup.dataDir / kTxDbName; - uintmax_t const dbSize = file_size(dbPath); + uintmax_t const dbSize = std::filesystem::file_size(dbPath); XRPL_ASSERT(dbSize != static_cast(-1), "xrpl::doVacuumDB : file_size succeeded"); - if (auto available = space(dbPath.parent_path()).available; available < dbSize) + if (auto available = std::filesystem::space(dbPath.parent_path()).available; available < dbSize) { std::cerr << "The database filesystem must have at least as " "much free space as the size of " diff --git a/src/libxrpl/tx/invariants/FreezeInvariant.cpp b/src/libxrpl/tx/invariants/FreezeInvariant.cpp index 0a604d4c39..c4340b9aec 100644 --- a/src/libxrpl/tx/invariants/FreezeInvariant.cpp +++ b/src/libxrpl/tx/invariants/FreezeInvariant.cpp @@ -73,6 +73,7 @@ TransfersNotFrozen::finalize( * view.rules().enabled(fixFreezeExploit); */ [[maybe_unused]] bool const enforce = view.rules().enabled(featureDeepFreeze); + bool const fixOverrideFreeze = view.rules().enabled(fixCleanup3_4_0); return std::ranges::all_of(balanceChanges_, [&](auto const& entry) { auto const& [issue, changes] = entry; @@ -90,7 +91,7 @@ TransfersNotFrozen::finalize( return !enforce; } - return validateIssuerChanges(issuerSle, changes, tx, j, enforce); + return validateIssuerChanges(issuerSle, changes, tx, j, enforce, fixOverrideFreeze); }); } @@ -199,7 +200,8 @@ TransfersNotFrozen::validateIssuerChanges( IssuerChanges const& changes, STTx const& tx, beast::Journal const& j, - bool enforce) + bool enforce, + bool fixOverrideFreeze) { if (!issuer) { @@ -225,7 +227,7 @@ TransfersNotFrozen::validateIssuerChanges( { bool const high = change.line->at(sfLowLimit).getIssuer() == issuer->at(sfAccount); - if (!validateFrozenState(change, high, tx, j, enforce, globalFreeze)) + if (!validateFrozenState(change, high, tx, j, enforce, globalFreeze, fixOverrideFreeze)) { return false; } @@ -241,26 +243,29 @@ TransfersNotFrozen::validateFrozenState( STTx const& tx, beast::Journal const& j, bool enforce, - bool globalFreeze) + bool globalFreeze, + bool fixOverrideFreeze) { bool const freeze = change.balanceChangeSign < 0 && change.line->isFlag(high ? lsfLowFreeze : lsfHighFreeze); bool const deepFreeze = change.line->isFlag(high ? lsfLowDeepFreeze : lsfHighDeepFreeze); bool const frozen = globalFreeze || deepFreeze || freeze; - bool const isAMMLine = change.line->isFlag(lsfAMMNode); - if (!frozen) { return true; } - // AMMClawbacks are allowed to override some freeze rules - if ((!isAMMLine || globalFreeze) && hasPrivilege(tx, OverrideFreeze)) + // Pre-fixCleanup3_4_0: the isAMMLine check incorrectly blocked clawback on + // individually-frozen or deep-frozen AMM trust lines. + // Post-fixCleanup3_4_0: AMMClawbacks are allowed to override all freeze types. + bool const isAMMLine = change.line->isFlag(lsfAMMNode); + if ((fixOverrideFreeze || !isAMMLine || globalFreeze) && hasPrivilege(tx, OverrideFreeze)) { JLOG(j.debug()) << "Invariant check allowing funds to be moved " << (change.balanceChangeSign > 0 ? "to" : "from") - << " a frozen trustline for AMMClawback " << tx.getTransactionID(); + << " a frozen trustline for a freeze privileged transaction " + << tx.getTransactionID(); return true; } diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 9b997e06dd..369206d9e6 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -1126,20 +1126,17 @@ NoModifiedUnmodifiableFields::finalize( auto const& before = slePair.first; auto const& after = slePair.second; auto const type = after->getType(); - bool bad = false; - [[maybe_unused]] bool enforce = false; + // featureLendingProtocol gates enforcement, not detection: changes are + // always logged, but the transaction is only failed once the amendment + // is enabled. Type-specific field lists may add their own gates (see + // ltVAULT). + bool const enforce = view.rules().enabled(featureLendingProtocol); + bool bad = kFieldChanged(before, after, sfLedgerEntryType) || + kFieldChanged(before, after, sfLedgerIndex); switch (type) { case ltLOAN_BROKER: - /* - * We check this invariant regardless of lending protocol - * amendment status, allowing for detection and logging of - * potential issues even when the amendment is disabled. - */ - enforce = view.rules().enabled(featureLendingProtocol); - bad = kFieldChanged(before, after, sfLedgerEntryType) || - kFieldChanged(before, after, sfLedgerIndex) || - kFieldChanged(before, after, sfSequence) || + bad = bad || kFieldChanged(before, after, sfSequence) || kFieldChanged(before, after, sfOwnerNode) || kFieldChanged(before, after, sfVaultNode) || kFieldChanged(before, after, sfVaultID) || @@ -1150,15 +1147,7 @@ NoModifiedUnmodifiableFields::finalize( kFieldChanged(before, after, sfCoverRateLiquidation); break; case ltLOAN: - /* - * We check this invariant regardless of lending protocol - * amendment status, allowing for detection and logging of - * potential issues even when the amendment is disabled. - */ - enforce = view.rules().enabled(featureLendingProtocol); - bad = kFieldChanged(before, after, sfLedgerEntryType) || - kFieldChanged(before, after, sfLedgerIndex) || - kFieldChanged(before, after, sfSequence) || + bad = bad || kFieldChanged(before, after, sfSequence) || kFieldChanged(before, after, sfOwnerNode) || kFieldChanged(before, after, sfLoanBrokerNode) || kFieldChanged(before, after, sfLoanBrokerID) || @@ -1177,19 +1166,28 @@ NoModifiedUnmodifiableFields::finalize( kFieldChanged(before, after, sfGracePeriod) || kFieldChanged(before, after, sfLoanScale); break; - default: + case ltVAULT: /* - * We check this invariant regardless of lending protocol - * amendment status, allowing for detection and logging of - * potential issues even when the amendment is disabled. - * - * We use the lending protocol as a gate, even though - * all transactions are affected because that's when it - * was added. + * sfAccount, sfAsset and sfShareMPTID are already + * captured by VaultInvariant. The additional fields + * below are introduced by featureLendingProtocolV1_1 + * and only exist on V1_1 vaults. */ - enforce = view.rules().enabled(featureLendingProtocol); - bad = kFieldChanged(before, after, sfLedgerEntryType) || - kFieldChanged(before, after, sfLedgerIndex); + if (view.rules().enabled(featureLendingProtocolV1_1)) + { + bad = bad || kFieldChanged(before, after, sfVaultKind) || + kFieldChanged(before, after, sfSubscriptionDate) || + kFieldChanged(before, after, sfRedemptionDate) || + kFieldChanged(before, after, sfSequence) || + kFieldChanged(before, after, sfOwnerNode) || + kFieldChanged(before, after, sfOwner) || + kFieldChanged(before, after, sfWithdrawalPolicy) || + kFieldChanged(before, after, sfScale) || + kFieldChanged(before, after, sfLEVersion); + } + break; + default: + break; } XRPL_ASSERT( !bad || enforce, diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index ce9a7c6e03..7b96790570 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -4,7 +4,10 @@ #include #include #include +#include +#include #include +#include #include #include #include // IWYU pragma: keep @@ -12,6 +15,8 @@ #include #include +#include + namespace xrpl { void @@ -26,7 +31,7 @@ ValidLoan::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after bool ValidLoan::finalize( STTx const& tx, - TER const, + TER const result, XRPAmount const, ReadView const& view, beast::Journal const& j) @@ -36,6 +41,35 @@ ValidLoan::finalize( for (auto const& [before, after] : loans_) { + // A closed-ended vault must not accept a loan whose final scheduled payment falls on or + // after the vault's RedemptionDate. This mirrors the LoanSet::preclaim gate and only fires + // on loan creation; once the loan exists, its StartDate / PaymentInterval are immutable and + // PaymentRemaining only decreases, so the bound is preserved. + if (!before && isTesSuccess(result)) + { + auto const broker = view.read(keylet::loanBroker(after->at(sfLoanBrokerID))); + if (broker) + { + auto const vault = view.read(keylet::vault(broker->at(sfVaultID))); + // We don't check for LendingProtocolV1_1 amendment because a ClosedEnded Vault will + // not exist without the amendment enabled + if (vault && getVaultKind(vault) == VaultKind::ClosedEnded) + { + std::uint32_t const startDate = after->at(sfStartDate); + std::uint32_t const interval = after->at(sfPaymentInterval); + std::uint32_t const remaining = after->at(sfPaymentRemaining); + std::uint32_t const redemption = vault->at(sfRedemptionDate); + if (std::uint64_t{startDate} + (std::uint64_t{interval} * remaining) >= + redemption) + { + JLOG(j.fatal()) << "Invariant failed: closed-ended loan final payment " + "must precede RedemptionDate"; + return false; + } + } + } + } + // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3223-invariants // If `Loan.PaymentRemaining = 0` then the loan MUST be fully paid off if (after->at(sfPaymentRemaining) == 0 && diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index 39922f69db..96d2c9de28 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -289,12 +289,13 @@ ValidMPTIssuance::finalize( "but created bad number of mptokens"; return false; } - // At most one MPToken may be created on withdraw/clawback since: + // At most two MPToken may be created on withdraw/clawback since: // - Liquidity Provider must have at least one token in order - // participate in AMM pool liquidity. + // participate in AMM pool liquidity or have LPTokens only. // - At most two MPTokens may be deleted if AMM pool, which has exactly // two tokens, is empty after withdraw/clawback. - if (mptokensCreated_ > 1 || mptokensDeleted_ > 2) + SOMETIMES(mptokensCreated_ == 2, "AMM withdraw/clawback recreated two MPTokens"); + if (mptokensCreated_ > 2 || mptokensDeleted_ > 2) { JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded " "but created/deleted bad number of mptokens"; diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index c577fdf356..dc6021beb5 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -24,11 +25,27 @@ #include #include #include +#include #include #include namespace xrpl { +namespace { + +/* + * True iff the recorded sfVaultKind identifies a closed-ended vault. + * Centralizes the presence + enum-value check used by the phase-gate + * invariants below. + */ +[[nodiscard]] bool +isClosedEnded(std::optional const& vaultKind) +{ + return vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded); +} + +} // namespace + ValidVault::Vault ValidVault::Vault::make(SLE const& from) { @@ -44,6 +61,9 @@ ValidVault::Vault::make(SLE const& from) self.assetsAvailable = from.at(sfAssetsAvailable); self.assetsMaximum = from.at(sfAssetsMaximum); self.lossUnrealized = from.at(sfLossUnrealized); + self.vaultKind = from[~sfVaultKind]; + self.subscriptionDate = from[~sfSubscriptionDate]; + self.redemptionDate = from[~sfRedemptionDate]; return self; } @@ -254,6 +274,37 @@ ValidVault::isVaultEmpty(Vault const& vault) return vault.assetsAvailable == 0 && vault.assetsTotal == 0; } +bool +ValidVault::finalizeLoanSet(ReadView const& view, beast::Journal const& j) const +{ + if (afterVault_.empty()) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::ValidVault::finalizeLoanSet : vault exists"); + return false; + // LCOV_EXCL_STOP + } + + auto const& afterVault = afterVault_[0]; + + // Loan origination against a closed-ended vault is only permitted while the vault is in the + // Investment phase - strictly past SubscriptionDate and before RedemptionDate. Open-ended + // vaults have NoPhase and are unaffected. + auto const phase = getVaultPhase( + view, afterVault.vaultKind, afterVault.subscriptionDate, afterVault.redemptionDate); + if (phase == VaultPhase::NoPhase) + return true; + + if (phase != VaultPhase::Investment) + { + JLOG(j.fatal()) << // + "Invariant failed: loan origination only allowed in Investment phase"; + return false; + } + + return true; +} + std::int32_t ValidVault::computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const { @@ -520,6 +571,9 @@ ValidVault::finalize( result = false; } + // Immutability of VaultKind, SubscriptionDate and RedemptionDate is enforced by + // NoModifiedUnmodifiableFields in InvariantCheck.cpp. + auto const beforeShares = [&]() -> std::optional { if (beforeVault_.empty()) return std::nullopt; @@ -606,6 +660,26 @@ ValidVault::finalize( result = false; } + if (isClosedEnded(afterVault.vaultKind)) + { + if (!afterVault.subscriptionDate || !afterVault.redemptionDate) + { + JLOG(j.fatal()) // + << "Invariant failed: closed-ended vault must have SubscriptionDate " + "and RedemptionDate"; + result = false; + } + else if (!isValidClosedEndedGap( + *afterVault.subscriptionDate, *afterVault.redemptionDate)) + { + JLOG(j.fatal()) // + << "Invariant failed: closed-ended vault RedemptionDate - " + "SubscriptionDate must be within [MIN_INVESTMENT_PERIOD, " + "MAX_INVESTMENT_PERIOD)"; + result = false; + } + } + return result; } case ttVAULT_SET: { @@ -666,6 +740,21 @@ ValidVault::finalize( !beforeVault_.empty(), "xrpl::ValidVault::finalize : deposit updated a vault"); auto const& beforeVault = beforeVault_[0]; + // Deposit is only allowed while the vault is in NoPhase or + // Subscription. + auto const depositPhase = getVaultPhase( + view, + afterVault.vaultKind, + afterVault.subscriptionDate, + afterVault.redemptionDate); + if (depositPhase != VaultPhase::NoPhase && depositPhase != VaultPhase::Subscription) + { + JLOG(j.fatal()) << // + "Invariant failed: deposit only allowed in " + "Subscription or NoPhase"; + result = false; + } + auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); if (!maybeVaultDeltaAssets) { @@ -804,6 +893,20 @@ ValidVault::finalize( "xrpl::ValidVault::finalize : withdrawal updated a vault"); auto const& beforeVault = beforeVault_[0]; + // Withdrawal from a closed-ended vault is not allowed during the Investment phase + // (strictly past SubscriptionDate, before RedemptionDate). + if (getVaultPhase( + view, + afterVault.vaultKind, + afterVault.subscriptionDate, + afterVault.redemptionDate) == VaultPhase::Investment) + { + JLOG(j.fatal()) << // + "Invariant failed: withdrawal not allowed during " + "Investment phase"; + result = false; + } + auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); if (!maybeVaultDeltaAssets) { @@ -1052,6 +1155,7 @@ ValidVault::finalize( } case ttLOAN_SET: + return finalizeLoanSet(view, j); case ttLOAN_MANAGE: case ttLOAN_PAY: return true; diff --git a/src/libxrpl/tx/paths/BookStep.cpp b/src/libxrpl/tx/paths/BookStep.cpp index e7c2e9ee29..2823627108 100644 --- a/src/libxrpl/tx/paths/BookStep.cpp +++ b/src/libxrpl/tx/paths/BookStep.cpp @@ -44,7 +44,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -653,7 +655,15 @@ limitStepIn( // under an amendment. ofrAmt = offer.limitIn(ofrAmt, inLmt, /* roundUp */ false); stpAmt.out = ofrAmt.out; - ownerGives = mulRatio(ofrAmt.out, transferRateOut, QUALITY_ONE, /*roundUp*/ false); + // Round up for MPT output so the offer owner pays the full + // ceil(amount × rate) fee, matching direct Payment semantics. IOU uses + // floating-point arithmetic so the floor/ceil distinction is sub-epsilon + // there; preserve the historical false to avoid changing IOU behavior. + ownerGives = mulRatio( + ofrAmt.out, + transferRateOut, + QUALITY_ONE, + /*roundUp*/ std::is_same_v); } } @@ -672,7 +682,11 @@ limitStepOut( if (limit < stpAmt.out) { stpAmt.out = limit; - ownerGives = mulRatio(stpAmt.out, transferRateOut, QUALITY_ONE, /*roundUp*/ false); + ownerGives = mulRatio( + stpAmt.out, + transferRateOut, + QUALITY_ONE, + /*roundUp*/ std::is_same_v); ofrAmt = offer.limitOut( ofrAmt, stpAmt.out, @@ -727,17 +741,20 @@ BookStep::forEachOffer( bool const isAssetInMPT = assetIn.holds(); auto const& owner = offer.owner(); - if (isAssetInMPT) - { - // Create MPToken for the offer's owner. No need to check - // for the reserve since the offer is removed if it is consumed. - // Therefore, the owner count remains the same. - if (auto const err = checkCreateMPT(sb, assetIn.get(), owner, {}, j_); - !isTesSuccess(err)) + auto removeOffer = [&](std::string_view logMessage = {}) { + auto const key = offer.key(); + if (!logMessage.empty()) { - return true; + JLOG(j_.trace()) << logMessage << (key ? " " + to_string(*key) : ""); } - } + if (key) + offers.permRmOffer(*key); + if (!offerAttempted) + { + // Change quality only if no previous offers were tried. + ofrQ = std::nullopt; + } + }; // It shouldn't matter from auth point of view whether it's sb // or afView. Amendment guard this change just in case. @@ -745,17 +762,15 @@ BookStep::forEachOffer( // Make sure offer owner has authorization to own Assets from issuer // and MPT assets can be traded/transferred. // An account can always own XRP or their own Assets. - if (!isTesSuccess(requireAuth(applyView, assetIn, owner)) || !checkMPTDEX(sb, owner)) + // Missing MPTokens are allowed during offer discovery; they are + // created later if the offer is actually consumed. + auto const authType = isAssetInMPT ? AuthType::WeakAuth : AuthType::Legacy; + if (!isTesSuccess(requireAuth(applyView, assetIn, owner, authType)) || + !checkMPTDEX(sb, owner)) { // Offer owner not authorized to hold IOU/MPT from issuer. // Remove this offer even if no crossing occurs. - if (auto const key = offer.key()) - offers.permRmOffer(*key); - if (!offerAttempted) - { - // Change quality only if no previous offers were tried. - ofrQ = std::nullopt; - } + removeOffer(); // Returning true causes offers.step() to delete the offer. return true; } @@ -768,52 +783,88 @@ BookStep::forEachOffer( static_cast(this)->getOfrOutRate(prevStep_, owner, strandDst_, trOut)); auto ofrAmt = offer.amount(); - TAmounts stpAmt{mulRatio(ofrAmt.in, ofrInRate, QUALITY_ONE, /*roundUp*/ true), ofrAmt.out}; - - // owner pays the transfer fee. - auto ownerGives = mulRatio(ofrAmt.out, ofrOutRate, QUALITY_ONE, /*roundUp*/ false); - - auto const funds = offer.isFunded() - ? ownerGives // Offer owner is issuer; they have unlimited funds - : offers.ownerFunds(); - - // Only if CLOB offer - if (funds < ownerGives) + TAmounts stpAmt{ofrAmt.in, ofrAmt.out}; + auto ownerGives = ofrAmt.out; + try { - // We already know offer.owner()!=offer.issueOut().account - ownerGives = funds; - stpAmt.out = mulRatio(ownerGives, QUALITY_ONE, ofrOutRate, /*roundUp*/ false); - - // It turns out we can prevent order book blocking by (strictly) - // rounding down the ceil_out() result. This adjustment changes - // transaction outcomes, so it must be made under an amendment. - ofrAmt = offer.limitOut(ofrAmt, stpAmt.out, /*roundUp*/ false); - + // All arithmetic in this block runs before the offer is consumed. + // A crafted MPTokensV2 offer can overflow while transfer rates or + // crossing limits are applied; remove that unusable offer instead + // of letting it persist as a tecINTERNAL source. stpAmt.in = mulRatio(ofrAmt.in, ofrInRate, QUALITY_ONE, /*roundUp*/ true); - } - // Limit offer's input if MPT, BookStep is the first step (an issuer - // is making a cross-currency payment), and this offer is not owned - // by the issuer. Otherwise, OutstandingAmount may overflow. - auto const& issuer = assetIn.getIssuer(); - if (isAssetInMPT && !prevStep_ && offer.owner() != issuer) - { - // Funds available to issue - auto const available = toAmount(accountFunds( - sb, - issuer, - assetIn, // STAmount{0}, but the default is not used - FreezeHandling::IgnoreFreeze, - AuthHandling::IgnoreAuth, - j_)); - if (stpAmt.in > available) + // owner pays the transfer fee. + ownerGives = mulRatio( + ofrAmt.out, + ofrOutRate, + QUALITY_ONE, + /*roundUp*/ std::is_same_v); + + auto const funds = offer.isFunded() + ? ownerGives // Offer owner is issuer; they have unlimited funds + : offers.ownerFunds(); + + // Only if CLOB offer + if (funds < ownerGives) { - limitStepIn(offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate, available); - } - } + // We already know offer.owner()!=offer.issueOut().account + ownerGives = funds; + stpAmt.out = mulRatio(ownerGives, QUALITY_ONE, ofrOutRate, /*roundUp*/ false); - offerAttempted = true; - return callback(offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate); + // It turns out we can prevent order book blocking by (strictly) + // rounding down the ceil_out() result. This adjustment changes + // transaction outcomes, so it must be made under an amendment. + ofrAmt = offer.limitOut(ofrAmt, stpAmt.out, /*roundUp*/ false); + + stpAmt.in = mulRatio(ofrAmt.in, ofrInRate, QUALITY_ONE, /*roundUp*/ true); + } + + // Limit offer's input if MPT, BookStep is the first step (an issuer + // is making a cross-currency payment), and this offer is not owned + // by the issuer. Otherwise, OutstandingAmount may overflow. + auto const& issuer = assetIn.getIssuer(); + if (isAssetInMPT && !prevStep_ && offer.owner() != issuer) + { + // Funds available to issue + auto const available = toAmount(accountFunds( + sb, + issuer, + assetIn, // STAmount{0}, but the default is not used + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + j_)); + if (stpAmt.in > available) + { + limitStepIn( + offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate, available); + } + } + + offerAttempted = true; + return callback(offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate); + } + catch (std::overflow_error const&) + { + if (sb.rules().enabled(featureMPTokensV2)) + { + SOMETIMES( + true, + "BookStep::forEachOffer removed MPT offer after " + "overflow during crossing"); + removeOffer("Removing offer with overflowing amount calculation"); + return true; + } + // An overflow can only be produced by a crafted MPT offer, and MPT + // offers require featureMPTokensV2 (enforced at OfferCreate + // preflight). So the amendment is always enabled when we get here + // and this legacy re-throw is unreachable in practice. + // LCOV_EXCL_START + XRPL_ASSERT( + sb.rules().enabled(featureMPTokensV2), + "xrpl::BookStep::forEachOffer : overflow implies MPTokensV2"); + throw; + // LCOV_EXCL_STOP + } }; // At any payment engine iteration, AMM offer can only be consumed once. @@ -873,6 +924,22 @@ BookStep::consumeOffer( // The offer owner gets the ofrAmt. The difference between ofrAmt and // stepAmt is a transfer fee that goes to book_.in.account { + if constexpr (std::is_same_v) + { + // If the offer's TakerPays asset is an MPT, the offer owner must + // hold an MPToken to receive it. Create one here if it doesn't + // already exist. + if (auto const err = checkCreateMPT(sb, book_.in.get(), offer.owner(), j_); + !isTesSuccess(err)) + { + // checkCreateMPT only fails on tecDIR_FULL (its source line is + // itself LCOV-excluded) or a missing offer-owner account, which + // cannot happen since that account owns the offer being + // consumed. Defensive and unreachable in practice. + Throw(err); // LCOV_EXCL_LINE + } + } + auto const dr = offer.send( sb, book_.in.getIssuer(), offer.owner(), toSTAmount(ofrAmt.in, book_.in), j_); if (!isTesSuccess(dr)) @@ -1043,6 +1110,13 @@ BookStep::revImp( auto ofrAdjAmt = ofrAmt; auto stpAdjAmt = stpAmt; auto ownerGivesAdj = ownerGives; + // This reduction can overflow via the transfer-rate mulRatio() on a + // 63-bit MPT amount (IOU rescales instead of throwing, and XRP stays + // under the int64 limit, so only MPT reaches it today), but + // savedIns/savedOuts are not updated until after it succeeds. The outer + // execOffer() catch can therefore remove the offer under + // featureMPTokensV2 (legacy propagate-the-exception behavior otherwise) + // without rolling back local state. limitStepOut( offer, ofrAdjAmt, @@ -1144,12 +1218,25 @@ BookStep::fwdImp( auto stpAdjAmt = stpAmt; auto ownerGivesAdj = ownerGives; + // limitStepIn()/limitStepOut() can throw std::overflow_error from the + // transfer-rate mulRatio() on a 63-bit MPT amount. (IOUAmount::mulRatio + // rescales rather than throwing, and XRP amounts/rates stay under the + // int64 limit, so in practice only MPT reaches this today.) execOffer() + // catches it: under featureMPTokensV2 the offending offer is removed; + // otherwise the legacy behavior (propagate the exception) is preserved. + // Keep candidate accumulator changes local until those calls succeed so + // the catch path does not observe partially updated state. Re-sum the + // staged sets to preserve historical flat_multiset summing behavior. + auto savedInsAdj = savedIns; + auto savedOutsAdj = savedOuts; + auto resultAdj = result; typename boost::container::flat_multiset::const_iterator lastOut; + if (stpAmt.in <= remainingIn) { - savedIns.insert(stpAmt.in); - lastOut = savedOuts.insert(stpAmt.out); - result = TAmounts(sum(savedIns), sum(savedOuts)); + savedInsAdj.insert(stpAmt.in); + lastOut = savedOutsAdj.insert(stpAmt.out); + resultAdj = TAmounts(sum(savedInsAdj), sum(savedOutsAdj)); // consume the offer even if stepAmt.in == remainingIn processMore = true; } @@ -1163,15 +1250,15 @@ BookStep::fwdImp( transferRateIn, transferRateOut, remainingIn); - savedIns.insert(remainingIn); - lastOut = savedOuts.insert(stpAdjAmt.out); - result.out = sum(savedOuts); - result.in = in; + savedInsAdj.insert(remainingIn); + lastOut = savedOutsAdj.insert(stpAdjAmt.out); + resultAdj.out = sum(savedOutsAdj); + resultAdj.in = in; processMore = false; } - if (result.out > cache_->out && result.in <= cache_->in) + if (resultAdj.out > cache_->out && resultAdj.in <= cache_->in) { // The step produced more output in the forward pass than the // reverse pass while consuming the same input (or less). If we @@ -1181,8 +1268,8 @@ BookStep::fwdImp( // input provided in the forward step and produce the output // requested from the reverse step. auto const lastOutAmt = *lastOut; - savedOuts.erase(lastOut); - auto const remainingOut = cache_->out - sum(savedOuts); + savedOutsAdj.erase(lastOut); + auto const remainingOut = cache_->out - sum(savedOutsAdj); auto ofrAdjAmtRev = ofrAmt; auto stpAdjAmtRev = stpAmt; auto ownerGivesAdjRev = ownerGives; @@ -1197,13 +1284,13 @@ BookStep::fwdImp( if (stpAdjAmtRev.in == remainingIn) { - result.in = in; - result.out = cache_->out; + resultAdj.in = in; + resultAdj.out = cache_->out; - savedIns.clear(); - savedIns.insert(result.in); - savedOuts.clear(); - savedOuts.insert(result.out); + savedInsAdj.clear(); + savedInsAdj.insert(resultAdj.in); + savedOutsAdj.clear(); + savedOutsAdj.insert(resultAdj.out); ofrAdjAmt = ofrAdjAmtRev; stpAdjAmt.in = remainingIn; @@ -1214,10 +1301,15 @@ BookStep::fwdImp( { // This is (likely) a problem case, and will be caught // with later checks - savedOuts.insert(lastOutAmt); + savedOutsAdj.insert(lastOutAmt); } } + // Commit the staged accounting only after limitStepIn()/limitStepOut() + // have succeeded. + savedIns = std::move(savedInsAdj); + savedOuts = std::move(savedOutsAdj); + result = resultAdj; remainingIn = in - result.in; this->consumeOffer(sb, offer, ofrAdjAmt, stpAdjAmt, ownerGivesAdj); diff --git a/src/libxrpl/tx/paths/MPTEndpointStep.cpp b/src/libxrpl/tx/paths/MPTEndpointStep.cpp index 0a0f6a9f27..a47cfa15a5 100644 --- a/src/libxrpl/tx/paths/MPTEndpointStep.cpp +++ b/src/libxrpl/tx/paths/MPTEndpointStep.cpp @@ -410,8 +410,7 @@ MPTEndpointOfferCrossingStep::checkCreateMPT(ApplyView& view, xrpl::DebtDirectio // for the reserve since the offer doesn't go on the books // if crossed. Insufficient reserve is allowed if the offer // crossed. See CreateOffer::applyGuts() for reserve check. - if (auto const err = xrpl::checkCreateMPT(view, mptIssue_, dst_, {}, j_); - !isTesSuccess(err)) + if (auto const err = xrpl::checkCreateMPT(view, mptIssue_, dst_, j_); !isTesSuccess(err)) { JLOG(j_.trace()) << "MPTEndpointStep::checkCreateMPT: failed create MPT"; resetCache(srcDebtDir); diff --git a/src/libxrpl/tx/paths/OfferStream.cpp b/src/libxrpl/tx/paths/OfferStream.cpp index ecc8416a2b..2f2fef49f0 100644 --- a/src/libxrpl/tx/paths/OfferStream.cpp +++ b/src/libxrpl/tx/paths/OfferStream.cpp @@ -29,6 +29,8 @@ #include #include +#include +#include namespace xrpl { @@ -136,17 +138,17 @@ template TOfferStreamBase::shouldRmSmallIncreasedQOffer() const { // Consider removing the offer if: - // o `TakerPays` is XRP (because of XRP drops granularity) or + // o `TakerPays` is integral (because XRP/MPT have indivisible units) or // o `TakerPays` and `TakerGets` are both IOU and `TakerPays`<`TakerGets` - static constexpr bool kInIsXrp = std::is_same_v; - static constexpr bool kOutIsXrp = std::is_same_v; + constexpr bool const kInIsIntegral = !std::is_same_v; + constexpr bool const kOutIsIntegral = !std::is_same_v; - if constexpr (kOutIsXrp) + if constexpr (!kInIsIntegral && kOutIsIntegral) { - // If `TakerGets` is XRP, the worst this offer's quality can change is - // to about 10^-81 `TakerPays` and 1 drop `TakerGets`. This will be - // remarkably good quality for any realistic asset, so these offers - // don't need this extra check. + // If only `TakerGets` is integral, the worst this offer's quality can + // change is to about 10^-81 `TakerPays` and 1 unit `TakerGets`. This + // will be perfect quality for any realistic asset, so these + // offers don't need this extra check. return false; } @@ -156,7 +158,7 @@ TOfferStreamBase::shouldRmSmallIncreasedQOffer() const TAmounts const ofrAmts{ toAmount(offer_.amount().in), toAmount(offer_.amount().out)}; - if constexpr (!kInIsXrp && !kOutIsXrp) + if constexpr (!kInIsIntegral && !kOutIsIntegral) { if (Number(ofrAmts.in) >= Number(ofrAmts.out)) return false; @@ -165,7 +167,12 @@ TOfferStreamBase::shouldRmSmallIncreasedQOffer() const TTakerGets const ownerFunds = toAmount(*ownerFunds_); auto const effectiveAmounts = [&] { - if (offer_.owner() != offer_.assetOut().getIssuer() && ownerFunds < ofrAmts.out) + // Issuer-owned IOU offers are self-funded without a limit. MPT issuer + // offers are bounded by remaining issuance capacity, so they still need + // to be clipped by ownerFunds. + bool const issuerHasUnlimitedFunds = offer_.owner() == offer_.assetOut().getIssuer() && + offer_.assetOut().template holds(); + if (!issuerHasUnlimitedFunds && ownerFunds < ofrAmts.out) { // adjust the amounts by owner funds. // @@ -305,7 +312,41 @@ TOfferStreamBase::step() continue; } - if (shouldRmSmallIncreasedQOffer()) + // Partially funded offers can be reduced before BookStep sees them. + // If that strict reduction overflows under MPTokensV2, remove the + // unusable offer instead of leaving it at the book tip. + bool shouldRemoveSmallIncreasedQOffer = false; + try + { + shouldRemoveSmallIncreasedQOffer = shouldRmSmallIncreasedQOffer(); + } + catch (std::overflow_error const&) + { + if (view_.rules().enabled(featureMPTokensV2)) + { + SOMETIMES( + true, + "OfferStream::step removed MPT offer with overflowing " + "reduced quality"); + permRmOffer(entry->key()); + JLOG(j_.warn()) << "Removing offer with overflowing reduced quality " + << entry->key(); + offer_ = TOffer{}; + continue; + } + // The strict reduction only overflows for a crafted MPT offer, and + // MPT offers require featureMPTokensV2 (enforced at OfferCreate + // preflight). So the amendment is always enabled here and this + // legacy re-throw is unreachable in practice. + // LCOV_EXCL_START + XRPL_ASSERT( + view_.rules().enabled(featureMPTokensV2), + "xrpl::TOfferStreamBase::step : overflow implies MPTokensV2"); + throw; + // LCOV_EXCL_STOP + } + + if (shouldRemoveSmallIncreasedQOffer) { auto const originalFunds = accountFundsHelper( cancelView_, diff --git a/src/libxrpl/tx/transactors/account/AccountDelete.cpp b/src/libxrpl/tx/transactors/account/AccountDelete.cpp index ce027f4cad..0936fe26dc 100644 --- a/src/libxrpl/tx/transactors/account/AccountDelete.cpp +++ b/src/libxrpl/tx/transactors/account/AccountDelete.cpp @@ -50,7 +50,7 @@ AccountDelete::preflight(PreflightContext const& ctx) return temDST_IS_SRC; } - if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err)) + if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err)) return err; return tesSUCCESS; diff --git a/src/libxrpl/tx/transactors/check/CheckCash.cpp b/src/libxrpl/tx/transactors/check/CheckCash.cpp index e4d8f192c0..857f759752 100644 --- a/src/libxrpl/tx/transactors/check/CheckCash.cpp +++ b/src/libxrpl/tx/transactors/check/CheckCash.cpp @@ -528,7 +528,7 @@ CheckCash::doApply() return tecINSUFFICIENT_RESERVE; if (auto const err = - checkCreateMPT(psb, mptID, accountID_, *sponsorSle, j_); + checkCreateMPT(psb, mptID, accountID_, *sponsorSle, 0, j_); !isTesSuccess(err)) { return err; diff --git a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp index c1ef9f875e..e690cd7693 100644 --- a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp @@ -227,6 +227,7 @@ AMMClawback::applyGuts(Sandbox& sb) sb, *ammSle, holder, + issuer, ammAccount, amountBalance, amount2Balance, @@ -256,7 +257,7 @@ AMMClawback::applyGuts(Sandbox& sb) } if (!isTesSuccess(result)) - return result; // LCOV_EXCL_LINE + return result; if (sb.rules().enabled(fixCleanup3_3_0) && sb.rules().enabled(fixAMMv1_3)) { @@ -311,6 +312,14 @@ AMMClawback::equalWithdrawMatchingOneAmount( STAmount const& holdLPtokens, STAmount const& amount) { + // The clawback issuer signs for its own asset only. Threaded into the + // withdrawal so a recreated MPToken is auto-authorized only for the + // clawback issuer's asset, never for a paired asset from another issuer. + // preflight guarantees sfAccount is the clawed asset's issuer (it rejects + // the tx as temMALFORMED when sfAsset's issuer != sfAccount), so this is + // the issuer, not just any signer. + AccountID const issuer = ctx_.tx[sfAccount]; + auto frac = Number{amount} / amountBalance; auto amount2Withdraw = amount2Balance * frac; @@ -324,6 +333,7 @@ AMMClawback::equalWithdrawMatchingOneAmount( sb, ammSle, holder, + issuer, ammAccount, amountBalance, amount2Balance, @@ -353,10 +363,18 @@ AMMClawback::equalWithdrawMatchingOneAmount( auto amountRounded = getRoundedAsset(rules, amountBalance, frac, IsDeposit::No); + // The requested clawback amount is likely too small and results in + // one-sided pool withdrawal due to round off. Fail so the issuer can + // clawback a larger amount. + if (rules.enabled(fixCleanup3_4_0) && + (amountRounded == beast::kZero || amount2Rounded == beast::kZero)) + return {tecAMM_FAILED, STAmount{}, STAmount{}, STAmount{}}; + return AMMWithdraw::withdraw( sb, ammSle, ammAccount, + issuer, holder, amountBalance, amountRounded, @@ -377,6 +395,7 @@ AMMClawback::equalWithdrawMatchingOneAmount( sb, ammSle, ammAccount, + issuer, holder, amountBalance, amount, diff --git a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp index 5294dd0c7f..edd2cc2037 100644 --- a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -516,6 +517,7 @@ AMMWithdraw::withdraw( view, ammSle, ammAccount, + std::nullopt, accountID_, amountBalance, amountWithdraw, @@ -536,6 +538,7 @@ AMMWithdraw::withdraw( Sandbox& view, SLE const& ammSle, AccountID const& ammAccount, + std::optional const& clawbackIssuer, AccountID const& account, STAmount const& amountBalance, STAmount const& amountWithdraw, @@ -703,14 +706,48 @@ AMMWithdraw::withdraw( if (mptokenKey && account != asset.getIssuer()) { auto const& mptIssue = asset.get(); + std::uint32_t createFlags = 0; if (auto const err = requireAuth(view, mptIssue, account, AuthType::WeakAuth); !isTesSuccess(err)) - return err; + { + if (authHandling != AuthHandling::IgnoreAuth || err != tecNO_AUTH) + { + // Unreachable in practice. Normal withdraws (authHandling + // != IgnoreAuth) are rejected for unauthorized holders in + // preclaim, so they never get here. Under clawback + // (IgnoreAuth) requireAuth returns a non-tecNO_AUTH error + // (e.g. tecEXPIRED) only for a domain-authorized MPT, but no + // such MPT can be in an AMM pool: a directly domain-gated + // RequireAuth MPT fails AMMCreate/deposit with tecNO_AUTH, + // and vault shares (whose recursive auth could yield + // tecEXPIRED) are rejected by AMMCreate with tecWRONG_ASSET. + return err; // LCOV_EXCL_LINE + } - if (auto const err = checkCreateMPT(view, mptIssue, account, {}, journal); + // AMMClawback ignores authorization so the issuer can recover + // MPT locked in the pool even if the holder deleted their + // MPToken. Only auto-authorize the recreated MPToken for the + // clawback issuer's own asset: authorization is granted by an + // asset's issuer, and the clawback transaction is signed by + // that issuer only for its own asset. For a paired asset issued + // by a different account, recreate the MPToken *unauthorized* so + // the clawback does not grant authorization on behalf of that + // issuer (which would bypass its lsfMPTRequireAuth). The holder + // still receives the paired asset (accountSend only requires the + // MPToken to exist, not to be authorized); the balance remains + // gated by its issuer until that issuer authorizes it. + if (clawbackIssuer && asset.getIssuer() == *clawbackIssuer) + createFlags = lsfMPTAuthorized; + } + + if (auto const err = checkCreateMPT(view, mptIssue, account, {}, createFlags, journal); !isTesSuccess(err)) { - return err; + // checkCreateMPT only fails on tecDIR_FULL (its source line is + // itself LCOV-excluded) or a missing account, which cannot + // happen since `account` is the withdrawing LP. Defensive and + // unreachable in practice. + return err; // LCOV_EXCL_LINE } } return tesSUCCESS; @@ -804,6 +841,7 @@ AMMWithdraw::equalWithdrawTokens( view, ammSle, accountID_, + std::nullopt, ammAccount, amountBalance, amount2Balance, @@ -856,6 +894,7 @@ AMMWithdraw::equalWithdrawTokens( Sandbox& view, SLE const& ammSle, AccountID const account, + std::optional const& clawbackIssuer, AccountID const& ammAccount, STAmount const& amountBalance, STAmount const& amount2Balance, @@ -878,6 +917,7 @@ AMMWithdraw::equalWithdrawTokens( view, ammSle, ammAccount, + clawbackIssuer, account, amountBalance, amountBalance, @@ -913,6 +953,7 @@ AMMWithdraw::equalWithdrawTokens( view, ammSle, ammAccount, + clawbackIssuer, account, amountBalance, amountWithdraw, diff --git a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp index 5fc0aef853..32f4d9ec48 100644 --- a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp +++ b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp @@ -111,7 +111,7 @@ EscrowFinish::preflightSigValidated(PreflightContext const& ctx) } } - if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err)) + if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err)) return err; return tesSUCCESS; diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp index 61fe14e8fc..06907ce366 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp @@ -14,7 +14,6 @@ #include #include #include -#include #include #include #include @@ -213,8 +212,6 @@ LoanBrokerDelete::doApply() view().erase(broker); - associateAsset(*broker, vaultAsset); - return tesSUCCESS; } diff --git a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp index 1a77489b4b..bc8e974d10 100644 --- a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp @@ -130,9 +130,6 @@ LoanDelete::doApply() // Decrement the borrower's owner count decreaseOwnerCountForObject(view, borrowerSle, loanSle, 1, j_); - // These associations shouldn't do anything, but do them just to be safe - associateAsset(*loanSle, vaultAsset); - associateAsset(*brokerSle, vaultAsset); associateAsset(*vaultSle, vaultAsset); return tesSUCCESS; diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 6533a47916..2def3d2eb2 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -225,6 +226,8 @@ TER LoanSet::preclaim(PreclaimContext const& ctx) { auto const& tx = ctx.tx; + auto const interval = ctx.tx.at(~sfPaymentInterval).value_or(kDefaultPaymentInterval); + auto const total = ctx.tx.at(~sfPaymentTotal).value_or(kDefaultPaymentTotal); { // Check for numeric overflow of the schedule before we load any @@ -238,9 +241,6 @@ LoanSet::preclaim(PreclaimContext const& ctx) static_assert(kMaxTime == 4'294'967'295); auto const timeAvailable = kMaxTime - getStartDate(ctx.view); - - auto const interval = ctx.tx.at(~sfPaymentInterval).value_or(kDefaultPaymentInterval); - auto const total = ctx.tx.at(~sfPaymentTotal).value_or(kDefaultPaymentTotal); auto const grace = ctx.tx.at(~sfGracePeriod).value_or(kDefaultGracePeriod); // The grace period can't be larger than the interval. Check it first, @@ -310,6 +310,32 @@ LoanSet::preclaim(PreclaimContext const& ctx) return tefBAD_LEDGER; // LCOV_EXCL_LINE } + if (ctx.view.rules().enabled(featureLendingProtocolV1_1)) + { + auto const phase = getVaultPhase(ctx.view, vault); + if (phase == VaultPhase::Subscription) + { + JLOG(ctx.j.warn()) << "Vault is still in the subscription phase."; + return tecTOO_SOON; + } + if (phase == VaultPhase::Redemption) + { + JLOG(ctx.j.warn()) << "Vault has entered the redemption phase."; + return tecEXPIRED; + } + if (phase == VaultPhase::Investment) + { + auto const finalPayment = + std::uint64_t{getStartDate(ctx.view)} + (std::uint64_t{interval} * total); + if (finalPayment >= vault->at(sfRedemptionDate)) + { + JLOG(ctx.j.warn()) << "Final loan payment date is on or after " + "the vault's redemption date."; + return tecNO_PERMISSION; + } + } + } + if (vault->at(sfAssetsMaximum) != 0 && vault->at(sfAssetsTotal) >= vault->at(sfAssetsMaximum)) { JLOG(ctx.j.warn()) << "Vault at maximum assets limit. Can't add another loan."; diff --git a/src/libxrpl/tx/transactors/payment/Payment.cpp b/src/libxrpl/tx/transactors/payment/Payment.cpp index 17c96a1919..c8b00f0193 100644 --- a/src/libxrpl/tx/transactors/payment/Payment.cpp +++ b/src/libxrpl/tx/transactors/payment/Payment.cpp @@ -281,7 +281,7 @@ Payment::preflight(PreflightContext const& ctx) } } - if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err)) + if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err)) return err; return tesSUCCESS; diff --git a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp index b8118bc49f..9143a675f6 100644 --- a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp +++ b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp @@ -87,7 +87,7 @@ PaymentChannelClaim::preflight(PreflightContext const& ctx) return temBAD_SIGNATURE; } - if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err)) + if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err)) return err; return tesSUCCESS; diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp index 6366e99105..19ec99702a 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include #include @@ -70,7 +71,14 @@ ConfidentialMPTClawback::preclaim(PreclaimContext const& ctx) // Sanity check: account must be the same as issuer if (sleIssuance->getAccountID(sfIssuer) != account) - return tefINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTClawback::preclaim : preflight already validated the " + "submitter is the issuer"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } // Check if issuance has issuer ElGamal public key if (!sleIssuance->isFieldPresent(sfIssuerEncryptionKey)) @@ -127,7 +135,14 @@ ConfidentialMPTClawback::doApply() auto sleHolderMPToken = view().peek(keylet::mptoken(mptIssuanceID, holder)); if (!sleIssuance || !sleHolderMPToken) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTClawback::doApply : preclaim already validated these " + "objects exist"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const clawAmount = ctx_.tx[sfMPTAmount]; @@ -137,11 +152,25 @@ ConfidentialMPTClawback::doApply() // After clawback, the balance should be encrypted zero. auto const encZeroForHolder = encryptCanonicalZeroAmount(holderPubKey, holder, mptIssuanceID); if (!encZeroForHolder) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTClawback::doApply : canonical zero encryption cannot fail " + "for an already-valid holder public key"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto encZeroForIssuer = encryptCanonicalZeroAmount(issuerPubKey, holder, mptIssuanceID); if (!encZeroForIssuer) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTClawback::doApply : canonical zero encryption cannot fail " + "for an already-valid issuer public key"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } // Set holder's confidential balances to encrypted zero (*sleHolderMPToken)[sfConfidentialBalanceInbox] = *encZeroForHolder; @@ -154,14 +183,28 @@ ConfidentialMPTClawback::doApply() // Sanity check: the issuance must have an auditor public key if // auditing is enabled. if (!sleIssuance->isFieldPresent(sfAuditorEncryptionKey)) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTClawback::doApply : the holder's auditor balance implies " + "the issuance has an auditor public key"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const auditorPubKey = (*sleIssuance)[sfAuditorEncryptionKey]; auto encZeroForAuditor = encryptCanonicalZeroAmount(auditorPubKey, holder, mptIssuanceID); if (!encZeroForAuditor) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTClawback::doApply : canonical zero encryption cannot " + "fail for an already-valid auditor public key"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } (*sleHolderMPToken)[sfAuditorEncryptedBalance] = std::move(*encZeroForAuditor); } diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp index 454eb39ead..5be3892151 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -89,7 +90,14 @@ ConfidentialMPTConvert::preclaim(PreclaimContext const& ctx) // already checked in preflight, but should also check that issuer on the // issuance isn't the account either if (sleIssuance->getAccountID(sfIssuer) == account) - return tefINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvert::preclaim : issuer derived from the MPT ID must " + "match the ledger's stored issuer"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } bool const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount); bool const requiresAuditor = sleIssuance->isFieldPresent(sfAuditorEncryptionKey); @@ -207,11 +215,25 @@ ConfidentialMPTConvert::doApply() auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_)); if (!sleMptoken) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvert::doApply : preclaim already validated the MPToken " + "exists"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto sleIssuance = view().peek(keylet::mptokenIssuance(mptIssuanceID)); if (!sleIssuance) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvert::doApply : preclaim already validated the issuance " + "exists"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const amtToConvert = ctx_.tx[sfMPTAmount]; auto const amt = (*sleMptoken)[~sfMPTAmount].valueOr(0); @@ -273,7 +295,14 @@ ConfidentialMPTConvert::doApply() if (auditorEc) { if (!sleMptoken->isFieldPresent(sfAuditorEncryptedBalance)) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvert::doApply : issuance-level auditing implies " + "the MPToken already carries an auditor balance"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto sum = homomorphicAdd(*auditorEc, (*sleMptoken)[sfAuditorEncryptedBalance]); if (!sum) @@ -308,7 +337,14 @@ ConfidentialMPTConvert::doApply() (*sleMptoken)[sfHolderEncryptionKey], accountID_, mptIssuanceID); if (!zeroBalance) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvert::doApply : canonical zero encryption cannot fail " + "for an already-valid holder public key"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } (*sleMptoken)[sfConfidentialBalanceSpending] = std::move(*zeroBalance); } @@ -316,7 +352,12 @@ ConfidentialMPTConvert::doApply() { // both sfIssuerEncryptedBalance and sfConfidentialBalanceInbox should // exist together - return tecINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvert::doApply : confidential balance fields must be all " + "present or all absent"); + return tecINTERNAL; + // LCOV_EXCL_STOP } view().update(sleIssuance); diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp index 87f9e476d6..1e3617ffbd 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -72,7 +73,14 @@ verifyProofs( std::shared_ptr const& mptoken) { if (!mptoken->isFieldPresent(sfHolderEncryptionKey)) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::verifyProofs : preclaim already validated the holder encryption key is " + "present"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const mptIssuanceID = tx[sfMPTokenIssuanceID]; auto const account = tx[sfAccount]; @@ -169,7 +177,14 @@ ConfidentialMPTConvertBack::preclaim(PreclaimContext const& ctx) // already checked in preflight, but should also check that issuer on // the issuance isn't the account either if (sleIssuance->getAccountID(sfIssuer) == account) - return tefINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvertBack::preclaim : issuer derived from the MPT ID must " + "match the ledger's stored issuer"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } auto const sleMptoken = ctx.view.read(keylet::mptoken(mptIssuanceID, account)); if (!sleMptoken) @@ -185,7 +200,14 @@ ConfidentialMPTConvertBack::preclaim(PreclaimContext const& ctx) // Sanity check: holder's MPToken must have auditor balance field if auditing // is enabled if (requiresAuditor && !sleMptoken->isFieldPresent(sfAuditorEncryptedBalance)) - return tefINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvertBack::preclaim : issuance-level auditing implies the " + "MPToken already carries an auditor balance"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } // if the total circulating confidential balance is smaller than what the // holder is trying to convert back, we know for sure this txn should @@ -215,11 +237,25 @@ ConfidentialMPTConvertBack::doApply() auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_)); if (!sleMptoken) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvertBack::doApply : preclaim already validated the " + "MPToken exists"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto sleIssuance = view().peek(keylet::mptokenIssuance(mptIssuanceID)); if (!sleIssuance) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvertBack::doApply : preclaim already validated the " + "issuance exists"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const amtToConvertBack = ctx_.tx[sfMPTAmount]; auto const amt = (*sleMptoken)[~sfMPTAmount].valueOr(0); diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp index 0b98382a61..6485578cb4 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -49,7 +50,14 @@ ConfidentialMPTMergeInbox::preclaim(PreclaimContext const& ctx) // already checked in preflight, but should also check that issuer on the // issuance isn't the account either if (sleIssuance->getAccountID(sfIssuer) == ctx.tx[sfAccount]) - return tefINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMergeInbox::preclaim : issuer derived from the MPT ID must " + "match the ledger's stored issuer"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } auto const sleMptoken = ctx.view.read(keylet::mptoken(ctx.tx[sfMPTokenIssuanceID], ctx.tx[sfAccount])); @@ -82,14 +90,26 @@ ConfidentialMPTMergeInbox::doApply() auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID]; auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_)); if (!sleMptoken) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMergeInbox::doApply : preclaim already validated the " + "MPToken exists"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } // sanity check if (!sleMptoken->isFieldPresent(sfConfidentialBalanceSpending) || !sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) || !sleMptoken->isFieldPresent(sfHolderEncryptionKey)) { - return tecINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMergeInbox::doApply : preclaim already validated these " + "fields are present"); + return tecINTERNAL; + // LCOV_EXCL_STOP } // Merge inbox into spending: spending = spending + inbox @@ -114,7 +134,14 @@ ConfidentialMPTMergeInbox::doApply() encryptCanonicalZeroAmount((*sleMptoken)[sfHolderEncryptionKey], accountID_, mptIssuanceID); if (!zeroEncryption) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMergeInbox::doApply : canonical zero encryption cannot fail " + "for an already-valid holder public key"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } (*sleMptoken)[sfConfidentialBalanceInbox] = std::move(*zeroEncryption); diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp index d121ec2634..e713ae5029 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -82,7 +83,7 @@ ConfidentialMPTSend::preflight(PreflightContext const& ctx) if (hasAuditor && !isValidCiphertext(ctx.tx[sfAuditorEncryptedAmount])) return temBAD_CIPHERTEXT; - if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err)) + if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err)) return err; return tesSUCCESS; @@ -105,7 +106,14 @@ verifySendProofs( { // Sanity check if (!sleSenderMPToken || !sleDestinationMPToken || !sleIssuance) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::detail::verifySendProofs : caller must pre-validate sender/destination/" + "issuance existence"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount); @@ -204,7 +212,14 @@ ConfidentialMPTSend::preclaim(PreclaimContext const& ctx) // Sanity check: issuer isn't the sender if (sleIssuance->getAccountID(sfIssuer) == ctx.tx[sfAccount]) - return tefINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTSend::preclaim : issuer derived from the MPT ID must match " + "the ledger's stored issuer"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } // Check sender's MPToken existence auto const sleSenderMPToken = ctx.view.read(keylet::mptoken(mptIssuanceID, account)); @@ -238,7 +253,12 @@ ConfidentialMPTSend::preclaim(PreclaimContext const& ctx) (!sleSenderMPToken->isFieldPresent(sfAuditorEncryptedBalance) || !sleDestinationMPToken->isFieldPresent(sfAuditorEncryptedBalance))) { - return tefINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTSend::preclaim : issuance-level auditing implies both " + "MPTokens already carry an auditor balance"); + return tefINTERNAL; + // LCOV_EXCL_STOP } // Check lock @@ -283,7 +303,14 @@ ConfidentialMPTSend::doApply() auto const sleDestAcct = view().read(keylet::account(destination)); if (!sleSenderMPToken || !sleDestinationMPToken || !sleIssuance || !sleDestAcct) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTSend::doApply : preclaim already validated these objects " + "exist"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } // Deposit preauth authorization was already verified in preclaim. // Remove any expired credentials. @@ -353,7 +380,13 @@ ConfidentialMPTSend::doApply() auto rerandomizedDestEc = rerandomizeCiphertext( destEc, (*sleDestinationMPToken)[sfHolderEncryptionKey], sendChallenge); if (!rerandomizedDestEc) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + JLOG(ctx_.journal.error()) + << "ConfidentialMPTSend failed to rerandomize destination inbox ciphertext."; + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const curInbox = (*sleDestinationMPToken)[sfConfidentialBalanceInbox]; auto newInbox = homomorphicAdd(curInbox, *rerandomizedDestEc); @@ -374,7 +407,13 @@ ConfidentialMPTSend::doApply() auto rerandomizedIssuerEc = rerandomizeCiphertext(issuerEc, (*sleIssuance)[sfIssuerEncryptionKey], sendChallenge); if (!rerandomizedIssuerEc) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + JLOG(ctx_.journal.error()) + << "ConfidentialMPTSend failed to rerandomize destination issuer ciphertext."; + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const curIssuerEnc = (*sleDestinationMPToken)[sfIssuerEncryptedBalance]; auto newIssuerEnc = homomorphicAdd(curIssuerEnc, *rerandomizedIssuerEc); @@ -396,7 +435,13 @@ ConfidentialMPTSend::doApply() auto rerandomizedAuditorEc = rerandomizeCiphertext( *auditorEc, (*sleIssuance)[sfAuditorEncryptionKey], sendChallenge); if (!rerandomizedAuditorEc) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + JLOG(ctx_.journal.error()) + << "ConfidentialMPTSend failed to rerandomize destination auditor ciphertext."; + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const curAuditorEnc = (*sleDestinationMPToken)[sfAuditorEncryptedBalance]; auto newAuditorEnc = homomorphicAdd(curAuditorEnc, *rerandomizedAuditorEc); diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index f74a27c39b..7ade4ed5ab 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -43,6 +44,11 @@ VaultCreate::checkExtraFeatures(PreflightContext const& ctx) if (ctx.tx.isFieldPresent(sfDomainID) && !ctx.rules.enabled(featurePermissionedDomains)) return false; + if (!ctx.rules.enabled(featureLendingProtocolV1_1) && + (ctx.tx.isFieldPresent(sfVaultKind) || ctx.tx.isFieldPresent(sfSubscriptionDate) || + ctx.tx.isFieldPresent(sfRedemptionDate))) + return false; + return true; } @@ -99,6 +105,22 @@ VaultCreate::preflight(PreflightContext const& ctx) return temMALFORMED; } + if (!isValidVaultKind(ctx.tx)) + return temMALFORMED; + auto const kind = getVaultKind(ctx.tx); + auto const hasSubscription = ctx.tx.isFieldPresent(sfSubscriptionDate); + auto const hasRedemption = ctx.tx.isFieldPresent(sfRedemptionDate); + auto const isClosedEnded = kind == VaultKind::ClosedEnded; + if (!isClosedEnded && (hasSubscription || hasRedemption)) + return temMALFORMED; + if (isClosedEnded) + { + if (!hasSubscription || !hasRedemption) + return temMALFORMED; + if (!isValidClosedEndedGap(ctx.tx[sfSubscriptionDate], ctx.tx[sfRedemptionDate])) + return temMALFORMED; + } + return tesSUCCESS; } @@ -136,6 +158,16 @@ VaultCreate::preclaim(PreclaimContext const& ctx) accountId == beast::kZero) return terADDRESS_COLLISION; + // preflight enforces red >= sub + kMinInvestmentPeriod for closed-ended + // vaults, so a past RedemptionDate always implies a strictly-earlier, + // equally-past SubscriptionDate. The RedemptionDate arm below is therefore + // defensive: it cannot be the sole cause of tecEXPIRED. It is kept to + // preserve the invariant locally in case the preflight gap check is ever + // weakened. + if (hasExpired(ctx.view, ctx.tx[~sfSubscriptionDate]) || + hasExpired(ctx.view, ctx.tx[~sfRedemptionDate])) + return tecEXPIRED; + return tesSUCCESS; } @@ -242,7 +274,17 @@ VaultCreate::doApply() if (scale != 0u) vault->at(sfScale) = scale; if (view().rules().enabled(featureLendingProtocolV1_1)) + { vault->at(sfLEVersion) = std::to_underlying(VaultVersion::CashBasis); + + auto const kind = getVaultKind(tx); + vault->at(sfVaultKind) = std::to_underlying(kind); + if (kind == VaultKind::ClosedEnded) + { + vault->at(sfSubscriptionDate) = tx[sfSubscriptionDate]; + vault->at(sfRedemptionDate) = tx[sfRedemptionDate]; + } + } view().insert(vault); // Explicitly create MPToken for the vault owner diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp index aa9cfc8537..a3c0a94eb5 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -71,6 +72,17 @@ VaultDeposit::preclaim(PreclaimContext const& ctx) if (!vault) return tecNO_ENTRY; + if (ctx.view.rules().enabled(featureLendingProtocolV1_1)) + { + auto const phase = getVaultPhase(ctx.view, vault); + if (phase == VaultPhase::Investment || phase == VaultPhase::Redemption) + { + JLOG(ctx.j.debug()) << "VaultDeposit: vault deposit is not allowed in the investment " + "or redemption phase."; + return tecEXPIRED; + } + } + auto const& account = ctx.tx[sfAccount]; auto const amount = ctx.tx[sfAmount]; auto const vaultAsset = vault->at(sfAsset); diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index 353b72c30d..7b5bb1ea94 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -73,6 +73,16 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) if (!vault) return tecNO_ENTRY; + if (ctx.view.rules().enabled(featureLendingProtocolV1_1)) + { + if (getVaultPhase(ctx.view, vault) == VaultPhase::Investment) + { + JLOG(ctx.j.debug()) + << "VaultWithdraw: vault withdrawal is not allowed in the investment phase."; + return tecTOO_SOON; + } + } + auto const amount = ctx.tx[sfAmount]; auto const vaultAsset = vault->at(sfAsset); auto const vaultShare = vault->at(sfShareMPTID); diff --git a/src/test/app/AMMClawbackMPT_test.cpp b/src/test/app/AMMClawbackMPT_test.cpp index 6facafde4a..1d75c4db22 100644 --- a/src/test/app/AMMClawbackMPT_test.cpp +++ b/src/test/app/AMMClawbackMPT_test.cpp @@ -16,6 +16,8 @@ #include #include #include +#include +#include #include #include #include @@ -137,7 +139,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite AMM amm(env, gw, btc(100), usd(100)); env.close(); amm.deposit(alice, 1'000); - env.close(); // can not clawback when tfMPTCanClawback is not enabled env(amm::ammClawback(gw, alice, btc, usd, std::nullopt), Ter(tecNO_PERMISSION)); @@ -503,6 +504,150 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite } } + void + testAMMClawbackAmountRoundsToZero(FeatureBitset features) + { + // Ensure a clawback that rounds down to zero MPT fails with + // tecAMM_FAILED instead of silently burning the holder's LP. + testcase("test AMMClawback amount that rounds down to zero"); + using namespace jtx; + + Env env(*this, features); + Account const gw{"gateway"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(10'000'000), gw, alice, bob); + env.close(); + + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + + // The clawed asset (amountRounded) rounds to zero while its XRP + // counterpart is always large. + { + MPTTester const mptBtc( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .pay = 1'000, + .flags = tfMPTCanClawback | kMptDexFlags}); + MPT const btc = mptBtc; + + AMM amm(env, alice, btc(3), XRP(333'000)); + amm.deposit(bob, btc(3), XRP(333'000)); + + [[maybe_unused]] auto const [poolBtcBefore, poolXrpBefore, lptBefore] = amm.balances(); + BEAST_EXPECT(poolBtcBefore == btc(6)); + + auto const issuerOABefore = mptBtc.getBalance(gw); + auto const aliceLpBefore = amm.getLPTokensBalance(alice.id()); + auto const bobLpBefore = amm.getLPTokensBalance(bob.id()); + + // Attempt to clawback 1/6th of the BTC pool. When the zero-rounding + // guard is active (gated by fixCleanup3_4_0) the rounded amount + // drops to 0 and should trigger tecAMM_FAILED. + env(amm::ammClawback(gw, alice, btc, XRP, btc(1)), + Ter(features[fixCleanup3_4_0] ? TER{tecAMM_FAILED} : TER{tesSUCCESS})); + env.close(); + + [[maybe_unused]] auto const [poolBtcAfter, poolXrpAfter, lptAfter] = amm.balances(); + auto const issuerOAAfter = mptBtc.getBalance(gw); + auto const aliceLpAfter = amm.getLPTokensBalance(alice.id()); + auto const bobLpAfter = amm.getLPTokensBalance(bob.id()); + + if (features[fixCleanup3_4_0]) + { + // Post-fixCleanup3_4_0: Clawback fails because the BTC balance + // would round to zero. All balances must remain untouched. + BEAST_EXPECT(poolBtcAfter == poolBtcBefore); + BEAST_EXPECT(poolXrpAfter == poolXrpBefore); + BEAST_EXPECT(issuerOAAfter == issuerOABefore); + BEAST_EXPECT(aliceLpAfter == aliceLpBefore); + BEAST_EXPECT(bobLpAfter == bobLpBefore); + } + else + { + // Pre-fixCleanup3_4_0: BTC rounds to zero and the clawback + // silently burns alice's LP without clawing back any BTC. + BEAST_EXPECT(poolBtcAfter == poolBtcBefore); + BEAST_EXPECT(poolXrpAfter < poolXrpBefore); + BEAST_EXPECT(issuerOAAfter == issuerOABefore); + BEAST_EXPECT(aliceLpAfter < aliceLpBefore); + BEAST_EXPECT(bobLpAfter == bobLpBefore); + } + } + + // The pool above only ever rounds the clawed asset (amountRounded) to + // zero; its XRP counterpart is always large. Exercise the other operand + // of the guard (amount2Rounded == 0) with an MPT/MPT pool where the + // *paired* asset is the tiny integer that floors to zero while the + // clawed asset still rounds non-zero. + { + Account const carol{"carol"}; + Account const dan{"dan"}; + env.fund(XRP(10'000'000), carol, dan); + env.close(); + + MPTTester const mptBtc( + {.env = env, + .issuer = gw, + .holders = {carol, dan}, + .pay = 100'000, + .flags = tfMPTCanClawback | kMptDexFlags}); + MPT const btc = mptBtc; + + MPTTester const mptEth( + {.env = env, + .issuer = gw, + .holders = {carol, dan}, + .pay = 1'000, + .flags = tfMPTCanClawback | kMptDexFlags}); + MPT const eth = mptEth; + + // btc pool dwarfs the eth pool, so a ~1/12th claw withdraws a + // non-zero btc amount while the eth counterpart rounds to zero. + AMM amm(env, carol, btc(3'000), eth(3)); + amm.deposit(dan, btc(3'000), eth(3)); + + [[maybe_unused]] auto const [poolBtcBefore, poolEthBefore, lptBefore] = amm.balances(); + BEAST_EXPECT(poolBtcBefore == btc(6'000)); + BEAST_EXPECT(poolEthBefore == eth(6)); + + auto const carolLpBefore = amm.getLPTokensBalance(carol.id()); + auto const danLpBefore = amm.getLPTokensBalance(dan.id()); + + env(amm::ammClawback(gw, carol, btc, eth, btc(500)), + Ter(features[fixCleanup3_4_0] ? TER{tecAMM_FAILED} : TER{tesSUCCESS})); + env.close(); + + [[maybe_unused]] auto const [poolBtcAfter, poolEthAfter, lptAfter] = amm.balances(); + auto const carolLpAfter = amm.getLPTokensBalance(carol.id()); + auto const danLpAfter = amm.getLPTokensBalance(dan.id()); + + if (features[fixCleanup3_4_0]) + { + // Post-fixCleanup3_4_0: clawback fails because the ETH (Asset2) + // balance would round to zero (guard fires via + // amount2Rounded == 0). All balances must remain untouched. + BEAST_EXPECT(poolBtcAfter == poolBtcBefore); + BEAST_EXPECT(poolEthAfter == poolEthBefore); + BEAST_EXPECT(carolLpAfter == carolLpBefore); + BEAST_EXPECT(danLpAfter == danLpBefore); + } + else + { + // Pre-fixCleanup3_4_0: the asymmetric round-off goes through. + // btc is clawed (non-zero) but eth rounds to zero, so the eth + // pool is untouched while carol's LP is burned. This asymmetry + // proves amount2Rounded == 0 is the trigger. + BEAST_EXPECT(poolBtcAfter < poolBtcBefore); + BEAST_EXPECT(poolEthAfter == poolEthBefore); + BEAST_EXPECT(carolLpAfter < carolLpBefore); + BEAST_EXPECT(danLpAfter == danLpBefore); + } + } + } + void testAMMClawbackAll(FeatureBitset features) { @@ -543,7 +688,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite // gw clawback all BTC from alice amm.deposit(bob, btc(1'000'000000), usd(2000)); - env.close(); BEAST_EXPECT(amm.expectBalances(btc(3'000'000000), usd(3000), IOUAmount(3000000))); auto aliceBTC = env.balance(alice, btc); @@ -921,7 +1065,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite BEAST_EXPECT(amm.expectBalances(btc(2'000'000000), usd(8'000), IOUAmount(4'000'000))); amm.deposit(bob, btc(1'000'000000), usd(4'000)); - env.close(); BEAST_EXPECT(amm.expectBalances(btc(3'000'000000), usd(12'000), IOUAmount(6'000'000))); auto aliceBTC = env.balance(alice, btc); @@ -1335,6 +1478,60 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite } } + void + testClawbackCreatesMissingMPToken(FeatureBitset features) + { + testcase("test AMMClawback creates missing MPToken"); + using namespace jtx; + + auto test = [&](std::optional const clawAmount) { + Env env{*this, features}; + Account const gw{"gateway"}; + Account const alice{"alice"}; + env.fund(XRP(1'000'000), gw, alice); + env.close(); + + MPTTester token( + {.env = env, + .issuer = gw, + .holders = {alice}, + .pay = 1'000, + .flags = tfMPTCanClawback | tfMPTRequireAuth | kMptDexFlags, + .authHolder = true}); + + AMM ammAlice(env, alice, token(1'000), XRP(1'000)); + env.close(); + BEAST_EXPECT(env.balance(alice, token) == token(0)); + + // The holder can delete the zero-balance MPToken while still + // holding LP tokens. A regular AMMWithdraw remains subject to + // RequireAuth and cannot recreate the missing token. + token.authorize({.account = alice, .flags = tfMPTUnauthorize}); + env.close(); + BEAST_EXPECT(!env.le(keylet::mptoken(token.issuanceID(), alice.id()))); + ammAlice.withdrawAll(alice, std::nullopt, Ter(tecNO_AUTH)); + env.close(); + BEAST_EXPECT(!env.le(keylet::mptoken(token.issuanceID(), alice.id()))); + + // AMMClawback ignores authorization and must be able to recreate + // the holder MPToken so the issuer can recover MPT from the pool. + std::optional amount; + if (clawAmount) + amount = token(*clawAmount); + env(amm::ammClawback(gw, alice, token, XRP, amount)); + env.close(); + + auto const sleMpt = env.le(keylet::mptoken(token.issuanceID(), alice.id())); + BEAST_EXPECT(sleMpt && sleMpt->isFlag(lsfMPTAuthorized)); + env.require(Balance(alice, token(0))); + + BEAST_EXPECT(clawAmount ? ammAlice.ammExists() : !ammAlice.ammExists()); + }; + + test(std::nullopt); + test(400); + } + void testSingleDepositAndClawback(FeatureBitset features) { @@ -1361,7 +1558,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite env.close(); BEAST_EXPECT(amm.expectBalances(XRP(100), btc(400), IOUAmount(200000))); amm.deposit(alice, btc(400)); - env.close(); BEAST_EXPECT(amm.expectBalances(XRP(100), btc(800), IOUAmount{282842'712474619, -9})); auto aliceBTC = env.balance(alice, MPT(btc)); @@ -1407,7 +1603,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite env.close(); BEAST_EXPECT(amm.expectBalances(usd(100), btc(400), IOUAmount(200))); amm.deposit(alice, btc(400)); - env.close(); BEAST_EXPECT(amm.expectBalances(usd(100), btc(800), IOUAmount{282'842712474619, -12})); auto aliceBTC = env.balance(alice, MPT(btc)); @@ -1462,7 +1657,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite env.close(); BEAST_EXPECT(amm.expectBalances(usd(100), btc(400), IOUAmount(200))); amm.deposit(alice, btc(400)); - env.close(); BEAST_EXPECT(amm.expectBalances(usd(100), btc(800), IOUAmount{282'842712474619, -12})); auto aliceBTC = env.balance(alice, MPT(btc)); @@ -1669,7 +1863,7 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite env(amm::ammClawback(gw, alice, btc, usd, std::nullopt), Ter(tecNO_PERMISSION)); // Although USD is clawable with asfAllowTrustLineClawback. - // When tfClawTwoAssets is set, we will claw Asser2 as well. + // When tfClawTwoAssets is set, we will claw Asset2 as well. // But Asset2 is not clawable. tfMPTCanClawback was not set for BTC. env(amm::ammClawback(gw, alice, usd, btc, std::nullopt), Txflags(tfClawTwoAssets), @@ -1811,6 +2005,199 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite } } + // Test that AMMClawback succeeds when the LP has previously deleted both + // zero-balance MPToken objects in an MPT/MPT pool. The fix changes the + // ValidMPTIssuance invariant threshold from > 1 to > 2 so that the two + // MPToken creations triggered by the internal AMMWithdraw are permitted. + void + testClawbackAfterDeletingMPTokens(FeatureBitset features) + { + testcase("test AMMClawback after holder deletes zero-balance MPTokens"); + using namespace jtx; + + // Partial clawback (one asset): verify both MPTokens are recreated and + // the non-claw asset is returned to alice. + { + Env env(*this, features); + Account const gw{"gateway"}; + Account const alice{"alice"}; + env.fund(XRP(100'000), gw, alice); + env.close(); + + MPTTester btc( + {.env = env, + .issuer = gw, + .holders = {alice}, + .pay = 10'000, + .flags = tfMPTCanClawback | kMptDexFlags}); + + MPTTester eth( + {.env = env, + .issuer = gw, + .holders = {alice}, + .pay = 10'000, + .flags = tfMPTCanClawback | kMptDexFlags}); + + // Alice deposits everything into the MPT/MPT pool; her MPT + // balances drop to zero. + AMM const amm(env, alice, btc(10'000), eth(10'000)); + env.close(); + BEAST_EXPECT(amm.expectBalances(btc(10'000), eth(10'000), IOUAmount{10'000})); + + auto aliceBTC = env.balance(alice, btc); + auto aliceETH = env.balance(alice, eth); + BEAST_EXPECT(aliceBTC == btc(0)); + BEAST_EXPECT(aliceETH == eth(0)); + + // Alice deletes both zero-balance MPTokens to reclaim reserves. + btc.authorize({.account = alice, .flags = tfMPTUnauthorize}); + eth.authorize({.account = alice, .flags = tfMPTUnauthorize}); + BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID(), alice.id()))); + BEAST_EXPECT(!env.le(keylet::mptoken(eth.issuanceID(), alice.id()))); + + // gw claws back some BTC from alice's share in the pool. + // AMMWithdraw internally creates both missing MPTokens + // (mptokensCreated_ == 2); the invariant (> 2) allows this. + env(amm::ammClawback(gw, alice, btc, eth, btc(1'000))); + env.close(); + + // Both MPToken objects must have been recreated. + BEAST_EXPECT(env.le(keylet::mptoken(btc.issuanceID(), alice.id()))); + BEAST_EXPECT(env.le(keylet::mptoken(eth.issuanceID(), alice.id()))); + + // The non-claw asset (eth) was returned to alice. + BEAST_EXPECT(env.balance(alice, eth) > aliceETH); + // The claw asset (btc) was burned; alice's btc balance stays 0. + env.require(Balance(alice, aliceBTC)); + BEAST_EXPECT(amm.ammExists()); + } + + // Full clawback (two assets, tfClawTwoAssets): verify both MPTokens + // are recreated and the AMM is deleted when fully drained. + { + Env env(*this, features); + Account const gw{"gateway"}; + Account const alice{"alice"}; + env.fund(XRP(100'000), gw, alice); + env.close(); + + MPTTester btc( + {.env = env, + .issuer = gw, + .holders = {alice}, + .pay = 10'000, + .flags = tfMPTCanClawback | kMptDexFlags}); + + MPTTester eth( + {.env = env, + .issuer = gw, + .holders = {alice}, + .pay = 10'000, + .flags = tfMPTCanClawback | kMptDexFlags}); + + AMM const amm(env, alice, btc(10'000), eth(10'000)); + env.close(); + + auto aliceBTC = env.balance(alice, btc); + auto aliceETH = env.balance(alice, eth); + + btc.authorize({.account = alice, .flags = tfMPTUnauthorize}); + eth.authorize({.account = alice, .flags = tfMPTUnauthorize}); + BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID(), alice.id()))); + BEAST_EXPECT(!env.le(keylet::mptoken(eth.issuanceID(), alice.id()))); + + // Full two-asset clawback: both assets are clawed and alice + // receives nothing back. The AMM should be empty and deleted. + env(amm::ammClawback(gw, alice, btc, eth, std::nullopt), Txflags(tfClawTwoAssets)); + env.close(); + + BEAST_EXPECT(!amm.ammExists()); + // Both assets were clawed; alice's balances remain at zero. + env.require(Balance(alice, aliceBTC)); + env.require(Balance(alice, aliceETH)); + } + } + + void + testClawbackCrossIssuerPairedAssetAuth(FeatureBitset features) + { + testcase("test AMMClawback recreates paired-issuer MPToken unauthorized"); + using namespace jtx; + + // Cross-issuer MPT/MPT pool: btc is issued by gw, eth by gw2, and both + // require authorization. Alice deposits her entire balance of both and + // deletes the resulting zero-balance MPTokens. When gw claws back its + // own asset (btc), the two-asset withdrawal must recreate both of + // Alice's MPTokens so the pool can pay her the paired asset. The + // recreated MPToken may only be auto-authorized for the clawback + // issuer's own asset (btc); the paired asset's issuer (gw2) never + // consented, so eth must be recreated *unauthorized*, leaving gw2 in + // control of its own token and preserving its RequireAuth guarantee. + Env env(*this, features); + Account const gw{"gateway"}; + Account const gw2{"gateway2"}; + Account const alice{"alice"}; + env.fund(XRP(100'000), gw, gw2, alice); + env.close(); + + MPTTester btc( + {.env = env, + .issuer = gw, + .holders = {alice}, + .pay = 10'000, + .flags = tfMPTCanClawback | tfMPTRequireAuth | kMptDexFlags, + .authHolder = true}); + + MPTTester eth( + {.env = env, + .issuer = gw2, + .holders = {alice}, + .pay = 10'000, + .flags = tfMPTCanClawback | tfMPTRequireAuth | kMptDexFlags, + .authHolder = true}); + + // Alice deposits everything into the pool; her MPT balances drop to 0. + AMM const amm(env, alice, btc(10'000), eth(10'000)); + env.close(); + BEAST_EXPECT(amm.expectBalances(btc(10'000), eth(10'000), IOUAmount{10'000})); + BEAST_EXPECT(env.balance(alice, btc) == btc(0)); + BEAST_EXPECT(env.balance(alice, eth) == eth(0)); + + // Alice deletes both zero-balance MPTokens to reclaim reserves. + btc.authorize({.account = alice, .flags = tfMPTUnauthorize}); + eth.authorize({.account = alice, .flags = tfMPTUnauthorize}); + BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID(), alice.id()))); + BEAST_EXPECT(!env.le(keylet::mptoken(eth.issuanceID(), alice.id()))); + + // gw (issuer of btc) claws back part of Alice's btc. This is a + // cross-issuer pool, so tfClawTwoAssets is not permitted: only btc is + // clawed back, while the paired eth is returned to Alice. + env(amm::ammClawback(gw, alice, btc, eth, btc(1'000))); + env.close(); + + // Both MPTokens were recreated so the withdrawal could pay Alice. + auto const sleBtc = env.le(keylet::mptoken(btc.issuanceID(), alice.id())); + auto const sleEth = env.le(keylet::mptoken(eth.issuanceID(), alice.id())); + BEAST_EXPECT(sleBtc); + BEAST_EXPECT(sleEth); + + // The clawback issuer's own asset (btc) may be recreated authorized: + // gw has authority over its own token. + BEAST_EXPECT(sleBtc && sleBtc->isFlag(lsfMPTAuthorized)); + + // The paired asset (eth) is issued by gw2, who did not sign this + // transaction. It must be recreated *unauthorized* so gw2's RequireAuth + // is not bypassed. This is the core assertion for the cross-issuer fix. + BEAST_EXPECT(sleEth && !sleEth->isFlag(lsfMPTAuthorized)); + + // The clawback still completed: btc was clawed back (Alice keeps a zero + // btc balance) and the paired eth was delivered into Alice's now + // unauthorized, gw2-gated MPToken (non-zero raw balance). + BEAST_EXPECT(sleBtc && sleBtc->getFieldU64(sfMPTAmount) == 0); + BEAST_EXPECT(sleEth && sleEth->getFieldU64(sfMPTAmount) > 0); + BEAST_EXPECT(amm.ammExists()); + } + void run() override { @@ -1819,11 +2206,17 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite testInvalidRequest(all); testFeatureDisabled(all); testAMMClawbackAmount(all); + testAMMClawbackAmount(all - fixCleanup3_4_0); + testAMMClawbackAmountRoundsToZero(all); + testAMMClawbackAmountRoundsToZero(all - fixCleanup3_4_0); testAMMClawbackAll(all); testAMMClawbackAmountSameIssuer(all); testAMMClawbackAllSameIssuer(all); testAMMClawbackIssuesEachOther(all); testAssetFrozenOrLocked(all); + testClawbackCreatesMissingMPToken(all); + testClawbackAfterDeletingMPTokens(all); + testClawbackCrossIssuerPairedAssetAuth(all); testSingleDepositAndClawback(all); testLastHolderLPTokenBalance(all); testLastHolderLPTokenBalance(all - fixAMMv1_3 - fixAMMClawbackRounding); diff --git a/src/test/app/AMMClawback_test.cpp b/src/test/app/AMMClawback_test.cpp index ba416d8192..90bface1fb 100644 --- a/src/test/app/AMMClawback_test.cpp +++ b/src/test/app/AMMClawback_test.cpp @@ -2155,6 +2155,209 @@ class AMMClawback_test : public beast::unit_test::Suite } BEAST_EXPECT(env.balance(carol, eur) == eur(7750)); } + + // gw (USD issuer) individually freezes the AMM-USD trust line. + // AMMClawback must still succeed because the freeze invariant + // short-circuits before reaching the AMM line check (no receivers in + // the USD issuer's change set). Behavior is identical with or without + // fixCleanup3_4_0. + { + Env env(*this, features); + Account const gw{"gateway"}; + Account const gw2{"gateway2"}; + Account const alice{"alice"}; + env.fund(XRP(1000000), gw, gw2, alice); + env.close(); + + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + env.require(Flags(gw, asfAllowTrustLineClawback)); + + auto const usd = gw["USD"]; + env.trust(usd(100000), alice); + env(pay(gw, alice, usd(3000))); + env.close(); + + auto const eur = gw2["EUR"]; + env.trust(eur(100000), alice); + env(pay(gw2, alice, eur(3000))); + env.close(); + + AMM const amm(env, alice, eur(1000), usd(2000), Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT( + amm.expectBalances(usd(2000), eur(1000), IOUAmount{1414213562373095, -12})); + + // gw individually freezes the AMM-USD trust line (AMM pseudo-account + // <-> gw), not alice's trust line. + env(trust(gw, STAmount{Issue{usd.currency, amm.ammAccount()}, 0}, tfSetFreeze)); + env.close(); + + env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tesSUCCESS)); + env.close(); + + env.require(Balance(alice, usd(1000))); + env.require(Balance(alice, eur(2500))); + BEAST_EXPECT(amm.expectBalances(usd(1000), eur(500), IOUAmount{7071067811865475, -13})); + BEAST_EXPECT(amm.expectLPTokens(alice, IOUAmount{7071067811865475, -13})); + } + + // gw2 (EUR issuer) individually freezes the AMM-EUR trust line. + // The EUR flow (AMM → alice) is a genuine P2P transfer checked by the + // freeze invariant. Pre-fixCleanup3_4_0 the isAMMNode guard incorrectly + // blocked AMMClawback's overrideFreeze privilege on that trust line. + { + Env env(*this, features); + Account const gw{"gateway"}; + Account const gw2{"gateway2"}; + Account const alice{"alice"}; + env.fund(XRP(1000000), gw, gw2, alice); + env.close(); + + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + env.require(Flags(gw, asfAllowTrustLineClawback)); + + auto const usd = gw["USD"]; + env.trust(usd(100000), alice); + env(pay(gw, alice, usd(3000))); + env.close(); + + auto const eur = gw2["EUR"]; + env.trust(eur(100000), alice); + env(pay(gw2, alice, eur(3000))); + env.close(); + + AMM const amm(env, alice, eur(1000), usd(2000), Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT( + amm.expectBalances(usd(2000), eur(1000), IOUAmount{1414213562373095, -12})); + + // gw2 individually freezes the AMM-EUR trust line. + env(trust(gw2, STAmount{Issue{eur.currency, amm.ammAccount()}, 0}, tfSetFreeze)); + env.close(); + + if (features[fixCleanup3_4_0]) + { + // Post-fixCleanup3_4_0: overrideFreeze privilege applies to + // all freeze types on AMM trust lines. + env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tesSUCCESS)); + env.close(); + + env.require(Balance(alice, usd(1000))); + env.require(Balance(alice, eur(2500))); + BEAST_EXPECT( + amm.expectBalances(usd(1000), eur(500), IOUAmount{7071067811865475, -13})); + BEAST_EXPECT(amm.expectLPTokens(alice, IOUAmount{7071067811865475, -13})); + } + else + { + // Pre-fixCleanup3_4_0: the isAMMNode guard prevents the + // overrideFreeze privilege from applying to individually-frozen + // AMM trust lines, so the invariant blocks the clawback. + env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tecINVARIANT_FAILED)); + } + } + + // gw2 (EUR issuer) globally freezes its issued assets. AMMClawback + // must still be able to return EUR from the AMM to alice. + { + Env env(*this, features); + Account const gw{"gateway"}; + Account const gw2{"gateway2"}; + Account const alice{"alice"}; + env.fund(XRP(1000000), gw, gw2, alice); + env.close(); + + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + env.require(Flags(gw, asfAllowTrustLineClawback)); + + auto const usd = gw["USD"]; + env.trust(usd(100000), alice); + env(pay(gw, alice, usd(3000))); + env.close(); + + auto const eur = gw2["EUR"]; + env.trust(eur(100000), alice); + env(pay(gw2, alice, eur(3000))); + env.close(); + + AMM const amm(env, alice, eur(1000), usd(2000), Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT( + amm.expectBalances(usd(2000), eur(1000), IOUAmount{1414213562373095, -12})); + + env(fset(gw2, asfGlobalFreeze)); + env.close(); + + env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tesSUCCESS)); + env.close(); + + env.require(Balance(alice, usd(1000))); + env.require(Balance(alice, eur(2500))); + BEAST_EXPECT(amm.expectBalances(usd(1000), eur(500), IOUAmount{7071067811865475, -13})); + BEAST_EXPECT(amm.expectLPTokens(alice, IOUAmount{7071067811865475, -13})); + } + + // Same as above but gw2 deep-freezes the AMM-EUR trust line. + if (features[featureDeepFreeze]) + { + Env env(*this, features); + Account const gw{"gateway"}; + Account const gw2{"gateway2"}; + Account const alice{"alice"}; + env.fund(XRP(1000000), gw, gw2, alice); + env.close(); + + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + env.require(Flags(gw, asfAllowTrustLineClawback)); + + auto const usd = gw["USD"]; + env.trust(usd(100000), alice); + env(pay(gw, alice, usd(3000))); + env.close(); + + auto const eur = gw2["EUR"]; + env.trust(eur(100000), alice); + env(pay(gw2, alice, eur(3000))); + env.close(); + + AMM const amm(env, alice, eur(1000), usd(2000), Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT( + amm.expectBalances(usd(2000), eur(1000), IOUAmount{1414213562373095, -12})); + + // gw2 deep-freezes the AMM-EUR trust line. + env(trust( + gw2, + STAmount{Issue{eur.currency, amm.ammAccount()}, 0}, + tfSetFreeze | tfSetDeepFreeze)); + env.close(); + + if (features[fixCleanup3_4_0]) + { + env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tesSUCCESS)); + env.close(); + + env.require(Balance(alice, usd(1000))); + env.require(Balance(alice, eur(2500))); + BEAST_EXPECT( + amm.expectBalances(usd(1000), eur(500), IOUAmount{7071067811865475, -13})); + BEAST_EXPECT(amm.expectLPTokens(alice, IOUAmount{7071067811865475, -13})); + } + else + { + // Pre-fixCleanup3_4_0: same isAMMNode guard issue blocks the + // clawback on deep-frozen AMM trust lines. + env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tecINVARIANT_FAILED)); + } + } } void @@ -2530,6 +2733,7 @@ class AMMClawback_test : public beast::unit_test::Suite // precision loss caught in transaction layer -> tecPRECISION_LOSS all - fixAMMClawbackRounding - featureMPTokensV2, all - featureMPTokensV2, + all - fixCleanup3_4_0, all}) { testAMMClawbackSpecificAmount(features); diff --git a/src/test/app/AMMExtendedMPT_test.cpp b/src/test/app/AMMExtendedMPT_test.cpp index f04ea39f2b..5059128d4b 100644 --- a/src/test/app/AMMExtendedMPT_test.cpp +++ b/src/test/app/AMMExtendedMPT_test.cpp @@ -188,20 +188,28 @@ private: {features}); // tfPassive -- place the offer without crossing it. - testAMM( - [&](AMM& ammAlice, Env& env) { - // Carol creates a passive offer that could cross AMM. - // Carol's offer should stay in the ledger. - auto const& btc = MPT(ammAlice[1]); - env(offer(carol_, XRP(100), btc(100), tfPassive)); - env.close(); - BEAST_EXPECT(ammAlice.expectBalances(XRP(10'100), btc(10'000), ammAlice.tokens())); - BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100), btc(100)}}})); - }, - {{XRP(10'100), gAmmmpt(10'000)}}, - 0, - std::nullopt, - {features}); + { + Env env{*this, features}; + fund(env, gw_, {alice_, carol_}, XRP(30'000'000)); + + MPTTester const btc( + {.env = env, + .issuer = gw_, + .holders = {alice_, carol_}, + .pay = 30'000'000, + .flags = kMptDexFlags}); + + AMM const ammAlice(env, alice_, XRP(10'100'000), btc(10'000'000)); + + // Scale the exact-quality fixture up so the visual relationship + // stays clear: the passive CLOB offer has the same 1:1 quality as + // the generated AMM offer, so it should not cross. + env(offer(carol_, XRP(100'000), btc(100'000), tfPassive)); + env.close(); + BEAST_EXPECT( + ammAlice.expectBalances(XRP(10'100'000), btc(10'000'000), ammAlice.tokens())); + BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100'000), btc(100'000)}}})); + } // tfPassive -- cross only offers of better quality. testAMM( @@ -1084,9 +1092,9 @@ private: // AMM is consumed up to the first cam Offer quality BEAST_EXPECT(ammCarol.expectBalances( - aBux(3'093'541'659'651'604), bBux(3'200'215'509'984'418), ammCarol.tokens())); + aBux(3'093'541'659'651'603), bBux(3'200'215'509'984'419), ammCarol.tokens())); BEAST_EXPECT(expectOffers( - env, cam, 1, {{Amounts{bBux(200'215'509'984'418), aBux(200'215'509'984'419)}}})); + env, cam, 1, {{Amounts{bBux(200'215'509'984'419), aBux(200'215'509'984'419)}}})); } void @@ -1241,7 +1249,7 @@ private: BEAST_EXPECT(sa == XRP(100'000'000)); // Bob gets ~99.99e12ETH. This is the amount Bob // can get out of AMM for 100,000,000XRP. - BEAST_EXPECT(equal(da, eth(99'999'900'000'100))); + BEAST_EXPECT(equal(da, eth(99'999'900'000'099))); } // carol holds ETH, sells ETH for XRP @@ -1505,6 +1513,96 @@ private: } } + void + pathFindMPTAMMExecutableSourceAmount() + { + testcase("Path Find: MPT AMM source amount is executable"); + using namespace jtx; + + auto const checkQuote = [&](std::int64_t usdPool, + std::int64_t eurPool, + std::int64_t deliverAmount, + std::int64_t expectedSourceAmount) { + Env env = pathTestEnv(); + env.fund(XRP(30'000), gw_, alice_, bob_, carol_); + env.close(); + + MPTTester const usd( + {.env = env, + .issuer = gw_, + .holders = {alice_, bob_, carol_}, + .pay = usdPool, + .flags = kMptDexFlags}); + + MPTTester const eur( + {.env = env, + .issuer = gw_, + .holders = {alice_, bob_, carol_}, + .pay = eurPool, + .flags = kMptDexFlags}); + + AMM const ammCarol(env, carol_, usd(usdPool), eur(eurPool)); + env.close(); + + STPathSet st; + STAmount sa, da; + auto const deliver = eur(deliverAmount); + std::tie(st, sa, da) = findPaths( + env, + alice_, + bob_, + deliver, + std::nullopt, + usd.issuanceID(), + std::nullopt, + std::nullopt); + + // Each quote must execute when used as an exact-output SendMax. + BEAST_EXPECT(equal(da, deliver)); + BEAST_EXPECT(equal(sa, usd(expectedSourceAmount))); + BEAST_EXPECT(!st.empty()); + + auto const before = eur.getBalance(bob_); + env(pay(alice_, bob_, deliver), + Json(jss::Paths, st.getJson(JsonOptions::Values::None)), + Sendmax(sa), + Txflags(tfNoRippleDirect)); + BEAST_EXPECT(eur.getBalance(bob_) == before + deliverAmount); + }; + + struct TestCase + { + std::int64_t usdPool; + std::int64_t eurPool; + std::int64_t deliverAmount; + std::int64_t expectedSourceAmount; + }; + + // Cover the original 2:1 pool and the same pool scaled down by 1000. + // clang-format off + TestCase const testCases[] = { + {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 1, .expectedSourceAmount = 3}, + {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 2, .expectedSourceAmount = 5}, + {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 10, .expectedSourceAmount = 21}, + {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 100, .expectedSourceAmount = 201}, + {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 1'000, .expectedSourceAmount = 2'003}, + {.usdPool = 2'000, .eurPool = 1'000, .deliverAmount = 1, .expectedSourceAmount = 3}, + {.usdPool = 2'000, .eurPool = 1'000, .deliverAmount = 2, .expectedSourceAmount = 5}, + {.usdPool = 2'000, .eurPool = 1'000, .deliverAmount = 10, .expectedSourceAmount = 21}, + {.usdPool = 2'000, .eurPool = 1'000, .deliverAmount = 100, .expectedSourceAmount = 223}, + }; + // clang-format on + + for (auto const& testCase : testCases) + { + checkQuote( + testCase.usdPool, + testCase.eurPool, + testCase.deliverAmount, + testCase.expectedSourceAmount); + } + } + void testFalseDry(FeatureBitset features) { @@ -3583,6 +3681,7 @@ private: pathFind01(); pathFind02(); pathFind06(); + pathFindMPTAMMExecutableSourceAmount(); } void diff --git a/src/test/app/AMMExtended_test.cpp b/src/test/app/AMMExtended_test.cpp index bb532b361a..83c848b7c4 100644 --- a/src/test/app/AMMExtended_test.cpp +++ b/src/test/app/AMMExtended_test.cpp @@ -267,20 +267,39 @@ private: {features}); // tfPassive -- place the offer without crossing it. - testAMM( - [&](AMM& ammAlice, Env& env) { - // Carol creates a passive offer that could cross AMM. - // Carol's offer should stay in the ledger. - env(offer(carol_, XRP(100), USD(100), tfPassive)); - env.close(); - BEAST_EXPECT( - ammAlice.expectBalances(XRP(10'100), STAmount{USD, 10'000}, ammAlice.tokens())); - BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100), STAmount{USD, 100}}}})); - }, - {{XRP(10'100), USD(10'000)}}, - 0, - std::nullopt, - {features}); + if (features[featureMPTokensV2]) + { + Env env{*this, features}; + fund(env, gw_, {alice_, carol_}, XRP(30'000'000), {USD(30'000'000)}); + + AMM const ammAlice(env, alice_, XRP(10'100'000), USD(10'000'000)); + + // Scale the exact-quality fixture up so the visual relationship + // stays clear: the passive CLOB offer has the same 1:1 quality as + // the generated AMM offer, so it should not cross. + env(offer(carol_, XRP(100'000), USD(100'000), tfPassive)); + env.close(); + BEAST_EXPECT( + ammAlice.expectBalances(XRP(10'100'000), USD(10'000'000), ammAlice.tokens())); + BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100'000), USD(100'000)}}})); + } + else + { + testAMM( + [&](AMM& ammAlice, Env& env) { + // Carol creates a passive offer that could cross AMM. + // Carol's offer should stay in the ledger. + env(offer(carol_, XRP(100), USD(100), tfPassive)); + env.close(); + BEAST_EXPECT(ammAlice.expectBalances( + XRP(10'100), STAmount{USD, 10'000}, ammAlice.tokens())); + BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100), STAmount{USD, 100}}}})); + }, + {{XRP(10'100), USD(10'000)}}, + 0, + std::nullopt, + {features}); + } // tfPassive -- cross only offers of better quality. testAMM( @@ -1359,6 +1378,7 @@ private: testRmFundedOffer(all_ - fixAMMv1_1 - fixAMMv1_3); testEnforceNoRipple(all_); testFillModes(all_); + testFillModes(all_ - featureMPTokensV2); testOfferCrossWithXRP(all_); testOfferCrossWithLimitOverride(all_); testCurrencyConversionEntire(all_); diff --git a/src/test/app/AMMMPT_test.cpp b/src/test/app/AMMMPT_test.cpp index 7078ea6769..90a267f56f 100644 --- a/src/test/app/AMMMPT_test.cpp +++ b/src/test/app/AMMMPT_test.cpp @@ -32,14 +32,17 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include +#include #include #include #include @@ -3269,6 +3272,48 @@ private: ammAlice.expectBalances(MPT(ammAlice[1])(1), XRP(10'000), IOUAmount{100000})); }, {{XRP(10'000), gAmmmpt(10'000)}}); + + // MPT/MPT equal withdrawal after LP deletes both zero-balance MPTokens. + // AMMWithdraw must recreate both missing MPTokens; the invariant allows + // up to two MPToken creations per AMMWithdraw/AMMClawback (threshold > 2). + { + Env env{*this}; + env.fund(XRP(30'000), gw_, alice_); + env.close(); + MPTTester btc( + {.env = env, + .issuer = gw_, + .holders = {alice_}, + .pay = 10'000, + .flags = kMptDexFlags}); + MPTTester eth( + {.env = env, + .issuer = gw_, + .holders = {alice_}, + .pay = 10'000, + .flags = kMptDexFlags}); + + // Alice deposits everything into the MPT/MPT pool; her MPT + // balances drop to zero. + AMM ammAlice(env, alice_, btc(10'000), eth(10'000)); + BEAST_EXPECT(expectMPT(env, alice_, btc(0))); + BEAST_EXPECT(expectMPT(env, alice_, eth(0))); + + // Alice deletes both zero-balance MPTokens to reclaim reserve. + btc.authorize({.account = alice_, .flags = tfMPTUnauthorize}); + eth.authorize({.account = alice_, .flags = tfMPTUnauthorize}); + BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID(), alice_.id()))); + BEAST_EXPECT(!env.le(keylet::mptoken(eth.issuanceID(), alice_.id()))); + + // Equal withdrawal succeeds: both missing MPTokens are recreated + // (mptokensCreated_ == 2, which satisfies the > 2 invariant check). + ammAlice.withdrawAll(alice_); + BEAST_EXPECT(env.le(keylet::mptoken(btc.issuanceID(), alice_.id()))); + BEAST_EXPECT(env.le(keylet::mptoken(eth.issuanceID(), alice_.id()))); + BEAST_EXPECT(expectMPT(env, alice_, btc(10'000))); + BEAST_EXPECT(expectMPT(env, alice_, eth(10'000))); + BEAST_EXPECT(!ammAlice.ammExists()); + } } void @@ -4041,9 +4086,9 @@ private: { auto jtx = env.jt(tx, Seq(1), Fee(10)); env.app().config().features.erase(featureMPTokensV2); - PreflightContext const pfctx( + PreflightContext const ctx( env.app(), *jtx.stx, env.current()->rules(), TapNone, env.journal); - auto pf = AMMBid::checkExtraFeatures(pfctx); + auto pf = AMMBid::checkExtraFeatures(ctx); BEAST_EXPECT(pf == false); env.app().config().features.insert(featureMPTokensV2); } @@ -4053,9 +4098,9 @@ private: jtx.jv["Asset2"]["currency"] = "XRP"; jtx.jv["Asset2"].removeMember("mpt_issuance_id"); jtx.stx = env.ust(jtx); - PreflightContext const pfctx( + PreflightContext const ctx( env.app(), *jtx.stx, env.current()->rules(), TapNone, env.journal); - auto pf = AMMBid::preflight(pfctx); + auto pf = AMMBid::preflight(ctx); BEAST_EXPECT(pf == temBAD_AMM_TOKENS); } } @@ -4901,7 +4946,7 @@ private: XRP(10'100), MPT(ammAlice[1])(10'000'000000000001), ammAlice.tokens())); env.require(Balance(carol_, MPT(ammAlice[1])(30'199'999999999999))); - // Initial 30,000 - 10000(AMM pool LP) - 100(AMMoffer) - + // Initial 30,000 - 10000(AMM pool LP) - 100(AMM offer) - // - 100(offer) - 10(tx fee) - 10(tx fee of MPTTester init as // holder) - one reserve BEAST_EXPECT(expectLedgerEntryRoot( @@ -5010,12 +5055,12 @@ private: env.close(); BEAST_EXPECT( - amm.expectBalances(XRPAmount(909'090'909), btc(550'000000055001), amm.tokens())); - // Offer ~91XRP/49.99e12BTC + amm.expectBalances(XRPAmount(909'090'910), btc(549'999999450001), amm.tokens())); + // Offer ~91XRP/50e12BTC BEAST_EXPECT(expectOffers( - env, carol_, 1, {{Amounts{XRPAmount{9'090'909}, btc(4'999999950000)}}})); - // Carol pays 0.1% fee on 50'000000055000BTC = 50'000000055BTC - env.require(Balance(carol_, btc(29'949'949'999'944'943))); + env, carol_, 1, {{Amounts{XRPAmount{9'090'910}, btc(5'000000500000)}}})); + // Carol pays 0.1% fee on 49'999999450001BTC. + env.require(Balance(carol_, btc(29'949'950'000'550'548))); } { @@ -5065,15 +5110,15 @@ private: env.close(); BEAST_EXPECT(ammAlice.expectBalances( - btc(1'060'6848287928033), eth(1'037'0658372213574), ammAlice.tokens())); + btc(1'060'6848287928025), eth(1'037'0658372213582), ammAlice.tokens())); // Consumed offer ~72.93e13ETH/72.93e13BTC BEAST_EXPECT(expectOffers( - env, carol_, 1, {Amounts{eth(27'0658372213574), btc(27'0658372213575)}})); + env, carol_, 1, {Amounts{eth(27'0658372213582), btc(27'0658372213582)}})); BEAST_EXPECT(expectOffers(env, bob_, 0)); BEAST_EXPECT(expectOffers(env, ed, 0)); - env.require(Balance(carol_, btc(19'116'439'640'089'955))); - env.require(Balance(carol_, eth(20'729'341'627'786'426))); + env.require(Balance(carol_, btc(19'116'439'640'089'965))); + env.require(Balance(carol_, eth(20'729'341'627'786'418))); env.require(Balance(bob_, btc(20'100'000'000'000'000))); env.require(Balance(ed, eth(19'875'000'000'000'000))); } @@ -5672,6 +5717,87 @@ private: }); } + void + testAMMOfferGenerationPolicy(FeatureBitset features) + { + testcase("AMM payment offer generation picks economically coarser integral side"); + + using namespace jtx; + + enum class GeneratedFirst { TakerPays, TakerGets }; + + auto const check = [&](std::uint64_t mptUnitsPerXRP, GeneratedFirst generatedFirst) { + TAmounts const pool{ + XRPAmount{1'000'000}, MPTAmount{1'000'000'125}}; + TAmounts const clobOffer{ + kDropsPerXrp, MPTAmount{static_cast(mptUnitsPerXRP)}}; + Quality const clobQuality{clobOffer}; + + auto const expectedAmounts = generatedFirst == GeneratedFirst::TakerGets + ? getAMMOfferStartWithTakerGets(pool, clobQuality, 0) + : getAMMOfferStartWithTakerPays(pool, clobQuality, 0); + auto const otherAmounts = generatedFirst == GeneratedFirst::TakerGets + ? getAMMOfferStartWithTakerPays(pool, clobQuality, 0) + : getAMMOfferStartWithTakerGets(pool, clobQuality, 0); + BEAST_EXPECT(expectedAmounts); + BEAST_EXPECT(otherAmounts); + if (!expectedAmounts || !otherAmounts) + return; + + // Make the tested branch observable: these cases are chosen so the + // payment consumes different AMM amounts depending on which side + // is generated first. + BEAST_EXPECT(*expectedAmounts != *otherAmounts); + + Env env(*this, features); + auto const gw = Account("gw"); + auto const lp = Account("lp"); + auto const maker = Account("maker"); + auto const taker = Account("taker"); + auto const dst = Account("dst"); + + env.fund(XRP(10'000), gw, lp, maker, taker, dst); + env.close(); + + MPTTester const token( + {.env = env, .issuer = gw, .holders = {lp, maker, dst}, .flags = kMptDexFlags}); + env(pay(gw, lp, token(pool.out.value()))); + env(pay(gw, maker, token(10'000'000))); + env.close(); + + AMM const amm(env, lp, drops(pool.in), token(pool.out.value())); + auto const makerOfferSeq = env.seq(maker); + env(offer(maker, XRP(1), token(mptUnitsPerXRP)), Txflags(tfPassive)); + env.close(); + + env(pay(taker, dst, token(expectedAmounts->out.value())), + Sendmax(drops(expectedAmounts->in))); + env.close(); + + BEAST_EXPECT(amm.expectBalances( + drops(pool.in + expectedAmounts->in), + token((pool.out - expectedAmounts->out).value()), + amm.tokens())); + env.require(Balance(dst, token(expectedAmounts->out.value()))); + BEAST_EXPECT(env.le(keylet::offer(maker.id(), SeqProxy::rawSequence(makerOfferSeq)))); + }; + + // CLOB price: 10'000'000 MPT per 1 XRP, so one raw MPT unit is worth + // 0.1 drops. One drop is the economically coarser unit and the AMM + // offer is generated from takerPays. + check(10 * kDropsPerXrp.drops(), GeneratedFirst::TakerPays); + + // CLOB price: 1'000'000 MPT per 1 XRP, so one raw MPT unit is worth + // one drop. Ties use takerGets to preserve the historical XRP-output + // behavior. + check(kDropsPerXrp.drops(), GeneratedFirst::TakerGets); + + // CLOB price: 100'000 MPT per 1 XRP, so one raw MPT unit is worth + // 10 drops. MPT is the economically coarser unit and the AMM offer is + // generated from takerGets. + check(kDropsPerXrp.drops() / 10, GeneratedFirst::TakerGets); + } + void testTradingFee(FeatureBitset features) { @@ -7242,7 +7368,7 @@ private: // overflow. Deposit has no such bound, which is why only the deposit // path was exposed. // - // These mirror the deposit repros: the same oversized two-asset + // These mirror the deposit tests: the same oversized two-asset // request is rejected cleanly. If the preclaim bound is ever weakened, // equalWithdrawLimit would be reached with a huge frac and // Number::operator rep() would escape as tefEXCEPTION, failing this. @@ -7318,6 +7444,7 @@ private: testAMMTokens(); testAmendment(); testAMMAndCLOB(all); + testAMMOfferGenerationPolicy(all); testTradingFee(all); testTradingFee(all - fixAMMv1_3); testAdjustedTokens(all); diff --git a/src/test/app/AMM_test.cpp b/src/test/app/AMM_test.cpp index 58b783fd4b..dc2b838db7 100644 --- a/src/test/app/AMM_test.cpp +++ b/src/test/app/AMM_test.cpp @@ -3779,6 +3779,21 @@ private: BEAST_EXPECT(amm.expectBalances(XRP(1'000), USD(500), amm.tokens())); BEAST_EXPECT(expectOffers(env, carol_, 1, {{Amounts{XRP(100), USD(55)}}})); } + else if (!features[featureMPTokensV2]) + { + BEAST_EXPECT(amm.expectBalances( + XRPAmount(909'090'909), + STAmount{USD, UINT64_C(550'000000055), -9}, + amm.tokens())); + BEAST_EXPECT(expectOffers( + env, + carol_, + 1, + {{Amounts{XRPAmount{9'090'909}, STAmount{USD, 4'99999995, -8}}}})); + BEAST_EXPECT( + env.balance(carol_, USD) == + STAmount(USD, UINT64_C(29'949'94999999494), -11)); + } else { // Post-amendment the transfer fee is taken into account @@ -3789,19 +3804,19 @@ private: // quality. // AMM offer ~50USD/91XRP BEAST_EXPECT(amm.expectBalances( - XRPAmount(909'090'909), - STAmount{USD, UINT64_C(550'000000055), -9}, + XRPAmount(909'090'910), + STAmount{USD, UINT64_C(549'99999945), -8}, amm.tokens())); - // Offer ~91XRP/49.99USD + // Offer ~91XRP/50USD BEAST_EXPECT(expectOffers( env, carol_, 1, - {{Amounts{XRPAmount{9'090'909}, STAmount{USD, 4'99999995, -8}}}})); + {{Amounts{XRPAmount{9'090'910}, STAmount{USD, 5'0000005, -7}}}})); // Carol pays 0.1% fee on ~50USD =~ 0.05USD BEAST_EXPECT( env.balance(carol_, USD) == - STAmount(USD, UINT64_C(29'949'94999999494), -11)); + STAmount(USD, UINT64_C(29'949'95000060055), -11)); } }, {{XRP(1'000), USD(500)}}, @@ -6497,7 +6512,7 @@ private: BEAST_EXPECT(expectOffers(env, bob_, 1, {{Amounts{USD(1), XRPAmount(500)}}})); BEAST_EXPECT(expectOffers(env, carol_, 1, {{Amounts{XRP(100), USD(55)}}})); } - else + else if (!features[featureMPTokensV2]) { BEAST_EXPECT(amm.expectBalances( XRPAmount(909'090'909), @@ -6510,6 +6525,19 @@ private: {{Amounts{XRPAmount{9'090'909}, STAmount{USD, 4'99999995, -8}}}})); BEAST_EXPECT(expectOffers(env, bob_, 1, {{Amounts{USD(1), XRPAmount(500)}}})); } + else + { + BEAST_EXPECT(amm.expectBalances( + XRPAmount(909'090'910), + STAmount{USD, UINT64_C(549'99999945), -8}, + amm.tokens())); + BEAST_EXPECT(expectOffers( + env, + carol_, + 1, + {{Amounts{XRPAmount{9'090'910}, STAmount{USD, 5'0000005, -7}}}})); + BEAST_EXPECT(expectOffers(env, bob_, 1, {{Amounts{USD(1), XRPAmount(500)}}})); + } } // There is no blocking offer, the same AMM liquidity is consumed @@ -6521,10 +6549,30 @@ private: AMM const amm(env, alice_, XRP(1'000), USD(500)); env(offer(carol_, XRP(100), USD(55))); env.close(); - BEAST_EXPECT(amm.expectBalances( - XRPAmount(909'090'909), STAmount{USD, UINT64_C(550'000000055), -9}, amm.tokens())); - BEAST_EXPECT(expectOffers( - env, carol_, 1, {{Amounts{XRPAmount{9'090'909}, STAmount{USD, 4'99999995, -8}}}})); + if (!features[featureMPTokensV2]) + { + BEAST_EXPECT(amm.expectBalances( + XRPAmount(909'090'909), + STAmount{USD, UINT64_C(550'000000055), -9}, + amm.tokens())); + BEAST_EXPECT(expectOffers( + env, + carol_, + 1, + {{Amounts{XRPAmount{9'090'909}, STAmount{USD, 4'99999995, -8}}}})); + } + else + { + BEAST_EXPECT(amm.expectBalances( + XRPAmount(909'090'910), + STAmount{USD, UINT64_C(549'99999945), -8}, + amm.tokens())); + BEAST_EXPECT(expectOffers( + env, + carol_, + 1, + {{Amounts{XRPAmount{9'090'910}, STAmount{USD, 5'0000005, -7}}}})); + } } } @@ -7447,6 +7495,7 @@ private: testFlags(); testRippling(); testAMMAndCLOB(all); + testAMMAndCLOB(all - featureMPTokensV2); testAMMAndCLOB(all - fixAMMv1_1 - fixAMMv1_3); testTradingFee(all); testTradingFee(all - fixAMMv1_3); @@ -7466,8 +7515,10 @@ private: testOverflowOffer(all - fixAMMv1_1 - fixAMMv1_3); testSwapRounding(); testFixChangeSpotPriceQuality(all); + testFixChangeSpotPriceQuality(all - featureMPTokensV2); testFixChangeSpotPriceQuality(all - fixAMMv1_1 - fixAMMv1_3); testFixAMMOfferBlockedByLOB(all); + testFixAMMOfferBlockedByLOB(all - featureMPTokensV2); testFixAMMOfferBlockedByLOB(all - fixAMMv1_1 - fixAMMv1_3); testLPTokenBalance(all); testLPTokenBalance(all - fixAMMv1_3); diff --git a/src/test/app/DepositAuth_test.cpp b/src/test/app/DepositAuth_test.cpp index 881441e0f9..c987e603be 100644 --- a/src/test/app/DepositAuth_test.cpp +++ b/src/test/app/DepositAuth_test.cpp @@ -934,6 +934,46 @@ struct DepositPreauth_test : public beast::unit_test::Suite } } + void + testZeroCredentialID(FeatureBitset features) + { + testcase("Zero credential ID"); + + using namespace jtx; + + char const credType[] = "abcde"; + Account const issuer{"issuer"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + + Env env(*this, features); + + env.fund(XRP(5000), issuer, alice, bob); + env.close(); + + env(credentials::create(alice, issuer, credType)); + env.close(); + env(credentials::accept(alice, issuer, credType)); + env.close(); + + auto const jv = credentials::ledgerEntry(env, alice, issuer, credType); + std::string const credIdx = jv[jss::result][jss::index].asString(); + + std::string const zeroIdx(64, '0'); + + // post-fixCleanup3_4_0: a zero ID is rejected by checkFields in + // preflight; pre-fixCleanup3_4_0, it will trigger assertion, so it is not testable. + env(pay(alice, bob, XRP(100)), credentials::Ids({zeroIdx}), Ter(temMALFORMED)); + env.close(); + + env(pay(alice, bob, XRP(100)), credentials::Ids({credIdx, zeroIdx}), Ter(temMALFORMED)); + env.close(); + + // A valid credential succeeds + env(pay(alice, bob, XRP(100)), credentials::Ids({credIdx})); + env.close(); + } + void testCredentialsCreation() { @@ -1446,6 +1486,7 @@ struct DepositPreauth_test : public beast::unit_test::Suite testPayment(supported - featureCredentials); testPayment(supported); testCredentialsPayment(); + testZeroCredentialID(supported); testCredentialsCreation(); testExpiredCreds(); testSortingCredentials(); diff --git a/src/test/app/EscrowToken_test.cpp b/src/test/app/EscrowToken_test.cpp index 7e7509c3b7..72db63bd3f 100644 --- a/src/test/app/EscrowToken_test.cpp +++ b/src/test/app/EscrowToken_test.cpp @@ -3749,6 +3749,186 @@ struct EscrowToken_test : public beast::unit_test::Suite BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 0); } + void + testMPTLargeLockedRate(FeatureBitset features) + { + testcase("MPT large locked rate"); + using namespace test::jtx; + using namespace std::literals; + + auto constexpr escrowAmount = 200'000'000'000'000'000LL; + auto constexpr noOverflowEscrowAmount = 186'000'000'000'000'000LL; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + for (auto const testFeatures : + {features - featureMPTokensV2 - fixCleanup3_4_0, + features - featureMPTokensV2, + (features | featureMPTokensV2) - fixCleanup3_4_0, + features | featureMPTokensV2}) + { + bool const mptV2 = testFeatures[featureMPTokensV2]; + bool const tokenEscrowV1 = testFeatures[fixTokenEscrowV1]; + // The transfer-fee split in EscrowFinish only overflows on the + // legacy divideRound(amount, lockedRate, ...) path, which runs when + // fixCleanup3_4_0 is disabled. With fixCleanup3_4_0 the split uses + // mulRatio (128-bit intermediate), which cannot overflow. Without + // it, this large amount overflows unless the MPTokensV2 Number path + // is active. So the finish succeeds when either amendment is enabled. + bool const cleanup340 = testFeatures[fixCleanup3_4_0]; + bool const noOverflow = cleanup340 || mptV2; + auto const expectedErr = noOverflow ? Ter(tesSUCCESS) : Ter(tefEXCEPTION); + + // Finish with a large MPT amount and non-zero transfer fee. When the + // computation overflows (legacy divideRound path, no MPTokensV2) the + // finish fails with tefEXCEPTION and the escrow is untouched; + // otherwise it unlocks the escrow. + { + Env env{*this, testFeatures}; + env.fund(XRP(1'000), alice, bob, gw); + auto const baseFee = env.current()->fees().base; + + MPTTester const mpt( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .transferFee = 1'000, + .flags = tfMPTCanEscrow | tfMPTCanTransfer}); + env(pay(gw, alice, mpt(escrowAmount))); + env.close(); + + auto const preAlice = env.balance(alice, mpt); + auto const preBob = env.balance(bob, mpt); + auto const seq = env.seq(alice); + env(escrow::create(alice, bob, mpt(escrowAmount)), + escrow::kCondition(escrow::kCb1), + escrow::kFinishTime(env.now() + 1s), + escrow::kCancelTime(env.now() + 500s), + Fee(baseFee * 150)); + env.close(); + + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == escrowAmount); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == escrowAmount); + + env(escrow::finish(bob, alice, seq), + escrow::kCondition(escrow::kCb1), + escrow::kFulfillment(escrow::kFb1), + Fee(baseFee * 150), + expectedErr); + env.close(); + + if (noOverflow) + { + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(seq)))); + BEAST_EXPECT(env.balance(alice, mpt) == preAlice - mpt(escrowAmount)); + auto const postBob = env.balance(bob, mpt); + BEAST_EXPECT(postBob.value() > preBob.value()); + BEAST_EXPECT(postBob.value() < (preBob + mpt(escrowAmount)).value()); + auto const xferFee = escrowAmount - (postBob.value() - preBob.value()); + auto const expectedEscrow = tokenEscrowV1 ? 0 : xferFee; + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == expectedEscrow); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == expectedEscrow); + } + else + { + BEAST_EXPECT(env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(seq)))); + BEAST_EXPECT(env.balance(alice, mpt) == preAlice - mpt(escrowAmount)); + BEAST_EXPECT(env.balance(bob, mpt) == preBob); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == escrowAmount); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == escrowAmount); + } + } + + // Control: a still-large amount below the legacy overflow boundary + // finishes successfully in both feature modes. + { + Env env{*this, testFeatures}; + env.fund(XRP(1'000), alice, bob, gw); + auto const baseFee = env.current()->fees().base; + + MPTTester const mpt( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .transferFee = 1'000, + .flags = tfMPTCanEscrow | tfMPTCanTransfer}); + env(pay(gw, alice, mpt(noOverflowEscrowAmount))); + env.close(); + + auto const preAlice = env.balance(alice, mpt); + auto const preBob = env.balance(bob, mpt); + auto const seq = env.seq(alice); + env(escrow::create(alice, bob, mpt(noOverflowEscrowAmount)), + escrow::kCondition(escrow::kCb1), + escrow::kFinishTime(env.now() + 1s), + escrow::kCancelTime(env.now() + 500s), + Fee(baseFee * 150)); + env.close(); + + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == noOverflowEscrowAmount); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == noOverflowEscrowAmount); + + env(escrow::finish(bob, alice, seq), + escrow::kCondition(escrow::kCb1), + escrow::kFulfillment(escrow::kFb1), + Fee(baseFee * 150), + Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(seq)))); + BEAST_EXPECT(env.balance(alice, mpt) == preAlice - mpt(noOverflowEscrowAmount)); + auto const postBob = env.balance(bob, mpt); + BEAST_EXPECT(postBob.value() > preBob.value()); + BEAST_EXPECT(postBob.value() < (preBob + mpt(noOverflowEscrowAmount)).value()); + auto const xferFee = noOverflowEscrowAmount - (postBob.value() - preBob.value()); + auto const expectedEscrow = tokenEscrowV1 ? 0 : xferFee; + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == expectedEscrow); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == expectedEscrow); + } + + // Cancel returns the escrow to the owner using parity rate, so it + // does not hit the transfer-rate division in either feature mode. + { + Env env{*this, testFeatures}; + env.fund(XRP(1'000), alice, bob, gw); + auto const baseFee = env.current()->fees().base; + + MPTTester const mpt( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .transferFee = 1'000, + .flags = tfMPTCanEscrow | tfMPTCanTransfer}); + env(pay(gw, alice, mpt(escrowAmount))); + env.close(); + + auto const preAlice = env.balance(alice, mpt); + auto const preBob = env.balance(bob, mpt); + auto const seq = env.seq(alice); + env(escrow::create(alice, bob, mpt(escrowAmount)), + escrow::kCondition(escrow::kCb1), + escrow::kFinishTime(env.now() + 1s), + escrow::kCancelTime(env.now() + 3s), + Fee(baseFee * 150)); + env.close(); + + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == escrowAmount); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == escrowAmount); + + env(escrow::cancel(alice, alice, seq), Fee(baseFee), Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(seq)))); + BEAST_EXPECT(env.balance(alice, mpt) == preAlice); + BEAST_EXPECT(env.balance(bob, mpt) == preBob); + BEAST_EXPECT(env.balance(gw, mpt) == -mpt(escrowAmount)); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == 0); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 0); + } + } + } + void testMPTRequireAuth(FeatureBitset features) { @@ -4047,6 +4227,7 @@ struct EscrowToken_test : public beast::unit_test::Suite testMPTMetaAndOwnership(features); testMPTGateway(features); testMPTLockedRate(features); + testMPTLargeLockedRate(features); testMPTRequireAuth(features); testMPTLock(features); testMPTCanTransfer(features); diff --git a/src/test/app/FlowMPT_test.cpp b/src/test/app/FlowMPT_test.cpp index a94834eb28..49e3f9be94 100644 --- a/src/test/app/FlowMPT_test.cpp +++ b/src/test/app/FlowMPT_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -742,6 +743,164 @@ struct FlowMPT_test : public beast::unit_test::Suite return result; } + void + testOfferOwnerMPTCreation(FeatureBitset features) + { + using namespace jtx; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const gw("gw"); + + { + testcase("Reserve-edge offer owner cannot create another object"); + + Env env(*this, features); + + auto const baseFee = env.current()->fees().base; + auto const ownerIncrement = reserve(env, 1) - reserve(env, 0); + auto const xrpOffer = ownerIncrement - drops(1); + auto const bobStart = reserve(env, 2) - drops(1) + baseFee; + + env.fund(XRP(10'000), alice, gw); + env.fund(bobStart, bob); + env.close(); + + MPTTester const usd({.env = env, .issuer = gw, .maxAmt = 10}); + + env(offer(bob, usd(1), xrpOffer)); + env.close(); + + env.require(Balance(bob, reserve(env, 2) - drops(1)), Owners(bob, 1)); + + // This mirrors the full-crossing setup below. Bob has enough XRP + // for the resting offer, but not enough to pay a fee and add + // another owner-count object while the offer remains on ledger. + env(check::create(bob, alice, drops(1)), Ter(tecINSUFFICIENT_RESERVE)); + env.close(); + + env.require(Owners(bob, 1)); + BEAST_EXPECT(offersOnAccount(env, bob).size() == 1); + } + + { + testcase("Reserve-edge offer owner creates MPToken during consume"); + + Env env(*this, features); + + auto const baseFee = env.current()->fees().base; + auto const ownerIncrement = reserve(env, 1) - reserve(env, 0); + auto const xrpOffer = ownerIncrement - drops(1); + auto const bobStart = reserve(env, 2) - drops(1) + baseFee; + + env.fund(XRP(10'000), alice, carol, gw); + env.fund(bobStart, bob); + env.close(); + + MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10}); + + env(pay(gw, alice, usd(1))); + env(offer(bob, usd(1), xrpOffer)); + env.close(); + + env.require(Balance(bob, reserve(env, 2) - drops(1)), Owners(bob, 1)); + BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), bob.id()))); + auto const carolXRP = env.balance(carol); + + // Bob has enough XRP for the resting offer but is close to + // reserve. The payment should not create Bob's USD MPToken until + // the offer is actually consumed, otherwise the temporary owner + // count increase can make the offer look underfunded during path + // execution. + env(pay(alice, carol, xrpOffer), + Path(~XRP), + Sendmax(usd(1)), + Txflags(tfNoRippleDirect)); + env.close(); + + env.require(Balance(carol, carolXRP + xrpOffer)); + env.require(Balance(bob, usd(1))); + env.require(Balance(bob, reserve(env, 1)), Owners(bob, 1)); + BEAST_EXPECT(env.le(keylet::mptoken(usd.issuanceID(), bob.id()))); + BEAST_EXPECT(offersOnAccount(env, bob).empty()); + } + + { + testcase("Partial offer owner creates MPToken during consume"); + + Env env(*this, features); + + auto const baseFee = env.current()->fees().base; + auto const ownerIncrement = reserve(env, 1) - reserve(env, 0); + auto const bobStart = reserve(env, 3) + baseFee; + + env.fund(XRP(10'000), alice, carol, gw); + env.fund(bobStart, bob); + env.close(); + + MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10}); + + env(pay(gw, alice, usd(1))); + env(offer(bob, usd(2), drops(2 * ownerIncrement))); + env.close(); + + env.require(Balance(bob, reserve(env, 3)), Owners(bob, 1)); + BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), bob.id()))); + auto const carolXRP = env.balance(carol); + + // Partial consumption leaves Bob's offer on the ledger, so he ends + // up owning both the remaining offer and a newly created MPToken. + // The MPToken is created regardless of reserve; this setup simply + // funds Bob enough that he still meets reserve(2) afterward (the + // under-reserved case is covered in OfferMPT_test's no-reserve-check + // testcase). + env(pay(alice, carol, drops(ownerIncrement)), + Path(~XRP), + Sendmax(usd(1)), + Txflags(tfNoRippleDirect)); + env.close(); + + env.require(Balance(carol, carolXRP + drops(ownerIncrement))); + env.require(Balance(bob, usd(1))); + env.require(Balance(bob, reserve(env, 2)), Owners(bob, 2)); + BEAST_EXPECT(env.le(keylet::mptoken(usd.issuanceID(), bob.id()))); + BEAST_EXPECT(offersOnAccount(env, bob).size() == 1); + BEAST_EXPECT(isOffer(env, bob, usd(1), drops(ownerIncrement))); + } + + { + testcase("Issuer-owned offer does not create issuer MPToken"); + + Env env(*this, features); + + env.fund(XRP(10'000), alice, carol, gw); + env.close(); + + MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10}); + + env(pay(gw, alice, usd(1))); + env(offer(gw, usd(1), drops(1'000))); + env.close(); + + BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), gw.id()))); + auto const carolXRP = env.balance(carol); + + // The issuer can own an offer that receives its own MPT without an + // MPToken. Consuming that offer should keep the issuer side + // tokenless. + env(pay(alice, carol, drops(1'000)), + Path(~XRP), + Sendmax(usd(1)), + Txflags(tfNoRippleDirect)); + env.close(); + + env.require(Balance(alice, usd(0))); + env.require(Balance(carol, carolXRP + drops(1'000))); + BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), gw.id()))); + BEAST_EXPECT(offersOnAccount(env, gw).empty()); + } + } + void testSelfPayment1(FeatureBitset features) { @@ -2121,6 +2280,7 @@ struct FlowMPT_test : public beast::unit_test::Suite testFalseDry(features); testDirectStep(features); testBookStep(features); + testOfferOwnerMPTCreation(features); testTransferRate(features); testSelfPayment1(features); testSelfPayment2(features); diff --git a/src/test/app/GRPCServerTLS_test.cpp b/src/test/app/GRPCServerTLS_test.cpp index a48986d004..58ccf33959 100644 --- a/src/test/app/GRPCServerTLS_test.cpp +++ b/src/test/app/GRPCServerTLS_test.cpp @@ -1,13 +1,12 @@ #include #include +#include #include #include #include #include -#include - #include #include #include @@ -17,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -254,10 +254,8 @@ public: TemporaryTLSCertificates() { - auto tmpDir = std::filesystem::temp_directory_path(); - auto uniqueDirName = - boost::filesystem::unique_path(std::string(kCertsDirPrefix) + "%%%%%%%%"); - tempDir_ = tmpDir / uniqueDirName.string(); + tempDir_ = xrpl::uniqueRandomPath( + std::filesystem::temp_directory_path(), std::string(kCertsDirPrefix)); std::filesystem::create_directories(tempDir_); writeFile(tempDir_ / kCaCertFilename, kCaCertContent); diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index ffdfe6bc83..6878b2b5d0 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -65,6 +66,7 @@ #include #include #include +#include #include #include #include @@ -135,7 +137,8 @@ class Invariants_test : public beast::unit_test::Suite STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, Preclose const& preclose = {}, - TxAccount setTxAccount = TxAccount::None) + TxAccount setTxAccount = TxAccount::None, + std::source_location const& loc = std::source_location::current()) { doInvariantCheck( makeEnv(defaultAmendments()), @@ -145,7 +148,8 @@ class Invariants_test : public beast::unit_test::Suite tx, ters, preclose, - setTxAccount); + setTxAccount, + loc); } void @@ -157,7 +161,8 @@ class Invariants_test : public beast::unit_test::Suite STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, Preclose const& preclose = {}, - TxAccount setTxAccount = TxAccount::None) + TxAccount setTxAccount = TxAccount::None, + std::source_location const& loc = std::source_location::current()) { using namespace test::jtx; @@ -171,7 +176,7 @@ class Invariants_test : public beast::unit_test::Suite if (setTxAccount != TxAccount::None) tx.setAccountID(sfAccount, setTxAccount == TxAccount::A1 ? a1.id() : a2.id()); - doInvariantCheck(std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters); + doInvariantCheck(std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc); } void @@ -184,7 +189,8 @@ class Invariants_test : public beast::unit_test::Suite Precheck const& precheck, XRPAmount fee = XRPAmount{}, STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, - std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}) + std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + std::source_location const& loc = std::source_location::current()) { using namespace test::jtx; @@ -211,23 +217,27 @@ class Invariants_test : public beast::unit_test::Suite for (TER const& terExpect : ters) { terActual = transactor->checkInvariants(terActual, fee); - BEAST_EXPECTS( + expect( terExpect == terActual, - "expected: " + transToken(terExpect) + " got: " + transToken(terActual)); + "expected: " + transToken(terExpect) + " got: " + transToken(terActual), + loc.file_name(), + loc.line()); auto const messages = sink.messages().str(); if (!isTesSuccess(terActual)) { - BEAST_EXPECTS( + expect( messages.starts_with("Invariant failed:") || messages.starts_with("Transaction caused an exception"), - messages); + messages, + loc.file_name(), + loc.line()); } // std::cerr << messages << '\n'; for (auto const& m : expectLogs) { - BEAST_EXPECTS(messages.contains(m), m); + expect(messages.contains(m), m, loc.file_name(), loc.line()); } } } @@ -437,16 +447,10 @@ class Invariants_test : public beast::unit_test::Suite XRPAmount{}, STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - for (auto const& keyletInfo : kDirectAccountKeylets) + for (auto const& [keyletfunc, type, includeInTests] : kDirectAccountKeylets) { - // TODO: Use structured binding once LLVM 16 is the minimum - // supported version. See also: - // https://github.com/llvm/llvm-project/issues/48582 - // https://github.com/llvm/llvm-project/commit/127bf44385424891eb04cff8e52d3f157fc2cb7c - if (!keyletInfo.includeInTests) + if (!includeInTests) continue; - auto const& keyletfunc = keyletInfo.function; - auto const& type = keyletInfo.expectedLEName; using namespace std::string_literals; @@ -2481,6 +2485,54 @@ class Invariants_test : public beast::unit_test::Suite // TODO: Loan Object + // VaultKind, SubscriptionDate and RedemptionDate are immutable once set at creation. + // Enforced by NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged. + Keylet closedEndedVaultKeylet = keylet::amendments(); + Preclose const createClosedEndedVault = [&, this]( + Account const& a, Account const&, Env& env) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod + 1'000'000; + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = a, + .asset = xrpIssue(), + .vaultKind = std::to_underlying(VaultKind::ClosedEnded), + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + closedEndedVaultKeylet = keylet; + return BEAST_EXPECT(env.le(closedEndedVaultKeylet)); + }; + + { + // Each mutation must keep the vault otherwise valid so that only the immutability check + // fires. Shifting both dates by the same offset preserves the gap; bumping sfVaultKind + // stays within the recognised range. + auto const mods = std::to_array>({ + [](SLE::pointer& sle) { sle->at(sfVaultKind) += 1; }, + [](SLE::pointer& sle) { sle->at(sfSubscriptionDate) += 1; }, + [](SLE::pointer& sle) { sle->at(sfRedemptionDate) += 1; }, + }); + + for (auto const& mod : mods) + { + doInvariantCheck( + {{"changed an unchangeable field"}}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(closedEndedVaultKeylet); + if (!sle) + return false; + mod(sle); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createClosedEndedVault); + } + } + { auto const mods = std::to_array>({ [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; }, @@ -4373,6 +4425,286 @@ class Invariants_test : public beast::unit_test::Suite }}, {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, precloseMpt); + + // ───────────────────────────────────────────────────────────── + // Closed-ended vault invariants added in ValidVault::finalize (create must supply both + // dates and satisfy the redemption-buffer gap), deposit only in Subscription / NoPhase, + // withdraw not in Investment, loan origination only in Investment. + + using d = NetClock::duration; + using tp = NetClock::time_point; + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + + // Vault keylet captured by precloseClosedEnded so precheck does not have to rederive it + // from ac.view().seq(), which depends on how many env.close() calls preclose issued. + Keylet closedEndedKeylet = keylet::amendments(); + + // Preclose that creates a closed-ended vault (in Subscription), optionally seeds it with + // three deposits (so a1/a2/a3 hold a share MPToken that kAdjust can then adjust), and + // optionally advances parent close time past SubscriptionDate. A negative @p advanceBySub + // leaves the vault in Subscription. + auto const precloseClosedEnded = [&](std::int32_t advanceBySub, bool doDeposit) { + return [&, advanceBySub, doDeposit]( + Account const& a1, Account const& a2, Env& env) -> bool { + env.fund(XRP(1000), a3, a4); + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod + 1'000'000; + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = a1, + .asset = xrpIssue(), + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + closedEndedKeylet = keylet; + if (doDeposit) + { + env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); + env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)})); + env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)})); + } + if (advanceBySub >= 0) + env.close(tp{d{sub + advanceBySub}}); + return true; + }; + }; + + // Manually insert a bare closed-ended vault (+ pseudo-account + share MPTokenIssuance) + // directly into the view, bypassing the transactor path. Used to synthesize ttVAULT_CREATE + // states no legitimate transactor would produce. + auto const insertBareClosedEndedVault = + [closedEnded]( + ApplyContext& ac, + Account const& owner, + std::optional subscriptionDate, + std::optional redemptionDate) -> bool { + auto const sequence = ac.view().seq(); + auto const vaultKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(sequence)); + auto sleVault = std::make_shared(vaultKeylet); + auto const vaultPage = ac.view().dirInsert( + keylet::ownerDir(owner.id()), sleVault->key(), describeOwnerDir(owner.id())); + if (!vaultPage) + return false; + sleVault->setFieldU64(sfOwnerNode, *vaultPage); + + auto const pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key); + auto sleAccount = std::make_shared(keylet::account(pseudoId)); + sleAccount->setAccountID(sfAccount, pseudoId); + sleAccount->setFieldAmount(sfBalance, STAmount{}); + sleAccount->setFieldU32(sfSequence, 0); + sleAccount->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); + sleAccount->setFieldH256(sfVaultID, vaultKeylet.key); + ac.view().insert(sleAccount); + + auto const sharesMptId = makeMptID(sequence, pseudoId); + auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); + auto sleShares = std::make_shared(sharesKeylet); + auto const sharesPage = ac.view().dirInsert( + keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId)); + if (!sharesPage) + return false; + sleShares->setFieldU64(sfOwnerNode, *sharesPage); + sleShares->at(sfFlags) = 0; + sleShares->at(sfIssuer) = pseudoId; + sleShares->at(sfOutstandingAmount) = 0; + sleShares->at(sfSequence) = sequence; + + sleVault->at(sfAccount) = pseudoId; + sleVault->at(sfFlags) = 0; + sleVault->at(sfSequence) = sequence; + sleVault->at(sfOwner) = owner.id(); + sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, Asset{xrpIssue()}}); + sleVault->at(sfAssetsTotal) = Number(0); + sleVault->at(sfAssetsAvailable) = Number(0); + sleVault->at(sfLossUnrealized) = Number(0); + sleVault->at(sfShareMPTID) = sharesMptId; + sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; + sleVault->at(sfVaultKind) = closedEnded; + if (subscriptionDate) + sleVault->at(sfSubscriptionDate) = *subscriptionDate; + if (redemptionDate) + sleVault->at(sfRedemptionDate) = *redemptionDate; + + ac.view().insert(sleVault); + ac.view().insert(sleShares); + return true; + }; + + testcase << "Vault create closed-ended"; + + // A fresh closed-ended vault must carry both SubscriptionDate and RedemptionDate. + doInvariantCheck( + {"closed-ended vault must have SubscriptionDate and RedemptionDate"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + return insertBareClosedEndedVault(ac, a1, std::nullopt, std::nullopt); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + // Gap smaller than MIN_INVESTMENT_PERIOD but with RedemptionDate > SubscriptionDate; + // exercises the sub-minimum branch of the gap check. + doInvariantCheck( + {"closed-ended vault RedemptionDate - SubscriptionDate must be " + "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + std::uint32_t const sub = 1'000'000'000; + std::uint32_t const red = sub + kMinInvestmentPeriod - 1; + return insertBareClosedEndedVault(ac, a1, sub, red); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + // RedemptionDate strictly before SubscriptionDate; the signed int64 gap is negative and + // is caught by the sub-minimum branch of the gap check. + doInvariantCheck( + {"closed-ended vault RedemptionDate - SubscriptionDate must be " + "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + std::uint32_t const sub = 1'000'000'000; + std::uint32_t const red = sub - 1; + return insertBareClosedEndedVault(ac, a1, sub, red); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + // Gap exactly MAX_INVESTMENT_PERIOD is out of range (bound is half-open on the right). + doInvariantCheck( + {"closed-ended vault RedemptionDate - SubscriptionDate must be " + "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + std::uint32_t const sub = 1'000'000'000; + std::uint32_t const red = sub + kMaxInvestmentPeriod; + return insertBareClosedEndedVault(ac, a1, sub, red); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + testcase << "Vault deposit closed-ended"; + + // A deposit into a closed-ended vault that has advanced past SubscriptionDate. kArgs + // simulates an otherwise valid deposit shape so only the phase invariant fires. + doInvariantCheck( + {"deposit only allowed in Subscription or NoPhase"}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + return kAdjust( + ac.view(), closedEndedKeylet, kArgs(a2.id(), 10, [](Adjustments&) {})); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), + TxAccount::A2); + + testcase << "Vault withdrawal closed-ended"; + + // A withdrawal from a closed-ended vault in the Investment phase. + doInvariantCheck( + {"withdrawal not allowed during Investment phase"}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + return kAdjust( + ac.view(), closedEndedKeylet, kArgs(a2.id(), -10, [](Adjustments&) {})); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), + TxAccount::A2); + + testcase << "Vault loan set"; + + // ttLOAN_SET against a closed-ended vault that is not in Investment. finalizeLoanSet fires + // on any vault mutation; touching the vault SLE with no field change is sufficient. + doInvariantCheck( + {"loan origination only allowed in Investment phase"}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto sleVault = ac.view().peek(closedEndedKeylet); + if (!sleVault) + return false; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseClosedEnded(/*advanceBySub=*/-1, /*doDeposit=*/false)); + + testcase << "Vault loan set - closed-ended final payment past " + "RedemptionDate"; + + // A newly-created loan against a closed-ended vault must satisfy StartDate + + // PaymentInterval * PaymentRemaining < RedemptionDate. LoanSet::preclaim enforces the same + // bound; this test synthesises an invalid loan directly in the ApplyView so the invariant + // catches it even when preclaim is bypassed. + Keylet closedEndedBrokerKeylet = keylet::amendments(); + std::uint32_t closedEndedRed = 0; + doInvariantCheck( + {"closed-ended loan final payment must precede RedemptionDate"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + // Touch the vault so ValidVault::finalizeLoanSet sees an + // entry in afterVault_; the vault is in Investment, so + // finalizeLoanSet itself passes. + auto sleVault = ac.view().peek(closedEndedKeylet); + if (!sleVault) + return false; + ac.view().update(sleVault); + + // Read the broker's next loan sequence to build the loan + // keylet the same way LoanSet::doApply would. + auto sleBroker = ac.view().peek(closedEndedBrokerKeylet); + if (!sleBroker) + return false; + std::uint32_t const loanSeq = sleBroker->at(sfLoanSequence); + + // Synthesize a Loan whose final scheduled payment lands + // exactly at RedemptionDate: StartDate = red, interval = 60, + // remaining = 1 => red + 60 >= red. + auto sleLoan = std::make_shared( + keylet::loan(closedEndedBrokerKeylet.key, SeqProxy::rawSequence(loanSeq))); + sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key; + sleLoan->at(sfLoanSequence) = loanSeq; + sleLoan->at(sfBorrower) = a1.id(); + sleLoan->at(sfStartDate) = closedEndedRed; + sleLoan->at(sfPaymentInterval) = 60; + sleLoan->at(sfPaymentRemaining) = 1; + sleLoan->at(sfTotalValueOutstanding) = Number(100); + sleLoan->at(sfPeriodicPayment) = Number(1); + ac.view().insert(sleLoan); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const&, Env& env) -> bool { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod + 1'000'000; + closedEndedRed = red; + + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = a1, + .asset = xrpIssue(), + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + closedEndedKeylet = keylet; + + // Create the loan broker; LoanBrokerSet has no phase gate. + closedEndedBrokerKeylet = + keylet::loanBroker(a1.id(), SeqProxy::rawSequence(env.seq(a1))); + env(loan_broker::set(a1, keylet.key)); + + // Advance parent close time into Investment so + // ValidVault::finalizeLoanSet is satisfied. + env.close(tp{d{sub + 1}}); + return true; + }); } void diff --git a/src/test/app/LedgerLoad_test.cpp b/src/test/app/LedgerLoad_test.cpp index ee3bfe5192..8fb10c1088 100644 --- a/src/test/app/LedgerLoad_test.cpp +++ b/src/test/app/LedgerLoad_test.cpp @@ -7,10 +7,10 @@ #include +#include #include #include #include -#include #include #include #include @@ -18,16 +18,16 @@ #include #include -#include -#include #include +#include #include #include #include #include #include #include +#include namespace xrpl { @@ -61,7 +61,7 @@ class LedgerLoad_test : public beast::unit_test::Suite }; SetupData - setupLedger(beast::TempDir const& td) + setupLedger(TempDir const& td) { using namespace test::jtx; SetupData retval = {.dbPath = td.path()}; @@ -139,7 +139,7 @@ class LedgerLoad_test : public beast::unit_test::Suite { testcase("Load ledger: Bad Files"); using namespace test::jtx; - using namespace boost::filesystem; + using namespace std::filesystem; // empty path except([&] { @@ -161,8 +161,8 @@ class LedgerLoad_test : public beast::unit_test::Suite }); // make a corrupted version of the ledger file (last 10 bytes removed). - boost::system::error_code ec; - auto ledgerFileCorrupt = boost::filesystem::path{sd.dbPath} / "ledgerdata_bad.json"; + std::error_code ec; + auto ledgerFileCorrupt = std::filesystem::path{sd.dbPath} / "ledgerdata_bad.json"; copy_file(sd.ledgerFile, ledgerFileCorrupt, copy_options::overwrite_existing, ec); if (!BEAST_EXPECTS(!ec, ec.message())) return; @@ -330,7 +330,7 @@ public: void run() override { - beast::TempDir const td; + TempDir const td; auto sd = setupLedger(td); // test cases diff --git a/src/test/app/Manifest_test.cpp b/src/test/app/Manifest_test.cpp index ef2043a22c..14d176b45f 100644 --- a/src/test/app/Manifest_test.cpp +++ b/src/test/app/Manifest_test.cpp @@ -22,14 +22,12 @@ #include #include -#include -#include - #include #include #include #include #include +#include #include #include #include @@ -56,18 +54,18 @@ private: } static void - cleanupDatabaseDir(boost::filesystem::path const& dbPath) + cleanupDatabaseDir(std::filesystem::path const& dbPath) { - using namespace boost::filesystem; + using namespace std::filesystem; if (!exists(dbPath) || !is_directory(dbPath) || !is_empty(dbPath)) return; remove(dbPath); } static void - setupDatabaseDir(boost::filesystem::path const& dbPath) + setupDatabaseDir(std::filesystem::path const& dbPath) { - using namespace boost::filesystem; + using namespace std::filesystem; if (!exists(dbPath)) { create_directory(dbPath); @@ -80,10 +78,10 @@ private: Throw("Cannot create directory: " + dbPath.string()); } } - static boost::filesystem::path + static std::filesystem::path getDatabasePath() { - return boost::filesystem::current_path() / "manifest_test_databases"; + return std::filesystem::current_path() / "manifest_test_databases"; } public: @@ -351,7 +349,7 @@ public: BEAST_EXPECT(loaded.revoked(pk)); } } - boost::filesystem::remove(getDatabasePath() / boost::filesystem::path(dbName)); + std::filesystem::remove(getDatabasePath() / std::filesystem::path(dbName)); } void diff --git a/src/test/app/NFToken_test.cpp b/src/test/app/NFToken_test.cpp index a7437eea7f..7fcd34640b 100644 --- a/src/test/app/NFToken_test.cpp +++ b/src/test/app/NFToken_test.cpp @@ -4790,6 +4790,87 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite checkOffers("nft_buy_offers", 501, 2, __LINE__); } + void + testNftXxxOffersMarkerWrongSide(FeatureBitset features) + { + // A pagination marker passed to nft_buy_offers / nft_sell_offers must + // reference an offer on the same side (buy vs. sell) as the directory + // being enumerated. A wrong-side marker is rejected with invalidParams. + // + // Note: the pre-fix code also returned invalidParams for a wrong-side + // marker, but only after scanning the entire target directory (an + // O(directory size) walk usable to burn CPU). The fix short-circuits + // that scan. The scan-avoidance is not observable from the RPC + // response, so this test locks the rejection contract (wrong-side -> + // error, same-side -> success) rather than the performance property. + testcase("nft_buy_offers and nft_sell_offers wrong-side marker"); + + using namespace test::jtx; + + Env env{*this, features}; + + Account const issuer{"issuer"}; + Account const buyer{"buyer"}; + + env.fund(XRP(10000), issuer, buyer); + env.close(); + + // Mint a transferable NFT. + uint256 const nftID{token::getNextID(env, issuer, 0u, tfTransferable)}; + env(token::mint(issuer, 0), Txflags(tfTransferable)); + env.close(); + + // Create one sell offer (from the issuer, who owns the NFT) and one + // buy offer (from the buyer) for the same NFT. + env(token::createOffer(issuer, nftID, XRP(100)), Txflags(tfSellNFToken)); + env(token::createOffer(buyer, nftID, XRP(50)), token::Owner(issuer)); + env.close(); + + // Grab the index of the single offer on each side from the RPC + // response so we can use it as a marker. + auto firstOfferIndex = [this, &env, &nftID](char const* request) { + json::Value params; + params[jss::nft_id] = to_string(nftID); + json::Value const result = env.rpc("json", request, to_string(params))[jss::result]; + BEAST_EXPECT(result.isMember(jss::offers) && result[jss::offers].size() == 1); + return result[jss::offers][0u][jss::nft_offer_index].asString(); + }; + + std::string const sellOfferIndex = firstOfferIndex("nft_sell_offers"); + std::string const buyOfferIndex = firstOfferIndex("nft_buy_offers"); + + auto queryWithMarker = [&env, &nftID](char const* request, std::string const& marker) { + json::Value params; + params[jss::nft_id] = to_string(nftID); + params[jss::marker] = marker; + return env.rpc("json", request, to_string(params))[jss::result]; + }; + + // A marker referencing an offer on the wrong side is rejected with + // invalidParams. + { + // Sell-side marker passed to nft_buy_offers. + json::Value const result = queryWithMarker("nft_buy_offers", sellOfferIndex); + BEAST_EXPECT(result[jss::error].asString() == "invalidParams"); + } + { + // Buy-side marker passed to nft_sell_offers. + json::Value const result = queryWithMarker("nft_sell_offers", buyOfferIndex); + BEAST_EXPECT(result[jss::error].asString() == "invalidParams"); + } + + // A same-side marker is still accepted. With a single offer on each + // side, resuming after it simply yields no further offers. + { + json::Value const result = queryWithMarker("nft_buy_offers", buyOfferIndex); + BEAST_EXPECT(!result.isMember(jss::error)); + } + { + json::Value const result = queryWithMarker("nft_sell_offers", sellOfferIndex); + BEAST_EXPECT(!result.isMember(jss::error)); + } + } + void testNFTokenNegOffer(FeatureBitset features) { @@ -7305,6 +7386,7 @@ protected: testNFTokenWithTickets(features); testNFTokenDeleteAccount(features); testNftXxxOffers(features); + testNftXxxOffersMarkerWrongSide(features); testNFTokenNegOffer(features); testIOUWithTransferFee(features); testBrokeredSaleToSelf(features); diff --git a/src/test/app/OfferMPT_test.cpp b/src/test/app/OfferMPT_test.cpp index d03b1b8e93..e262954fdf 100644 --- a/src/test/app/OfferMPT_test.cpp +++ b/src/test/app/OfferMPT_test.cpp @@ -1,3 +1,5 @@ +#include +#include #include #include #include @@ -5,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -22,6 +25,7 @@ #include #include +#include #include #include #include @@ -35,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +51,7 @@ #include #include #include +#include #include #include #include @@ -609,6 +615,267 @@ public: testHelper2TokensMix(test); } + void + testMPTIssuerOfferUsesRemainingCapacity(FeatureBitset features) + { + testcase("MPT issuer offer dust removal uses remaining issuance capacity"); + + using namespace jtx; + + Account const issuer{"issuer"}; + Account const carol{"carol"}; + Account const bob{"bob"}; + + Env env{*this, features}; + env.fund(XRP(10'000), issuer, carol, bob); + env.close(); + + MPTTester const musd( + {.env = env, .issuer = issuer, .holders = {carol, bob}, .maxAmt = 101}); + + // The issuer offer is fully fundable when placed. Later issuance leaves + // only one MPT of remaining capacity, so this issuer-owned MPT offer + // must be clipped by owner funds just like a holder-funded offer. + auto const issuerOfferSeq = env.seq(issuer); + env(offer(issuer, drops(1), musd(100))); + env.close(); + + env(pay(issuer, carol, musd(100))); + env.close(); + BEAST_EXPECT(env.balance(issuer, musd) == musd(-100)); + BEAST_EXPECT(env.balance(carol, musd) == musd(100)); + + // Carol's same-quality offer provides the legitimately funded side of + // the crossing. Without the issuer-cap dust-removal check, Bob would + // receive Carol's 100 MPT plus one free self-issued MPT from issuer's + // stale offer while paying only Carol's one drop. + auto const carolOfferSeq = env.seq(carol); + env(offer(carol, drops(1), musd(100))); + env.close(); + + auto const issuerOffer = keylet::offer(issuer.id(), SeqProxy::rawSequence(issuerOfferSeq)); + auto const carolOffer = keylet::offer(carol.id(), SeqProxy::rawSequence(carolOfferSeq)); + BEAST_EXPECT(env.le(issuerOffer) != nullptr); + BEAST_EXPECT(env.le(carolOffer) != nullptr); + + env(offer(bob, musd(101), drops(2), tfImmediateOrCancel)); + env.close(); + + BEAST_EXPECT(env.le(issuerOffer) == nullptr); + BEAST_EXPECT(env.le(carolOffer) == nullptr); + env.require(offers(issuer, 0), offers(carol, 0), offers(bob, 0)); + BEAST_EXPECT(env.balance(issuer, musd) == musd(-100)); + BEAST_EXPECT(env.balance(carol, musd) == musd(0)); + BEAST_EXPECT(env.balance(bob, musd) == musd(100)); + } + + void + testPartiallyFundedMPTInputOfferZeroInput(FeatureBitset features) + { + using namespace jtx; + auto const alice = Account{"alice"}; + auto const bob = Account{"bob"}; + + { + testcase("Partially funded MPT/XRP input offer cannot be consumed for free"); + + Env env{*this, features}; + auto const gw = Account{"gw"}; + + env.fund(XRP(10'000), gw, alice, bob); + env.close(); + + MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}}); + + auto const aliceOfferSeq = env.seq(alice); + env(offer(alice, usd(1), drops(1'000'000))); + env.close(); + + auto const targetBalance = reserve(env, 2) + drops(999'999); + auto const drain = env.balance(alice).value().xrp() - targetBalance.value().xrp() - + env.current()->fees().base; + env(pay(alice, gw, drops(drain))); + env.close(); + + auto const aliceXRPBefore = env.balance(alice); + auto const bobXRPBefore = env.balance(bob); + + env(pay(gw, bob, drops(1'000'000)), + Sendmax(usd(1)), + Path(~XRP), + Txflags(tfNoRippleDirect | tfPartialPayment), + Ter(tecPATH_DRY)); + env.close(); + + // alice's offer sells 1,000,000 drops for usd(1) but she can fund + // only 999,999. Filling the clipped remainder would require a + // fractional usd (MPT) input that rounds down to zero, so without + // the fix the taker could take the funded drops for free. + // shouldRmSmallIncreasedQOffer() now treats the MPT input as + // integral (like XRP) and removes the degraded offer, so the + // payment goes dry. The removal happens only inside the crossing: + // tecPATH_DRY discards everything but the fee, so the offer itself + // stays in the ledger, unconsumed. + BEAST_EXPECT( + env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(aliceOfferSeq))) != nullptr); + BEAST_EXPECT(env.balance(alice) == aliceXRPBefore); + BEAST_EXPECT(env.balance(bob) == bobXRPBefore); + } + + { + testcase("Partially funded MPT/IOU input offer cannot be consumed for free"); + + Env env{*this, features}; + auto const mptIssuer = Account{"mptIssuer"}; + auto const iouIssuer = Account{"iouIssuer"}; + + env.fund(XRP(10'000), mptIssuer, iouIssuer, alice, bob); + env.close(); + + auto const eur = iouIssuer["EUR"]; + env.trust(eur(100), alice, bob); + env(pay(iouIssuer, alice, eur(0.5))); + env.close(); + + MPTTester const usd({.env = env, .issuer = mptIssuer, .holders = {alice}}); + + auto const aliceOfferSeq = env.seq(alice); + env(offer(alice, usd(1), eur(1))); + env.close(); + + auto const aliceEURBefore = env.balance(alice, eur); + auto const bobEURBefore = env.balance(bob, eur); + + env(pay(mptIssuer, bob, eur(1)), + Sendmax(usd(1)), + Path(~eur), + Txflags(tfNoRippleDirect | tfPartialPayment), + Ter(tecPATH_DRY)); + env.close(); + + // Same zero-input regression as the MPT/XRP case above, but with + // an IOU (eur) output leg: the fractional usd (MPT) input rounds + // to zero. The degraded offer is removed during crossing, the + // payment goes dry, and tecPATH_DRY leaves the offer in the ledger. + BEAST_EXPECT( + env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(aliceOfferSeq))) != nullptr); + BEAST_EXPECT(env.balance(alice, eur) == aliceEURBefore); + BEAST_EXPECT(env.balance(bob, eur) == bobEURBefore); + } + + { + testcase("Partially funded MPT/MPT input offer cannot be consumed for free"); + + Env env{*this, features}; + auto const issuerA = Account{"issuerA"}; + auto const issuerB = Account{"issuerB"}; + + env.fund(XRP(10'000), issuerA, issuerB, alice, bob); + env.close(); + + MPTTester const usd({.env = env, .issuer = issuerA, .holders = {alice}}); + MPTTester const eur({.env = env, .issuer = issuerB, .holders = {alice, bob}}); + + env(pay(issuerB, alice, eur(999'999))); + env.close(); + + auto const aliceOfferSeq = env.seq(alice); + env(offer(alice, usd(1), eur(1'000'000))); + env.close(); + + auto const aliceEURBefore = eur.getBalance(alice); + auto const bobEURBefore = eur.getBalance(bob); + + env(pay(issuerA, bob, eur(1'000'000)), + Sendmax(usd(1)), + Path(~eur), + Txflags(tfNoRippleDirect | tfPartialPayment), + Ter(tecPATH_DRY)); + env.close(); + + // Same zero-input regression as above, but with both legs MPT: the + // fractional usd (MPT) input rounds to zero. The degraded offer is + // removed during crossing, the payment goes dry, and tecPATH_DRY + // leaves the offer in the ledger. + BEAST_EXPECT( + env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(aliceOfferSeq))) != nullptr); + BEAST_EXPECT(env.balance(alice, eur) == eur(aliceEURBefore)); + BEAST_EXPECT(env.balance(bob, eur) == eur(bobEURBefore)); + } + + { + // The dry cases above never observe the degraded offer actually + // being removed, because tecPATH_DRY rolls the removal back. Here a + // second, fully funded offer lets the crossing succeed, so the + // removal persists: alice's degraded offer is deleted from the + // book (not taken for free) while carol's good offer fills. + testcase( + "Partially funded MPT input offer is removed, not consumed, " + "when a funded offer crosses"); + + Env env{*this, features}; + auto const gw = Account{"gw"}; + auto const carol = Account{"carol"}; + + env.fund(XRP(10'000), gw, alice, carol, bob); + env.close(); + + MPTTester const usd({.env = env, .issuer = gw, .holders = {alice, carol, bob}}); + + // alice's offer sells 1,000,000 drops for usd(1) but, as in the + // dry cases above, she can fund only 999,999 drops, so filling the + // clipped remainder would require a fractional usd (MPT) input that + // rounds down to zero. + auto const aliceOfferSeq = env.seq(alice); + env(offer(alice, usd(1), drops(1'000'000))); + env.close(); + + auto const targetBalance = reserve(env, 2) + drops(999'999); + auto const drain = env.balance(alice).value().xrp() - targetBalance.value().xrp() - + env.current()->fees().base; + env(pay(alice, gw, drops(drain))); + env.close(); + + // carol's same-quality offer is fully funded and provides the + // legitimate side of the crossing. + auto const carolOfferSeq = env.seq(carol); + env(offer(carol, usd(1), drops(1'000'000))); + env.close(); + + // bob needs usd to buy drops. + env(pay(gw, bob, usd(2))); + env.close(); + + auto const aliceOffer = keylet::offer(alice.id(), SeqProxy::rawSequence(aliceOfferSeq)); + auto const carolOffer = keylet::offer(carol.id(), SeqProxy::rawSequence(carolOfferSeq)); + BEAST_EXPECT(env.le(aliceOffer) != nullptr); + BEAST_EXPECT(env.le(carolOffer) != nullptr); + + auto const aliceXRPBefore = env.balance(alice); + auto const bobXRPBefore = env.balance(bob); + + // bob buys drops with usd, wanting more than carol alone supplies so + // the crossing also reaches alice's offer. carol's offer fills; + // alice's degraded offer is removed rather than taken for free, so + // bob receives only carol's 1,000,000 drops and pays only usd(1). + env(offer(bob, drops(2'000'000), usd(2), tfImmediateOrCancel)); + env.close(); + + BEAST_EXPECT(env.le(aliceOffer) == nullptr); + BEAST_EXPECT(env.le(carolOffer) == nullptr); + env.require(offers(alice, 0), offers(carol, 0), offers(bob, 0)); + + // alice's offer was removed, not consumed: her balances are + // unchanged and none of her funded 999'999 drops leaked to bob. + BEAST_EXPECT(env.balance(alice) == aliceXRPBefore); + BEAST_EXPECT(env.balance(alice, usd) == usd(0)); + BEAST_EXPECT(env.balance(carol, usd) == usd(1)); + BEAST_EXPECT(env.balance(bob, usd) == usd(1)); + BEAST_EXPECT( + env.balance(bob) == bobXRPBefore + drops(1'000'000) - env.current()->fees().base); + } + } + void testInsufficientReserve(FeatureBitset features) { @@ -947,6 +1214,161 @@ public: } } + void + testMPTAMMLimitQualityRounding(FeatureBitset features) + { + testcase("MPT AMM limitQuality checks rounded integral output"); + + using namespace jtx; + + Account const gw{"gateway"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + + // IOC used to reject the AMM strand with tecKILLED. The continuous + // limitQuality target is about 32.88 MPT; rounding to nearest requested + // 33 MPT and made the realized AMM quality miss Bob's limit. The + // discrete fallback takes the largest satisfying integer output: 32. + { + Env env{*this, features}; + + env.fund(XRP(10'000), gw, alice, bob); + env.close(); + + MPTTester const btc( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .pay = 100'000, + .flags = kMptDexFlags}); + AMM const amm(env, alice, XRP(100), btc(1'000)); + + auto const bobBTCBefore = btc.getBalance(bob); + auto const [xrpBefore, btcBefore, lpBefore] = amm.balances(); + + env(offer(bob, btc(100), drops(10'340'000)), Txflags(tfImmediateOrCancel)); + env.close(); + + auto const [xrpAfter, btcAfter, lpAfter] = amm.balances(); + BEAST_EXPECT(btc.getBalance(bob) == bobBTCBefore + 32); + BEAST_EXPECT(xrpAfter > xrpBefore); + BEAST_EXPECT(btcAfter < btcBefore); + BEAST_EXPECT(lpAfter == lpBefore); + BEAST_EXPECT(expectOffers(env, bob, 0)); + } + + // A standard OfferCreate at the same limit used to bypass the AMM and + // rest unchanged on the book. It should now take the largest + // satisfying 32-MPT AMM fill first, then leave only the remainder on + // the book. + { + Env env{*this, features}; + + env.fund(XRP(10'000), gw, alice, bob); + env.close(); + + MPTTester const btc( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .pay = 100'000, + .flags = kMptDexFlags}); + AMM const amm(env, alice, XRP(100), btc(1'000)); + + auto const bobBTCBefore = btc.getBalance(bob); + auto const [xrpBefore, btcBefore, lpBefore] = amm.balances(); + + env(offer(bob, btc(100), drops(10'340'000))); + env.close(); + + auto const [xrpAfter, btcAfter, lpAfter] = amm.balances(); + BEAST_EXPECT(btc.getBalance(bob) == bobBTCBefore + 32); + BEAST_EXPECT(xrpAfter > xrpBefore); + BEAST_EXPECT(btcAfter < btcBefore); + BEAST_EXPECT(lpAfter == lpBefore); + BEAST_EXPECT(expectOffers(env, bob, 1)); + + auto const bobOffers = offersOnAccount(env, bob); + if (BEAST_EXPECT(bobOffers.size() == 1)) + { + BEAST_EXPECT((*bobOffers[0])[sfTakerPays] != btc(100)); + BEAST_EXPECT((*bobOffers[0])[sfTakerGets] != drops(10'340'000)); + } + } + + // Mirror the IOC case with the integral output flipped from MPT units + // to XRP drops. The same continuous target (~32.88) used to round up + // to 33 drops and miss limitQuality; the discrete fallback allows the + // largest satisfying 32-drop AMM fill. + { + Env env{*this, features}; + + env.fund(XRP(10'000), gw, alice, bob); + env.close(); + + MPTTester const btc( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .pay = 200'000'000, + .flags = kMptDexFlags}); + AMM const amm(env, alice, drops(1'000), btc(100'000'000)); + + auto const bobXRPBefore = env.balance(bob, XRP); + auto const baseFee = env.current()->fees().base; + auto const [xrpBefore, btcBefore, lpBefore] = amm.balances(); + + env(offer(bob, drops(100), btc(10'340'000)), Txflags(tfImmediateOrCancel)); + env.close(); + + auto const [xrpAfter, btcAfter, lpAfter] = amm.balances(); + env.require(Balance(bob, bobXRPBefore + drops(32) - baseFee)); + BEAST_EXPECT(xrpAfter < xrpBefore); + BEAST_EXPECT(btcAfter > btcBefore); + BEAST_EXPECT(lpAfter == lpBefore); + BEAST_EXPECT(expectOffers(env, bob, 0)); + } + + // Mirror the standard OfferCreate case as well. It should consume the + // largest satisfying 32-drop AMM fill before leaving only the remainder + // on the book. + { + Env env{*this, features}; + + env.fund(XRP(10'000), gw, alice, bob); + env.close(); + + MPTTester const btc( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .pay = 200'000'000, + .flags = kMptDexFlags}); + AMM const amm(env, alice, drops(1'000), btc(100'000'000)); + + auto const bobXRPBefore = env.balance(bob, XRP); + auto const baseFee = env.current()->fees().base; + auto const [xrpBefore, btcBefore, lpBefore] = amm.balances(); + + env(offer(bob, drops(100), btc(10'340'000))); + env.close(); + + auto const [xrpAfter, btcAfter, lpAfter] = amm.balances(); + env.require(Balance(bob, bobXRPBefore + drops(32) - baseFee)); + BEAST_EXPECT(xrpAfter < xrpBefore); + BEAST_EXPECT(btcAfter > btcBefore); + BEAST_EXPECT(lpAfter == lpBefore); + BEAST_EXPECT(expectOffers(env, bob, 1)); + + auto const bobOffers = offersOnAccount(env, bob); + if (BEAST_EXPECT(bobOffers.size() == 1)) + { + BEAST_EXPECT((*bobOffers[0])[sfTakerPays] != drops(100)); + BEAST_EXPECT((*bobOffers[0])[sfTakerGets] != btc(10'340'000)); + } + } + } + void testMalformed(FeatureBitset features) { @@ -2727,6 +3149,50 @@ public: using namespace jtx; auto const gw1 = Account("gateway1"); + { + auto const issuer = Account("issuer"); + auto const sender = Account("sender"); + auto const receiver = Account("receiver"); + auto const seller = Account("seller"); + auto const buyer = Account("buyer"); + + Env env{*this, features}; + env.fund(XRP(10'000), issuer, sender, receiver, seller, buyer); + env.close(); + + MPTTester mpt{ + {.env = env, + .issuer = issuer, + .holders = {sender, receiver, seller, buyer}, + .transferFee = 100}}; + MPT const token = mpt; + + mpt.pay(issuer, sender, 2'000); + mpt.pay(issuer, seller, 2'000); + + // A direct holder-to-holder payment of 999 MPT at a 0.1% fee + // requires 1000 from the sender and burns one MPT. + env(pay(sender, receiver, token(999)), Ter(tecPATH_PARTIAL)); + env.close(); + env(pay(sender, receiver, token(999)), Sendmax(token(1'000))); + env.close(); + + BEAST_EXPECT(mpt.getBalance(sender) == 1'000); + BEAST_EXPECT(mpt.getBalance(receiver) == 999); + BEAST_EXPECT(mpt.getBalance(issuer) == 3'999); + + // CLOB crossing should apply the same fee quantum. The offer + // owner pays ceil(999 * 1.001) = 1000, not floor(...) = 999. + env(offer(seller, XRP(999), token(999))); + env.close(); + env(offer(buyer, token(999), XRP(999))); + env.close(); + + BEAST_EXPECT(mpt.getBalance(seller) == 1'000); + BEAST_EXPECT(mpt.getBalance(buyer) == 999); + BEAST_EXPECT(mpt.getBalance(issuer) == 3'998); + } + auto test = [&](auto&& issue1, auto&& issue2) { Env env{*this, features}; @@ -3102,6 +3568,247 @@ public: } } + void + testTransferRateOverflowOffer(FeatureBitset features) + { + testcase("Transfer Rate Overflow Offer"); + + using namespace jtx; + + auto const issuer = Account("issuer"); + auto const taker = Account("taker"); + + { + Env env{*this, features}; + env.fund(XRP(10'000), issuer, taker); + env.close(); + + auto constexpr takerFunds = 2'000'000'000'000'000'000LL; + MPTTester const token{ + {.env = env, + .issuer = issuer, + .holders = {taker}, + .transferFee = 50'000, + .pay = takerFunds, + .maxAmt = kMaxMpTokenAmount}}; + + // Covers OfferCreate::flowCross() sendMax calculation. A large + // non-issuer MPT offer with a transfer fee used to overflow in + // multiplyRound() before the offer could be placed. + auto constexpr offerAmount = 1'230'000'000'000'000'000LL; + auto const takerSeq = env.seq(taker); + env(offer(taker, XRP(1), token(offerAmount))); + env.close(); + + BEAST_EXPECT( + env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr); + BEAST_EXPECT(env.balance(taker, token) == token(takerFunds)); + } + + // Each scenario below targets a BookStep/OfferStream overflow path. + // The expected behavior is the same in all cases: remove the unusable + // book tip offer and let the taker's crossing offer remain rather than + // returning tecINTERNAL with the poison offer still on-ledger. + { + Env env{*this, features}; + env.fund(XRP(10'000), issuer, taker); + env.close(); + + MPTTester const token{ + {.env = env, .issuer = issuer, .holders = {taker}, .transferFee = 10'000}}; + + // Covers BookStep::forEachOffer() offer preparation, where + // ownerGives = mulRatio(ofrAmt.out, transferRateOut) overflowed + // for an oversized MPT output with a transfer fee. + std::int64_t const poisonAmount = 8'500'000'000'000'000'000LL; + auto const poisonSeq = env.seq(issuer); + env(offer(issuer, XRP(1), token(poisonAmount))); + env.close(); + + auto const poisonKeylet = keylet::offer(issuer.id(), SeqProxy::rawSequence(poisonSeq)); + BEAST_EXPECT(env.le(poisonKeylet) != nullptr); + + auto const takerSeq = env.seq(taker); + env(offer(taker, token(100), XRP(100))); + env.close(); + + BEAST_EXPECT(env.le(poisonKeylet) == nullptr); + BEAST_EXPECT( + env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr); + } + + { + auto const gwA = Account("gatewayA"); + auto const gwB = Account("gatewayB"); + auto const alice = Account("alice"); + auto const mallory = Account("mallory"); + + Env env{*this, features}; + env.fund(XRP(10'000), gwA, gwB, alice, mallory); + env.close(); + + MPTTester const tokenA{ + {.env = env, .issuer = gwA, .holders = {alice, mallory}, .transferFee = 50'000}}; + + MPTTester const tokenB{{.env = env, .issuer = gwB, .holders = {alice, mallory}}}; + + env(pay(gwA, alice, tokenA(1'000))); + + // Covers BookStep::forEachOffer() offer preparation, where + // stpAmt.in = mulRatio(ofrAmt.in, transferRateIn) overflowed. + // The MPT/MPT amounts keep the offer quality reachable while + // applying tokenA's transfer rate overflows the input side. + std::int64_t const poisonPays = 6'148'914'691'236'517'205LL; + std::int64_t const poisonGets = 34'000'000'000'000'000LL; + env(pay(gwB, mallory, tokenB(poisonGets))); + + auto const poisonSeq = env.seq(mallory); + env(offer(mallory, tokenA(poisonPays), tokenB(poisonGets))); + env.close(); + + auto const poisonKeylet = keylet::offer(mallory.id(), SeqProxy::rawSequence(poisonSeq)); + BEAST_EXPECT(env.le(poisonKeylet) != nullptr); + + auto const aliceSeq = env.seq(alice); + env(offer(alice, tokenB(1), tokenA(100))); + env.close(); + + BEAST_EXPECT(env.le(poisonKeylet) == nullptr); + BEAST_EXPECT( + env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(aliceSeq))) != nullptr); + } + + { + Env env{*this, features}; + env.fund(XRP(10'000), issuer, taker); + env.close(); + + MPTTester const token{ + {.env = env, .issuer = issuer, .holders = {taker}, .maxAmt = kMaxMpTokenAmount}}; + + // Give the taker exactly one MPT. If the old rounding overflow + // collapsed the required input to the minimum positive amount, the + // taker could afford the bad fill and the balance checks below + // would catch the economic gain. + env(pay(issuer, taker, token(1))); + env.close(); + + // Covers BookStep::revImp() output reduction. The issuer's offer + // is fully funded and has no transfer fee, so offer preparation + // succeeds. The taker asks for slightly less output, forcing + // limitStepOut() to reduce the offer; that strict reduction used + // to overflow and leave the poison offer on the book. + auto const funded = 1'844'674'407'370'955'162LL; + auto const offerOut = funded + 1; + + auto const poisonSeq = env.seq(issuer); + env(offer(issuer, XRP(1), token(offerOut))); + env.close(); + + auto const poisonKeylet = keylet::offer(issuer.id(), SeqProxy::rawSequence(poisonSeq)); + BEAST_EXPECT(env.le(poisonKeylet) != nullptr); + + auto const issuerXRPBefore = env.balance(issuer, XRP); + auto const takerXRPBefore = env.balance(taker, XRP); + auto const takerMPTBefore = env.balance(taker, token); + auto const fee = env.current()->fees().base; + + auto const takerSeq = env.seq(taker); + env(offer(taker, token(funded), XRP(1))); + env.close(); + + // The former overflow point must not turn into a near-free fill: + // the unusable offer is removed, the taker's offer remains, and no + // value changes hands beyond the taker's transaction fee. + BEAST_EXPECT(env.le(poisonKeylet) == nullptr); + BEAST_EXPECT( + env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr); + BEAST_EXPECT(env.balance(issuer, XRP) == issuerXRPBefore); + BEAST_EXPECT(env.balance(taker, XRP) == takerXRPBefore - fee); + BEAST_EXPECT(env.balance(taker, token) == takerMPTBefore); + } + + { + auto const poisonMaker = Account("poisonMaker"); + + Env env{*this, features}; + env.fund(XRP(10'000), issuer, poisonMaker, taker); + env.close(); + + MPTTester const token{ + {.env = env, + .issuer = issuer, + .holders = {poisonMaker, taker}, + .maxAmt = kMaxMpTokenAmount}}; + + // Covers OfferStream::step() filtering. The offer is mostly + // funded, but reducing it to the actual owner funds inside + // shouldRmSmallIncreasedQOffer() used to overflow before BookStep + // saw the offer. + auto const funded = 1'844'674'407'370'955'162LL; + auto const offerOut = funded + 1; + env(pay(issuer, poisonMaker, token(funded))); + + auto const poisonSeq = env.seq(poisonMaker); + env(offer(poisonMaker, XRP(1), token(offerOut))); + env.close(); + + auto const poisonKeylet = + keylet::offer(poisonMaker.id(), SeqProxy::rawSequence(poisonSeq)); + BEAST_EXPECT(env.le(poisonKeylet) != nullptr); + + auto const takerSeq = env.seq(taker); + env(offer(taker, token(1), XRP(1))); + env.close(); + + BEAST_EXPECT(env.le(poisonKeylet) == nullptr); + BEAST_EXPECT( + env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr); + BEAST_EXPECT(env.balance(poisonMaker, token) == token(funded)); + BEAST_EXPECT(env.balance(taker, token) == token(0)); + } + + { + // Same overflow scenario as the ownerGives case above, but run with + // trace-level logging so BookStep::forEachOffer's removeOffer() + // emits its "Removing offer with overflowing amount calculation" + // trace line. This exercises the JLOG body inside removeOffer, + // which is skipped when logging is above trace severity. + std::string logs; + { + Env env{ + *this, + envconfig(), + features, + std::make_unique(&logs), + beast::Severity::Trace}; + env.fund(XRP(10'000), issuer, taker); + env.close(); + + MPTTester const token{ + {.env = env, .issuer = issuer, .holders = {taker}, .transferFee = 10'000}}; + + std::int64_t const poisonAmount = 8'500'000'000'000'000'000LL; + auto const poisonSeq = env.seq(issuer); + env(offer(issuer, XRP(1), token(poisonAmount))); + env.close(); + + auto const poisonKeylet = + keylet::offer(issuer.id(), SeqProxy::rawSequence(poisonSeq)); + BEAST_EXPECT(env.le(poisonKeylet) != nullptr); + + auto const takerSeq = env.seq(taker); + env(offer(taker, token(100), XRP(100))); + env.close(); + + BEAST_EXPECT(env.le(poisonKeylet) == nullptr); + BEAST_EXPECT( + env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr); + } + BEAST_EXPECT(logs.contains("Removing offer with overflowing amount calculation")); + } + } + void testSelfCrossOffer1(FeatureBitset features) { @@ -4920,6 +5627,7 @@ public: testSellOffer(features); testSellWithFillOrKill(features); testTransferRateOffer(features); + testTransferRateOverflowOffer(features); testSelfCrossOffer(features); testSelfIssueOffer(features); testDirectToDirectPath(features); @@ -4934,8 +5642,11 @@ public: testDeletedOfferIssuer(features); testTicketOffer(features); testTicketCancelOffer(features); + testMPTAMMLimitQualityRounding(features); testRmSmallIncreasedQOffersXRP(features); testRmSmallIncreasedQOffersMPT(features); + testMPTIssuerOfferUsesRemainingCapacity(features); + testPartiallyFundedMPTInputOfferZeroInput(features); testFillOrKill(features); testTickSize(features); testAutoCreateReserve(features); diff --git a/src/test/app/SHAMapStore_test.cpp b/src/test/app/SHAMapStore_test.cpp index 6ee7442d23..82019affba 100644 --- a/src/test/app/SHAMapStore_test.cpp +++ b/src/test/app/SHAMapStore_test.cpp @@ -23,10 +23,9 @@ #include #include -#include - #include #include +#include #include #include #include @@ -493,7 +492,7 @@ public: makeBackendRotating(jtx::Env& env, NodeStoreScheduler& scheduler, std::string path) { Section section{env.app().config().section(Sections::kNodeDatabase)}; - boost::filesystem::path newPath; + std::filesystem::path newPath; if (!BEAST_EXPECT(path.size())) return {}; diff --git a/src/test/app/ValidatorSite_test.cpp b/src/test/app/ValidatorSite_test.cpp index 8400f2d794..8373efe85b 100644 --- a/src/test/app/ValidatorSite_test.cpp +++ b/src/test/app/ValidatorSite_test.cpp @@ -15,13 +15,12 @@ #include #include -#include -#include #include #include #include +#include #include #include #include @@ -704,7 +703,7 @@ public: .effectiveOverlap = detail::kDefaultEffectiveOverlap, .expectedRefreshMin = 60 * 24}}); // max of 24 hours } - using namespace boost::filesystem; + using namespace std::filesystem; for (auto const& file : directory_iterator(good.subdir())) { remove_all(file); diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index f0d63bdcae..34237bbc99 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -28,6 +28,7 @@ #include #include +#include #include #include #include @@ -41,11 +42,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -63,10 +66,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -82,6 +87,75 @@ class Vault_test : public beast::unit_test::Suite return {STAmount{asset.raw(), 1ul, 0, true, STAmount::Unchecked{}}, ""}; }; + /** + * Get the current ledger's close time resolution. + * @param env The test environment. + */ + static NetClock::duration + getLedgerTimeResolution(test::jtx::Env& env) + { + return env.current()->header().closeTimeResolution; + } + + void + closeToTime( + test::jtx::Env& env, + NetClock::time_point time, + std::source_location const& loc = std::source_location::current()) + { + using namespace std::chrono_literals; + env.close(time - env.closed()->header().closeTimeResolution + 1s); + expect( + env.closed()->header().closeTime == time, + std::format( + "current ledger time {} is not equal to the target ledger time {}", + env.closed()->header().closeTime.time_since_epoch(), + time.time_since_epoch()), + loc.file_name(), + loc.line()); + } + + using d = NetClock::duration; + using tp = NetClock::time_point; + + // Vault holds an Env& so no default initializer is possible; the + // struct is always aggregate-initialized by makeClosedEndedVault. + // NOLINTBEGIN(cppcoreguidelines-pro-type-member-init) + struct ClosedEndedSetup + { + test::jtx::Vault vault; + Keylet keylet; + std::uint32_t sub = 0; + std::uint32_t red = 0; + }; + // NOLINTEND(cppcoreguidelines-pro-type-member-init) + + // Submit a VaultCreate for a closed-ended vault with SubscriptionDate at + // env.now() + subOffset and RedemptionDate at SubscriptionDate + gap, then + // close the ledger. Returns the Vault helper, the vault's keylet and the + // resolved sub/red timestamps. + static ClosedEndedSetup + makeClosedEndedVault( + test::jtx::Env& env, + test::jtx::Account const& owner, + Asset const& asset, + std::uint32_t subOffset, + std::uint32_t gap) + { + auto const sub = env.now().time_since_epoch().count() + subOffset; + auto const red = sub + gap; + test::jtx::Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = std::to_underlying(VaultKind::ClosedEnded), + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + return {.vault = vault, .keylet = keylet, .sub = sub, .red = red}; + } + void testSequences() { @@ -1107,6 +1181,949 @@ class Vault_test : public beast::unit_test::Suite }); } + // VaultCreate malformation and happy paths for closed-ended vaults, plus the + // featureLendingProtocolV1_1 gate. + void + testVaultCreateClosedEnded() + { + testcase("closed-ended VaultCreate"); + using namespace test::jtx; + + auto const withEnv = [this](FeatureBitset features, auto&& body) { + Env env{*this, features}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + Vault vault{env}; + body(env, owner, vault); + }; + + Asset const asset = xrpIssue(); + auto const minPeriod = kMinInvestmentPeriod; + auto const maxPeriod = kMaxInvestmentPeriod; + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + + // Gate: the three new fields require featureLendingProtocolV1_1. + withEnv( + testableAmendments() - featureLendingProtocolV1_1, + [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub + minPeriod}); + env(tx, Ter{temDISABLED}); + }); + + /* + * Valid closed-ended creation with a comfortably interior gap (well above + * MIN_INVESTMENT_PERIOD and well below MAX_INVESTMENT_PERIOD). + */ + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + 86400; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfVaultKind) == closedEnded); + BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub); + BEAST_EXPECT(sle->at(sfRedemptionDate) == red); + } + }); + + // ClosedEnded missing one of SubscriptionDate / RedemptionDate => temMALFORMED. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .redemptionDate = sub + minPeriod}); + env(tx, Ter{temMALFORMED}); + }); + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub}); + env(tx, Ter{temMALFORMED}); + }); + + /* + * SubscriptionDate not strictly after parent close time (preclaim, state-dependent - + * returns tecEXPIRED). This is the only reachable path to tecEXPIRED in VaultCreate; see + * the note below the next case. Note: there is no separate "expired RedemptionDate" test + * case here. preflight enforces red >= sub + kMinInvestmentPeriod, so any past + * RedemptionDate implies a strictly-earlier, equally-past SubscriptionDate; the + * SubscriptionDate check above short-circuits first. The RedemptionDate arm of the + * hasExpired check in VaultCreate::preclaim is defensive and unreachable as the sole cause + * of tecEXPIRED. + */ + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const nowSec = env.now().time_since_epoch().count(); + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = nowSec, + .redemptionDate = nowSec + minPeriod}); + env(tx, Ter{tecEXPIRED}); + }); + + /* + * Gap smaller than MIN_INVESTMENT_PERIOD => temMALFORMED. Includes the SubscriptionDate >= + * RedemptionDate degenerate cases: the red == sub boundary and the strictly-reversed red < + * sub case, the latter yielding a negative signed int64 gap that is caught by the + * sub-minimum branch of the gap check. + */ + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub + minPeriod - 1}); + env(tx, Ter{temMALFORMED}); + }); + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub}); + env(tx, Ter{temMALFORMED}); + }); + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub - 1}); + env(tx, Ter{temMALFORMED}); + }); + + // Gap equal to MAX_INVESTMENT_PERIOD => temMALFORMED (bound is half-open on the right). + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub + maxPeriod}); + env(tx, Ter{temMALFORMED}); + }); + + // Gap strictly greater than MAX_INVESTMENT_PERIOD => temMALFORMED. Same code path as + // gap == MAX_INVESTMENT_PERIOD above, but covers the "gap >= MAX" bullet fully. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub + maxPeriod + 1}); + env(tx, Ter{temMALFORMED}); + }); + + // Happy path: gap exactly equal to MIN_INVESTMENT_PERIOD is accepted (lower bound is + // inclusive). + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + minPeriod; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfRedemptionDate) == red); + } + }); + + // Happy path: gap one second less than MAX_INVESTMENT_PERIOD is + // accepted (upper bound is exclusive). + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + maxPeriod - 1; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfRedemptionDate) == red); + } + }); + + // OpenEnded (absent/0) with SubscriptionDate or RedemptionDate present + // => temMALFORMED. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = + vault.create({.owner = owner, .asset = asset, .subscriptionDate = sub}); + env(tx, Ter{temMALFORMED}); + }); + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = + vault.create({.owner = owner, .asset = asset, .redemptionDate = sub + minPeriod}); + env(tx, Ter{temMALFORMED}); + }); + + // Unrecognised VaultKind => temMALFORMED. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = static_cast(closedEnded + 1)}); + env(tx, Ter{temMALFORMED}); + }); + + // Happy path: open-ended vault (no new fields present) is unaffected. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind)); + BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate)); + BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate)); + } + }); + + // Happy path: explicit `VaultKind = 0` (OpenEnded) behaves the same + // as absent. Per spec, absent and OpenEnded are equivalent. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = std::to_underlying(VaultKind::OpenEnded)}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + // OpenEnded is sfVaultKind's default; SoeDefault fields + // aren't serialized when they hold the default value. + BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind)); + BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate)); + BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate)); + } + }); + } + + // Phase derivation across the SubscriptionDate / RedemptionDate boundaries, including the now + // == SubscriptionDate case (which must still resolve to Subscription). + void + testVaultPhaseDerivation() + { + testcase("closed-ended phase derivation"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + env.fund(XRP(1000), owner, depositor); + env.close(); + + Asset const asset = xrpIssue(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod); + + // Pre-seed shares during Subscription so the depositor has capital to + // withdraw at the Redemption boundary below. + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(10).value()})); + env.close(); + + auto const deposit = + [&](TER expected, std::source_location const& loc = std::source_location::current()) { + env( + WithSourceLocation{ + vault.deposit( + {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), + loc}, + Ter{expected}); + }; + auto const withdraw = + [&](TER expected, std::source_location const& loc = std::source_location::current()) { + env( + WithSourceLocation{ + vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), + loc}, + Ter{expected}); + }; + + auto const runTest = [&](TER expectedDeposit, + TER expectedWithdraw, + std::source_location const& loc = + std::source_location::current()) { + deposit(expectedDeposit, loc); + withdraw(expectedWithdraw, loc); + }; + + // Assert both deposit and withdraw return codes at each point so the + // active phase is uniquely identified: + // Subscription: deposit tesSUCCESS, withdraw tesSUCCESS + // Investment: deposit tecEXPIRED, withdraw tecTOO_SOON + // Redemption: deposit tecEXPIRED, withdraw tesSUCCESS + + // Ledger time comfortably before SubscriptionDate: Subscription. + runTest(tesSUCCESS, tesSUCCESS); + + // Boundary: parent close time exactly at SubscriptionDate must still + // be Subscription. + closeToTime(env, tp{d{sub}}); + runTest(tesSUCCESS, tesSUCCESS); + + // One second past SubscriptionDate: Investment. + closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env)); + runTest(tecEXPIRED, tecTOO_SOON); + + // Any point strictly before RedemptionDate remains Investment. + closeToTime(env, tp{d{red}} - getLedgerTimeResolution(env)); + runTest(tecEXPIRED, tecTOO_SOON); + + // Boundary: parent close time == RedemptionDate is Redemption (per + // spec table: now >= RedemptionDate). Deposits are rejected but + // withdrawals succeed. + closeToTime(env, tp{d{red}}); + runTest(tecEXPIRED, tesSUCCESS); + env.close(); + } + + // Open-ended vaults are always in VaultPhase::NoPhase, regardless of the ledger clock or any + // dates present on the vault. + void + testVaultPhaseDerivationOpenEnded() + { + testcase("open-ended phase derivation"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + + Asset const asset = xrpIssue(); + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + auto const checkPhaseAt = [&](NetClock::time_point at) { + closeToTime(env, at); + auto const sle = env.le(keylet); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(getVaultPhase(*env.current(), sle) == VaultPhase::NoPhase); + }; + + // Advance the clock through a wide range of ledger times: an open-ended vault's phase + // must be NoPhase at every one of them, because the derivation short-circuits on + // VaultKind::OpenEnded before it looks at any dates. + auto const ledgerTime = tp{d{30}} + env.closed()->header().closeTimeResolution; + checkPhaseAt(ledgerTime); + checkPhaseAt(ledgerTime + std::chrono::seconds{kMinInvestmentPeriod}); + checkPhaseAt( + ledgerTime + std::chrono::seconds{kMaxInvestmentPeriod} - + env.closed()->header().closeTimeResolution); + } + + // VaultDeposit is allowed only during Subscription (or NoPhase). Rejected during Investment and + // Redemption. + void + testVaultDepositClosedEnded() + { + testcase("closed-ended VaultDeposit phase gating"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + env.fund(XRP(1000), owner, depositor); + env.close(); + + Asset const asset = xrpIssue(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod); + + auto const deposit = + [&](TER expected, std::source_location const& loc = std::source_location::current()) { + env( + WithSourceLocation{ + vault.deposit( + {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), + loc}, + Ter{expected}); + env.close(); + }; + + // Subscription: allowed. + deposit(tesSUCCESS); + + // Investment: rejected. + env.close(tp{d{sub + 1}}); + deposit(tecEXPIRED); + + // Redemption: rejected. + env.close(tp{d{red}}); + deposit(tecEXPIRED); + } + + // VaultWithdraw is allowed in Subscription and Redemption; rejected in Investment. The + // AssetsAvailable cap continues to apply and is exercised in Redemption against a vault with + // capital deployed as an outstanding loan. + void + testVaultWithdrawClosedEnded() + { + testcase("closed-ended VaultWithdraw phase gating"); + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000), owner, depositor, borrower); + env.close(); + + Asset const asset = xrpIssue(); + // Widen the Investment window so a single-payment loan (min payment + // interval kMinPaymentInterval = 60s) fits before RedemptionDate. + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod + 3600u); + + // Deposit XRP(100) in Subscription so the depositor's shares are + // worth XRP(100). The vault holds XRP(100) with + // AssetsAvailable == AssetsTotal. + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + + // Create a loan broker backed by this vault. LoanBrokerSet has no + // phase gate, so this is fine to do in Subscription. + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, keylet.key)); + env.close(); + + auto const withdraw = [&](STAmount const& amount, + TER expected, + std::source_location const& loc = + std::source_location::current()) { + env( + WithSourceLocation{ + vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount}), + loc}, + Ter{expected}); + env.close(); + }; + + // Subscription: allowed (LP cancel). + withdraw(XRP(1).value(), tesSUCCESS); + + // Investment: rejected. + closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env)); + withdraw(XRP(1).value(), tecTOO_SOON); + + // Deploy capital: borrower takes a loan of XRP(60) against the + // vault, dropping AssetsAvailable to ~XRP(39) while AssetsTotal + // remains ~XRP(99). + env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(60), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2)); + env.close(); + + // Redemption: withdrawals are allowed but subject to the AssetsAvailable cap. A small + // withdrawal within AssetsAvailable succeeds. A withdrawal within the depositor's share + // value but exceeding the vault's liquid balance fails with tecINSUFFICIENT_FUNDS from the + // vault-shortage guard (not the insufficient-shares guard). + closeToTime(env, tp{d{red}}); + withdraw(XRP(10).value(), tesSUCCESS); + withdraw(XRP(80).value(), tecINSUFFICIENT_FUNDS); + } + + // End-to-end lifecycle of a closed-ended vault (Subscription → Investment → Redemption) with + // multiple depositors and a real loan originated through the Investment leg. Exercises every + // phase transition and verifies the expected deposit, withdrawal, and lending behaviour in each + // phase. + void + testVaultClosedEndedLifecycle() + { + testcase("closed-ended vault lifecycle (subscribe → invest → redeem)"); + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000), owner, alice, bob, borrower); + env.close(); + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + Asset const asset = xrpIssue(); + // Widen the Investment window so a single-payment loan (min payment interval + // kMinPaymentInterval = 60s) fits before RedemptionDate with headroom. + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u); + + auto const sleCreate = env.le(keylet); + BEAST_EXPECT(sleCreate); + MPTIssue const shares{sleCreate->at(sfShareMPTID)}; + + auto const balancesEq = [&](STAmount const& available, STAmount const& total) { + auto const sle = env.le(keylet); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == available); + BEAST_EXPECT(sle->at(sfAssetsTotal) == total); + }; + auto const availableEq = [&](STAmount const& expected) { balancesEq(expected, expected); }; + + // env.balance(account, mptIssue) name-resolves the issuer via Env::lookup, but the share + // issuer is the vault's pseudo-account and is never registered with the jtx Env. Read the + // MPToken SLE directly to avoid the lookup. + auto const sharesEq = [&](Account const& holder, std::uint64_t expected) { + auto const sle = env.le(keylet::mptoken(shares.getMptID(), holder.id())); + std::uint64_t const actual = sle ? sle->getFieldU64(sfMPTAmount) : 0u; + BEAST_EXPECT(actual == expected); + }; + + // ---- Subscription phase ---- + // A legitimate VaultSet succeeds (positive control for 3.7). + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfData] = "AA"; + env(tx); + env.close(); + } + + // alice deposits 100 XRP. + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + sharesEq(alice, 100'000'000); + availableEq(XRP(100).value()); + + // bob deposits 200 XRP. + env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()})); + env.close(); + sharesEq(bob, 200'000'000); + availableEq(XRP(300).value()); + + // alice cancels 25 XRP (LP cancel is permitted in Subscription). + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(25).value()})); + env.close(); + sharesEq(alice, 75'000'000); + availableEq(XRP(275).value()); + + // Create a loan broker backed by this vault. LoanBrokerSet has no phase gate, so it is + // fine to do in Subscription. + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, keylet.key)); + env.close(); + + // ---- Investment phase (now == sub + 1) ---- + env.close(tp{d{sub + 1}}); + + // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED. + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), + Ter{tecEXPIRED}); + env.close(); + // Withdrawals from a closed-ended vault during the Investment phase return tecTOO_SOON. + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), + Ter{tecTOO_SOON}); + env.close(); + + // A real loan is originated during Investment (permitted only in this phase). Zero-interest + // one-payment schedule keeps AssetsTotal unchanged (both accrual and cash-basis + // accounting recognise no interest at origination); AssetsAvailable drops by the loan + // principal. + env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(60), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2)); + env.close(); + auto const sleBroker = env.le(keylet::loanBroker(brokerKeylet.key)); + BEAST_EXPECT(sleBroker); + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u)); + BEAST_EXPECT(env.le(loanKeylet)); + balancesEq(XRP(215).value(), XRP(275).value()); + + // Non-immutable VaultSet still works in Investment (positive control). + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfData] = "BB"; + env(tx); + env.close(); + } + + // Depositor share balances unchanged by the loan origination; only AssetsAvailable moved. + sharesEq(alice, 75'000'000); + sharesEq(bob, 200'000'000); + + // ---- Redemption phase (now == red) ---- + env.close(tp{d{red}}); + + // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED, in both + // Investment and Redemption. + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), + Ter{tecEXPIRED}); + env.close(); + + // alice redeems her remaining 75 XRP (fits within AssetsAvailable = 215). + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(75).value()})); + env.close(); + sharesEq(alice, 0); + balancesEq(XRP(140).value(), XRP(200).value()); + + // bob has 200 XRP-worth of shares but only 140 XRP is available (the remaining 60 XRP + // sits in the outstanding loan). A full 200 XRP withdrawal fails against the + // AssetsAvailable cap; bob redeems 140 XRP instead and is left holding 60M shares backed + // by the loan receivable — the realistic outcome when capital is still deployed at + // Redemption. + env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()}), + Ter{tecINSUFFICIENT_FUNDS}); + env.close(); + env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(140).value()})); + env.close(); + sharesEq(bob, 60'000'000); + balancesEq(XRP(0).value(), XRP(60).value()); + + // Defensive spot-check that the three immutable fields have not changed across the entire + // lifecycle. Direct immutability coverage lives with the invariant tests. + auto const sleFinal = env.le(keylet); + if (BEAST_EXPECT(sleFinal)) + { + BEAST_EXPECT(sleFinal->at(sfVaultKind) == closedEnded); + BEAST_EXPECT(sleFinal->at(sfSubscriptionDate) == sub); + BEAST_EXPECT(sleFinal->at(sfRedemptionDate) == red); + } + } + + // SubscriptionDate boundary cases at the top of the UINT32 range. + // (1) The largest legal sub picks red = UINT32_MAX exactly, which hits + // the inclusive lower bound of the kMinInvestmentPeriod gap check. + // (2) sub = UINT32_MAX must be rejected: sub + kMinInvestmentPeriod is + // unrepresentable as the tx's UINT32 sfRedemptionDate, so no red value + // can satisfy the gap check. + void + testVaultCreateSubscriptionDateBoundary() + { + testcase("closed-ended VaultCreate SubscriptionDate near UINT32_MAX"); + using namespace test::jtx; + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + Asset const asset = xrpIssue(); + + { + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + + Vault const vault{env}; + auto const sub = std::numeric_limits::max() - kMinInvestmentPeriod; + auto const red = std::numeric_limits::max(); + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub); + BEAST_EXPECT(sle->at(sfRedemptionDate) == red); + } + } + + // sub = UINT32_MAX: no legal red exists because sub + kMinInvestmentPeriod + // wraps in a UINT32. Every candidate red must fall to temMALFORMED via + // the gap check in preflight. + auto const rejectAtMax = [&, this](std::uint32_t red) { + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = std::numeric_limits::max(), + .redemptionDate = red}); + env(tx, Ter{temMALFORMED}); + }; + rejectAtMax(std::numeric_limits::max()); + rejectAtMax(0u); + rejectAtMax(kMinInvestmentPeriod - 1u); + } + + // A loan whose payment is made after the Investment phase has ended + // (well past its next-due-date and grace period, into Redemption) must + // still be repayable. The vault phase must not gate LoanPay. + void + testVaultLoanLatePaymentAfterInvestment() + { + testcase("closed-ended vault: late loan payment during Redemption succeeds"); + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000), owner, alice, borrower); + env.close(); + + Asset const asset = xrpIssue(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u); + + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, keylet.key)); + env.close(); + + // Investment phase: originate a zero-interest, single-payment loan + // with a 300s payment interval and 60s grace. The payment is due + // shortly after origination and well before RedemptionDate. + env.close(tp{d{sub + 1}}); + env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(300), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2)); + env.close(); + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u)); + BEAST_EXPECT(env.le(loanKeylet)); + + // Advance to Redemption. The payment is now past its due date and + // grace, and the vault is no longer in Investment. + closeToTime(env, tp{d{red}}); + + env(loan::pay(borrower, loanKeylet.key, XRP(60).value(), tfLoanLatePayment)); + env.close(); + + // Loan principal returned to the vault; assetsAvailable == assetsTotal. + auto const sleAfter = env.le(keylet); + if (BEAST_EXPECT(sleAfter)) + { + BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == sleAfter->at(sfAssetsTotal)); + BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == XRP(100).value()); + } + + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + } + + // Two concurrent loans against the same closed-ended vault in Investment + // must coexist: both loan SLEs are created, AssetsAvailable reflects the + // sum of the two outstanding principals, and each can be repaid + // independently. + void + testVaultClosedEndedMultipleLoans() + { + testcase("closed-ended vault: multiple concurrent loans in Investment"); + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const borrower1{"borrower1"}; + Account const borrower2{"borrower2"}; + env.fund(XRP(10'000), owner, alice, bob, borrower1, borrower2); + env.close(); + + Asset const asset = xrpIssue(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u); + + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, keylet.key)); + env.close(); + + env.close(tp{d{sub + 1}}); + + auto const originate = [&](Account const& b, STAmount const& principal) { + env(loan::set(b, brokerKeylet.key, principal), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(300), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2)); + env.close(); + }; + originate(borrower1, XRP(50).value()); + originate(borrower2, XRP(70).value()); + + auto const loan1 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u)); + auto const loan2 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(2u)); + BEAST_EXPECT(env.le(loan1)); + BEAST_EXPECT(env.le(loan2)); + + // Zero-interest at origination: AssetsTotal unchanged, AssetsAvailable + // drops by the sum of the two loan principals. + { + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value()); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(80).value()); + } + } + + // Repay the first loan; the second remains outstanding. + env(loan::pay(borrower1, loan1.key, XRP(50).value())); + env.close(); + { + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value()); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(130).value()); + } + } + + // Repay the second loan; vault is fully liquid again. + env(loan::pay(borrower2, loan2.key, XRP(70).value())); + env.close(); + { + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfAssetsAvailable) == sle->at(sfAssetsTotal)); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(200).value()); + } + } + + // Redemption: both depositors withdraw in full. + env.close(tp{d{red}}); + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + } + + // VaultClawback has no phase gate: an issuer must be able to reclaim + // asset from a depositor in Subscription, Investment and Redemption + // alike. Uses an IOU with asfAllowTrustLineClawback so the issuer path + // is exercised (XRP clawback with an explicit amount is temMALFORMED). + void + testVaultClawbackClosedEndedPhases() + { + testcase("closed-ended vault: VaultClawback succeeds in each phase"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const alice{"alice"}; + env.fund(XRP(10'000), issuer, owner, alice); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const iou = issuer["IOU"]; + env.trust(iou(10'000), alice); + env(pay(issuer, alice, iou(1'000))); + env.close(); + + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, iou, 300u, kMinInvestmentPeriod + 3600u); + + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = iou(300).value()})); + env.close(); + + auto const totalsEq = [&](STAmount const& expected) { + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + BEAST_EXPECT(sle->at(sfAssetsTotal) == expected); + }; + + // Subscription phase clawback. + env(vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()})); + env.close(); + totalsEq(iou(290).value()); + + // Investment phase clawback. + env.close(tp{d{sub + 1}}); + env(vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()})); + env.close(); + totalsEq(iou(280).value()); + + // Redemption phase clawback. + env.close(tp{d{red}}); + env(vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()})); + env.close(); + totalsEq(iou(270).value()); + } + // Test for non-asset specific behaviors. void testCreateFailXRP() @@ -3017,6 +4034,192 @@ class Vault_test : public beast::unit_test::Suite } } + void + testDomainLossAfterAcquisition() + { + using namespace test::jtx; + + testcase("private vault share transfer after depositor loses domain"); + + // The "Private Vault - Access Control Rules" spec requires that a holder who + // loses Layer 2 (Permissioned Domain membership) after acquiring shares be + // blocked from sending them onward, by P2P transfer or DEX offer, the same + // way a brand-new never-authorized holder is blocked. Only withdrawal to + // self is meant to stay open. + // + // For a domain-gated share MPToken, requireAuth()'s escape hatch for + // holders who already have an MPToken (MPTokenHelpers.cpp) only applies to + // the classic explicit-issuer-authorization flag, which + // enforceMPTokenAuthorization documents as "meaningless" for + // domain-authorized holders and never sets. So a stale MPToken does not + // carry authorization forward once the account's domain credential is + // gone, and both actions below are correctly blocked. + + Env env{*this, testableAmendments()}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const bob{"bob"}; + Account const pdOwner{"pdOwner"}; + Account const credIssuer{"credIssuer"}; + std::string const credType = "credential"; + Vault const vault{env}; + env.fund(XRP(1000), issuer, owner, depositor, bob, pdOwner, credIssuer); + env.close(); + + PrettyAsset const asset = issuer["IOU"]; + env.trust(asset(1000), owner); + env(pay(issuer, owner, asset(500))); + env.trust(asset(1000), depositor); + env(pay(issuer, depositor, asset(500))); + env.trust(asset(1000), bob); + env(pay(issuer, bob, asset(500))); + env.close(); + + // Transferable shares (no tfVaultShareNonTransferable): sections 3.3/3.4 of + // the spec (DEX trading / P2P transfer) only apply to transferable shares. + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate}); + env(tx); + env.close(); + + pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}}; + env(pdomain::setTx(pdOwner, credentials)); + auto const domainId = [&]() { + auto tx = env.tx()->getJson(JsonOptions::Values::None); + return pdomain::getNewDomain(env.meta()); + }(); + { + auto domainTx = vault.set({.owner = owner, .id = keylet.key}); + domainTx[sfDomainID] = to_string(domainId); + env(domainTx); + env.close(); + } + + // Both depositor and bob acquire domain membership and deposit, so each + // ends up with an authorized share MPToken. + env(credentials::create(depositor, credIssuer, credType)); + env(credentials::accept(depositor, credIssuer, credType)); + env(credentials::create(bob, credIssuer, credType)); + env(credentials::accept(bob, credIssuer, credType)); + env.close(); + + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)})); + env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(100)})); + env.close(); + + auto const shares = [&env, keylet = keylet, this]() -> PrettyAsset { + auto const sle = env.le(keylet); + BEAST_EXPECT(sle != nullptr); + return MPTIssue(sle->at(sfShareMPTID)); + }(); + + // Depositor loses Layer 2: their Permissioned Domain credential is revoked. + auto const credKeylet = credentials::keylet(depositor, credIssuer, credType); + env(credentials::deleteCred(credIssuer, depositor, credIssuer, credType)); + env.close(); + BEAST_EXPECT(env.le(credKeylet) == nullptr); + + // Sanity check, mirrors testWithDomainCheck's "not authorized yet" case: a + // brand-new depositor with no MPToken yet is still correctly blocked. The + // gap below is specific to holders who already hold shares. + { + Account const charlie{"charlie"}; + env.fund(XRP(1000), charlie); + env.close(); + auto depTx = + vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(1)}); + env(depTx, Ter{tecNO_AUTH}); + } + + // P2P transfer: spec section 3.4 requires this blocked once Layer 2 is + // lost, and it is. + env(pay(depositor, bob, shares(1)), Ter{tecNO_AUTH}); + env.close(); + + // DEX/CLOB: spec section 3.3 requires the seller leg blocked the same way. + // The offer can't even be created: preclaim treats the seller as + // unfunded once their share balance reads as zero for auth purposes. + env(offer(depositor, XRP(1), shares(1)), Ter{tecUNFUNDED_OFFER}); + env.close(); + BEAST_EXPECT(expectOffers(env, depositor, 0)); + } + + void + testDomainCheckBuyerSideOffer() + { + using namespace test::jtx; + + testcase("private vault share purchase via DEX requires buyer domain membership"); + + // The "Private Vault - Access Control Rules" spec requires the buyer leg + // of a DEX trade in private-vault shares to hold Layer 1 and Layer 2 as + // well, not just the seller. + + Env env{*this, testableAmendments()}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const bob{"bob"}; + Account const charlie{"charlie"}; + Account const pdOwner{"pdOwner"}; + Account const credIssuer{"credIssuer"}; + std::string const credType = "credential"; + Vault const vault{env}; + env.fund(XRP(1000), issuer, owner, bob, charlie, pdOwner, credIssuer); + env.close(); + + PrettyAsset const asset = issuer["IOU"]; + env.trust(asset(1000), owner); + env(pay(issuer, owner, asset(500))); + env.trust(asset(1000), bob); + env(pay(issuer, bob, asset(500))); + env.close(); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate}); + env(tx); + env.close(); + + pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}}; + env(pdomain::setTx(pdOwner, credentials)); + auto const domainId = [&]() { + auto tx = env.tx()->getJson(JsonOptions::Values::None); + return pdomain::getNewDomain(env.meta()); + }(); + { + auto domainTx = vault.set({.owner = owner, .id = keylet.key}); + domainTx[sfDomainID] = to_string(domainId); + env(domainTx); + env.close(); + } + + // Only bob joins the domain and deposits; charlie never does. + env(credentials::create(bob, credIssuer, credType)); + env(credentials::accept(bob, credIssuer, credType)); + env.close(); + env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(100)})); + env.close(); + + auto const shares = [&env, keylet = keylet, this]() -> PrettyAsset { + auto const sle = env.le(keylet); + BEAST_EXPECT(sle != nullptr); + return MPTIssue(sle->at(sfShareMPTID)); + }(); + + // Bob (domain member, holds shares) rests a sell offer. + env(offer(bob, XRP(1), shares(1))); + env.close(); + BEAST_EXPECT(expectOffers(env, bob, 1)); + + // Charlie never held the domain credential. Buying shares via a + // crossing offer must be blocked the same way a direct MPTokenAuthorize + // + pay attempt already is (see testWithDomainChecXRP's "cannot pay + // shares to 3rd party"): checkAcceptAsset() rejects the offer outright + // in preclaim, before any funding check is even reached. + env(offer(charlie, shares(1), XRP(1)), Ter{tecNO_AUTH}); + env.close(); + BEAST_EXPECT(expectOffers(env, bob, 1)); + BEAST_EXPECT(expectOffers(env, charlie, 0)); + } + void testWithDomainChecXRP() { @@ -4405,6 +5608,90 @@ class Vault_test : public beast::unit_test::Suite } } + // RPC coverage: closed-ended vaults must return VaultKind, SubscriptionDate and RedemptionDate + // in both vault_info and ledger_entry responses. Open-ended vaults must not. + void + testRPCClosedEnded() + { + using namespace test::jtx; + + testcase("RPC closed-ended vault fields"); + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const owner2{"owner2"}; + env.fund(XRP(1000), owner, owner2); + env.close(); + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + Asset const asset = xrpIssue(); + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod; + + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + + auto [tx2, keylet2] = vault.create({.owner = owner2, .asset = asset}); + env(tx2); + env.close(); + + auto const asUInt = [](json::Value const& jv) -> json::UInt { + return jv.isUInt() ? jv.asUInt() : json::UInt(jv.asInt()); + }; + auto const checkClosedEnded = [&](json::Value const& v) { + BEAST_EXPECT(v.isObject()); + BEAST_EXPECT(v.isMember(sfVaultKind.fieldName)); + BEAST_EXPECT(asUInt(v[sfVaultKind.fieldName]) == json::UInt(closedEnded)); + BEAST_EXPECT(v.isMember(sfSubscriptionDate.fieldName)); + BEAST_EXPECT(asUInt(v[sfSubscriptionDate.fieldName]) == json::UInt(sub)); + BEAST_EXPECT(v.isMember(sfRedemptionDate.fieldName)); + BEAST_EXPECT(asUInt(v[sfRedemptionDate.fieldName]) == json::UInt(red)); + }; + auto const checkOpenEnded = [&](json::Value const& v) { + BEAST_EXPECT(v.isObject()); + BEAST_EXPECT(!v.isMember(sfVaultKind.fieldName)); + BEAST_EXPECT(!v.isMember(sfSubscriptionDate.fieldName)); + BEAST_EXPECT(!v.isMember(sfRedemptionDate.fieldName)); + }; + + { + json::Value jvParams; + jvParams[jss::vault_id] = strHex(keylet.key); + auto jv = env.rpc("json", "vault_info", to_string(jvParams)); + BEAST_EXPECT(!jv[jss::result].isMember(jss::error)); + checkClosedEnded(jv[jss::result][jss::vault]); + } + { + json::Value jvParams; + jvParams[jss::ledger_index] = jss::validated; + jvParams[jss::vault] = strHex(keylet.key); + auto jv = env.rpc("json", "ledger_entry", to_string(jvParams)); + BEAST_EXPECT(!jv[jss::result].isMember(jss::error)); + checkClosedEnded(jv[jss::result][jss::node]); + } + { + json::Value jvParams; + jvParams[jss::vault_id] = strHex(keylet2.key); + auto jv = env.rpc("json", "vault_info", to_string(jvParams)); + BEAST_EXPECT(!jv[jss::result].isMember(jss::error)); + checkOpenEnded(jv[jss::result][jss::vault]); + } + { + json::Value jvParams; + jvParams[jss::ledger_index] = jss::validated; + jvParams[jss::vault] = strHex(keylet2.key); + auto jv = env.rpc("json", "ledger_entry", to_string(jvParams)); + BEAST_EXPECT(!jv[jss::result].isMember(jss::error)); + checkOpenEnded(jv[jss::result][jss::node]); + } + } + void testVaultClawbackBurnShares() { @@ -8504,14 +9791,27 @@ public: testCreateFailXRP(); testCreateFailIOU(); testCreateFailMPT(); + testVaultCreateClosedEnded(); + testVaultCreateSubscriptionDateBoundary(); + testVaultPhaseDerivation(); + testVaultPhaseDerivationOpenEnded(); + testVaultDepositClosedEnded(); + testVaultWithdrawClosedEnded(); + testVaultClosedEndedLifecycle(); + testVaultLoanLatePaymentAfterInvestment(); + testVaultClosedEndedMultipleLoans(); + testVaultClawbackClosedEndedPhases(); testWithMPT(); testWithIOU(); testWithDomainCheck(); + testDomainLossAfterAcquisition(); + testDomainCheckBuyerSideOffer(); testWithDomainChecXRP(); testNonTransferableShares(); testFailedPseudoAccount(); testScaleIOU(); testRPC(); + testRPCClosedEnded(); testVaultClawbackBurnShares(); testVaultClawbackAssets(); testVaultEscrowedMPT(); diff --git a/src/test/app/lending/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp index 2b05dada88..31fead08eb 100644 --- a/src/test/app/lending/LoanBroker_test.cpp +++ b/src/test/app/lending/LoanBroker_test.cpp @@ -1116,7 +1116,7 @@ class LoanBroker_test : public beast::unit_test::Suite // holder == account env(jtx, Ter(temINVALID)); - // holder == beast::zero + // holder == beast::kZero STAmount const bad(Issue{usd.currency, beast::kZero}, 100); jtx.jv[sfAmount] = bad.getJson(); jtx.stx = env.ust(jtx); diff --git a/src/test/app/lending/LoanMisc_test.cpp b/src/test/app/lending/LoanMisc_test.cpp index 2cb4f38ecf..c5a7d54311 100644 --- a/src/test/app/lending/LoanMisc_test.cpp +++ b/src/test/app/lending/LoanMisc_test.cpp @@ -473,14 +473,21 @@ protected: TenthBips16 const managementFeeRate{managementFeeRateDist_(engine_)}; auto const serviceFee = serviceFeeDist_(engine_); TenthBips32 interest{interestRateDist_(engine_)}; - auto const payTotal = paymentTotalDist_(engine_); + auto payTotal = paymentTotalDist_(engine_); auto const payInterval = paymentIntervalDist_(engine_); + // The end of the last payment's grace period must fit in a 32-bit + // ripple-epoch timestamp, or LoanSet fails with tecKILLED. Cap the + // schedule well below that horizon (2e9 seconds is roughly 63 years, + // leaving ample headroom over the ledger start date). + constexpr std::uint32_t kMaxScheduleSeconds = 2'000'000'000; + payTotal = std::min(payTotal, static_cast(kMaxScheduleSeconds / payInterval)); BrokerParameters const brokerParams{ .vaultDeposit = principalRequest * 10, .debtMax = 0, .coverRateMin = TenthBips32{0}, - .managementFeeRate = managementFeeRate}; + .managementFeeRate = managementFeeRate, + .coverRateLiquidation = TenthBips32{0}}; LoanParameters const loanParams{ .account = lender, .counter = borrower, diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp index 85528ee9a0..3571853b47 100644 --- a/src/test/app/lending/LoanSet_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -13,12 +13,14 @@ #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -26,6 +28,7 @@ #include #include +#include #include #include #include @@ -592,6 +595,127 @@ private: nullptr); } + // LoanSet in a closed-ended vault — phase gating and maturity bound. + void + testLoanSetClosedEnded() + { + testcase("LoanSet closed-ended: phase and maturity bound"); + using namespace jtx; + using namespace loan; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + // Common loan schedule used by the phase-rejection cases below. + constexpr std::uint32_t kInterval = 3600u * 24u; // 1 day + constexpr std::uint32_t kTotal = 2u; + + // featureLendingProtocolV1_1 is excluded from `all_` by convention (see the comment on + // `all_`), so callers must opt in. Closed-ended vaults are gated on this amendment; without + // it VaultCreate returns temDISABLED and every follow-on txn sees tecNO_ENTRY. + auto const withEnv = [&, this](auto&& body) { + Env env(*this, testableAmendments() | featureLendingProtocolV1_1); + env.fund(XRP(1'000'000'000), issuer, lender, borrower); + env.close(); + PrettyAsset const asset{xrpIssue(), 1'000'000}; + body(env, asset); + }; + + auto const setLoan = [&](Env& env, BrokerInfo const& broker, TER expected) { + env(set(lender, broker.brokerID, broker.asset(100).value()), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + kPaymentTotal(kTotal), + kPaymentInterval(kInterval), + Ter(expected)); + env.close(); + }; + + // 1. Rejected during Subscription: the broker is created in Subscription (skipPhaseAdvance + // = true), then LoanSet is attempted before advancing past SubscriptionDate. + withEnv([&](Env& env, PrettyAsset const& asset) { + auto const broker = createVaultAndBroker( + env, + asset, + lender, + BrokerParameters{.vaultKind = VaultKind::ClosedEnded, .skipPhaseAdvance = true}); + setLoan(env, broker, tecTOO_SOON); + }); + + // 2. Rejected during Redemption: broker is set up normally (which lands the vault in + // Investment), then advance the clock past RedemptionDate before attempting LoanSet. + withEnv([&](Env& env, PrettyAsset const& asset) { + auto const broker = createVaultAndBroker( + env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded}); + BEAST_EXPECT(broker.redemptionDate.has_value()); + using d = NetClock::duration; + using tp = NetClock::time_point; + env.close(tp{d{*broker.redemptionDate + 1}}); + setLoan(env, broker, tecEXPIRED); + }); + + // 3. Accepted during Investment when the schedule comfortably fits before RedemptionDate. + withEnv([&](Env& env, PrettyAsset const& asset) { + auto const broker = createVaultAndBroker( + env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded}); + setLoan(env, broker, tesSUCCESS); + }); + + // 4. Rejected during Investment when the loan's final payment would land on or after + // RedemptionDate. Use a tight redemptionOffset and a schedule whose final payment is well + // past that boundary. + withEnv([&](Env& env, PrettyAsset const& asset) { + constexpr std::uint32_t kRedemptionOffset = 3u * 24u * 3600u; + auto const broker = createVaultAndBroker( + env, + asset, + lender, + BrokerParameters{ + .vaultKind = VaultKind::ClosedEnded, .redemptionOffset = kRedemptionOffset}); + env(set(lender, broker.brokerID, broker.asset(100).value()), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + kPaymentTotal(10u), + kPaymentInterval(kInterval), + Ter(tecNO_PERMISSION)); + env.close(); + }); + + // 5. Boundary: schedule whose finalPayment lands exactly (RedemptionDate - 1) is accepted, + // and one second later (== RedemptionDate) is rejected. Uses payTotal = 1 so the arithmetic + // is simple: finalPayment = startDate + interval. + withEnv([&](Env& env, PrettyAsset const& asset) { + auto const broker = createVaultAndBroker( + env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded}); + BEAST_EXPECT(broker.redemptionDate.has_value()); + + auto const startDate = env.now().time_since_epoch().count(); + auto const acceptInterval = *broker.redemptionDate - 1 - startDate; + env(set(lender, broker.brokerID, broker.asset(100).value()), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + kPaymentTotal(1u), + kPaymentInterval(acceptInterval), + Ter(tesSUCCESS)); + env.close(); + + auto const rejectInterval = + *broker.redemptionDate - env.now().time_since_epoch().count(); + env(set(lender, broker.brokerID, broker.asset(100).value()), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + kPaymentTotal(1u), + kPaymentInterval(rejectInterval), + Ter(tecNO_PERMISSION)); + env.close(); + }); + } + public: void run() override @@ -599,6 +723,8 @@ public: for (auto const& features : jtx::amendmentCombinations( {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) testLoanSet(features); + + testLoanSetClosedEnded(); } }; diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index dabdfc9bed..950b196043 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -95,6 +95,23 @@ protected: // tests that need finer loanScale to exercise rounding edge cases. std::optional vaultScale = std::nullopt; // NOLINT(readability-redundant-member-init) + // Vault kind axis. When ClosedEnded, createVaultAndBroker sets sfSubscriptionDate / + // sfRedemptionDate from env.now() using the offsets below and advances the ledger clock + // past SubscriptionDate so the vault is in the Investment phase by the time the broker is + // set up. Requires featureLendingProtocolV1_1. + VaultKind vaultKind = VaultKind::OpenEnded; + // Seconds past env.now() at which SubscriptionDate lands. Must be strictly positive + // (VaultCreate::preclaim rejects SubscriptionDate <= parentCloseTime). + std::uint32_t subscriptionOffset = 60; + // Seconds between SubscriptionDate and RedemptionDate. Must be >= kMinInvestmentPeriod, < + // kMaxInvestmentPeriod, and generous enough to fit any loan schedule the test runs + // (finalPayment must be strictly before RedemptionDate). Default sized to comfortably + // exceed any schedule realistic tests are likely to configure. + std::uint32_t redemptionOffset = 10u * 365u * 24u * 60u * 60u; + // When true, createVaultAndBroker skips its automatic clock advance past SubscriptionDate. + // Useful for tests that need to observe the vault while it is still in the Subscription + // phase. Ignored for open-ended vaults. + bool skipPhaseAdvance = false; [[nodiscard]] Number maxCoveredLoanValue(Number const& currentDebt) const @@ -122,15 +139,23 @@ protected: uint256 brokerID; uint256 vaultID; BrokerParameters params; + // Absolute dates resolved by createVaultAndBroker when params.vaultKind + // is ClosedEnded; std::nullopt for open-ended vaults. + std::optional subscriptionDate; + std::optional redemptionDate; BrokerInfo( jtx::PrettyAsset const& asset, Keylet const& brokerKeylet, Keylet const& vaultKeylet, - BrokerParameters p) + BrokerParameters p, + std::optional subscriptionDate = std::nullopt, + std::optional redemptionDate = std::nullopt) : asset(asset) , brokerID(brokerKeylet.key) , vaultID(vaultKeylet.key) , params(std::move(p)) + , subscriptionDate(subscriptionDate) + , redemptionDate(redemptionDate) { } @@ -461,7 +486,23 @@ protected: auto const coverRateMinValue = params.coverRateMin; - auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); + std::optional subscriptionDate; + std::optional redemptionDate; + if (params.vaultKind == VaultKind::ClosedEnded) + { + auto const nowSec = env.now().time_since_epoch().count(); + subscriptionDate = nowSec + params.subscriptionOffset; + redemptionDate = *subscriptionDate + params.redemptionOffset; + } + + auto [tx, vaultKeylet] = vault.create( + {.owner = lender, + .asset = asset, + .vaultKind = params.vaultKind == VaultKind::OpenEnded + ? std::optional{} + : std::optional{std::to_underlying(params.vaultKind)}, + .subscriptionDate = subscriptionDate, + .redemptionDate = redemptionDate}); if (params.vaultScale) tx[sfScale] = *params.vaultScale; env(tx); @@ -475,6 +516,15 @@ protected: BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value()); } + // For closed-ended vaults, advance past SubscriptionDate so subsequent LoanSet operations + // run in the Investment phase (unless the caller explicitly asked to stay in Subscription). + if (subscriptionDate && !params.skipPhaseAdvance) + { + using d = NetClock::duration; + using tp = NetClock::time_point; + env.close(tp{d{*subscriptionDate + 1}}); + } + auto const keylet = keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender))); using namespace loan_broker; @@ -490,7 +540,7 @@ protected: env.close(); - return {asset, keylet, vaultKeylet, params}; + return {asset, keylet, vaultKeylet, params, subscriptionDate, redemptionDate}; } /** diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp index 884384db55..c6ff22bbb3 100644 --- a/src/test/app/lending/LoanValidation_test.cpp +++ b/src/test/app/lending/LoanValidation_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -90,9 +91,11 @@ private: } void - testInvalidLoanSet() + testInvalidLoanSet(VaultKind vaultKind) { - testcase("Invalid LoanSet"); + testcase( + std::string("Invalid LoanSet (") + + (vaultKind == VaultKind::OpenEnded ? "open-ended" : "closed-ended") + " vault)"); using namespace jtx; using namespace loan; Account const lender{"lender"}; @@ -106,7 +109,8 @@ private: env.fund(XRP(1'000), lender, issuer, borrower, sponsor); env(trust(lender, iou(10'000'000))); env(pay(issuer, lender, iou(5'000'000))); - BrokerInfo const brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; + BrokerInfo const brokerInfo{ + createVaultAndBroker(env, issuer["IOU"], lender, {.vaultKind = vaultKind})}; auto const loanSetFee = Fee(env.current()->fees().base * 2); Number const debtMaximumRequest = brokerInfo.asset(1'000).value(); @@ -530,7 +534,8 @@ private: runAmendmentIndependent() { testDisabled(); - testInvalidLoanSet(); + for (auto const kind : {VaultKind::OpenEnded, VaultKind::ClosedEnded}) + testInvalidLoanSet(kind); testInvalidLoanDelete(); testInvalidLoanManage(); testInvalidLoanPay(); diff --git a/src/test/basics/PerfLog_test.cpp b/src/test/basics/PerfLog_test.cpp index 24ea971515..f7679dc488 100644 --- a/src/test/basics/PerfLog_test.cpp +++ b/src/test/basics/PerfLog_test.cpp @@ -15,14 +15,10 @@ #include #include -#include -#include -#include -#include - #include #include #include +#include #include #include #include @@ -31,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -43,7 +40,7 @@ class PerfLog_test : public beast::unit_test::Suite { enum class WithFile : bool { No = false, Yes = true }; - using path = boost::filesystem::path; + using path = std::filesystem::path; // We're only using Env for its Journal. That Journal gives better // coverage in unit tests. @@ -66,14 +63,14 @@ class PerfLog_test : public beast::unit_test::Suite // The error code is intentionally ignored: if the path doesn't // exist (the common case on a clean runner) remove_all returns // an error, and that's fine — there's nothing to clean up. - using namespace boost::filesystem; - boost::system::error_code ec; + using namespace std::filesystem; + std::error_code ec; remove_all(logDir(), ec); } ~Fixture() { - using namespace boost::filesystem; + using namespace std::filesystem; auto const dir{logDir()}; auto const file{logFile()}; @@ -96,7 +93,7 @@ class PerfLog_test : public beast::unit_test::Suite static path logDir() { - using namespace boost::filesystem; + using namespace std::filesystem; return temp_directory_path() / "perf_log_test_dir"; } @@ -129,7 +126,7 @@ class PerfLog_test : public beast::unit_test::Suite static void wait() { - using namespace boost::filesystem; + using namespace std::filesystem; auto const path = logFile(); if (!exists(path)) @@ -201,7 +198,7 @@ public: void testFileCreation() { - using namespace boost::filesystem; + using namespace std::filesystem; { // Verify a PerfLog creates its file when constructed. @@ -250,28 +247,30 @@ public: // Put a write protected file where PerfLog wants to write its // file. Make sure that PerfLog tries to shutdown the server // since it can't open its file. + using std::filesystem::perms; + Fixture fixture{env_.app(), j_}; if (!BEAST_EXPECT(!exists(fixture.logDir()))) return; // Construct and write protect a file to prevent PerfLog // from creating its file. - boost::system::error_code ec; - boost::filesystem::create_directories(fixture.logDir(), ec); + std::error_code ec; + std::filesystem::create_directories(fixture.logDir(), ec); if (!BEAST_EXPECT(!ec)) return; - auto fileWriteable = [](boost::filesystem::path const& p) -> bool { - return std::ofstream{p.c_str(), std::ios::out | std::ios::app}.is_open(); + auto fileWriteable = [](std::filesystem::path const& p) -> bool { + return std::ofstream{p, std::ios::out | std::ios::app}.is_open(); }; if (!BEAST_EXPECT(fileWriteable(fixture.logFile()))) return; - boost::filesystem::permissions( + std::filesystem::permissions( fixture.logFile(), - perms::remove_perms | perms::owner_write | perms::others_write | - perms::group_write); + perms::owner_write | perms::others_write | perms::group_write, + std::filesystem::perm_options::remove); // If the test is running as root, then the write protect may have // no effect. Make sure write protect worked before proceeding. @@ -295,9 +294,10 @@ public: perfLog->stop(); // Fix file permissions so the file can be cleaned up. - boost::filesystem::permissions( + std::filesystem::permissions( fixture.logFile(), - perms::add_perms | perms::owner_write | perms::others_write | perms::group_write); + perms::owner_write | perms::others_write | perms::group_write, + std::filesystem::perm_options::add); } } @@ -962,7 +962,7 @@ public: // We can't fully test rotate because unit tests must run on Windows, // and Windows doesn't (may not?) support rotate. But at least call // the interface and see that it doesn't crash. - using namespace boost::filesystem; + using namespace std::filesystem; Fixture fixture{env_.app(), j_}; BEAST_EXPECT(!exists(fixture.logDir())); diff --git a/src/test/core/Config_test.cpp b/src/test/core/Config_test.cpp index ac5471fd3c..dec6393010 100644 --- a/src/test/core/Config_test.cpp +++ b/src/test/core/Config_test.cpp @@ -3,14 +3,13 @@ #include +#include #include -#include #include #include #include // IWYU pragma: keep #include -#include #include // IWYU pragma: keep #include #include @@ -20,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -179,7 +179,7 @@ public: [[nodiscard]] bool dataDirExists() const { - return boost::filesystem::is_directory(dataDir_); + return std::filesystem::is_directory(dataDir_); } [[nodiscard]] bool @@ -192,7 +192,7 @@ public: { try { - using namespace boost::filesystem; + using namespace std::filesystem; if (rmDataDir_) rmDir(dataDir_); } @@ -273,7 +273,7 @@ public: class Config_test final : public TestSuite { private: - using path = boost::filesystem::path; + using path = std::filesystem::path; public: void @@ -309,7 +309,7 @@ port_wss_admin { testcase("config_file"); - using namespace boost::filesystem; + using namespace std::filesystem; auto const cwd = current_path(); // Test both config file names. @@ -319,7 +319,7 @@ port_wss_admin for (auto const& configFile : configFiles) { // Use a temporary directory for testing. - beast::TempDir const td; + TempDir const td; current_path(td.path()); path const f = td.file(std::string{configFile}); std::ofstream o(f.string()); @@ -341,13 +341,13 @@ port_wss_admin { // Point the current working directory to a temporary directory, so // we don't pick up an actual config file from the repository root. - beast::TempDir const td; + TempDir const td; current_path(td.path()); // The XDG config directory is set: the config file must be in a // subdirectory named after the system. { - beast::TempDir const tc; + TempDir const tc; // Set the HOME and XDG_CONFIG_HOME environment variables. The // HOME variable is not used when XDG_CONFIG_HOME is set, but @@ -381,7 +381,7 @@ port_wss_admin // The XDG config directory is not set: the config file must be in a // subdirectory named .config followed by the system name. { - beast::TempDir const tc; + TempDir const tc; // Set only the HOME environment variable. char const* h = getenv("HOME"); @@ -425,7 +425,7 @@ port_wss_admin { testcase("database_path"); - using namespace boost::filesystem; + using namespace std::filesystem; { boost::format cc("[database_path]\n%1%\n"); @@ -601,7 +601,7 @@ main { testcase("validators_file"); - using namespace boost::filesystem; + using namespace std::filesystem; { // load should throw for missing specified validators file boost::format cc("[validators_file]\n%1%\n"); diff --git a/src/test/core/SociDB_test.cpp b/src/test/core/SociDB_test.cpp index 373ec66cd1..a7bb8e71bc 100644 --- a/src/test/core/SociDB_test.cpp +++ b/src/test/core/SociDB_test.cpp @@ -6,9 +6,6 @@ #include #include -#include -#include -#include #include // IWYU pragma: keep #include // IWYU pragma: keep @@ -20,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -32,7 +30,7 @@ class SociDB_test final : public TestSuite { private: static void - setupSQLiteConfig(BasicConfig& config, boost::filesystem::path const& dbPath) + setupSQLiteConfig(BasicConfig& config, std::filesystem::path const& dbPath) { config.overwrite(Sections::kSqdb, Keys::kBackend, "sqlite"); auto value = dbPath.string(); @@ -41,18 +39,18 @@ private: } static void - cleanupDatabaseDir(boost::filesystem::path const& dbPath) + cleanupDatabaseDir(std::filesystem::path const& dbPath) { - using namespace boost::filesystem; + using namespace std::filesystem; if (!exists(dbPath) || !is_directory(dbPath) || !is_empty(dbPath)) return; remove(dbPath); } static void - setupDatabaseDir(boost::filesystem::path const& dbPath) + setupDatabaseDir(std::filesystem::path const& dbPath) { - using namespace boost::filesystem; + using namespace std::filesystem; if (!exists(dbPath)) { create_directory(dbPath); @@ -65,10 +63,10 @@ private: Throw("Cannot create directory: " + dbPath.string()); } } - static boost::filesystem::path + static std::filesystem::path getDatabasePath() { - return boost::filesystem::current_path() / "socidb_test_databases"; + return std::filesystem::current_path() / "socidb_test_databases"; } public: @@ -108,7 +106,7 @@ public: for (auto const& i : d) { DBConfig const sc(c, i.first); - BEAST_EXPECT(boost::ends_with(sc.connectionString(), i.first + i.second)); + BEAST_EXPECT(sc.connectionString().ends_with(i.first + i.second)); } } void @@ -158,7 +156,7 @@ public: checkValues(s); } { - namespace bfs = boost::filesystem; + namespace bfs = std::filesystem; // Remove the database bfs::path const dbPath(sc.connectionString()); if (bfs::is_regular_file(dbPath)) @@ -232,7 +230,7 @@ public: // boost::tuple. DO NOT USE soci row! } { - namespace bfs = boost::filesystem; + namespace bfs = std::filesystem; // Remove the database bfs::path const dbPath(sc.connectionString()); if (bfs::is_regular_file(dbPath)) @@ -284,7 +282,7 @@ public: s << "SELECT LedgerSeq FROM Ledgers;", soci::into(ledgersLS); BEAST_EXPECT(ledgersLS.size() == numRows); } - namespace bfs = boost::filesystem; + namespace bfs = std::filesystem; // Remove the database bfs::path const dbPath(sc.connectionString()); if (bfs::is_regular_file(dbPath)) diff --git a/src/test/jtx/TestHelpers.h b/src/test/jtx/TestHelpers.h index 5c8486e6c5..801c3627b8 100644 --- a/src/test/jtx/TestHelpers.h +++ b/src/test/jtx/TestHelpers.h @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -315,19 +316,11 @@ auto const kData = JTxFieldWrapper(sfData); auto const kAmount = JTxFieldWrapper(sfAmount); -// TODO We only need this long "requires" clause as polyfill, for C++20 -// implementations which are missing header. Replace with -// `std::ranges::range`, and accordingly use std::ranges::begin/end -// when we have moved to better compilers. -template +template auto makeVector(Input const& input) - requires requires(Input& v) { - std::begin(v); - std::end(v); - } { - return std::vector(std::begin(input), std::end(input)); + return std::vector(std::ranges::begin(input), std::ranges::end(input)); } // Functions used in debugging diff --git a/src/test/jtx/TrustedPublisherServer.h b/src/test/jtx/TrustedPublisherServer.h index f5ee8aac3a..941af374ef 100644 --- a/src/test/jtx/TrustedPublisherServer.h +++ b/src/test/jtx/TrustedPublisherServer.h @@ -16,7 +16,6 @@ #include #include -#include #include #include #include @@ -549,7 +548,7 @@ private: res.keep_alive(req.keep_alive()); bool prepare = true; - if (boost::starts_with(path, "/validators2")) + if (path.starts_with("/validators2")) { res.result(http::status::ok); res.insert("Content-Type", "application/json"); @@ -565,7 +564,7 @@ private: { int refresh = 5; static constexpr char const* kRefreshPrefix = "/validators2/refresh/"; - if (boost::starts_with(path, kRefreshPrefix)) + if (path.starts_with(kRefreshPrefix)) { refresh = boost::lexical_cast( path.substr(strlen(kRefreshPrefix))); @@ -573,7 +572,7 @@ private: res.body() = getList2_(refresh); } } - else if (boost::starts_with(path, "/validators")) + else if (path.starts_with("/validators")) { res.result(http::status::ok); res.insert("Content-Type", "application/json"); @@ -589,7 +588,7 @@ private: { int refresh = 5; static constexpr char const* kRefreshPrefix = "/validators/refresh/"; - if (boost::starts_with(path, kRefreshPrefix)) + if (path.starts_with(kRefreshPrefix)) { refresh = boost::lexical_cast( path.substr(strlen(kRefreshPrefix))); @@ -597,13 +596,13 @@ private: res.body() = getList_(refresh); } } - else if (boost::starts_with(path, "/textfile")) + else if (path.starts_with("/textfile")) { prepare = false; res.result(http::status::ok); res.insert("Content-Type", "text/example"); // if huge was requested, lie about content length - std::uint64_t const cl = boost::starts_with(path, "/textfile/huge") + std::uint64_t const cl = path.starts_with("/textfile/huge") ? std::numeric_limits::max() : 1024; res.content_length(cl); @@ -617,41 +616,39 @@ private: } } } - else if (boost::starts_with(path, "/sleep/")) + else if (path.starts_with("/sleep/")) { auto const sleepSec = boost::lexical_cast(path.substr(7)); std::this_thread::sleep_for(std::chrono::seconds(sleepSec)); } - else if (boost::starts_with(path, "/redirect")) + else if (path.starts_with("/redirect")) { - if (boost::ends_with(path, "/301")) + if (path.ends_with("/301")) { res.result(http::status::moved_permanently); } - else if (boost::ends_with(path, "/302")) + else if (path.ends_with("/302")) { res.result(http::status::found); } - else if (boost::ends_with(path, "/307")) + else if (path.ends_with("/307")) { res.result(http::status::temporary_redirect); } - else if (boost::ends_with(path, "/308")) + else if (path.ends_with("/308")) { res.result(http::status::permanent_redirect); } std::stringstream location; - if (boost::starts_with(path, "/redirect_to/")) + if (path.starts_with("/redirect_to/")) { location << path.substr(13); } - else if (!boost::starts_with(path, "/redirect_nolo")) + else if (!path.starts_with("/redirect_nolo")) { location << (ssl ? "https://" : "http://") << localEndpoint() - << (boost::starts_with(path, "/redirect_forever/") - ? path - : "/validators"); + << (path.starts_with("/redirect_forever/") ? path : "/validators"); } if (!location.str().empty()) res.insert("Location", location.str()); diff --git a/src/test/jtx/amount.h b/src/test/jtx/amount.h index 57a4502db9..94dd8aef9e 100644 --- a/src/test/jtx/amount.h +++ b/src/test/jtx/amount.h @@ -162,12 +162,6 @@ operator==(PrettyAmount const& lhs, PrettyAmount const& rhs) return lhs.value() == rhs.value(); } -inline bool -operator!=(PrettyAmount const& lhs, PrettyAmount const& rhs) -{ - return !operator==(lhs, rhs); -} - std::ostream& operator<<(std::ostream& os, PrettyAmount const& amount); diff --git a/src/test/jtx/impl/vault.cpp b/src/test/jtx/impl/vault.cpp index baff576243..978c3864d6 100644 --- a/src/test/jtx/impl/vault.cpp +++ b/src/test/jtx/impl/vault.cpp @@ -28,6 +28,12 @@ Vault::create(CreateArgs const& args) const jv[jss::Asset] = toJson(args.asset); if (args.flags) jv[jss::Flags] = *args.flags; + if (args.vaultKind) + jv[sfVaultKind] = *args.vaultKind; + if (args.subscriptionDate) + jv[sfSubscriptionDate] = *args.subscriptionDate; + if (args.redemptionDate) + jv[sfRedemptionDate] = *args.redemptionDate; return {jv, keylet}; } diff --git a/src/test/jtx/vault.h b/src/test/jtx/vault.h index e72eae89b7..992051b61f 100644 --- a/src/test/jtx/vault.h +++ b/src/test/jtx/vault.h @@ -25,6 +25,12 @@ struct Vault Asset asset; std::optional flags = std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional vaultKind = + std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional subscriptionDate = + std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional redemptionDate = + std::nullopt; // NOLINT(readability-redundant-member-init) }; /** diff --git a/src/test/protocol/Hooks_test.cpp b/src/test/protocol/Hooks_test.cpp deleted file mode 100644 index 082507aca7..0000000000 --- a/src/test/protocol/Hooks_test.cpp +++ /dev/null @@ -1,189 +0,0 @@ - - -#include // IWYU pragma: keep - -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -namespace xrpl { - -class Hooks_test : public beast::unit_test::Suite -{ - /** - * This unit test was requested here: - * https://github.com/XRPLF/rippled/pull/4089#issuecomment-1050274539 - * These are tests that exercise facilities that are reserved for when Hooks - * is merged in the future. - **/ - - void - testHookFields() - { - testcase("Test Hooks fields"); - - using namespace test::jtx; - - std::vector> const fieldsToTest = { - sfHookResult, - sfHookStateChangeCount, - sfHookEmitCount, - sfHookExecutionIndex, - sfHookApiVersion, - sfHookStateCount, - sfEmitGeneration, - sfHookOn, - sfHookInstructionCount, - sfEmitBurden, - sfHookReturnCode, - sfReferenceCount, - sfEmitParentTxnID, - sfEmitNonce, - sfEmitHookHash, - sfHookStateKey, - sfHookHash, - sfHookNamespace, - sfHookSetTxnID, - sfHookStateData, - sfHookReturnString, - sfHookParameterName, - sfHookParameterValue, - sfEmitCallback, - sfHookAccount, - sfEmittedTxn, - sfHook, - sfHookDefinition, - sfHookParameter, - sfHookGrant, - sfEmitDetails, - sfHookExecutions, - sfHookExecution, - sfHookParameters, - sfHooks, - sfHookGrants}; - - for (auto const& rf : fieldsToTest) - { - SField const& f = rf.get(); - - STObject dummy{sfGeneric}; - - BEAST_EXPECT(!dummy.isFieldPresent(f)); - - switch (f.fieldType) - { - case STI_UINT8: { - dummy.setFieldU8(f, 0); - BEAST_EXPECT(dummy.getFieldU8(f) == 0); - - dummy.setFieldU8(f, 255); - BEAST_EXPECT(dummy.getFieldU8(f) == 255); - - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - case STI_UINT16: { - dummy.setFieldU16(f, 0); - BEAST_EXPECT(dummy.getFieldU16(f) == 0); - - dummy.setFieldU16(f, 0xFFFFU); - BEAST_EXPECT(dummy.getFieldU16(f) == 0xFFFFU); - - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - case STI_UINT32: { - dummy.setFieldU32(f, 0); - BEAST_EXPECT(dummy.getFieldU32(f) == 0); - - dummy.setFieldU32(f, 0xFFFFFFFFU); - BEAST_EXPECT(dummy.getFieldU32(f) == 0xFFFFFFFFU); - - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - case STI_UINT64: { - dummy.setFieldU64(f, 0); - BEAST_EXPECT(dummy.getFieldU64(f) == 0); - - dummy.setFieldU64(f, 0xFFFFFFFFFFFFFFFFU); - BEAST_EXPECT(dummy.getFieldU64(f) == 0xFFFFFFFFFFFFFFFFU); - - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - case STI_UINT256: { - uint256 const u = uint256::fromVoid( - "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBE" - "EFDEADBEEF"); - dummy.setFieldH256(f, u); - BEAST_EXPECT(dummy.getFieldH256(f) == u); - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - case STI_VL: { - std::vector const v{1, 2, 3}; - dummy.setFieldVL(f, v); - BEAST_EXPECT(dummy.getFieldVL(f) == v); - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - case STI_ACCOUNT: { - // NOLINTBEGIN(bugprone-unchecked-optional-access) - AccountID const id = - *parseBase58("rwfSjJNK2YQuN64bSWn7T2eY9FJAyAPYJT"); - // NOLINTEND(bugprone-unchecked-optional-access) - dummy.setAccountID(f, id); - BEAST_EXPECT(dummy.getAccountID(f) == id); - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - case STI_OBJECT: { - dummy.emplaceBack(STObject{f}); - BEAST_EXPECT(dummy.getField(f).getFName() == f); - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - case STI_ARRAY: { - STArray dummy2{f, 2}; - dummy2.pushBack(STObject{sfGeneric}); - dummy2.pushBack(STObject{sfGeneric}); - dummy.setFieldArray(f, dummy2); - BEAST_EXPECT(dummy.getFieldArray(f) == dummy2); - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - default: - BEAST_EXPECT(false); - } - } - } - -public: - void - run() override - { - using namespace test::jtx; - testHookFields(); - } -}; - -BEAST_DEFINE_TESTSUITE(Hooks, protocol, xrpl); - -} // namespace xrpl diff --git a/src/test/protocol/STAmount_test.cpp b/src/test/protocol/STAmount_test.cpp index f6c5a94752..c3a681cf01 100644 --- a/src/test/protocol/STAmount_test.cpp +++ b/src/test/protocol/STAmount_test.cpp @@ -1,16 +1,21 @@ #include +#include #include +#include #include #include #include #include #include +#include #include #include #include #include #include +#include +#include #include #include #include @@ -24,6 +29,7 @@ #include #include #include +#include namespace xrpl { @@ -990,6 +996,84 @@ public: } } + void + testMPTRateRounding() + { + testcase("MPT transfer rate rounding uses Number arithmetic"); + + MPTIssue const asset{makeMptID(1, AccountID(0x4985601))}; + Rate const transferRate{1'500'000'000}; + STAmount const largeAmount{asset, UINT64_C(1'230'000'000'000'000'000)}; + STAmount const scaledAmount{asset, UINT64_C(1'845'000'000'000'000'000)}; + + auto rules = [](bool const mptV2) { + // Rules keeps a reference to the presets set, so use static + // storage here rather than a local temporary. + static std::unordered_set> const kNoFeatures; + static std::unordered_set> const kMptV2Features{ + featureMPTokensV2}; + return Rules{mptV2 ? kMptV2Features : kNoFeatures}; + }; + + auto throwsOverflow = [&](auto&& f, bool expected = true) { + bool threw = false; + try + { + f(); + } + catch (std::overflow_error const&) + { + threw = true; + } + BEAST_EXPECT(threw == expected); + }; + + { + CurrentTransactionRulesGuard const rg(rules(false)); + + throwsOverflow([&] { (void)multiplyRound(largeAmount, transferRate, asset, true); }); + throwsOverflow([&] { (void)divideRound(scaledAmount, transferRate, asset, true); }); + } + + { + CurrentTransactionRulesGuard const rg(rules(true)); + + throwsOverflow( + [&] { (void)multiplyRound(largeAmount, transferRate, asset, true); }, false); + throwsOverflow( + [&] { (void)divideRound(scaledAmount, transferRate, asset, true); }, false); + } + + { + CurrentTransactionRulesGuard const rg(rules(true)); + STAmount const one{asset, 1}; + STAmount const two{asset, 2}; + + BEAST_EXPECT(multiplyRound(one, transferRate, asset, true) == two); + BEAST_EXPECT(multiplyRound(one, transferRate, asset, false) == one); + BEAST_EXPECT(divideRound(two, transferRate, asset, true) == two); + BEAST_EXPECT(divideRound(two, transferRate, asset, false) == one); + + BEAST_EXPECT(multiplyRound(largeAmount, transferRate, asset, true) == scaledAmount); + BEAST_EXPECT(divideRound(scaledAmount, transferRate, asset, true) == largeAmount); + } + + { + // mulRound with an integral (XRP) operand whose mantissa is below + // kMinValue exercises the legacy value-scaling loop that normalizes + // the mantissa before multiply. The MPTokensV2 Number path is + // not taken here because the target asset is an IOU. + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + STAmount const iouVal{usd, 5}; + STAmount const xrpVal{XRPAmount{7}}; // integral, mantissa < kMinValue + + auto const up = mulRound(iouVal, xrpVal, usd, /*roundUp*/ true); + auto const down = mulRound(iouVal, xrpVal, usd, /*roundUp*/ false); + BEAST_EXPECT(down.signum() > 0); + BEAST_EXPECT(up >= down); + } + } + void testCanSubtractXRP() { @@ -1267,6 +1351,7 @@ public: testCanAddXRP(); testCanAddIOU(); testCanAddMPT(); + testMPTRateRounding(); testCanSubtractXRP(); testCanSubtractIOU(); testCanSubtractMPT(); diff --git a/src/test/rpc/AccountLines_test.cpp b/src/test/rpc/AccountLines_test.cpp index cb20de9bf5..3de2bdefa3 100644 --- a/src/test/rpc/AccountLines_test.cpp +++ b/src/test/rpc/AccountLines_test.cpp @@ -94,6 +94,24 @@ public: LedgerHeader const ledger3Info = env.closed()->header(); BEAST_EXPECT(ledger3Info.seq == 3); + { + // test peer non-string + auto testInvalidPeerParam = [&](auto const& param) { + json::Value params; + params[jss::account] = alice.human(); + params[jss::peer] = param; + auto jrr = env.rpc("json", "account_lines", to_string(params))[jss::result]; + BEAST_EXPECT(jrr[jss::error] == "invalidParams"); + BEAST_EXPECT(jrr[jss::error_message] == "Invalid field 'peer'."); + }; + + testInvalidPeerParam(1); + testInvalidPeerParam(1.1); + testInvalidPeerParam(true); + testInvalidPeerParam(json::Value(json::ValueType::Null)); + testInvalidPeerParam(json::Value(json::ValueType::Object)); + testInvalidPeerParam(json::Value(json::ValueType::Array)); + } { // alice is funded but has no lines. An empty array is returned. json::Value params; @@ -775,6 +793,35 @@ public: LedgerHeader const ledger3Info = env.closed()->header(); BEAST_EXPECT(ledger3Info.seq == 3); + { + // test peer non-string + auto testInvalidPeerParam = [&](auto const& param) { + json::Value params; + params[jss::account] = alice.human(); + params[jss::peer] = param; + + json::Value request; + request[jss::method] = "account_lines"; + request[jss::jsonrpc] = "2.0"; + request[jss::ripplerpc] = "2.0"; + request[jss::id] = 5; + request[jss::params] = params; + + auto const lines = env.rpc("json2", to_string(request)); + BEAST_EXPECT(lines[jss::error][jss::error] == "invalidParams"); + BEAST_EXPECT(lines[jss::error][jss::message] == "Invalid field 'peer'."); + BEAST_EXPECT(lines.isMember(jss::jsonrpc) && lines[jss::jsonrpc] == "2.0"); + BEAST_EXPECT(lines.isMember(jss::ripplerpc) && lines[jss::ripplerpc] == "2.0"); + BEAST_EXPECT(lines.isMember(jss::id) && lines[jss::id] == 5); + }; + + testInvalidPeerParam(1); + testInvalidPeerParam(1.1); + testInvalidPeerParam(true); + testInvalidPeerParam(json::Value(json::ValueType::Null)); + testInvalidPeerParam(json::Value(json::ValueType::Object)); + testInvalidPeerParam(json::Value(json::ValueType::Array)); + } { // alice is funded but has no lines. An empty array is returned. json::Value params; diff --git a/src/test/rpc/GatewayBalances_test.cpp b/src/test/rpc/GatewayBalances_test.cpp index 106b9b5f1a..91d9126f61 100644 --- a/src/test/rpc/GatewayBalances_test.cpp +++ b/src/test/rpc/GatewayBalances_test.cpp @@ -176,6 +176,45 @@ public: }); } + void + testGWBInvalidAccount(FeatureBitset features) + { + testcase("Gateway Balances with non-string account/ident"); + using namespace std::chrono_literals; + using namespace jtx; + Env env(*this, features); + + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + env.close(); + + auto wsc = makeWSClient(env.app().config()); + + // A non-string "account" must be rejected cleanly with invalidParams + // rather than throwing a Json::LogicError that surfaces as internal. + json::Value qry; + qry[jss::account] = 42; + qry[jss::hotwallet] = alice.human(); + + forAllApiVersions([&, this](unsigned apiVersion) { + qry[jss::api_version] = apiVersion; + auto jv = wsc->invoke("gateway_balances", qry); + expect(jv[jss::status] == "error"); + BEAST_EXPECT(jv[jss::result][jss::error] == "invalidParams"); + }); + + // The same applies to a non-string "ident". + json::Value qry2; + qry2[jss::ident] = 42; + + forAllApiVersions([&, this](unsigned apiVersion) { + qry2[jss::api_version] = apiVersion; + auto jv = wsc->invoke("gateway_balances", qry2); + expect(jv[jss::status] == "error"); + BEAST_EXPECT(jv[jss::result][jss::error] == "invalidParams"); + }); + } + void testGWBOverflow() { @@ -280,6 +319,7 @@ public: { testGWB(feature); testGWBApiVersions(feature); + testGWBInvalidAccount(feature); } testGWBWithMPT(); testGWBOverflow(); diff --git a/src/test/rpc/NoRippleCheck_test.cpp b/src/test/rpc/NoRippleCheck_test.cpp index 6e30f944c7..8e719e6407 100644 --- a/src/test/rpc/NoRippleCheck_test.cpp +++ b/src/test/rpc/NoRippleCheck_test.cpp @@ -27,8 +27,6 @@ #include #include -#include - #include #include @@ -203,13 +201,13 @@ class NoRippleCheck_test : public beast::unit_test::Suite if (user) { - BEAST_EXPECT(boost::starts_with(pa[0u].asString(), "You appear to have set")); - BEAST_EXPECT(boost::starts_with(pa[1u].asString(), "You should probably set")); + BEAST_EXPECT(pa[0u].asString().starts_with("You appear to have set")); + BEAST_EXPECT(pa[1u].asString().starts_with("You should probably set")); } else { - BEAST_EXPECT(boost::starts_with(pa[0u].asString(), "You should immediately set")); - BEAST_EXPECT(boost::starts_with(pa[1u].asString(), "You should clear")); + BEAST_EXPECT(pa[0u].asString().starts_with("You should immediately set")); + BEAST_EXPECT(pa[1u].asString().starts_with("You should clear")); } } else diff --git a/src/test/server/ServerStatus_test.cpp b/src/test/server/ServerStatus_test.cpp index 5adf6a08f5..f1989ed171 100644 --- a/src/test/server/ServerStatus_test.cpp +++ b/src/test/server/ServerStatus_test.cpp @@ -56,8 +56,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En static auto makeConfig(std::string const& proto, bool admin = true, bool credentials = false) { - auto const sectionName = - boost::starts_with(proto, "h") ? Sections::kPortRpc : Sections::kPortWs; + auto const sectionName = proto.starts_with("h") ? Sections::kPortRpc : Sections::kPortWs; auto p = jtx::envconfig(); p->overwrite(sectionName, Keys::kProtocol, proto); @@ -71,9 +70,9 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En } p->overwrite( - boost::starts_with(proto, "h") ? Sections::kPortWs : Sections::kPortRpc, + proto.starts_with("h") ? Sections::kPortWs : Sections::kPortRpc, Keys::kProtocol, - boost::starts_with(proto, "h") ? "ws" : "http"); + proto.starts_with("h") ? "ws" : "http"); if (proto == "https") { @@ -261,7 +260,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En } } - if (boost::starts_with(proto, "h")) + if (proto.starts_with("h")) { auto jrc = makeJSONRPCClient(env.app().config()); jrr = jrc->invoke("ledger_accept", jp); @@ -289,7 +288,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En Env env{*this, makeConfig(proto, admin, credentials)}; json::Value jrr; - auto const protoWs = boost::starts_with(proto, "w"); + auto const protoWs = proto.starts_with("w"); // the set of checks we do are different depending // on how the admin config options are set @@ -485,7 +484,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En boost::beast::http::response resp; boost::system::error_code ec; - if (boost::starts_with(clientProtocol, "h")) + if (clientProtocol.starts_with("h")) { doHTTPRequest(env, yield, clientProtocol == "https", resp, ec); BEAST_EXPECT(ec); diff --git a/src/test/unit_test/FileDirGuard.h b/src/test/unit_test/FileDirGuard.h index b583f821a4..2e6b3fd179 100644 --- a/src/test/unit_test/FileDirGuard.h +++ b/src/test/unit_test/FileDirGuard.h @@ -3,9 +3,8 @@ #include #include -#include - #include +#include #include #include #include @@ -20,7 +19,7 @@ namespace xrpl::detail { class DirGuard { protected: - using path = boost::filesystem::path; + using path = std::filesystem::path; private: path subDir_; @@ -47,7 +46,7 @@ public: DirGuard(beast::unit_test::Suite& test, path subDir, bool useCounter = true) : subDir_(std::move(subDir)), test_(test) { - using namespace boost::filesystem; + using namespace std::filesystem; static auto kSubDirCounter = 0; if (useCounter) @@ -73,7 +72,7 @@ public: { try { - using namespace boost::filesystem; + using namespace std::filesystem; if (rmSubDir_) rmDir(subDir_); @@ -130,7 +129,7 @@ public: { try { - using namespace boost::filesystem; + using namespace std::filesystem; if (exists(file_)) { remove(file_); @@ -160,7 +159,7 @@ public: [[nodiscard]] bool fileExists() const { - return boost::filesystem::exists(file_); + return std::filesystem::exists(file_); } }; diff --git a/src/test/unit_test/multi_runner.cpp b/src/test/unit_test/multi_runner.cpp index 71208313a4..918fc7c89f 100644 --- a/src/test/unit_test/multi_runner.cpp +++ b/src/test/unit_test/multi_runner.cpp @@ -7,7 +7,6 @@ #include #include #include -#include #include #include @@ -36,7 +35,7 @@ fmtdur(typename clock_type::duration const& d) using namespace std::chrono; auto const ms = duration_cast(d); if (ms < seconds{1}) - return boost::lexical_cast(ms.count()) + "ms"; + return std::to_string(ms.count()) + "ms"; std::stringstream ss; ss << std::fixed << std::setprecision(1) << (ms.count() / 1000.) << "s"; return ss.str(); diff --git a/src/tests/libxrpl/basics/Buffer.cpp b/src/tests/libxrpl/basics/Buffer.cpp index 9cdf610282..a3f78e8bcf 100644 --- a/src/tests/libxrpl/basics/Buffer.cpp +++ b/src/tests/libxrpl/basics/Buffer.cpp @@ -4,6 +4,7 @@ #include +#include #include #include #include @@ -12,8 +13,18 @@ namespace xrpl::test { +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_assignable_v); + struct BufferTest : public ::testing::Test { + static constexpr auto kRandomData = std::to_array( + {0xa8, 0xa1, 0x38, 0x45, 0x23, 0xec, 0xe4, 0x23, 0x71, 0x6d, 0x2a, + 0x18, 0xb4, 0x70, 0xcb, 0xf5, 0xac, 0x2d, 0x89, 0x4d, 0x19, 0x9c, + 0xf0, 0x2c, 0x15, 0xd1, 0xf9, 0x9b, 0x66, 0xd2, 0x30, 0xd3}); + + static constexpr std::size_t kHalf = kRandomData.size() / 2; + static bool sane(Buffer const& b) { @@ -22,239 +33,321 @@ struct BufferTest : public ::testing::Test return b.data() != nullptr; } + + /** + * Check the state Buffer documents for a moved-from buffer: "the other buffer is reset", i.e. + * empty and sane. + * + * Zeroing the size is not incidental tidiness. Moving the member unique_ptr nulls the data + * pointer whether Buffer wants it or not, so a moved-from buffer that kept its old size would + * lie about itself everywhere: alloc() would take its `n == size_` early-out and hand back a + * null pointer while still reporting the old size, fill() would run std::fill_n over a null + * pointer, and the Slice conversion would publish {nullptr, oldSize} to callers. A moved-from + * Buffer has to be a usable empty Buffer rather than a landmine, which is why the tests below + * assert this state instead of treating a moved-from buffer as untouchable. + */ + static void + checkEmptyAfterMove(Buffer const& buf) + { + EXPECT_TRUE(sane(buf)); + EXPECT_TRUE(buf.empty()); + } + + Buffer const emptyBuffer; + Buffer const firstHalf{kRandomData.data(), kHalf}; + Buffer const secondHalf{kRandomData.data() + kHalf, kHalf}; + Buffer const whole{kRandomData.data(), kRandomData.size()}; }; -TEST_F(BufferTest, buffer) +TEST_F(BufferTest, default_constructed_is_empty) { - std::uint8_t const data[] = {0xa8, 0xa1, 0x38, 0x45, 0x23, 0xec, 0xe4, 0x23, 0x71, 0x6d, 0x2a, - 0x18, 0xb4, 0x70, 0xcb, 0xf5, 0xac, 0x2d, 0x89, 0x4d, 0x19, 0x9c, - 0xf0, 0x2c, 0x15, 0xd1, 0xf9, 0x9b, 0x66, 0xd2, 0x30, 0xd3}; + Buffer const b; - Buffer const b0; - EXPECT_TRUE(sane(b0)); - EXPECT_TRUE(b0.empty()); + EXPECT_TRUE(sane(b)); + EXPECT_TRUE(b.empty()); + EXPECT_EQ(b.data(), nullptr); +} - Buffer b1{0}; - EXPECT_TRUE(sane(b1)); - EXPECT_TRUE(b1.empty()); - std::memcpy(b1.alloc(16), data, 16); - EXPECT_TRUE(sane(b1)); - EXPECT_FALSE(b1.empty()); - EXPECT_EQ(b1.size(), 16); +TEST_F(BufferTest, zero_sized_construction_is_empty) +{ + Buffer const b{0}; - Buffer b2{b1.size()}; - EXPECT_TRUE(sane(b2)); - EXPECT_FALSE(b2.empty()); - EXPECT_EQ(b2.size(), b1.size()); - std::memcpy(b2.data(), data + 16, 16); + EXPECT_TRUE(sane(b)); + EXPECT_TRUE(b.empty()); +} - Buffer b3{data, sizeof(data)}; - EXPECT_TRUE(sane(b3)); - EXPECT_FALSE(b3.empty()); - EXPECT_EQ(b3.size(), sizeof(data)); - EXPECT_EQ(std::memcmp(b3.data(), data, b3.size()), 0); +TEST_F(BufferTest, alloc_grows_an_empty_buffer) +{ + Buffer b{0}; + std::memcpy(b.alloc(kHalf), kRandomData.data(), kHalf); - // Check equality and inequality comparisons. - // For code readability, we want to use general - // EXPECT_TRUE instead of specific EXPECT_EQ etc. - EXPECT_TRUE(b0 == b0); - EXPECT_TRUE(b0 != b1); - EXPECT_TRUE(b1 == b1); - EXPECT_TRUE(b1 != b2); - EXPECT_TRUE(b2 != b3); + EXPECT_TRUE(sane(b)); + EXPECT_FALSE(b.empty()); + EXPECT_EQ(b.size(), kHalf); + EXPECT_EQ(b, firstHalf); +} - // Check copy constructors and copy assignments: - { - Buffer x{b0}; - EXPECT_EQ(x, b0); - EXPECT_TRUE(sane(x)); - Buffer y{b1}; - EXPECT_EQ(y, b1); - EXPECT_TRUE(sane(y)); - x = b2; - EXPECT_EQ(x, b2); - EXPECT_TRUE(sane(x)); - x = y; - EXPECT_EQ(x, y); - EXPECT_TRUE(sane(x)); - y = b3; - EXPECT_EQ(y, b3); - EXPECT_TRUE(sane(y)); - x = b0; - EXPECT_EQ(x, b0); - EXPECT_TRUE(sane(x)); +TEST_F(BufferTest, sized_construction_reserves_without_filling) +{ + Buffer b{kHalf}; + + EXPECT_TRUE(sane(b)); + EXPECT_FALSE(b.empty()); + EXPECT_EQ(b.size(), kHalf); + + std::memcpy(b.data(), kRandomData.data() + kHalf, kHalf); + EXPECT_EQ(b, secondHalf); +} + +TEST_F(BufferTest, construction_copies_raw_memory) +{ + Buffer const b{kRandomData.data(), kRandomData.size()}; + + EXPECT_TRUE(sane(b)); + EXPECT_FALSE(b.empty()); + EXPECT_EQ(b.size(), kRandomData.size()); + EXPECT_EQ(std::memcmp(b.data(), kRandomData.data(), b.size()), 0); +} + +TEST_F(BufferTest, equality_compares_contents) +{ + // Uses EXPECT_TRUE rather than EXPECT_EQ/EXPECT_NE because the operators are what is under test + // here. + EXPECT_TRUE(emptyBuffer == emptyBuffer); + EXPECT_TRUE(firstHalf == firstHalf); + + EXPECT_TRUE(emptyBuffer != firstHalf); + EXPECT_TRUE(firstHalf != secondHalf); + EXPECT_TRUE(secondHalf != whole); +} + +TEST_F(BufferTest, copy_construction) +{ + Buffer const fromEmpty{emptyBuffer}; + EXPECT_TRUE(sane(fromEmpty)); + EXPECT_EQ(fromEmpty, emptyBuffer); + + Buffer const fromNonEmpty{firstHalf}; + EXPECT_TRUE(sane(fromNonEmpty)); + EXPECT_EQ(fromNonEmpty, firstHalf); +} + +TEST_F(BufferTest, copy_assignment) +{ + Buffer b{emptyBuffer}; + + // empty <- non-empty + b = secondHalf; + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b, secondHalf); + + // non-empty <- non-empty of a different size + b = whole; + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b, whole); + + // non-empty <- empty + b = emptyBuffer; + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b, emptyBuffer); +} + +TEST_F(BufferTest, self_assignment_preserves_contents) +{ #ifdef __clang__ #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wself-assign-overloaded" #endif - x = x; - EXPECT_EQ(x, b0); - EXPECT_TRUE(sane(x)); - y = y; - EXPECT_EQ(y, b3); - EXPECT_TRUE(sane(y)); + Buffer emptyCopy{emptyBuffer}; + emptyCopy = emptyCopy; + EXPECT_TRUE(sane(emptyCopy)); + EXPECT_EQ(emptyCopy, emptyBuffer); + + Buffer wholeCopy{whole}; + wholeCopy = wholeCopy; + EXPECT_TRUE(sane(wholeCopy)); + EXPECT_EQ(wholeCopy, whole); #ifdef __clang__ #pragma clang diagnostic pop #endif - } +} - // Check move constructor & move assignments: +TEST_F(BufferTest, move_construct_from_empty) +{ + Buffer source; + Buffer const moved{std::move(source)}; + + checkEmptyAfterMove(source); // NOLINT(bugprone-use-after-move) + EXPECT_TRUE(sane(moved)); + EXPECT_TRUE(moved.empty()); +} + +TEST_F(BufferTest, move_construct_from_non_empty) +{ + Buffer source{firstHalf}; + Buffer const moved{std::move(source)}; + + checkEmptyAfterMove(source); // NOLINT(bugprone-use-after-move) + EXPECT_TRUE(sane(moved)); + EXPECT_EQ(moved, firstHalf); +} + +TEST_F(BufferTest, move_assign_empty_to_empty) +{ + Buffer target; + Buffer source; + + target = std::move(source); + + EXPECT_TRUE(sane(target)); + EXPECT_TRUE(target.empty()); + checkEmptyAfterMove(source); // NOLINT(bugprone-use-after-move) +} + +TEST_F(BufferTest, move_assign_non_empty_to_empty) +{ + Buffer target; + Buffer source{firstHalf}; + + target = std::move(source); + + EXPECT_TRUE(sane(target)); + EXPECT_EQ(target, firstHalf); + checkEmptyAfterMove(source); // NOLINT(bugprone-use-after-move) +} + +TEST_F(BufferTest, move_assign_empty_to_non_empty) +{ + Buffer target{firstHalf}; + Buffer source; + + target = std::move(source); + + EXPECT_TRUE(sane(target)); + EXPECT_TRUE(target.empty()); + checkEmptyAfterMove(source); // NOLINT(bugprone-use-after-move) +} + +TEST_F(BufferTest, move_assign_non_empty_to_non_empty) +{ + Buffer target{firstHalf}; + Buffer sameSize{secondHalf}; + Buffer largerSize{whole}; + + target = std::move(sameSize); + EXPECT_TRUE(sane(target)); + EXPECT_EQ(target, secondHalf); + checkEmptyAfterMove(sameSize); // NOLINT(bugprone-use-after-move) + + target = std::move(largerSize); + EXPECT_TRUE(sane(target)); + EXPECT_EQ(target, whole); + checkEmptyAfterMove(largerSize); // NOLINT(bugprone-use-after-move) +} + +TEST_F(BufferTest, construction_from_slice) +{ + Buffer const fromEmpty{static_cast(emptyBuffer)}; + EXPECT_TRUE(sane(fromEmpty)); + EXPECT_EQ(fromEmpty, emptyBuffer); + + Buffer const fromNonEmpty{static_cast(whole)}; + EXPECT_TRUE(sane(fromNonEmpty)); + EXPECT_EQ(fromNonEmpty, whole); +} + +TEST_F(BufferTest, assignment_from_slice) +{ + Buffer b; + + // empty <- empty slice + b = static_cast(emptyBuffer); + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b, emptyBuffer); + + // empty <- non-empty slice + b = static_cast(firstHalf); + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b, firstHalf); + + // non-empty <- non-empty slice + b = static_cast(secondHalf); + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b, secondHalf); + + // non-empty <- empty slice + b = static_cast(emptyBuffer); + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b, emptyBuffer); +} + +TEST_F(BufferTest, resize_allocates_and_clear_releases) +{ + auto check = [](Buffer const& original, std::size_t size) { + SCOPED_TRACE(::testing::Message() << "size: " << size); + + Buffer b{original}; + + // Resizing to zero is equivalent to clearing. + b(size); + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b.size(), size); + EXPECT_EQ(b.data() == nullptr, size == 0); + + b(size + 1); + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b.size(), size + 1); + EXPECT_NE(b.data(), nullptr); + + b.clear(); + EXPECT_TRUE(sane(b)); + EXPECT_TRUE(b.empty()); + EXPECT_EQ(b.data(), nullptr); + + // clear() is idempotent. + b.clear(); + EXPECT_TRUE(sane(b)); + EXPECT_TRUE(b.empty()); + EXPECT_EQ(b.data(), nullptr); + }; + + for (auto size = 0uz; size < kHalf; ++size) { - static_assert(std::is_nothrow_move_constructible_v); - static_assert(std::is_nothrow_move_assignable_v); - - { // Move-construct from empty buf - Buffer x; - Buffer const y{std::move(x)}; - EXPECT_TRUE(sane(x)); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(x.empty()); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(sane(y)); - EXPECT_TRUE(y.empty()); - EXPECT_EQ(x, y); // NOLINT(bugprone-use-after-move) - } - - { // Move-construct from non-empty buf - Buffer x{b1}; - Buffer const y{std::move(x)}; - EXPECT_TRUE(sane(x)); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(x.empty()); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(sane(y)); - EXPECT_EQ(y, b1); - } - - { // Move assign empty buf to empty buf - Buffer x; - Buffer y; - - x = std::move(y); - EXPECT_TRUE(sane(x)); - EXPECT_TRUE(x.empty()); - EXPECT_TRUE(sane(y)); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(y.empty()); // NOLINT(bugprone-use-after-move) - } - - { // Move assign non-empty buf to empty buf - Buffer x; - Buffer y{b1}; - - x = std::move(y); - EXPECT_TRUE(sane(x)); - EXPECT_EQ(x, b1); - EXPECT_TRUE(sane(y)); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(y.empty()); // NOLINT(bugprone-use-after-move) - } - - { // Move assign empty buf to non-empty buf - Buffer x{b1}; - Buffer y; - - x = std::move(y); - EXPECT_TRUE(sane(x)); - EXPECT_TRUE(x.empty()); - EXPECT_TRUE(sane(y)); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(y.empty()); // NOLINT(bugprone-use-after-move) - } - - { // Move assign non-empty buf to non-empty buf - Buffer x{b1}; - Buffer y{b2}; - Buffer z{b3}; - - x = std::move(y); - EXPECT_TRUE(sane(x)); - EXPECT_FALSE(x.empty()); - EXPECT_TRUE(sane(y)); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(y.empty()); // NOLINT(bugprone-use-after-move) - - x = std::move(z); - EXPECT_TRUE(sane(x)); - EXPECT_FALSE(x.empty()); - EXPECT_TRUE(sane(z)); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(z.empty()); // NOLINT(bugprone-use-after-move) - } - } - - { - Buffer w{static_cast(b0)}; - EXPECT_TRUE(sane(w)); - EXPECT_EQ(w, b0); - - Buffer x{static_cast(b1)}; - EXPECT_TRUE(sane(x)); - EXPECT_EQ(x, b1); - - Buffer y{static_cast(b2)}; - EXPECT_TRUE(sane(y)); - EXPECT_EQ(y, b2); - - Buffer z{static_cast(b3)}; - EXPECT_TRUE(sane(z)); - EXPECT_EQ(z, b3); - - // Assign empty slice to empty buffer - w = static_cast(b0); - EXPECT_TRUE(sane(w)); - EXPECT_EQ(w, b0); - - // Assign non-empty slice to empty buffer - w = static_cast(b1); - EXPECT_TRUE(sane(w)); - EXPECT_EQ(w, b1); - - // Assign non-empty slice to non-empty buffer - x = static_cast(b2); - EXPECT_TRUE(sane(x)); - EXPECT_EQ(x, b2); - - // Assign non-empty slice to non-empty buffer - y = static_cast(z); - EXPECT_TRUE(sane(y)); - EXPECT_EQ(y, z); - - // Assign empty slice to non-empty buffer: - z = static_cast(b0); - EXPECT_TRUE(sane(z)); - EXPECT_EQ(z, b0); - } - - { - auto test = [](Buffer const& b, std::size_t i) { - Buffer x{b}; - - // Try to allocate some number of bytes, possibly - // zero (which means clear) and sanity check - x(i); - EXPECT_TRUE(sane(x)); - EXPECT_EQ(x.size(), i); - EXPECT_EQ((x.data() == nullptr), (i == 0)); - - // Try to allocate some more data (always non-zero) - x(i + 1); - EXPECT_TRUE(sane(x)); - EXPECT_EQ(x.size(), i + 1); - EXPECT_NE(x.data(), nullptr); - - // Try to clear: - x.clear(); - EXPECT_TRUE(sane(x)); - EXPECT_TRUE(x.empty()); - EXPECT_EQ(x.data(), nullptr); - - // Try to clear again: - x.clear(); - EXPECT_TRUE(sane(x)); - EXPECT_TRUE(x.empty()); - EXPECT_EQ(x.data(), nullptr); - }; - - for (std::size_t i = 0; i < 16; ++i) - { - test(b0, i); - test(b1, i); - } + check(emptyBuffer, size); + check(firstHalf, size); } } +TEST_F(BufferTest, fill_sets_every_byte) +{ + Buffer b{4}; + b.fill(0xab); + + EXPECT_EQ(b.size(), 4); + for (auto const byte : Slice{b}) + EXPECT_EQ(byte, 0xab); +} + +TEST_F(BufferTest, fill_overwrites_and_keeps_size) +{ + Buffer b{4}; + b.fill(0xab); + b.fill(0x00); + + EXPECT_EQ(b.size(), 4); + for (auto const byte : Slice{b}) + EXPECT_EQ(byte, 0x00); +} + +TEST_F(BufferTest, fill_on_empty_buffer_is_a_noop) +{ + Buffer empty; + empty.fill(0xff); + + EXPECT_TRUE(empty.empty()); + EXPECT_EQ(empty.data(), nullptr); +} + } // namespace xrpl::test diff --git a/src/tests/libxrpl/basics/FileUtilities.cpp b/src/tests/libxrpl/basics/FileUtilities.cpp index cd24abd696..5cf2b72709 100644 --- a/src/tests/libxrpl/basics/FileUtilities.cpp +++ b/src/tests/libxrpl/basics/FileUtilities.cpp @@ -2,16 +2,14 @@ #include -#include -#include -#include -#include - #include +#include #include +#include #include #include +#include namespace xrpl { @@ -20,15 +18,14 @@ namespace { class TempFile { public: - explicit TempFile(boost::filesystem::path file, std::string const& contents) - : dir_( - boost::filesystem::temp_directory_path() / - boost::filesystem::unique_path("xrpl-file-utilities-%%%%-%%%%-%%%%")) - , file_(dir_ / file) + explicit TempFile(std::string const& file, std::string const& contents) + : file_( + uniqueRandomPath(std::filesystem::temp_directory_path(), "xrpl-file-utilities-") / + file) { - boost::filesystem::create_directory(dir_); + std::filesystem::create_directory(file_.parent_path()); - std::ofstream output(file_.string()); + std::ofstream output(file_); if (!output) throw std::runtime_error("Unable to create temporary test file"); @@ -37,33 +34,36 @@ public: ~TempFile() { - boost::system::error_code ec; - boost::filesystem::remove(file_, ec); - boost::filesystem::remove(dir_, ec); + // use non-throwing calls in the destructor + std::error_code ec; + auto const dir = file_.parent_path(); + std::filesystem::remove_all(dir, ec); + if (ec) + { + std::cerr << "Unable to remove temporary directory '" << dir.string() + << "': " << ec.message() << '\n'; + } } - [[nodiscard]] boost::filesystem::path const& + [[nodiscard]] std::filesystem::path const& file() const { return file_; } private: - boost::filesystem::path dir_; - boost::filesystem::path file_; + std::filesystem::path file_; }; } // namespace TEST(FileUtilitiesTest, get_file_contents) { - using namespace boost::system; - constexpr char const* kExpectedContents = "This file is very short. That's all we need."; TempFile const file("test_file", "This is temporary text that should get overwritten"); - error_code ec; + std::error_code ec; auto const path = file.file(); writeFileContents(ec, path, kExpectedContents); @@ -86,7 +86,7 @@ TEST(FileUtilitiesTest, get_file_contents) { // Test with small max auto const bad = getFileContents(ec, path, 16); - EXPECT_TRUE(ec && ec.value() == boost::system::errc::file_too_large); + EXPECT_TRUE(ec && ec.value() == static_cast(std::errc::file_too_large)); EXPECT_TRUE(bad.empty()); } } diff --git a/src/tests/libxrpl/basics/IntrusiveShared.cpp b/src/tests/libxrpl/basics/IntrusiveShared.cpp index b9f8930b7b..c6c9fcfef0 100644 --- a/src/tests/libxrpl/basics/IntrusiveShared.cpp +++ b/src/tests/libxrpl/basics/IntrusiveShared.cpp @@ -92,6 +92,7 @@ public: static constexpr std::size_t kMaxStates = 128; static std::array, kMaxStates> state; static std::atomic nextId; + static TrackedState getState(std::size_t id) { @@ -100,13 +101,12 @@ public: return state[id].load(std::memory_order_acquire); } + static void resetStates(bool resetCallback) { for (std::size_t i = 0; i < kMaxStates; ++i) - { state[i].store(TrackedState::Uninitialized, std::memory_order_release); - } nextId.store(0, std::memory_order_release); if (resetCallback) TIBase::tracingCallback = [](TrackedState, std::optional) {}; @@ -120,6 +120,7 @@ public: { TIBase::resetStates(resetCallback); } + ~ResetStatesGuard() { TIBase::resetStates(resetCallback); @@ -130,6 +131,7 @@ public: { state[id].store(TrackedState::Alive, std::memory_order_relaxed); } + ~TIBase() override { using enum TrackedState; @@ -218,9 +220,7 @@ TEST(IntrusiveSharedTest, basics) EXPECT_EQ(TIBase::getState(id), Alive); EXPECT_EQ(b->useCount(), 1); for (auto i = 0uz; i < 10; ++i) - { strong.push_back(b); - } b.reset(); EXPECT_EQ(TIBase::getState(id), Alive); strong.resize(strong.size() - 1); @@ -244,8 +244,7 @@ TEST(IntrusiveSharedTest, basics) EXPECT_EQ(TIBase::getState(id), PartiallyDeleted); while (!weak.empty()) { - weak.resize(weak.size() - 1); - if (!weak.empty()) + if (weak.resize(weak.size() - 1); !weak.empty()) { EXPECT_EQ(TIBase::getState(id), PartiallyDeleted); } diff --git a/src/tests/libxrpl/basics/StringUtilities.cpp b/src/tests/libxrpl/basics/StringUtilities.cpp index a10711abdb..0180e25db0 100644 --- a/src/tests/libxrpl/basics/StringUtilities.cpp +++ b/src/tests/libxrpl/basics/StringUtilities.cpp @@ -290,4 +290,44 @@ TEST_F(StringUtilitiesTest, to_string) EXPECT_EQ(result, "hello"); } +TEST_F(StringUtilitiesTest, trimWhitespace) +{ + EXPECT_EQ(trimWhitespace(""), ""); + EXPECT_EQ(trimWhitespace(" "), ""); + EXPECT_EQ(trimWhitespace("abc"), "abc"); + EXPECT_EQ(trimWhitespace(" abc"), "abc"); + EXPECT_EQ(trimWhitespace("abc "), "abc"); + EXPECT_EQ(trimWhitespace(" \t\n\v\f\r abc \t\n\v\f\r "), "abc"); + + // Interior whitespace is preserved. + EXPECT_EQ(trimWhitespace(" a b\tc "), "a b\tc"); +} + +TEST_F(StringUtilitiesTest, toLower) +{ + EXPECT_EQ(toLower(""), ""); + EXPECT_EQ(toLower("ABC"), "abc"); + EXPECT_EQ(toLower("AbC123"), "abc123"); + EXPECT_EQ(toLower("already lower"), "already lower"); + + // Only 'A'-'Z' are remapped. Neighbouring punctuation and digits, which a + // buggy range check could catch, must survive untouched. + EXPECT_EQ(toLower("@[`{_^"), "@[`{_^"); +} + +// Both helpers are documented as depending only on their input. Guard that by +// checking the bytes just outside ASCII, which a locale-aware isspace/tolower +// could classify differently. +TEST_F(StringUtilitiesTest, trimAndLowerIgnoreLocale) +{ + // 0xA0 is NO-BREAK SPACE in Latin-1 and is whitespace to some locales. + std::string const nbsp("\xA0", 1); + EXPECT_EQ(trimWhitespace(nbsp), nbsp); + EXPECT_EQ(trimWhitespace(" " + nbsp + " "), nbsp); + + // 0xC0 is LATIN CAPITAL LETTER A WITH GRAVE in Latin-1. + std::string const agrave("\xC0", 1); + EXPECT_EQ(toLower(agrave), agrave); +} + } // namespace xrpl diff --git a/src/tests/libxrpl/basics/base_uint.cpp b/src/tests/libxrpl/basics/base_uint.cpp index 10795f4563..969705b5b7 100644 --- a/src/tests/libxrpl/basics/base_uint.cpp +++ b/src/tests/libxrpl/basics/base_uint.cpp @@ -6,6 +6,7 @@ #include +#include #include #include @@ -205,125 +206,119 @@ TEST_F(BaseUintTest, base_uint) Blob const raw{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}; EXPECT_EQ(BaseUInt96::kBytes, raw.size()); - BaseUInt96 u = BaseUInt96::fromRaw(raw); - uset.insert(u); - EXPECT_EQ(raw.size(), u.size()); - EXPECT_EQ(to_string(u), "0102030405060708090A0B0C"); - EXPECT_EQ(toShortString(u), "01020304..."); - EXPECT_EQ(*u.data(), 1); - EXPECT_EQ(u.signum(), 1); - EXPECT_FALSE(!u); - EXPECT_FALSE(u.isZero()); - EXPECT_TRUE(u.isNonZero()); - unsigned char t = 0; - for (auto& d : u) - { - EXPECT_EQ(d, ++t); - } + BaseUInt96 ascending = BaseUInt96::fromRaw(raw); + uset.insert(ascending); + EXPECT_EQ(raw.size(), ascending.size()); + EXPECT_EQ(to_string(ascending), "0102030405060708090A0B0C"); + EXPECT_EQ(toShortString(ascending), "01020304..."); + EXPECT_EQ(*ascending.data(), 1); + EXPECT_EQ(ascending.signum(), 1); + EXPECT_FALSE(!ascending); + EXPECT_FALSE(ascending.isZero()); + EXPECT_TRUE(ascending.isNonZero()); + unsigned char expectedByte = 0; + for (auto& byte : ascending) + EXPECT_EQ(byte, ++expectedByte); - // Test hash_append by "hashing" with a no-op hasher (h) + // Test hash_append by "hashing" with a no-op hasher (hasher) // and then extracting the bytes that were written during hashing - // back into another base_uint (w) for comparison with the original - Nonhash<96> h{}; - hash_append(h, u); - BaseUInt96 const w = - BaseUInt96::fromRaw(std::vector(h.data.begin(), h.data.end())); - EXPECT_EQ(w, u); + // back into another base_uint (rehashed) for comparison with the original + Nonhash<96> hasher{}; + hash_append(hasher, ascending); + BaseUInt96 const rehashed = + BaseUInt96::fromRaw(std::vector(hasher.data.begin(), hasher.data.end())); + EXPECT_EQ(rehashed, ascending); - BaseUInt96 v{~u}; - uset.insert(v); - EXPECT_EQ(to_string(v), "FEFDFCFBFAF9F8F7F6F5F4F3"); - EXPECT_EQ(toShortString(v), "FEFDFCFB..."); - EXPECT_EQ(*v.data(), 0xfe); - EXPECT_EQ(v.signum(), 1); - EXPECT_FALSE(!v); - EXPECT_FALSE(v.isZero()); - EXPECT_TRUE(v.isNonZero()); + BaseUInt96 complement{~ascending}; + uset.insert(complement); + EXPECT_EQ(to_string(complement), "FEFDFCFBFAF9F8F7F6F5F4F3"); + EXPECT_EQ(toShortString(complement), "FEFDFCFB..."); + EXPECT_EQ(*complement.data(), 0xfe); + EXPECT_EQ(complement.signum(), 1); + EXPECT_FALSE(!complement); + EXPECT_FALSE(complement.isZero()); + EXPECT_TRUE(complement.isNonZero()); - t = 0xff; - for (auto& d : v) - { - EXPECT_EQ(d, --t); - } + expectedByte = 0xff; + for (auto& byte : complement) + EXPECT_EQ(byte, --expectedByte); - EXPECT_LT(u, v); - EXPECT_GT(v, u); + EXPECT_LT(ascending, complement); + EXPECT_GT(complement, ascending); - v = u; - EXPECT_EQ(v, u); + complement = ascending; + EXPECT_EQ(complement, ascending); - BaseUInt96 z{beast::kZero}; - uset.insert(z); - EXPECT_EQ(to_string(z), "000000000000000000000000"); - EXPECT_EQ(toShortString(z), "00000000..."); - EXPECT_EQ(*z.data(), 0); - EXPECT_EQ(*z.begin(), 0); - EXPECT_EQ(*std::prev(z.end(), 1), 0); - EXPECT_EQ(z.signum(), 0); - EXPECT_TRUE(!z); - EXPECT_TRUE(z.isZero()); - EXPECT_FALSE(z.isNonZero()); - for (auto& d : z) - { - EXPECT_EQ(d, 0); - } + BaseUInt96 zero{beast::kZero}; + uset.insert(zero); + EXPECT_EQ(to_string(zero), "000000000000000000000000"); + EXPECT_EQ(toShortString(zero), "00000000..."); + EXPECT_EQ(*zero.data(), 0); + EXPECT_EQ(*zero.begin(), 0); + EXPECT_EQ(*std::prev(zero.end(), 1), 0); + EXPECT_EQ(zero.signum(), 0); + EXPECT_TRUE(!zero); + EXPECT_TRUE(zero.isZero()); + EXPECT_FALSE(zero.isNonZero()); + for (auto& byte : zero) + EXPECT_EQ(byte, 0); { // There are several ways to create a zero. beast::kZero is tested above. Test some // others. - BaseUInt96 const z1; - EXPECT_EQ(z1, z) << to_string(z1); + BaseUInt96 const defaultZero; + EXPECT_EQ(defaultZero, zero) << to_string(defaultZero); - BaseUInt96 const z2{}; - EXPECT_EQ(z2, z) << to_string(z2); + BaseUInt96 const bracedZero{}; + EXPECT_EQ(bracedZero, zero) << to_string(bracedZero); - BaseUInt96 const z3{0u}; - EXPECT_EQ(z3, z) << to_string(z3); + BaseUInt96 const zeroFromUInt{0u}; + EXPECT_EQ(zeroFromUInt, zero) << to_string(zeroFromUInt); } - BaseUInt96 n{z}; - n++; - EXPECT_EQ(n, BaseUInt96(1)); - n--; - EXPECT_EQ(n, beast::kZero); - EXPECT_EQ(n, z); - n--; - EXPECT_EQ(to_string(n), "FFFFFFFFFFFFFFFFFFFFFFFF"); - EXPECT_EQ(toShortString(n), "FFFFFFFF..."); - n = beast::kZero; - EXPECT_EQ(n, z); + BaseUInt96 counter{zero}; + counter++; + EXPECT_EQ(counter, BaseUInt96(1)); + counter--; + EXPECT_EQ(counter, beast::kZero); + EXPECT_EQ(counter, zero); + counter--; + EXPECT_EQ(to_string(counter), "FFFFFFFFFFFFFFFFFFFFFFFF"); + EXPECT_EQ(toShortString(counter), "FFFFFFFF..."); + counter = beast::kZero; + EXPECT_EQ(counter, zero); - BaseUInt96 zp1{z}; - zp1++; - BaseUInt96 zm1{z}; - zm1--; - BaseUInt96 const x{zm1 ^ zp1}; - uset.insert(x); - EXPECT_EQ(to_string(x), "FFFFFFFFFFFFFFFFFFFFFFFE") << to_string(x); - EXPECT_EQ(toShortString(x), "FFFFFFFF...") << toShortString(x); + BaseUInt96 zeroPlusOne{zero}; + zeroPlusOne++; + BaseUInt96 zeroMinusOne{zero}; + zeroMinusOne--; + BaseUInt96 const xored{zeroMinusOne ^ zeroPlusOne}; + uset.insert(xored); + EXPECT_EQ(to_string(xored), "FFFFFFFFFFFFFFFFFFFFFFFE") << to_string(xored); + EXPECT_EQ(toShortString(xored), "FFFFFFFF...") << toShortString(xored); EXPECT_EQ(uset.size(), 4); - BaseUInt96 tmp; - EXPECT_TRUE(tmp.parseHex(to_string(u))); - EXPECT_EQ(tmp, u); - tmp = z; + BaseUInt96 parsed; + EXPECT_TRUE(parsed.parseHex(to_string(ascending))); + EXPECT_EQ(parsed, ascending); + parsed = zero; // fails with extra char - EXPECT_FALSE(tmp.parseHex("A" + to_string(u))); - tmp = z; + EXPECT_FALSE(parsed.parseHex("A" + to_string(ascending))); + parsed = zero; // fails with extra char at end - EXPECT_FALSE(tmp.parseHex(to_string(u) + "A")); + EXPECT_FALSE(parsed.parseHex(to_string(ascending) + "A")); // fails with a non-hex character at some point in the string: - tmp = z; + parsed = zero; for (std::size_t i = 0; i != 24; ++i) { - std::string x = to_string(z); - x[i] = ('G' + (i % 10)); - EXPECT_FALSE(tmp.parseHex(x)); + std::string xored = to_string(zero); + xored[i] = ('G' + (i % 10)); + EXPECT_FALSE(parsed.parseHex(xored)); } // Walking 1s: @@ -332,8 +327,8 @@ TEST_F(BaseUintTest, base_uint) std::string s1 = "000000000000000000000000"; s1[i] = '1'; - EXPECT_TRUE(tmp.parseHex(s1)); - EXPECT_EQ(to_string(tmp), s1); + EXPECT_TRUE(parsed.parseHex(s1)); + EXPECT_EQ(to_string(parsed), s1); } // Walking 0s: @@ -342,8 +337,8 @@ TEST_F(BaseUintTest, base_uint) std::string s1 = "111111111111111111111111"; s1[i] = '0'; - EXPECT_TRUE(tmp.parseHex(s1)); - EXPECT_EQ(to_string(tmp), s1); + EXPECT_TRUE(parsed.parseHex(s1)); + EXPECT_EQ(to_string(parsed), s1); } // Constexpr constructors @@ -357,39 +352,27 @@ TEST_F(BaseUintTest, base_uint) // Using the constexpr constructor in a non-constexpr context // with an error in the parsing throws an exception. { - // Invalid length for string. - bool caught = false; - try - { - // Try to prevent constant evaluation. - std::vector str(23, '7'); + // Invalid length for string. The vector keeps this out of a constant + // expression, so the constructor throws instead of failing to compile. + auto tooShort = [] { + std::vector const str(23, '7'); std::string_view const sView(str.data(), str.size()); [[maybe_unused]] BaseUInt96 const t96(sView); - } - catch (std::invalid_argument const& e) - { - EXPECT_EQ(e.what(), std::string("invalid length for hex string")); - caught = true; - } - EXPECT_TRUE(caught); + }; + EXPECT_THAT( + tooShort, + ::testing::ThrowsMessage("invalid length for hex string")); } { // Invalid character in string. - bool caught = false; - try - { - // Try to prevent constant evaluation. + auto badCharacter = [] { std::vector str(23, '7'); str.push_back('G'); std::string_view const sView(str.data(), str.size()); [[maybe_unused]] BaseUInt96 const t96(sView); - } - catch (std::range_error const& e) - { - EXPECT_EQ(e.what(), std::string("invalid hex character")); - caught = true; - } - EXPECT_TRUE(caught); + }; + EXPECT_THAT( + badCharacter, ::testing::ThrowsMessage("invalid hex character")); } // Verify that constexpr base_uints interpret a string the same @@ -412,11 +395,11 @@ TEST_F(BaseUintTest, base_uint) "fFfFfFfFfFfFfFfFfFfFfFfF", }); - for (StrBaseUInt const& t : kTestCases) + for (StrBaseUInt const& expectedByte : kTestCases) { BaseUInt96 t96; - EXPECT_TRUE(t96.parseHex(t.str)); - EXPECT_EQ(t96, t.tst); + EXPECT_TRUE(t96.parseHex(expectedByte.str)); + EXPECT_EQ(t96, expectedByte.tst); } } } diff --git a/src/tests/libxrpl/nodestore/Backend.cpp b/src/tests/libxrpl/nodestore/Backend.cpp index eb78851429..3bd36ced8d 100644 --- a/src/tests/libxrpl/nodestore/Backend.cpp +++ b/src/tests/libxrpl/nodestore/Backend.cpp @@ -1,8 +1,8 @@ #include #include +#include #include -#include #include #include #include @@ -84,7 +84,7 @@ protected: } DummyScheduler scheduler_; - beast::TempDir const tempDir_; + TempDir const tempDir_; beast::Journal const journal_{TestSink::instance()}; Section params_; Batch batch_; diff --git a/src/tests/libxrpl/nodestore/Database.cpp b/src/tests/libxrpl/nodestore/Database.cpp index 82012ed347..a3f7340f62 100644 --- a/src/tests/libxrpl/nodestore/Database.cpp +++ b/src/tests/libxrpl/nodestore/Database.cpp @@ -1,8 +1,8 @@ #include #include +#include #include -#include #include #include #include @@ -81,7 +81,7 @@ protected: } DummyScheduler scheduler_; - beast::TempDir const nodeDb_; + TempDir const nodeDb_; beast::Journal const journal_{TestSink::instance()}; Section nodeParams_; Batch batch_; @@ -157,7 +157,7 @@ INSTANTIATE_TEST_SUITE_P( TEST(NodeStoreDatabase, memory_earliest_seq) { DummyScheduler scheduler; - beast::TempDir const nodeDb; + TempDir const nodeDb; Section nodeParams; nodeParams.set("type", "memory"); nodeParams.set("path", nodeDb.path()); @@ -204,7 +204,7 @@ TEST_P(DatabaseImportTest, same_backend) DummyScheduler scheduler; beast::Journal const journal(TestSink::instance()); - beast::TempDir const srcDir; + TempDir const srcDir; Section srcParams; srcParams.set("type", type); srcParams.set("path", srcDir.path()); @@ -222,7 +222,7 @@ TEST_P(DatabaseImportTest, same_backend) // re-open source and import into a fresh destination auto src = Manager::instance().makeDatabase(megabytes(4), scheduler, 2, srcParams, journal); - beast::TempDir const destDir; + TempDir const destDir; Section destParams; destParams.set("type", type); destParams.set("path", destDir.path()); diff --git a/src/tests/libxrpl/nodestore/NuDBFactory.cpp b/src/tests/libxrpl/nodestore/NuDBFactory.cpp index c126984630..7240f08256 100644 --- a/src/tests/libxrpl/nodestore/NuDBFactory.cpp +++ b/src/tests/libxrpl/nodestore/NuDBFactory.cpp @@ -1,6 +1,6 @@ #include +#include #include -#include #include #include #include @@ -58,7 +58,7 @@ runRoundTrip(Section const& params, std::size_t expectedBlocksize) TEST(NuDBFactory, default_block_size) { - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path()); ASSERT_NO_FATAL_FAILURE(runRoundTrip(params, 4096)); } @@ -69,14 +69,14 @@ TEST(NuDBFactory, valid_block_sizes) for (auto const size : kValidSizes) { SCOPED_TRACE("size=" + std::to_string(size)); - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), std::to_string(size)); ASSERT_NO_FATAL_FAILURE(runRoundTrip(params, size)); } // empty value is ignored by config parser; default (4096) is used { - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), ""); ASSERT_NO_FATAL_FAILURE(runRoundTrip(params, 4096)); } @@ -101,7 +101,7 @@ TEST(NuDBFactory, invalid_block_sizes) for (auto const& size : kInvalidSizes) { SCOPED_TRACE("size='" + size + "'"); - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), size); EXPECT_THROW(runRoundTrip(params, 4096), std::exception); } @@ -111,7 +111,7 @@ TEST(NuDBFactory, invalid_block_sizes) for (auto const& size : kWhitespaceSizes) { SCOPED_TRACE("size='" + size + "'"); - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), size); EXPECT_THROW(runRoundTrip(params, 4096), std::exception); } @@ -121,7 +121,7 @@ TEST(NuDBFactory, log_messages) { // valid custom block size emits info log { - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), "8192"); test::CaptureSink sink(beast::Severity::Info); beast::Journal const journal(sink); @@ -135,7 +135,7 @@ TEST(NuDBFactory, log_messages) // invalid block size throws with informative message { - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), "5000"); test::CaptureSink sink(beast::Severity::Warning); beast::Journal const journal(sink); @@ -156,7 +156,7 @@ TEST(NuDBFactory, log_messages) // non-numeric value throws { - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), "invalid"); test::CaptureSink sink(beast::Severity::Warning); beast::Journal const journal(sink); @@ -191,7 +191,7 @@ TEST(NuDBFactory, power_of_two_validation) for (auto const& [size, shouldWork] : kCASES) { SCOPED_TRACE("size=" + size + " shouldWork=" + (shouldWork ? "true" : "false")); - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), size); test::CaptureSink sink(beast::Severity::Warning); beast::Journal const journal(sink); @@ -216,7 +216,7 @@ TEST(NuDBFactory, power_of_two_validation) TEST(NuDBFactory, both_constructor_variants) { - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), "16384"); DummyScheduler scheduler; beast::Journal const journal(TestSink::instance()); @@ -235,7 +235,7 @@ TEST(NuDBFactory, configuration_parsing) { // basic valid format emits success log { - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), "8192"); test::CaptureSink sink(beast::Severity::Info); beast::Journal const journal(sink); @@ -250,7 +250,7 @@ TEST(NuDBFactory, configuration_parsing) for (auto const& format : kWhitespaceFormats) { SCOPED_TRACE("format='" + format + "'"); - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), format); test::CaptureSink sink(beast::Severity::Debug); beast::Journal const journal(sink); @@ -265,7 +265,7 @@ TEST(NuDBFactory, data_persistence) for (auto const& size : kBlockSizes) { SCOPED_TRACE("size=" + size); - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), size); DummyScheduler scheduler; beast::Journal const journal(TestSink::instance()); diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp index f55d01f606..26dde55563 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp @@ -36,6 +36,9 @@ TEST(VaultTests, BuilderSettersRoundTrip) auto const withdrawalPolicyValue = canonical_UINT8(); auto const scaleValue = canonical_UINT8(); auto const lEVersionValue = canonical_UINT8(); + auto const vaultKindValue = canonical_UINT8(); + auto const subscriptionDateValue = canonical_UINT32(); + auto const redemptionDateValue = canonical_UINT32(); VaultBuilder builder{ previousTxnIDValue, @@ -56,6 +59,9 @@ TEST(VaultTests, BuilderSettersRoundTrip) builder.setLossUnrealized(lossUnrealizedValue); builder.setScale(scaleValue); builder.setLEVersion(lEVersionValue); + builder.setVaultKind(vaultKindValue); + builder.setSubscriptionDate(subscriptionDateValue); + builder.setRedemptionDate(redemptionDateValue); builder.setLedgerIndex(index); builder.setFlags(0x1u); @@ -176,6 +182,30 @@ TEST(VaultTests, BuilderSettersRoundTrip) EXPECT_TRUE(entry.hasLEVersion()); } + { + auto const& expected = vaultKindValue; + auto const actualOpt = entry.getVaultKind(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfVaultKind"); + EXPECT_TRUE(entry.hasVaultKind()); + } + + { + auto const& expected = subscriptionDateValue; + auto const actualOpt = entry.getSubscriptionDate(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfSubscriptionDate"); + EXPECT_TRUE(entry.hasSubscriptionDate()); + } + + { + auto const& expected = redemptionDateValue; + auto const actualOpt = entry.getRedemptionDate(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfRedemptionDate"); + EXPECT_TRUE(entry.hasRedemptionDate()); + } + EXPECT_TRUE(entry.hasLedgerIndex()); auto const ledgerIndex = entry.getLedgerIndex(); ASSERT_TRUE(ledgerIndex.has_value()); @@ -205,6 +235,9 @@ TEST(VaultTests, BuilderFromSleRoundTrip) auto const withdrawalPolicyValue = canonical_UINT8(); auto const scaleValue = canonical_UINT8(); auto const lEVersionValue = canonical_UINT8(); + auto const vaultKindValue = canonical_UINT8(); + auto const subscriptionDateValue = canonical_UINT32(); + auto const redemptionDateValue = canonical_UINT32(); auto sle = std::make_shared(Vault::entryType, index); @@ -224,6 +257,9 @@ TEST(VaultTests, BuilderFromSleRoundTrip) sle->at(sfWithdrawalPolicy) = withdrawalPolicyValue; sle->at(sfScale) = scaleValue; sle->at(sfLEVersion) = lEVersionValue; + sle->at(sfVaultKind) = vaultKindValue; + sle->at(sfSubscriptionDate) = subscriptionDateValue; + sle->at(sfRedemptionDate) = redemptionDateValue; VaultBuilder builderFromSle{sle}; EXPECT_TRUE(builderFromSle.validate()); @@ -415,6 +451,45 @@ TEST(VaultTests, BuilderFromSleRoundTrip) expectEqualField(expected, *fromBuilderOpt, "sfLEVersion"); } + { + auto const& expected = vaultKindValue; + + auto const fromSleOpt = entryFromSle.getVaultKind(); + auto const fromBuilderOpt = entryFromBuilder.getVaultKind(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfVaultKind"); + expectEqualField(expected, *fromBuilderOpt, "sfVaultKind"); + } + + { + auto const& expected = subscriptionDateValue; + + auto const fromSleOpt = entryFromSle.getSubscriptionDate(); + auto const fromBuilderOpt = entryFromBuilder.getSubscriptionDate(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfSubscriptionDate"); + expectEqualField(expected, *fromBuilderOpt, "sfSubscriptionDate"); + } + + { + auto const& expected = redemptionDateValue; + + auto const fromSleOpt = entryFromSle.getRedemptionDate(); + auto const fromBuilderOpt = entryFromBuilder.getRedemptionDate(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfRedemptionDate"); + expectEqualField(expected, *fromBuilderOpt, "sfRedemptionDate"); + } + EXPECT_EQ(entryFromSle.getKey(), index); EXPECT_EQ(entryFromBuilder.getKey(), index); } @@ -499,5 +574,11 @@ TEST(VaultTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getScale().has_value()); EXPECT_FALSE(entry.hasLEVersion()); EXPECT_FALSE(entry.getLEVersion().has_value()); + EXPECT_FALSE(entry.hasVaultKind()); + EXPECT_FALSE(entry.getVaultKind().has_value()); + EXPECT_FALSE(entry.hasSubscriptionDate()); + EXPECT_FALSE(entry.getSubscriptionDate().has_value()); + EXPECT_FALSE(entry.hasRedemptionDate()); + EXPECT_FALSE(entry.getRedemptionDate().has_value()); } } diff --git a/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp index 9c1e14f6f4..592d40a6f6 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp @@ -36,6 +36,9 @@ TEST(TransactionsVaultCreateTests, BuilderSettersRoundTrip) auto const withdrawalPolicyValue = canonical_UINT8(); auto const dataValue = canonical_VL(); auto const scaleValue = canonical_UINT8(); + auto const vaultKindValue = canonical_UINT8(); + auto const subscriptionDateValue = canonical_UINT32(); + auto const redemptionDateValue = canonical_UINT32(); VaultCreateBuilder builder{ accountValue, @@ -51,6 +54,9 @@ TEST(TransactionsVaultCreateTests, BuilderSettersRoundTrip) builder.setWithdrawalPolicy(withdrawalPolicyValue); builder.setData(dataValue); builder.setScale(scaleValue); + builder.setVaultKind(vaultKindValue); + builder.setSubscriptionDate(subscriptionDateValue); + builder.setRedemptionDate(redemptionDateValue); auto tx = builder.build(publicKey, secretKey); @@ -122,6 +128,30 @@ TEST(TransactionsVaultCreateTests, BuilderSettersRoundTrip) EXPECT_TRUE(tx.hasScale()); } + { + auto const& expected = vaultKindValue; + auto const actualOpt = tx.getVaultKind(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfVaultKind should be present"; + expectEqualField(expected, *actualOpt, "sfVaultKind"); + EXPECT_TRUE(tx.hasVaultKind()); + } + + { + auto const& expected = subscriptionDateValue; + auto const actualOpt = tx.getSubscriptionDate(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfSubscriptionDate should be present"; + expectEqualField(expected, *actualOpt, "sfSubscriptionDate"); + EXPECT_TRUE(tx.hasSubscriptionDate()); + } + + { + auto const& expected = redemptionDateValue; + auto const actualOpt = tx.getRedemptionDate(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRedemptionDate should be present"; + expectEqualField(expected, *actualOpt, "sfRedemptionDate"); + EXPECT_TRUE(tx.hasRedemptionDate()); + } + } // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, @@ -145,6 +175,9 @@ TEST(TransactionsVaultCreateTests, BuilderFromStTxRoundTrip) auto const withdrawalPolicyValue = canonical_UINT8(); auto const dataValue = canonical_VL(); auto const scaleValue = canonical_UINT8(); + auto const vaultKindValue = canonical_UINT8(); + auto const subscriptionDateValue = canonical_UINT32(); + auto const redemptionDateValue = canonical_UINT32(); // Build an initial transaction VaultCreateBuilder initialBuilder{ @@ -160,6 +193,9 @@ TEST(TransactionsVaultCreateTests, BuilderFromStTxRoundTrip) initialBuilder.setWithdrawalPolicy(withdrawalPolicyValue); initialBuilder.setData(dataValue); initialBuilder.setScale(scaleValue); + initialBuilder.setVaultKind(vaultKindValue); + initialBuilder.setSubscriptionDate(subscriptionDateValue); + initialBuilder.setRedemptionDate(redemptionDateValue); auto initialTx = initialBuilder.build(publicKey, secretKey); @@ -226,6 +262,27 @@ TEST(TransactionsVaultCreateTests, BuilderFromStTxRoundTrip) expectEqualField(expected, *actualOpt, "sfScale"); } + { + auto const& expected = vaultKindValue; + auto const actualOpt = rebuiltTx.getVaultKind(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfVaultKind should be present"; + expectEqualField(expected, *actualOpt, "sfVaultKind"); + } + + { + auto const& expected = subscriptionDateValue; + auto const actualOpt = rebuiltTx.getSubscriptionDate(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfSubscriptionDate should be present"; + expectEqualField(expected, *actualOpt, "sfSubscriptionDate"); + } + + { + auto const& expected = redemptionDateValue; + auto const actualOpt = rebuiltTx.getRedemptionDate(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRedemptionDate should be present"; + expectEqualField(expected, *actualOpt, "sfRedemptionDate"); + } + } // 3) Verify wrapper throws when constructed from wrong transaction type. @@ -295,6 +352,12 @@ TEST(TransactionsVaultCreateTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getData().has_value()); EXPECT_FALSE(tx.hasScale()); EXPECT_FALSE(tx.getScale().has_value()); + EXPECT_FALSE(tx.hasVaultKind()); + EXPECT_FALSE(tx.getVaultKind().has_value()); + EXPECT_FALSE(tx.hasSubscriptionDate()); + EXPECT_FALSE(tx.getSubscriptionDate().has_value()); + EXPECT_FALSE(tx.hasRedemptionDate()); + EXPECT_FALSE(tx.getRedemptionDate().has_value()); } } diff --git a/src/tests/libxrpl/shamap/SHAMap.cpp b/src/tests/libxrpl/shamap/SHAMap.cpp index e662e16be4..c84cdf504f 100644 --- a/src/tests/libxrpl/shamap/SHAMap.cpp +++ b/src/tests/libxrpl/shamap/SHAMap.cpp @@ -16,7 +16,6 @@ #include #include -#include #include #include #include @@ -113,7 +112,7 @@ protected: intToVuc(std::uint8_t v) { Buffer vuc{32}; - std::fill_n(vuc.data(), vuc.size(), v); + vuc.fill(v); return vuc; } }; diff --git a/src/xrpld/app/main/GRPCServer.cpp b/src/xrpld/app/main/GRPCServer.cpp index 1b20ff1d49..c1ea5e874b 100644 --- a/src/xrpld/app/main/GRPCServer.cpp +++ b/src/xrpld/app/main/GRPCServer.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -24,7 +25,6 @@ #include #include -#include #include #include #include @@ -49,6 +49,7 @@ #include #include #include +#include #include #include @@ -371,7 +372,7 @@ GRPCServerImpl::GRPCServerImpl(Application& app) std::string ip; while (std::getline(ss, ip, ',')) { - boost::algorithm::trim(ip); + ip = trimWhitespace(ip); auto const addr = boost::asio::ip::make_address(ip); if (addr.is_unspecified()) @@ -615,7 +616,7 @@ GRPCServerImpl::createServerCredentials() try { - boost::system::error_code ec; + std::error_code ec; grpc::SslServerCredentialsOptions sslOpts; grpc::SslServerCredentialsOptions::PemKeyCertPair keyCertPair; diff --git a/src/xrpld/app/main/Main.cpp b/src/xrpld/app/main/Main.cpp index a23b84f2e8..ba6520db5f 100644 --- a/src/xrpld/app/main/Main.cpp +++ b/src/xrpld/app/main/Main.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -21,7 +22,6 @@ #include #include -#include #include #include // IWYU pragma: keep #include @@ -211,7 +211,7 @@ public: boost::split(v, patterns, boost::algorithm::is_any_of(",")); selectors_.reserve(v.size()); std::ranges::for_each(v, [this](std::string s) { - boost::trim(s); + s = trimWhitespace(s); if (selectors_.empty() || !s.empty()) selectors_.emplace_back(beast::unit_test::Selector::ModeT::Automatch, s); }); @@ -614,7 +614,7 @@ run(int argc, char** argv) std::vector result; for (auto& s : strVec) { - boost::trim(s); + s = trimWhitespace(s); if (!s.empty()) result.push_back(std::stoi(s)); } diff --git a/src/xrpld/app/misc/FeeVoteImpl.cpp b/src/xrpld/app/misc/FeeVoteImpl.cpp index 76a4d8f186..f1cb944a52 100644 --- a/src/xrpld/app/misc/FeeVoteImpl.cpp +++ b/src/xrpld/app/misc/FeeVoteImpl.cpp @@ -260,39 +260,35 @@ FeeVoteImpl::doVoting( } // choose our positions - // TODO: Use structured binding once LLVM 16 is the minimum supported - // version. See also: https://github.com/llvm/llvm-project/issues/48582 - // https://github.com/llvm/llvm-project/commit/127bf44385424891eb04cff8e52d3f157fc2cb7c - auto const baseFee = baseFeeVote.getVotes(); - auto const baseReserve = baseReserveVote.getVotes(); - auto const incReserve = incReserveVote.getVotes(); + auto const [baseFee, baseFeeChanged] = baseFeeVote.getVotes(); + auto const [baseReserve, baseReserveChanged] = baseReserveVote.getVotes(); + auto const [incReserve, incReserveChanged] = incReserveVote.getVotes(); auto const seq = lastClosedLedger->header().seq + 1; // add transactions to our position - if (baseFee.second || baseReserve.second || incReserve.second) + if (baseFeeChanged || baseReserveChanged || incReserveChanged) { - JLOG(journal_.warn()) << "We are voting for a fee change: " << baseFee.first << "/" - << baseReserve.first << "/" << incReserve.first; + JLOG(journal_.warn()) << "We are voting for a fee change: " << baseFee << "/" << baseReserve + << "/" << incReserve; STTx const feeTx(ttFEE, [=, &rules](auto& obj) { obj[sfAccount] = AccountID(); obj[sfLedgerSequence] = seq; if (rules.enabled(featureXRPFees)) { - obj[sfBaseFeeDrops] = baseFee.first; - obj[sfReserveBaseDrops] = baseReserve.first; - obj[sfReserveIncrementDrops] = incReserve.first; + obj[sfBaseFeeDrops] = baseFee; + obj[sfReserveBaseDrops] = baseReserve; + obj[sfReserveIncrementDrops] = incReserve; } else { // Without the featureXRPFees amendment, these fields are // required. - obj[sfBaseFee] = baseFee.first.dropsAs(baseFeeVote.current()); - obj[sfReserveBase] = - baseReserve.first.dropsAs(baseReserveVote.current()); + obj[sfBaseFee] = baseFee.dropsAs(baseFeeVote.current()); + obj[sfReserveBase] = baseReserve.dropsAs(baseReserveVote.current()); obj[sfReserveIncrement] = - incReserve.first.dropsAs(incReserveVote.current()); + incReserve.dropsAs(incReserveVote.current()); obj[sfReferenceFeeUnits] = kFeeUnitsDeprecated; } }); diff --git a/src/xrpld/app/misc/SHAMapStoreImp.cpp b/src/xrpld/app/misc/SHAMapStoreImp.cpp index e41837d206..9e3f1ac52b 100644 --- a/src/xrpld/app/misc/SHAMapStoreImp.cpp +++ b/src/xrpld/app/misc/SHAMapStoreImp.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -27,12 +28,10 @@ #include #include -#include -#include -#include #include #include +#include #include #include #include @@ -426,10 +425,10 @@ SHAMapStoreImp::dbPaths() if (boost::iequals(get(section, Keys::kType), "memory")) return; - boost::filesystem::path dbPath = get(section, Keys::kPath); - if (boost::filesystem::exists(dbPath)) + std::filesystem::path dbPath = get(section, Keys::kPath); + if (std::filesystem::exists(dbPath)) { - if (!boost::filesystem::is_directory(dbPath)) + if (!std::filesystem::is_directory(dbPath)) { journal_.error() << "node db path must be a directory. " << dbPath.string(); Throw("node db path must be a directory."); @@ -437,7 +436,7 @@ SHAMapStoreImp::dbPaths() } else { - boost::filesystem::create_directories(dbPath); + std::filesystem::create_directories(dbPath); } SavedState state = stateDb_.getState(); @@ -448,8 +447,8 @@ SHAMapStoreImp::dbPaths() return false; // Check if configured "path" matches stored directory path - using namespace boost::filesystem; - auto const stored{path(sPath)}; + using namespace std::filesystem; + auto const stored{std::filesystem::path(sPath)}; if (stored.parent_path() == dbPath) return false; @@ -467,9 +466,9 @@ SHAMapStoreImp::dbPaths() bool writableDbExists = false; bool archiveDbExists = false; - std::vector pathsToDelete; - for (boost::filesystem::directory_iterator it(dbPath); - it != boost::filesystem::directory_iterator(); + std::vector pathsToDelete; + for (std::filesystem::directory_iterator it(dbPath); + it != std::filesystem::directory_iterator(); ++it) { if (state.writableDb == it->path().string()) @@ -490,7 +489,7 @@ SHAMapStoreImp::dbPaths() (!archiveDbExists && !state.archiveDb.empty()) || (writableDbExists != archiveDbExists) || state.writableDb.empty() != state.archiveDb.empty()) { - boost::filesystem::path stateDbPathName = app_.config().legacy(Sections::kDatabasePath); + std::filesystem::path stateDbPathName = app_.config().legacy(Sections::kDatabasePath); stateDbPathName /= dbName_; stateDbPathName += "*"; @@ -512,15 +511,15 @@ SHAMapStoreImp::dbPaths() } // The necessary directories exist. Now, remove any others. - for (boost::filesystem::path const& p : pathsToDelete) - boost::filesystem::remove_all(p); + for (std::filesystem::path const& p : pathsToDelete) + std::filesystem::remove_all(p); } std::unique_ptr SHAMapStoreImp::makeBackendRotating(std::string path) { Section section{app_.config().section(Sections::kNodeDatabase)}; - boost::filesystem::path newPath; + std::filesystem::path newPath; if (!path.empty()) { @@ -528,10 +527,7 @@ SHAMapStoreImp::makeBackendRotating(std::string path) } else { - boost::filesystem::path p = get(section, Keys::kPath); - p /= dbPrefix_; - p += ".%%%%"; - newPath = boost::filesystem::unique_path(p); + newPath = uniqueRandomPath(get(section, Keys::kPath), dbPrefix_ + "."); } section.set(Keys::kPath, newPath.string()); diff --git a/src/xrpld/app/misc/ValidatorList.h b/src/xrpld/app/misc/ValidatorList.h index 3f9039eab8..abec6cf4e0 100644 --- a/src/xrpld/app/misc/ValidatorList.h +++ b/src/xrpld/app/misc/ValidatorList.h @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -238,7 +239,7 @@ class ValidatorList ManifestCache& validatorManifests_; ManifestCache& publisherManifests_; TimeKeeper& timeKeeper_; - boost::filesystem::path const dataPath_; + std::filesystem::path const dataPath_; beast::Journal const j_; std::shared_mutex mutable mutex_; using scoped_lock = std::scoped_lock; @@ -866,7 +867,7 @@ private: /** * Get the filename used for caching UNLs */ - boost::filesystem::path + std::filesystem::path getCacheFileName(scoped_lock const&, PublicKey const& pubKey) const; /** diff --git a/src/xrpld/app/misc/detail/ValidatorList.cpp b/src/xrpld/app/misc/detail/ValidatorList.cpp index e355cfacab..0ada8ed55f 100644 --- a/src/xrpld/app/misc/detail/ValidatorList.cpp +++ b/src/xrpld/app/misc/detail/ValidatorList.cpp @@ -29,12 +29,8 @@ #include #include -#include #include #include -#include -#include -#include #include @@ -43,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -54,6 +51,7 @@ #include #include #include +#include #include #include @@ -288,7 +286,7 @@ ValidatorList::load( return true; } -boost::filesystem::path +std::filesystem::path ValidatorList::getCacheFileName(ValidatorList::scoped_lock const&, PublicKey const& pubKey) const { return dataPath_ / (kFilePrefix + strHex(pubKey)); @@ -372,9 +370,9 @@ ValidatorList::cacheValidatorFile(ValidatorList::scoped_lock const& lock, Public if (dataPath_.empty()) return; - boost::filesystem::path const filename = getCacheFileName(lock, pubKey); + std::filesystem::path const filename = getCacheFileName(lock, pubKey); - boost::system::error_code ec; + std::error_code ec; json::Value value = buildFileData(strHex(pubKey), publisherLists_.at(pubKey), j_); // xrpld should be the only process writing to this file, so @@ -1295,8 +1293,7 @@ std::vector ValidatorList::loadLists() { using namespace std::string_literals; - using namespace boost::filesystem; - using namespace boost::system::errc; + using namespace std::filesystem; std::scoped_lock const lock{mutex_}; @@ -1304,12 +1301,12 @@ ValidatorList::loadLists() sites.reserve(publisherLists_.size()); for (auto const& [pubKey, publisherCollection] : publisherLists_) { - boost::system::error_code ec; + std::error_code ec; if (publisherCollection.status == PublisherStatus::Available) continue; - boost::filesystem::path const filename = getCacheFileName(lock, pubKey); + std::filesystem::path const filename = getCacheFileName(lock, pubKey); auto const fullPath{canonical(filename, ec)}; if (ec) @@ -1320,7 +1317,7 @@ ValidatorList::loadLists() { // Treat an empty file as a missing file, because // nobody else is going to write it. - ec = make_error_code(no_such_file_or_directory); + ec = make_error_code(std::errc::no_such_file_or_directory); } if (ec) continue; diff --git a/src/xrpld/app/rdb/backend/detail/Node.cpp b/src/xrpld/app/rdb/backend/detail/Node.cpp index b2f14c71ea..ff57087ec5 100644 --- a/src/xrpld/app/rdb/backend/detail/Node.cpp +++ b/src/xrpld/app/rdb/backend/detail/Node.cpp @@ -40,7 +40,6 @@ #include #include -#include #include #include // IWYU pragma: keep #include @@ -58,6 +57,7 @@ #include #include #include +#include #include #include #include @@ -66,6 +66,7 @@ #include #include #include +#include #include #include #include @@ -1393,8 +1394,8 @@ getTransaction( bool dbHasSpace(soci::session& session, Config const& config, beast::Journal j) { - boost::filesystem::space_info const space = - boost::filesystem::space(config.legacy(Sections::kDatabasePath)); + std::filesystem::space_info const space = + std::filesystem::space(config.legacy(Sections::kDatabasePath)); if (space.available < megabytes(512)) { @@ -1405,9 +1406,9 @@ dbHasSpace(soci::session& session, Config const& config, beast::Journal j) if (config.useTxTables()) { DatabaseCon::Setup const dbSetup = setupDatabaseCon(config); - boost::filesystem::path const dbPath = dbSetup.dataDir / kTxDbName; - boost::system::error_code ec; - std::optional dbSize = boost::filesystem::file_size(dbPath, ec); + std::filesystem::path const dbPath = dbSetup.dataDir / kTxDbName; + std::error_code ec; + std::optional dbSize = std::filesystem::file_size(dbPath, ec); if (ec) { JLOG(j.error()) << "Error checking transaction db file size: " << ec.message(); diff --git a/src/xrpld/core/Config.h b/src/xrpld/core/Config.h index ac28b6e224..2dea8f3597 100644 --- a/src/xrpld/core/Config.h +++ b/src/xrpld/core/Config.h @@ -11,11 +11,10 @@ #include #include -#include // VFALCO FIX: This include should not be here - #include #include #include +#include #include #include #include @@ -97,17 +96,17 @@ public: /** * Returns the full path and filename of the debug log file. */ - [[nodiscard]] boost::filesystem::path + [[nodiscard]] std::filesystem::path getDebugLogFile() const; private: - boost::filesystem::path configFile_; + std::filesystem::path configFile_; public: - boost::filesystem::path configDir; + std::filesystem::path configDir; private: - boost::filesystem::path debugLogfile_; + std::filesystem::path debugLogfile_; void load(); diff --git a/src/xrpld/core/detail/Config.cpp b/src/xrpld/core/detail/Config.cpp index e93ccec56e..efe4ab1cc9 100644 --- a/src/xrpld/core/detail/Config.cpp +++ b/src/xrpld/core/detail/Config.cpp @@ -21,21 +21,19 @@ #include #include #include -#include -#include #include #include #include #include // IWYU pragma: keep #include #include -#include #include #include #include #include #include +#include #include #include #include @@ -45,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -185,7 +184,7 @@ parseIniFile(std::string const& strInput, bool const bTrim) for (auto& strValue : vLines) { if (bTrim) - boost::algorithm::trim(strValue); + strValue = trimWhitespace(strValue); if (strValue.empty() || strValue[0] == '#') { @@ -313,13 +312,13 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand // directory, use the current working directory as the // config directory and that with "db" as the data // directory. - boost::filesystem::path dataDir; + std::filesystem::path dataDir; if (!strConf.empty()) { // --conf= : everything is relative that file. configFile_ = strConf; - configDir = boost::filesystem::absolute(configFile_); + configDir = std::filesystem::absolute(configFile_); configDir.remove_filename(); dataDir = configDir / kDatabaseDirName; } @@ -330,13 +329,13 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand // Check if either of the config files exist in the current working // directory, in which case the databases will be stored in a // subdirectory. - configDir = boost::filesystem::current_path(); + configDir = std::filesystem::current_path(); dataDir = configDir / kDatabaseDirName; configFile_ = configDir / kConfigFileName; - if (boost::filesystem::exists(configFile_)) + if (std::filesystem::exists(configFile_)) break; configFile_ = configDir / kConfigLegacyName; - if (boost::filesystem::exists(configFile_)) + if (std::filesystem::exists(configFile_)) break; // Check if the home directory is set, and optionally the XDG config @@ -363,10 +362,10 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand dataDir = strXdgDataHome + "/" + systemName(); configDir = strXdgConfigHome + "/" + systemName(); configFile_ = configDir / kConfigFileName; - if (boost::filesystem::exists(configFile_)) + if (std::filesystem::exists(configFile_)) break; configFile_ = configDir / kConfigLegacyName; - if (boost::filesystem::exists(configFile_)) + if (std::filesystem::exists(configFile_)) break; } @@ -374,7 +373,7 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand dataDir = "/var/lib/" + systemName(); configDir = "/etc/" + systemName(); configFile_ = configDir / kConfigFileName; - if (boost::filesystem::exists(configFile_)) + if (std::filesystem::exists(configFile_)) break; configFile_ = configDir / kConfigLegacyName; } while (false); @@ -387,7 +386,7 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand std::string const dbPath(legacy(Sections::kDatabasePath)); if (!dbPath.empty()) { - dataDir = boost::filesystem::path(dbPath); + dataDir = std::filesystem::path(dbPath); } else if (runStandalone_) { @@ -397,13 +396,13 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand if (!dataDir.empty()) { - boost::system::error_code ec; - boost::filesystem::create_directories(dataDir, ec); + std::error_code ec; + std::filesystem::create_directories(dataDir, ec); if (ec) Throw(boost::str(boost::format("Can not create %s") % dataDir)); - legacy(Sections::kDatabasePath, boost::filesystem::absolute(dataDir).string()); + legacy(Sections::kDatabasePath, std::filesystem::absolute(dataDir).string()); } HTTPClient::initializeSSLContext(this->sslVerifyDir, this->sslVerifyFile, this->sslVerify, j_); @@ -455,7 +454,7 @@ Config::load() if (!quiet_) std::cerr << "Loading: " << configFile_ << "\n"; - boost::system::error_code ec; + std::error_code ec; auto const fileContents = getFileContents(ec, configFile_); if (ec) @@ -508,8 +507,8 @@ Config::loadFromString(std::string const& fileContents) std::string dbPath; if (getSingleSection(secConfig, Sections::kDatabasePath, dbPath, j_)) { - boost::filesystem::path const p(dbPath); - legacy(Sections::kDatabasePath, boost::filesystem::absolute(p).string()); + std::filesystem::path const p(dbPath); + legacy(Sections::kDatabasePath, std::filesystem::absolute(p).string()); } } @@ -1011,7 +1010,7 @@ Config::loadFromString(std::string const& fileContents) // If no path was specified, then look for validators.txt // in the same directory as the config file, but don't complain // if we can't find it. - boost::filesystem::path validatorsFile; + std::filesystem::path validatorsFile; if (getSingleSection(secConfig, Sections::kValidatorsFile, strTemp, j_)) { @@ -1026,7 +1025,7 @@ Config::loadFromString(std::string const& fileContents) if (!validatorsFile.is_absolute() && !configDir.empty()) validatorsFile = configDir / validatorsFile; - if (!boost::filesystem::exists(validatorsFile)) + if (!std::filesystem::exists(validatorsFile)) { Throw( std::string("The file specified in [") + Sections::kValidatorsFile + @@ -1035,8 +1034,8 @@ Config::loadFromString(std::string const& fileContents) validatorsFile.string()); } else if ( - !boost::filesystem::is_regular_file(validatorsFile) && - !boost::filesystem::is_symlink(validatorsFile)) + !std::filesystem::is_regular_file(validatorsFile) && + !std::filesystem::is_symlink(validatorsFile)) { Throw( std::string("Invalid file specified in [") + Sections::kValidatorsFile + @@ -1049,20 +1048,20 @@ Config::loadFromString(std::string const& fileContents) if (!validatorsFile.empty()) { - if (!boost::filesystem::exists(validatorsFile) || - (!boost::filesystem::is_regular_file(validatorsFile) && - !boost::filesystem::is_symlink(validatorsFile))) + if (!std::filesystem::exists(validatorsFile) || + (!std::filesystem::is_regular_file(validatorsFile) && + !std::filesystem::is_symlink(validatorsFile))) { validatorsFile.clear(); } } } - if (!validatorsFile.empty() && boost::filesystem::exists(validatorsFile) && - (boost::filesystem::is_regular_file(validatorsFile) || - boost::filesystem::is_symlink(validatorsFile))) + if (!validatorsFile.empty() && std::filesystem::exists(validatorsFile) && + (std::filesystem::is_regular_file(validatorsFile) || + std::filesystem::is_symlink(validatorsFile))) { - boost::system::error_code ec; + std::error_code ec; auto const data = getFileContents(ec, validatorsFile); if (ec) { @@ -1195,7 +1194,7 @@ Config::loadFromString(std::string const& fileContents) } } -boost::filesystem::path +std::filesystem::path Config::getDebugLogFile() const { auto logFile = debugLogfile_; @@ -1204,17 +1203,17 @@ Config::getDebugLogFile() const { // Unless an absolute path for the log file is specified, the // path is relative to the config file directory. - logFile = boost::filesystem::absolute(logFile, configDir); + logFile = std::filesystem::absolute(configDir / logFile); } if (!logFile.empty()) { auto logDir = logFile.parent_path(); - if (!boost::filesystem::is_directory(logDir)) + if (!std::filesystem::is_directory(logDir)) { - boost::system::error_code ec; - boost::filesystem::create_directories(logDir, ec); + std::error_code ec; + std::filesystem::create_directories(logDir, ec); // If we fail, we warn but continue so that the calling code can // decide how to handle this situation. diff --git a/src/xrpld/overlay/detail/ProtocolVersion.cpp b/src/xrpld/overlay/detail/ProtocolVersion.cpp index 2d5d0a56f7..93d4fae156 100644 --- a/src/xrpld/overlay/detail/ProtocolVersion.cpp +++ b/src/xrpld/overlay/detail/ProtocolVersion.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -32,31 +33,17 @@ constexpr ProtocolVersion const kSupportedProtocolList[]{ {2, 3}, }; -// This ugly construct ensures that supportedProtocolList is sorted in strictly -// ascending order and doesn't contain any duplicates. -// FIXME: With C++20 we can use std::is_sorted with an appropriate comparator +// There should be at least one protocol we're willing to speak. static_assert( - []() constexpr -> bool { - auto const len = - std::distance(std::begin(kSupportedProtocolList), std::end(kSupportedProtocolList)); + !std::ranges::empty(kSupportedProtocolList), + "There must be at least one supported protocol."); - // There should be at least one protocol we're willing to speak. - if (len == 0) - return false; - - // A list with only one entry is, by definition, sorted so we don't - // need to check it. - if (len != 1) - { - for (auto i = 0; i != len - 1; ++i) - { - if (kSupportedProtocolList[i] >= kSupportedProtocolList[i + 1]) - return false; - } - } - - return true; - }(), +// Searching for an adjacent pair where the first element is not less than the +// second one proves the list is sorted in strictly ascending order, which in +// turn means it holds no duplicates. +static_assert( + std::ranges::adjacent_find(kSupportedProtocolList, std::ranges::greater_equal{}) == + std::ranges::end(kSupportedProtocolList), "The list of supported protocols isn't properly sorted."); std::string diff --git a/src/xrpld/perflog/detail/PerfLogImp.cpp b/src/xrpld/perflog/detail/PerfLogImp.cpp index 3aa7e38ea2..2777e0dcdb 100644 --- a/src/xrpld/perflog/detail/PerfLogImp.cpp +++ b/src/xrpld/perflog/detail/PerfLogImp.cpp @@ -17,11 +17,9 @@ #include #include -#include -#include - #include #include +#include #include #include #include @@ -29,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -220,10 +219,10 @@ PerfLogImp::openLog() logFile_.close(); auto logDir = setup_.perfLog.parent_path(); - if (!boost::filesystem::is_directory(logDir)) + if (!std::filesystem::is_directory(logDir)) { - boost::system::error_code ec; - boost::filesystem::create_directories(logDir, ec); + std::error_code ec; + std::filesystem::create_directories(logDir, ec); if (ec) { JLOG(j_.fatal()) << "Unable to create performance log " @@ -478,17 +477,17 @@ PerfLogImp::stop() //----------------------------------------------------------------------------- PerfLog::Setup -setupPerfLog(Section const& section, boost::filesystem::path const& configDir) +setupPerfLog(Section const& section, std::filesystem::path const& configDir) { PerfLog::Setup setup; std::string perfLog; set(perfLog, "perf_log", section); if (!perfLog.empty()) { - setup.perfLog = boost::filesystem::path(perfLog); + setup.perfLog = std::filesystem::path(perfLog); if (setup.perfLog.is_relative()) { - setup.perfLog = boost::filesystem::absolute(setup.perfLog, configDir); + setup.perfLog = std::filesystem::absolute(configDir / setup.perfLog); } } diff --git a/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp b/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp index 52e68e87f1..19fe294924 100644 --- a/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp +++ b/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp @@ -331,6 +331,13 @@ getLedger<>(std::shared_ptr&, LedgerShortcut shortcut, Context c template Status getLedger<>(std::shared_ptr&, uint256 const&, Context const&); +// explicit instantiation of ledgerFromSpecifier +template Status +ledgerFromSpecifier<>( + std::shared_ptr&, + org::xrpl::rpc::v1::LedgerSpecifier const&, + Context const&); + // The previous version of the lookupLedger command would accept the // "ledger_index" argument as a string and silently treat it as a request to // return the current ledger which, while not strictly wrong, could cause a lot diff --git a/src/xrpld/rpc/detail/ServerHandler.cpp b/src/xrpld/rpc/detail/ServerHandler.cpp index 0181d5b10f..827d8705fd 100644 --- a/src/xrpld/rpc/detail/ServerHandler.cpp +++ b/src/xrpld/rpc/detail/ServerHandler.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -44,7 +45,6 @@ #include #include -#include #include #include #include @@ -113,7 +113,7 @@ authorized(Port const& port, std::map const& h) if ((it == h.end()) || (!it->second.starts_with("Basic "))) return false; std::string strUserPass64 = it->second.substr(6); - boost::trim(strUserPass64); + strUserPass64 = trimWhitespace(strUserPass64); std::string const strUserPass = base64Decode(strUserPass64); std::string::size_type const nColon = strUserPass.find(':'); if (nColon == std::string::npos) diff --git a/src/xrpld/rpc/handlers/account/AccountChannels.cpp b/src/xrpld/rpc/handlers/account/AccountChannels.cpp index d50bf1cf07..f2da1e31ee 100644 --- a/src/xrpld/rpc/handlers/account/AccountChannels.cpp +++ b/src/xrpld/rpc/handlers/account/AccountChannels.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -22,9 +23,6 @@ #include #include -#include -#include - #include #include #include @@ -129,7 +127,7 @@ doAccountChannels(rpc::JsonContext& context) return rpc::expectedFieldError(jss::marker, "string"); // Marker is composed of a comma separated index and start hint. The - // former will be read as hex, and the latter using boost lexical cast. + // former will be read as hex, and the latter as a decimal integer. std::stringstream marker(params[jss::marker].asString()); std::string value; if (!std::getline(marker, value, ',')) @@ -141,14 +139,10 @@ doAccountChannels(rpc::JsonContext& context) if (!std::getline(marker, value, ',')) return rpcError(RpcInvalidParams); - try - { - startHint = boost::lexical_cast(value); - } - catch (boost::bad_lexical_cast&) - { + auto const hint = toUInt64(value); + if (!hint.has_value()) return rpcError(RpcInvalidParams); - } + startHint = *hint; // We then must check if the object pointed to by the marker is actually // owned by the account in the request. diff --git a/src/xrpld/rpc/handlers/account/AccountInfo.cpp b/src/xrpld/rpc/handlers/account/AccountInfo.cpp index d4232cf451..eed4e4cfe3 100644 --- a/src/xrpld/rpc/handlers/account/AccountInfo.cpp +++ b/src/xrpld/rpc/handlers/account/AccountInfo.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -22,7 +23,6 @@ #include #include -#include #include #include @@ -51,22 +51,16 @@ void injectSLE(json::Value& jv, SLE const& sle) { jv = sle.getJson(JsonOptions::Values::None); - if (sle.getType() == ltACCOUNT_ROOT) + XRPL_ASSERT(sle.getType() == ltACCOUNT_ROOT, "xrpl::injectSLE : sle is account root"); + if (sle.isFieldPresent(sfEmailHash)) { - if (sle.isFieldPresent(sfEmailHash)) - { - auto const& hash = sle.getFieldH128(sfEmailHash); - Blob const b(hash.begin(), hash.end()); - std::string md5 = strHex(makeSlice(b)); - boost::to_lower(md5); - // VFALCO TODO Give a name to this constant and move it - // to a more visible location. - jv[jss::urlgravatar] = str(boost::format("https://www.gravatar.com/avatar/%s") % md5); - } - } - else - { - jv[jss::Invalid] = true; + auto const& hash = sle.getFieldH128(sfEmailHash); + Blob const b(hash.begin(), hash.end()); + std::string md5 = strHex(makeSlice(b)); + md5 = toLower(md5); + // VFALCO TODO Give a name to this constant and move it + // to a more visible location. + jv[jss::urlgravatar] = str(boost::format("https://www.gravatar.com/avatar/%s") % md5); } } diff --git a/src/xrpld/rpc/handlers/account/AccountLines.cpp b/src/xrpld/rpc/handlers/account/AccountLines.cpp index f134c8af92..ac98e271b6 100644 --- a/src/xrpld/rpc/handlers/account/AccountLines.cpp +++ b/src/xrpld/rpc/handlers/account/AccountLines.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -22,9 +23,6 @@ #include #include -#include -#include - #include #include #include @@ -109,7 +107,12 @@ doAccountLines(rpc::JsonContext& context) std::string strPeer; if (params.isMember(jss::peer)) + { + if (!params[jss::peer].isString()) + return rpc::invalidFieldError(jss::peer); + strPeer = params[jss::peer].asString(); + } auto const raPeerAccount = [&]() -> std::optional { return strPeer.empty() ? std::nullopt : parseBase58(strPeer); @@ -153,7 +156,7 @@ doAccountLines(rpc::JsonContext& context) return rpc::expectedFieldError(jss::marker, "string"); // Marker is composed of a comma separated index and start hint. The - // former will be read as hex, and the latter using boost lexical cast. + // former will be read as hex, and the latter as a decimal integer. std::stringstream marker(params[jss::marker].asString()); std::string value; if (!std::getline(marker, value, ',')) @@ -165,14 +168,10 @@ doAccountLines(rpc::JsonContext& context) if (!std::getline(marker, value, ',')) return rpcError(RpcInvalidParams); - try - { - startHint = boost::lexical_cast(value); - } - catch (boost::bad_lexical_cast&) - { + auto const hint = toUInt64(value); + if (!hint.has_value()) return rpcError(RpcInvalidParams); - } + startHint = *hint; // We then must check if the object pointed to by the marker is actually // owned by the account in the request. diff --git a/src/xrpld/rpc/handlers/account/AccountOffers.cpp b/src/xrpld/rpc/handlers/account/AccountOffers.cpp index 1467b14b48..a7933f65a7 100644 --- a/src/xrpld/rpc/handlers/account/AccountOffers.cpp +++ b/src/xrpld/rpc/handlers/account/AccountOffers.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -20,9 +21,6 @@ #include #include -#include -#include - #include #include #include @@ -97,7 +95,7 @@ doAccountOffers(rpc::JsonContext& context) return rpc::expectedFieldError(jss::marker, "string"); // Marker is composed of a comma separated index and start hint. The - // former will be read as hex, and the latter using boost lexical cast. + // former will be read as hex, and the latter as a decimal integer. std::stringstream marker(params[jss::marker].asString()); std::string value; if (!std::getline(marker, value, ',')) @@ -109,14 +107,10 @@ doAccountOffers(rpc::JsonContext& context) if (!std::getline(marker, value, ',')) return rpc::invalidFieldError(jss::marker); - try - { - startHint = boost::lexical_cast(value); - } - catch (boost::bad_lexical_cast&) - { + auto const hint = toUInt64(value); + if (!hint.has_value()) return rpc::invalidFieldError(jss::marker); - } + startHint = *hint; // We then must check if the object pointed to by the marker is actually // owned by the account in the request. diff --git a/src/xrpld/rpc/handlers/account/GatewayBalances.cpp b/src/xrpld/rpc/handlers/account/GatewayBalances.cpp index ff19d1d1e5..041e878a3f 100644 --- a/src/xrpld/rpc/handlers/account/GatewayBalances.cpp +++ b/src/xrpld/rpc/handlers/account/GatewayBalances.cpp @@ -63,6 +63,12 @@ doGatewayBalances(rpc::JsonContext& context) if (!(params.isMember(jss::account) || params.isMember(jss::ident))) return rpc::missingFieldError(jss::account); + if (params.isMember(jss::account) && !params[jss::account].isString()) + return rpc::invalidFieldError(jss::account); + + if (params.isMember(jss::ident) && !params[jss::ident].isString()) + return rpc::invalidFieldError(jss::ident); + std::string const strIdent( params.isMember(jss::account) ? params[jss::account].asString() : params[jss::ident].asString()); diff --git a/src/xrpld/rpc/handlers/admin/data/CanDelete.cpp b/src/xrpld/rpc/handlers/admin/data/CanDelete.cpp index 91db16bb4f..5c96bfb215 100644 --- a/src/xrpld/rpc/handlers/admin/data/CanDelete.cpp +++ b/src/xrpld/rpc/handlers/admin/data/CanDelete.cpp @@ -3,14 +3,13 @@ #include #include +#include #include #include #include #include #include -#include - #include #include #include @@ -38,7 +37,7 @@ doCanDelete(rpc::JsonContext& context) else { std::string canDeleteStr = canDelete.asString(); - boost::to_lower(canDeleteStr); + canDeleteStr = toLower(canDeleteStr); if (canDeleteStr.find_first_not_of("0123456789") == std::string::npos) { diff --git a/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h b/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h index e03830ae0d..21bf3f8be8 100644 --- a/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h +++ b/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h @@ -93,6 +93,17 @@ enumerateNFTOffers(rpc::JsonContext& context, uint256 const& nftId, Keylet const if (!sle || nftId != sle->getFieldH256(sfNFTokenID)) return rpcError(RpcInvalidParams); + // Reject a marker that references an offer on the opposite side + // (buy vs. sell) of the directory being enumerated. Without this + // check the marker's node hint points into the other directory, so + // forEachItemAfter never finds `startAfter` and instead scans every + // page of `directory` before returning invalidParams -- turning an + // O(1) rejection into an O(directory size) walk. + auto const offerDir = + sle->isFlag(lsfSellNFToken) ? keylet::nftSells(nftId) : keylet::nftBuys(nftId); + if (directory.key != offerDir.key) + return rpcError(RpcInvalidParams); + startHint = sle->getFieldU64(sfNFTokenOfferNode); appendNftOfferJson(context.app, sle, jsonOffers); offers.reserve(reserve); diff --git a/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp b/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp index b561ce6d38..c297c2482d 100644 --- a/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp +++ b/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -14,7 +15,6 @@ #include #include -#include #include #include @@ -64,7 +64,6 @@ ServerDefinitions::translate(std::string const& inp) return out; }; - // TODO: use string::contains with C++23 auto contains = [&](std::string_view s) -> bool { return inp.contains(s); }; if (contains("UINT")) @@ -107,7 +106,7 @@ ServerDefinitions::translate(std::string const& inp) std::string token = inpToProcess.substr(0, pos); if (token.size() > 1) { - boost::algorithm::to_lower(token); + token = toLower(token); token[0] -= ('a' - 'A'); out += token; }