diff --git a/.config/cspell.config.yaml b/.config/cspell.config.yaml index 5b573467ec..1d3edcdd4c 100644 --- a/.config/cspell.config.yaml +++ b/.config/cspell.config.yaml @@ -51,6 +51,7 @@ words: - Btrfs - canonicality - checkme + - choco - chrono - citardauq - clawback @@ -68,6 +69,7 @@ words: - cryptoconditional - cryptoconditions - csprng + - ctest - ctid - currenttxhash - daria @@ -103,6 +105,7 @@ words: - iou - ious - isrdc + - itype - jemalloc - jlog - keylet @@ -191,10 +194,12 @@ words: - roundings - sahyadri - Satoshi + - scons - secp - sendq - seqit - sf + - SFIELD - shamap - shamapitem - sidechain diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 669754554c..e64a05f925 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -15,196 +15,196 @@ "distro_version": "bookworm", "compiler_name": "gcc", "compiler_version": "12", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "gcc", "compiler_version": "13", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "gcc", "compiler_version": "15", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "clang", "compiler_version": "16", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "clang", "compiler_version": "17", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "clang", "compiler_version": "18", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "clang", "compiler_version": "19", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "clang", "compiler_version": "20", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "trixie", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "trixie", "compiler_name": "gcc", "compiler_version": "15", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "trixie", "compiler_name": "clang", "compiler_version": "20", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "trixie", "compiler_name": "clang", "compiler_version": "21", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "rhel", "distro_version": "8", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "rhel", "distro_version": "8", "compiler_name": "clang", "compiler_version": "any", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "rhel", "distro_version": "9", "compiler_name": "gcc", "compiler_version": "12", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "rhel", "distro_version": "9", "compiler_name": "gcc", "compiler_version": "13", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "rhel", "distro_version": "9", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "rhel", "distro_version": "9", "compiler_name": "clang", "compiler_version": "any", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "rhel", "distro_version": "10", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "rhel", "distro_version": "10", "compiler_name": "clang", "compiler_version": "any", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "ubuntu", "distro_version": "jammy", "compiler_name": "gcc", "compiler_version": "12", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "gcc", "compiler_version": "13", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "clang", "compiler_version": "16", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "clang", "compiler_version": "17", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "clang", "compiler_version": "18", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "clang", "compiler_version": "19", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" } ], "build_type": ["Debug", "Release"], diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 41e82fb6bb..00754e5eae 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -9,7 +9,7 @@ on: jobs: # Call the workflow in the XRPLF/actions repo that runs the pre-commit hooks. run-hooks: - uses: XRPLF/actions/.github/workflows/pre-commit.yml@5ca417783f0312ab26d6f48b85c78edf1de99bbd + uses: XRPLF/actions/.github/workflows/pre-commit.yml@282890f46d6921249d5659dd38babcb0bd8aef48 with: runs_on: ubuntu-latest - container: '{ "image": "ghcr.io/xrplf/ci/tools-rippled-pre-commit:sha-a8c7be1" }' + container: '{ "image": "ghcr.io/xrplf/ci/tools-rippled-pre-commit:sha-ab4d1f0" }' diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index fc80bbd216..bc0717e1a5 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -100,7 +100,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@65da1c59e81965eeb257caa3587b9d45066fb925 + uses: XRPLF/actions/prepare-runner@f05cab7b8541eee6473aa42beb9d2fe35608a190 with: enable_ccache: ${{ inputs.ccache_enabled }} diff --git a/.github/workflows/upload-conan-deps.yml b/.github/workflows/upload-conan-deps.yml index 55a9ab8864..29ae95fce5 100644 --- a/.github/workflows/upload-conan-deps.yml +++ b/.github/workflows/upload-conan-deps.yml @@ -70,7 +70,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@65da1c59e81965eeb257caa3587b9d45066fb925 + uses: XRPLF/actions/prepare-runner@f05cab7b8541eee6473aa42beb9d2fe35608a190 with: enable_ccache: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 00bec32ed6..603cf39375 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,12 +32,12 @@ repos: - id: prettier - repo: https://github.com/psf/black-pre-commit-mirror - rev: 25.11.0 + rev: 831207fd435b47aeffdf6af853097e64322b4d44 # frozen: v25.12.0 hooks: - id: black - repo: https://github.com/streetsidesoftware/cspell-cli - rev: v9.2.0 + rev: 1cfa010f078c354f3ffb8413616280cc28f5ba21 # frozen: v9.4.0 hooks: - id: cspell # Spell check changed files exclude: .config/cspell.config.yaml diff --git a/BUILD.md b/BUILD.md index 85b3e3ea74..2d1ac9b134 100644 --- a/BUILD.md +++ b/BUILD.md @@ -148,7 +148,8 @@ function extract_version { } # Define which recipes to export. -recipes=(ed25519 grpc secp256k1 snappy soci) +recipes=('ed25519' 'grpc' 'openssl' 'secp256k1' 'snappy' 'soci') +folders=('all' 'all' '3.x.x' 'all' 'all' 'all') # Selectively check out the recipes from our CCI fork. cd external @@ -157,20 +158,24 @@ cd conan-center-index git init git remote add origin git@github.com:XRPLF/conan-center-index.git git sparse-checkout init -for recipe in ${recipes[@]}; do - echo "Checking out ${recipe}..." - git sparse-checkout add recipes/${recipe}/all +for ((index = 1; index <= ${#recipes[@]}; index++)); do + recipe=${recipes[index]} + folder=${folders[index]} + echo "Checking out recipe '${recipe}' from folder '${folder}'..." + git sparse-checkout add recipes/${recipe}/${folder} done git fetch origin master git checkout master cd ../.. # Export the recipes into the local cache. -for recipe in ${recipes[@]}; do +for ((index = 1; index <= ${#recipes[@]}; index++)); do + recipe=${recipes[index]} + folder=${folders[index]} version=$(extract_version ${recipe}) - echo "Exporting ${recipe}/${version}..." + echo "Exporting '${recipe}/${version}' from '${recipe}/${folder}'..." conan export --version $(extract_version ${recipe}) \ - external/conan-center-index/recipes/${recipe}/all + external/conan-center-index/recipes/${recipe}/${folder} done ``` diff --git a/CMakeLists.txt b/CMakeLists.txt index 70bc02c66d..26fc310d39 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,34 +88,18 @@ endif() ### include(deps/Boost) -find_package(OpenSSL 1.1.1 REQUIRED) -set_target_properties(OpenSSL::SSL PROPERTIES - INTERFACE_COMPILE_DEFINITIONS OPENSSL_NO_SSL2 -) add_subdirectory(external/antithesis-sdk) -find_package(gRPC REQUIRED) -find_package(lz4 REQUIRED) -# Target names with :: are not allowed in a generator expression. -# We need to pull the include directories and imported location properties -# from separate targets. -find_package(LibArchive REQUIRED) -find_package(SOCI REQUIRED) -find_package(SQLite3 REQUIRED) - -option(rocksdb "Enable RocksDB" ON) -if(rocksdb) - find_package(RocksDB REQUIRED) - set_target_properties(RocksDB::rocksdb PROPERTIES - INTERFACE_COMPILE_DEFINITIONS XRPL_ROCKSDB_AVAILABLE=1 - ) - target_link_libraries(xrpl_libs INTERFACE RocksDB::rocksdb) -endif() - find_package(date REQUIRED) find_package(ed25519 REQUIRED) +find_package(gRPC REQUIRED) +find_package(LibArchive REQUIRED) +find_package(lz4 REQUIRED) find_package(nudb REQUIRED) +find_package(OpenSSL REQUIRED) find_package(secp256k1 REQUIRED) +find_package(SOCI REQUIRED) +find_package(SQLite3 REQUIRED) find_package(xxHash REQUIRED) target_link_libraries(xrpl_libs INTERFACE @@ -128,6 +112,15 @@ target_link_libraries(xrpl_libs INTERFACE SQLite::SQLite3 ) +option(rocksdb "Enable RocksDB" ON) +if(rocksdb) + find_package(RocksDB REQUIRED) + set_target_properties(RocksDB::rocksdb PROPERTIES + INTERFACE_COMPILE_DEFINITIONS XRPL_ROCKSDB_AVAILABLE=1 + ) + target_link_libraries(xrpl_libs INTERFACE RocksDB::rocksdb) +endif() + # Work around changes to Conan recipe for now. if(TARGET nudb::core) set(nudb nudb::core) diff --git a/cmake/Ccache.cmake b/cmake/Ccache.cmake index 092212075c..aa8d3ac59d 100644 --- a/cmake/Ccache.cmake +++ b/cmake/Ccache.cmake @@ -46,6 +46,12 @@ set(CMAKE_VS_GLOBALS "TrackFileAccess=false" "UseMultiToolTask=true") -# By default Visual Studio generators will use /Zi, which is not compatible with -# ccache, so tell it to use /Z7 instead. -set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "$<$:Embedded>") +# By default Visual Studio generators will use /Zi to capture debug information, +# which is not compatible with ccache, so tell it to use /Z7 instead. +if (MSVC) + foreach (var_ + CMAKE_C_FLAGS_DEBUG CMAKE_C_FLAGS_RELEASE + CMAKE_CXX_FLAGS_DEBUG CMAKE_CXX_FLAGS_RELEASE) + string (REPLACE "/Zi" "/Z7" ${var_} "${${var_}}") + endforeach () +endif () diff --git a/cmake/XrplCompiler.cmake b/cmake/XrplCompiler.cmake index 622b2d2f74..0777bf948c 100644 --- a/cmake/XrplCompiler.cmake +++ b/cmake/XrplCompiler.cmake @@ -44,6 +44,7 @@ if (MSVC) # omit debug info completely under CI (not needed) if (is_ci) string (REPLACE "/Zi" " " ${var_} "${${var_}}") + string (REPLACE "/Z7" " " ${var_} "${${var_}}") endif () endforeach () diff --git a/cmake/XrplSettings.cmake b/cmake/XrplSettings.cmake index a16513afc5..c3f013c575 100644 --- a/cmake/XrplSettings.cmake +++ b/cmake/XrplSettings.cmake @@ -68,6 +68,21 @@ if(is_linux) option(perf "Enables flags that assist with perf recording" OFF) option(use_gold "enables detection of gold (binutils) linker" ON) option(use_mold "enables detection of mold (binutils) linker" ON) + # Set a default value for the log flag based on the build type. + # This provides a sensible default (on for debug, off for release) + # while still allowing the user to override it for any build. + if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(TRUNCATED_LOGS_DEFAULT ON) + else() + set(TRUNCATED_LOGS_DEFAULT OFF) + endif() + option(TRUNCATED_THREAD_NAME_LOGS + "Show warnings about truncated thread names on Linux." + ${TRUNCATED_LOGS_DEFAULT} + ) + if(TRUNCATED_THREAD_NAME_LOGS) + add_compile_definitions(TRUNCATED_THREAD_NAME_LOGS) + endif() else() # we are not ready to allow shared-libs on windows because it would require # export declarations. On macos it's more feasible, but static openssl diff --git a/cmake/deps/Boost.cmake b/cmake/deps/Boost.cmake index 475c1033b2..19263e0ac9 100644 --- a/cmake/deps/Boost.cmake +++ b/cmake/deps/Boost.cmake @@ -1,4 +1,4 @@ -find_package(Boost 1.82 REQUIRED +find_package(Boost REQUIRED COMPONENTS chrono container diff --git a/conan.lock b/conan.lock index 1385ad05bd..44dc9031d2 100644 --- a/conan.lock +++ b/conan.lock @@ -1,44 +1,44 @@ { "version": "0.5", "requires": [ - "zlib/1.3.1#b8bc2603263cf7eccbd6e17e66b0ed76%1756234269.497", - "xxhash/0.8.3#681d36a0a6111fc56e5e45ea182c19cc%1756234289.683", - "sqlite3/3.49.1#8631739a4c9b93bd3d6b753bac548a63%1756234266.869", - "soci/4.0.3#a9f8d773cd33e356b5879a4b0564f287%1756234262.318", - "snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1756234314.246", - "secp256k1/0.7.0#9c4ab67bdc3860c16ea5b36aed8f74ea%1765202256.763", - "rocksdb/10.5.1#4a197eca381a3e5ae8adf8cffa5aacd0%1762797952.535", - "re2/20230301#ca3b241baec15bd31ea9187150e0b333%1764175362.029", - "protobuf/6.32.1#f481fd276fc23a33b85a3ed1e898b693%1764863245.83", - "openssl/3.5.4#a1d5835cc6ed5c5b8f3cd5b9b5d24205%1760106486.594", - "nudb/2.0.9#fb8dfd1a5557f5e0528114c2da17721e%1763150366.909", - "lz4/1.10.0#59fc63cac7f10fbe8e05c7e62c2f3504%1756234228.999", - "libiconv/1.17#1e65319e945f2d31941a9d28cc13c058%1756223727.64", - "libbacktrace/cci.20210118#a7691bfccd8caaf66309df196790a5a1%1756230911.03", - "libarchive/3.8.1#ffee18995c706e02bf96e7a2f7042e0d%1764175360.142", + "zlib/1.3.1#b8bc2603263cf7eccbd6e17e66b0ed76%1765850150.075", + "xxhash/0.8.3#681d36a0a6111fc56e5e45ea182c19cc%1765850149.987", + "sqlite3/3.49.1#8631739a4c9b93bd3d6b753bac548a63%1765850149.926", + "soci/4.0.3#a9f8d773cd33e356b5879a4b0564f287%1765850149.46", + "snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1765850147.878", + "secp256k1/0.7.0#9c4ab67bdc3860c16ea5b36aed8f74ea%1765850147.928", + "rocksdb/10.5.1#4a197eca381a3e5ae8adf8cffa5aacd0%1765850186.86", + "re2/20230301#ca3b241baec15bd31ea9187150e0b333%1765850148.103", + "protobuf/6.32.1#f481fd276fc23a33b85a3ed1e898b693%1765850161.038", + "openssl/3.5.4#1b986e61b38fdfda3b40bebc1b234393%1768312656.257", + "nudb/2.0.9#fb8dfd1a5557f5e0528114c2da17721e%1765850143.957", + "lz4/1.10.0#59fc63cac7f10fbe8e05c7e62c2f3504%1765850143.914", + "libiconv/1.17#1e65319e945f2d31941a9d28cc13c058%1765842973.492", + "libbacktrace/cci.20210118#a7691bfccd8caaf66309df196790a5a1%1765842973.03", + "libarchive/3.8.1#ffee18995c706e02bf96e7a2f7042e0d%1765850144.736", "jemalloc/5.3.0#e951da9cf599e956cebc117880d2d9f8%1729241615.244", - "grpc/1.72.0#f244a57bff01e708c55a1100b12e1589%1763158050.628", - "ed25519/2015.03#ae761bdc52730a843f0809bdf6c1b1f6%1764270189.893", - "doctest/2.4.12#eb9fb352fb2fdfc8abb17ec270945165%1762797941.757", - "date/3.0.4#862e11e80030356b53c2c38599ceb32b%1763584497.32", - "c-ares/1.34.5#5581c2b62a608b40bb85d965ab3ec7c8%1764175359.429", - "bzip2/1.0.8#c470882369c2d95c5c77e970c0c7e321%1764175359.429", - "boost/1.88.0#8852c0b72ce8271fb8ff7c53456d4983%1756223752.326", - "abseil/20250127.0#9e8e8cfc89a1324139fc0ee3bd4d8c8c%1753819045.301" + "grpc/1.72.0#f244a57bff01e708c55a1100b12e1589%1765850193.734", + "ed25519/2015.03#ae761bdc52730a843f0809bdf6c1b1f6%1765850143.772", + "doctest/2.4.12#eb9fb352fb2fdfc8abb17ec270945165%1765850143.95", + "date/3.0.4#862e11e80030356b53c2c38599ceb32b%1765850143.772", + "c-ares/1.34.5#5581c2b62a608b40bb85d965ab3ec7c8%1765850144.336", + "bzip2/1.0.8#c470882369c2d95c5c77e970c0c7e321%1765850143.837", + "boost/1.88.0#8852c0b72ce8271fb8ff7c53456d4983%1765850172.862", + "abseil/20250127.0#99262a368bd01c0ccca8790dfced9719%1766517936.993" ], "build_requires": [ - "zlib/1.3.1#b8bc2603263cf7eccbd6e17e66b0ed76%1756234269.497", - "strawberryperl/5.32.1.1#707032463aa0620fa17ec0d887f5fe41%1756234281.733", - "protobuf/6.32.1#f481fd276fc23a33b85a3ed1e898b693%1764863245.83", - "nasm/2.16.01#31e26f2ee3c4346ecd347911bd126904%1756234232.901", + "zlib/1.3.1#b8bc2603263cf7eccbd6e17e66b0ed76%1765850150.075", + "strawberryperl/5.32.1.1#707032463aa0620fa17ec0d887f5fe41%1765850165.196", + "protobuf/6.32.1#f481fd276fc23a33b85a3ed1e898b693%1765850161.038", + "nasm/2.16.01#31e26f2ee3c4346ecd347911bd126904%1765850144.707", "msys2/cci.latest#1996656c3c98e5765b25b60ff5cf77b4%1764840888.758", "m4/1.4.19#70dc8bbb33e981d119d2acc0175cf381%1763158052.846", - "cmake/4.2.0#ae0a44f44a1ef9ab68fd4b3e9a1f8671%1764175359.44", - "cmake/3.31.10#313d16a1aa16bbdb2ca0792467214b76%1764175359.429", - "b2/5.3.3#107c15377719889654eb9a162a673975%1756234226.28", + "cmake/4.2.0#ae0a44f44a1ef9ab68fd4b3e9a1f8671%1765850153.937", + "cmake/3.31.10#313d16a1aa16bbdb2ca0792467214b76%1765850153.479", + "b2/5.3.3#107c15377719889654eb9a162a673975%1765850144.355", "automake/1.16.5#b91b7c384c3deaa9d535be02da14d04f%1755524470.56", "autoconf/2.71#51077f068e61700d65bb05541ea1e4b0%1731054366.86", - "abseil/20250127.0#9e8e8cfc89a1324139fc0ee3bd4d8c8c%1753819045.301" + "abseil/20250127.0#99262a368bd01c0ccca8790dfced9719%1766517936.993" ], "python_requires": [], "overrides": { diff --git a/conanfile.py b/conanfile.py index 48e28cb275..96e3384979 100644 --- a/conanfile.py +++ b/conanfile.py @@ -87,7 +87,13 @@ class Xrpl(ConanFile): "libarchive/*:with_xattr": False, "libarchive/*:with_zlib": False, "lz4/*:shared": False, + "openssl/*:no_dtls": True, + "openssl/*:no_ssl": True, + "openssl/*:no_ssl3": True, + "openssl/*:no_tls1": True, + "openssl/*:no_tls1_1": True, "openssl/*:shared": False, + "openssl/*:tls_security_level": 2, "protobuf/*:shared": False, "protobuf/*:with_zlib": True, "rocksdb/*:enable_sse": False, diff --git a/include/xrpl/basics/Number.h b/include/xrpl/basics/Number.h index 4420530239..d1ef749784 100644 --- a/include/xrpl/basics/Number.h +++ b/include/xrpl/basics/Number.h @@ -1,8 +1,11 @@ #ifndef XRPL_BASICS_NUMBER_H_INCLUDED #define XRPL_BASICS_NUMBER_H_INCLUDED +#include + #include #include +#include #include #include @@ -13,42 +16,252 @@ class Number; std::string to_string(Number const& amount); +template +constexpr std::optional +logTen(T value) +{ + int log = 0; + while (value >= 10 && value % 10 == 0) + { + value /= 10; + ++log; + } + if (value == 1) + return log; + return std::nullopt; +} + template constexpr bool isPowerOfTen(T value) { - while (value >= 10 && value % 10 == 0) - value /= 10; - return value == 1; + return logTen(value).has_value(); } +/** MantissaRange defines a range for the mantissa of a normalized Number. + * + * The mantissa is in the range [min, max], where + * * min is a power of 10, and + * * max = min * 10 - 1. + * + * The mantissa_scale enum indicates whether the range is "small" or "large". + * This intentionally restricts the number of MantissaRanges that can be + * instantiated to two: one for each scale. + * + * The "small" scale is based on the behavior of STAmount for IOUs. It has a min + * value of 10^15, and a max value of 10^16-1. This was sufficient for + * uses before Lending Protocol was implemented, mostly related to AMM. + * + * However, it does not have sufficient precision to represent the full integer + * range of int64_t values (-2^63 to 2^63-1), which are needed for XRP and MPT + * values. The implementation of SingleAssetVault, and LendingProtocol need to + * represent those integer values accurately and precisely, both for the + * STNumber field type, and for internal calculations. That necessitated the + * "large" scale. + * + * The "large" scale is intended to represent all values that can be represented + * by an STAmount - IOUs, XRP, and MPTs. It has a min value of 10^18, and a max + * value of 10^19-1. + * + * Note that if the mentioned amendments are eventually retired, this class + * should be left in place, but the "small" scale option should be removed. This + * will allow for future expansion beyond 64-bits if it is ever needed. + */ +struct MantissaRange +{ + using rep = std::uint64_t; + enum mantissa_scale { small, large }; + + explicit constexpr MantissaRange(mantissa_scale scale_) + : min(getMin(scale_)) + , max(min * 10 - 1) + , log(logTen(min).value_or(-1)) + , scale(scale_) + { + } + + rep min; + rep max; + int log; + mantissa_scale scale; + +private: + static constexpr rep + getMin(mantissa_scale scale_) + { + switch (scale_) + { + case small: + return 1'000'000'000'000'000ULL; + case large: + return 1'000'000'000'000'000'000ULL; + default: + // Since this can never be called outside a non-constexpr + // context, this throw assures that the build fails if an + // invalid scale is used. + throw std::runtime_error("Unknown mantissa scale"); + } + } +}; + +// Like std::integral, but only 64-bit integral types. +template +concept Integral64 = + std::is_same_v || std::is_same_v; + +/** Number is a floating point type that can represent a wide range of values. + * + * It can represent all values that can be represented by an STAmount - + * regardless of asset type - XRPAmount, MPTAmount, and IOUAmount, with at least + * as much precision as those types require. + * + * ---- Internal Representation ---- + * + * Internally, Number is represented with three values: + * 1. a bool sign flag, + * 2. a std::uint64_t mantissa, + * 3. an int exponent. + * + * The internal mantissa is an unsigned integer in the range defined by the + * current MantissaRange. The exponent is an integer in the range + * [minExponent, maxExponent]. + * + * See the description of MantissaRange for more details on the ranges. + * + * A non-zero mantissa is (almost) always normalized, meaning it and the + * exponent are grown or shrunk until the mantissa is in the range + * [MantissaRange.min, MantissaRange.max]. + * + * Note: + * 1. Normalization can be disabled by using the "unchecked" ctor tag. This + * should only be used at specific conversion points, some constexpr + * values, and in unit tests. + * 2. The max of the "large" range, 10^19-1, is the largest 10^X-1 value that + * fits in an unsigned 64-bit number. (10^19-1 < 2^64-1 and + * 10^20-1 > 2^64-1). This avoids under- and overflows. + * + * ---- External Interface ---- + * + * The external interface of Number consists of a std::int64_t mantissa, which + * is restricted to 63-bits, and an int exponent, which must be in the range + * [minExponent, maxExponent]. The range of the mantissa depends on which + * MantissaRange is currently active. For the "short" range, the mantissa will + * be between 10^15 and 10^16-1. For the "large" range, the mantissa will be + * between -(2^63-1) and 2^63-1. As noted above, the "large" range is needed to + * represent the full range of valid XRP and MPT integer values accurately. + * + * Note: + * 1. 2^63-1 is between 10^18 and 10^19-1, which are the limits of the "large" + * mantissa range. + * 2. The functions mantissa() and exponent() return the external view of the + * Number value, specifically using a signed 63-bit mantissa. This may + * require altering the internal representation to fit into that range + * before the value is returned. The interface guarantees consistency of + * the two values. + * 3. Number cannot represent -2^63 (std::numeric_limits::min()) + * as an exact integer, but it doesn't need to, because all asset values + * on-ledger are non-negative. This is due to implementation details of + * several operations which use unsigned arithmetic internally. This is + * sufficient to represent all valid XRP values (where the absolute value + * can not exceed INITIAL_XRP: 10^17), and MPT values (where the absolute + * value can not exceed maxMPTokenAmount: 2^63-1). + * + * ---- Mantissa Range Switching ---- + * + * The mantissa range may be changed at runtime via setMantissaScale(). The + * default mantissa range is "large". The range is updated whenever transaction + * processing begins, based on whether SingleAssetVault or LendingProtocol are + * enabled. If either is enabled, the mantissa range is set to "large". If not, + * it is set to "small", preserving backward compatibility and correct + * "amendment-gating". + * + * It is extremely unlikely that any more calls to setMantissaScale() will be + * needed outside of unit tests. + * + * ---- Usage With Different Ranges ---- + * + * Outside of unit tests, and existing checks, code that uses Number should not + * know or care which mantissa range is active. + * + * The results of computations using Numbers with a small mantissa may differ + * from computations using Numbers with a large mantissa, specifically as it + * effects the results after rounding. That is why the large mantissa range is + * amendment gated in transaction processing. + * + * It is extremely unlikely that any more calls to getMantissaScale() will be + * needed outside of unit tests. + * + * Code that uses Number should not assume or check anything about the + * mantissa() or exponent() except that they fit into the "large" range + * specified in the "External Interface" section. + * + * ----- Unit Tests ----- + * + * Within unit tests, it may be useful to explicitly switch between the two + * ranges, or to check which range is active when checking the results of + * computations. If the test is doing the math directly, the + * set/getMantissaScale() functions may be most appropriate. However, if the + * test has anything to do with transaction processing, it should enable or + * disable the amendments that control the mantissa range choice + * (SingleAssetVault and LendingProtocol), and/or check if either of those + * amendments are enabled to determine which result to expect. + * + */ class Number { using rep = std::int64_t; - rep mantissa_{0}; + using internalrep = MantissaRange::rep; + + bool negative_{false}; + internalrep mantissa_{0}; int exponent_{std::numeric_limits::lowest()}; public: - // The range for the mantissa when normalized - constexpr static std::int64_t minMantissa = 1'000'000'000'000'000LL; - static_assert(isPowerOfTen(minMantissa)); - constexpr static std::int64_t maxMantissa = minMantissa * 10 - 1; - static_assert(maxMantissa == 9'999'999'999'999'999LL); - // The range for the exponent when normalized constexpr static int minExponent = -32768; constexpr static int maxExponent = 32768; + constexpr static internalrep maxRep = std::numeric_limits::max(); + static_assert(maxRep == 9'223'372'036'854'775'807); + static_assert(-maxRep == std::numeric_limits::min() + 1); + + // May need to make unchecked private struct unchecked { explicit unchecked() = default; }; + // Like unchecked, normalized is used with the ctors that take an + // internalrep mantissa. Unlike unchecked, those ctors will normalize the + // value. + // Only unit tests are expected to use this class + struct normalized + { + explicit normalized() = default; + }; + explicit constexpr Number() = default; Number(rep mantissa); explicit Number(rep mantissa, int exponent); - explicit constexpr Number(rep mantissa, int exponent, unchecked) noexcept; + explicit constexpr Number( + bool negative, + internalrep mantissa, + int exponent, + unchecked) noexcept; + // Assume unsigned values are... unsigned. i.e. positive + explicit constexpr Number( + internalrep mantissa, + int exponent, + unchecked) noexcept; + // Only unit tests are expected to use this ctor + explicit Number( + bool negative, + internalrep mantissa, + int exponent, + normalized); + // Assume unsigned values are... unsigned. i.e. positive + explicit Number(internalrep mantissa, int exponent, normalized); constexpr rep mantissa() const noexcept; @@ -78,11 +291,11 @@ public: Number& operator/=(Number const& x); - static constexpr Number + static Number min() noexcept; - static constexpr Number + static Number max() noexcept; - static constexpr Number + static Number lowest() noexcept; /** Conversions to Number are implicit and conversions away from Number @@ -96,7 +309,8 @@ public: friend constexpr bool operator==(Number const& x, Number const& y) noexcept { - return x.mantissa_ == y.mantissa_ && x.exponent_ == y.exponent_; + return x.negative_ == y.negative_ && x.mantissa_ == y.mantissa_ && + x.exponent_ == y.exponent_; } friend constexpr bool @@ -110,8 +324,8 @@ public: { // If the two amounts have different signs (zero is treated as positive) // then the comparison is true iff the left is negative. - bool const lneg = x.mantissa_ < 0; - bool const rneg = y.mantissa_ < 0; + bool const lneg = x.negative_; + bool const rneg = y.negative_; if (lneg != rneg) return lneg; @@ -139,7 +353,7 @@ public: constexpr int signum() const noexcept { - return (mantissa_ < 0) ? -1 : (mantissa_ ? 1 : 0); + return negative_ ? -1 : (mantissa_ ? 1 : 0); } Number @@ -169,6 +383,15 @@ public: return os << to_string(x); } + friend std::string + to_string(Number const& amount); + + friend Number + root(Number f, unsigned d); + + friend Number + root2(Number f); + // Thread local rounding control. Default is to_nearest enum rounding_mode { to_nearest, towards_zero, downward, upward }; static rounding_mode @@ -177,44 +400,206 @@ public: static rounding_mode setround(rounding_mode mode); + /** Returns which mantissa scale is currently in use for normalization. + * + * If you think you need to call this outside of unit tests, no you don't. + */ + static MantissaRange::mantissa_scale + getMantissaScale(); + /** Changes which mantissa scale is used for normalization. + * + * If you think you need to call this outside of unit tests, no you don't. + */ + static void + setMantissaScale(MantissaRange::mantissa_scale scale); + + inline static internalrep + minMantissa() + { + return range_.get().min; + } + + inline static internalrep + maxMantissa() + { + return range_.get().max; + } + + inline static int + mantissaLog() + { + return range_.get().log; + } + + /// oneSmall is needed because the ranges are private + constexpr static Number + oneSmall(); + /// oneLarge is needed because the ranges are private + constexpr static Number + oneLarge(); + + // And one is needed because it needs to choose between oneSmall and + // oneLarge based on the current range + static Number + one(); + + template + [[nodiscard]] + std::pair + normalizeToRange(T minMantissa, T maxMantissa) const; + private: static thread_local rounding_mode mode_; + // The available ranges for mantissa + + constexpr static MantissaRange smallRange{MantissaRange::small}; + static_assert(isPowerOfTen(smallRange.min)); + static_assert(smallRange.min == 1'000'000'000'000'000LL); + static_assert(smallRange.max == 9'999'999'999'999'999LL); + static_assert(smallRange.log == 15); + static_assert(smallRange.min < maxRep); + static_assert(smallRange.max < maxRep); + constexpr static MantissaRange largeRange{MantissaRange::large}; + static_assert(isPowerOfTen(largeRange.min)); + static_assert(largeRange.min == 1'000'000'000'000'000'000ULL); + static_assert(largeRange.max == internalrep(9'999'999'999'999'999'999ULL)); + static_assert(largeRange.log == 18); + static_assert(largeRange.min < maxRep); + static_assert(largeRange.max > maxRep); + + // The range for the mantissa when normalized. + // Use reference_wrapper to avoid making copies, and prevent accidentally + // changing the values inside the range. + static thread_local std::reference_wrapper range_; void normalize(); - constexpr bool + + /** Normalize Number components to an arbitrary range. + * + * min/maxMantissa are parameters because this function is used by both + * normalize(), which reads from range_, and by normalizeToRange, + * which is public and can accept an arbitrary range from the caller. + */ + template + static void + normalize( + bool& negative, + T& mantissa, + int& exponent, + internalrep const& minMantissa, + internalrep const& maxMantissa); + + template + friend void + doNormalize( + bool& negative, + T& mantissa_, + int& exponent_, + MantissaRange::rep const& minMantissa, + MantissaRange::rep const& maxMantissa); + + bool isnormal() const noexcept; + // Copy the number, but modify the exponent by "exponentDelta". Because the + // mantissa doesn't change, the result will be "mostly" normalized, but the + // exponent could go out of range, so it will be checked. + Number + shiftExponent(int exponentDelta) const; + + // Safely convert rep (int64) mantissa to internalrep (uint64). If the rep + // is negative, returns the positive value. This takes a little extra work + // because converting std::numeric_limits::min() flirts with + // UB, and can vary across compilers. + static internalrep + externalToInternal(rep mantissa); + class Guard; }; +inline constexpr Number::Number( + bool negative, + internalrep mantissa, + int exponent, + unchecked) noexcept + : negative_(negative), mantissa_{mantissa}, exponent_{exponent} +{ +} + +inline constexpr Number::Number( + internalrep mantissa, + int exponent, + unchecked) noexcept + : Number(false, mantissa, exponent, unchecked{}) +{ +} + constexpr static Number numZero{}; -inline constexpr Number::Number(rep mantissa, int exponent, unchecked) noexcept - : mantissa_{mantissa}, exponent_{exponent} +inline Number::Number( + bool negative, + internalrep mantissa, + int exponent, + normalized) + : Number(negative, mantissa, exponent, unchecked{}) +{ + normalize(); +} + +inline Number::Number(internalrep mantissa, int exponent, normalized) + : Number(false, mantissa, exponent, normalized{}) { } inline Number::Number(rep mantissa, int exponent) - : mantissa_{mantissa}, exponent_{exponent} + : Number(mantissa < 0, externalToInternal(mantissa), exponent, normalized{}) { - normalize(); } inline Number::Number(rep mantissa) : Number{mantissa, 0} { } +/** Returns the mantissa of the external view of the Number. + * + * Please see the "---- External Interface ----" section of the class + * documentation for an explanation of why the internal value may be modified. + */ inline constexpr Number::rep Number::mantissa() const noexcept { - return mantissa_; + auto m = mantissa_; + if (m > maxRep) + { + XRPL_ASSERT_PARTS( + !isnormal() || (m % 10 == 0 && m / 10 <= maxRep), + "xrpl::Number::mantissa", + "large normalized mantissa has no remainder"); + m /= 10; + } + auto const sign = negative_ ? -1 : 1; + return sign * static_cast(m); } +/** Returns the exponent of the external view of the Number. + * + * Please see the "---- External Interface ----" section of the class + * documentation for an explanation of why the internal value may be modified. + */ inline constexpr int Number::exponent() const noexcept { - return exponent_; + auto e = exponent_; + if (mantissa_ > maxRep) + { + XRPL_ASSERT_PARTS( + !isnormal() || (mantissa_ % 10 == 0 && mantissa_ / 10 <= maxRep), + "xrpl::Number::exponent", + "large normalized mantissa has no remainder"); + ++e; + } + return e; } inline constexpr Number @@ -226,15 +611,17 @@ Number::operator+() const noexcept inline constexpr Number Number::operator-() const noexcept { + if (mantissa_ == 0) + return Number{}; auto x = *this; - x.mantissa_ = -x.mantissa_; + x.negative_ = !x.negative_; return x; } inline Number& Number::operator++() { - *this += Number{1000000000000000, -15, unchecked{}}; + *this += one(); return *this; } @@ -249,7 +636,7 @@ Number::operator++(int) inline Number& Number::operator--() { - *this -= Number{1000000000000000, -15, unchecked{}}; + *this -= one(); return *this; } @@ -299,30 +686,54 @@ operator/(Number const& x, Number const& y) return z; } -inline constexpr Number +inline Number Number::min() noexcept { - return Number{minMantissa, minExponent, unchecked{}}; + return Number{false, range_.get().min, minExponent, unchecked{}}; } -inline constexpr Number +inline Number Number::max() noexcept { - return Number{maxMantissa, maxExponent, unchecked{}}; + return Number{ + false, std::min(range_.get().max, maxRep), maxExponent, unchecked{}}; } -inline constexpr Number +inline Number Number::lowest() noexcept { - return -Number{maxMantissa, maxExponent, unchecked{}}; + return Number{ + true, std::min(range_.get().max, maxRep), maxExponent, unchecked{}}; } -inline constexpr bool +inline bool Number::isnormal() const noexcept { - auto const abs_m = mantissa_ < 0 ? -mantissa_ : mantissa_; - return minMantissa <= abs_m && abs_m <= maxMantissa && - minExponent <= exponent_ && exponent_ <= maxExponent; + MantissaRange const& range = range_; + auto const abs_m = mantissa_; + return *this == Number{} || + (range.min <= abs_m && abs_m <= range.max && + (abs_m <= maxRep || abs_m % 10 == 0) && minExponent <= exponent_ && + exponent_ <= maxExponent); +} + +template +std::pair +Number::normalizeToRange(T minMantissa, T maxMantissa) const +{ + bool negative = negative_; + internalrep mantissa = mantissa_; + int exponent = exponent_; + + if constexpr (std::is_unsigned_v) + XRPL_ASSERT_PARTS( + !negative, + "xrpl::Number::normalizeToRange", + "Number is non-negative for unsigned range."); + Number::normalize(negative, mantissa, exponent, minMantissa, maxMantissa); + + auto const sign = negative ? -1 : 1; + return std::make_pair(static_cast(sign * mantissa), exponent); } inline constexpr Number @@ -364,6 +775,20 @@ squelch(Number const& x, Number const& limit) noexcept return x; } +inline std::string +to_string(MantissaRange::mantissa_scale const& scale) +{ + switch (scale) + { + case MantissaRange::small: + return "small"; + case MantissaRange::large: + return "large"; + default: + throw std::runtime_error("Bad scale"); + } +} + class saveNumberRoundMode { Number::rounding_mode mode_; @@ -402,6 +827,34 @@ public: operator=(NumberRoundModeGuard const&) = delete; }; +/** Sets the new scale and restores the old scale when it leaves scope. + * + * If you think you need to use this class outside of unit tests, no you don't. + * + */ +class NumberMantissaScaleGuard +{ + MantissaRange::mantissa_scale const saved_; + +public: + explicit NumberMantissaScaleGuard( + MantissaRange::mantissa_scale scale) noexcept + : saved_{Number::getMantissaScale()} + { + Number::setMantissaScale(scale); + } + + ~NumberMantissaScaleGuard() + { + Number::setMantissaScale(saved_); + } + + NumberMantissaScaleGuard(NumberMantissaScaleGuard const&) = delete; + + NumberMantissaScaleGuard& + operator=(NumberMantissaScaleGuard const&) = delete; +}; + } // namespace xrpl #endif // XRPL_BASICS_NUMBER_H_INCLUDED diff --git a/include/xrpl/beast/core/CurrentThreadName.h b/include/xrpl/beast/core/CurrentThreadName.h index 8e9d58b649..703246a76a 100644 --- a/include/xrpl/beast/core/CurrentThreadName.h +++ b/include/xrpl/beast/core/CurrentThreadName.h @@ -5,6 +5,8 @@ #ifndef BEAST_CORE_CURRENT_THREAD_NAME_H_INCLUDED #define BEAST_CORE_CURRENT_THREAD_NAME_H_INCLUDED +#include + #include #include @@ -16,6 +18,31 @@ namespace beast { void setCurrentThreadName(std::string_view newThreadName); +#if BOOST_OS_LINUX + +// On Linux, thread names are limited to 16 bytes including the null terminator. +// Maximum number of characters is therefore 15. +constexpr std::size_t maxThreadNameLength = 15; + +/** Sets the name of the caller thread with compile-time size checking. + @tparam N The size of the string literal including null terminator + @param newThreadName A string literal to set as the thread name + + This template overload enforces that thread names are at most 16 characters + (including null terminator) at compile time, matching Linux's limit. +*/ +template +void +setCurrentThreadName(char const (&newThreadName)[N]) +{ + static_assert( + N <= maxThreadNameLength + 1, + "Thread name cannot exceed 15 characters"); + + setCurrentThreadName(std::string_view(newThreadName, N - 1)); +} +#endif + /** Returns the name of the caller thread. The name returned is the name as set by a call to setCurrentThreadName(). diff --git a/src/xrpld/app/paths/Credit.h b/include/xrpl/ledger/Credit.h similarity index 93% rename from src/xrpld/app/paths/Credit.h rename to include/xrpl/ledger/Credit.h index 5bdcd70e74..09b65b3dde 100644 --- a/src/xrpld/app/paths/Credit.h +++ b/include/xrpl/ledger/Credit.h @@ -1,5 +1,5 @@ -#ifndef XRPL_APP_PATHS_CREDIT_H_INCLUDED -#define XRPL_APP_PATHS_CREDIT_H_INCLUDED +#ifndef XRPL_LEDGER_CREDIT_H_INCLUDED +#define XRPL_LEDGER_CREDIT_H_INCLUDED #include #include diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 51a46f20ee..8d5d7a08bd 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -61,6 +61,9 @@ enum FreezeHandling { fhIGNORE_FREEZE, fhZERO_IF_FROZEN }; /** Controls the treatment of unauthorized MPT balances */ enum AuthHandling { ahIGNORE_AUTH, ahZERO_IF_UNAUTHORIZED }; +/** Controls whether to include the account's full spendable balance */ +enum SpendableHandling { shSIMPLE_BALANCE, shFULL_BALANCE }; + [[nodiscard]] bool isGlobalFrozen(ReadView const& view, AccountID const& issuer); @@ -305,86 +308,57 @@ isLPTokenFrozen( Issue const& asset, Issue const& asset2); -// Returns the amount an account can spend without going into debt. +// Returns the amount an account can spend. // -// <-- saAmount: amount of currency held by account. May be negative. -[[nodiscard]] STAmount -accountHolds( - ReadView const& view, - AccountID const& account, - Currency const& currency, - AccountID const& issuer, - FreezeHandling zeroIfFrozen, - beast::Journal j); - -[[nodiscard]] STAmount -accountHolds( - ReadView const& view, - AccountID const& account, - Issue const& issue, - FreezeHandling zeroIfFrozen, - beast::Journal j); - -[[nodiscard]] STAmount -accountHolds( - ReadView const& view, - AccountID const& account, - MPTIssue const& mptIssue, - FreezeHandling zeroIfFrozen, - AuthHandling zeroIfUnauthorized, - beast::Journal j); - -[[nodiscard]] STAmount -accountHolds( - ReadView const& view, - AccountID const& account, - Asset const& asset, - FreezeHandling zeroIfFrozen, - AuthHandling zeroIfUnauthorized, - beast::Journal j); - -// Returns the amount an account can spend total. +// If shSIMPLE_BALANCE is specified, this is the amount the account can spend +// without going into debt. // -// These functions use accountHolds, but unlike accountHolds: -// * The account can go into debt. -// * If the account is the asset issuer the only limit is defined by the asset / +// If shFULL_BALANCE is specified, this is the amount the account can spend +// total. Specifically: +// * The account can go into debt if using a trust line, and the other side has +// a non-zero limit. +// * If the account is the asset issuer the limit is defined by the asset / // issuance. // // <-- saAmount: amount of currency held by account. May be negative. [[nodiscard]] STAmount -accountSpendable( +accountHolds( ReadView const& view, AccountID const& account, Currency const& currency, AccountID const& issuer, FreezeHandling zeroIfFrozen, - beast::Journal j); + beast::Journal j, + SpendableHandling includeFullBalance = shSIMPLE_BALANCE); [[nodiscard]] STAmount -accountSpendable( +accountHolds( ReadView const& view, AccountID const& account, Issue const& issue, FreezeHandling zeroIfFrozen, - beast::Journal j); + beast::Journal j, + SpendableHandling includeFullBalance = shSIMPLE_BALANCE); [[nodiscard]] STAmount -accountSpendable( +accountHolds( ReadView const& view, AccountID const& account, MPTIssue const& mptIssue, FreezeHandling zeroIfFrozen, AuthHandling zeroIfUnauthorized, - beast::Journal j); + beast::Journal j, + SpendableHandling includeFullBalance = shSIMPLE_BALANCE); [[nodiscard]] STAmount -accountSpendable( +accountHolds( ReadView const& view, AccountID const& account, Asset const& asset, FreezeHandling zeroIfFrozen, AuthHandling zeroIfUnauthorized, - beast::Journal j); + beast::Journal j, + SpendableHandling includeFullBalance = shSIMPLE_BALANCE); // Returns the amount an account can spend of the currency type saDefault, or // returns saDefault if this account is the issuer of the currency in @@ -745,7 +719,7 @@ createPseudoAccount( uint256 const& pseudoOwnerKey, SField const& ownerField); -// Returns true iff sleAcct is a pseudo-account or specific +// Returns true if and only if sleAcct is a pseudo-account or specific // pseudo-accounts in pseudoFieldFilter. // // Returns false if sleAcct is @@ -800,13 +774,16 @@ checkDestinationAndTag(SLE::const_ref toSle, bool hasDestinationTag); * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if * the sender has it. + * - Checks that the receiver will not exceed the limit (IOU trustline limit + * or MPT MaximumAmount). */ [[nodiscard]] TER canWithdraw( - AccountID const& from, ReadView const& view, + AccountID const& from, AccountID const& to, SLE::const_ref toSle, + STAmount const& amount, bool hasDestinationTag); /** Checks that can withdraw funds from an object to itself or a destination. @@ -820,12 +797,15 @@ canWithdraw( * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if * the sender has it. + * - Checks that the receiver will not exceed the limit (IOU trustline limit + * or MPT MaximumAmount). */ [[nodiscard]] TER canWithdraw( - AccountID const& from, ReadView const& view, + AccountID const& from, AccountID const& to, + STAmount const& amount, bool hasDestinationTag); /** Checks that can withdraw funds from an object to itself or a destination. @@ -839,6 +819,8 @@ canWithdraw( * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if * the sender has it. + * - Checks that the receiver will not exceed the limit (IOU trustline limit + * or MPT MaximumAmount). */ [[nodiscard]] TER canWithdraw(ReadView const& view, STTx const& tx); diff --git a/include/xrpl/protocol/AmountConversions.h b/include/xrpl/protocol/AmountConversions.h index 195e373fa0..2cdccecabb 100644 --- a/include/xrpl/protocol/AmountConversions.h +++ b/include/xrpl/protocol/AmountConversions.h @@ -121,7 +121,7 @@ toAmount( { if (isXRP(issue)) return STAmount(issue, static_cast(n)); - return STAmount(issue, n.mantissa(), n.exponent()); + return STAmount(issue, n); } else { diff --git a/include/xrpl/protocol/IOUAmount.h b/include/xrpl/protocol/IOUAmount.h index c04cb5cf70..405de18e29 100644 --- a/include/xrpl/protocol/IOUAmount.h +++ b/include/xrpl/protocol/IOUAmount.h @@ -26,8 +26,10 @@ class IOUAmount : private boost::totally_ordered, private boost::additive { private: - std::int64_t mantissa_; - int exponent_; + using mantissa_type = std::int64_t; + using exponent_type = int; + mantissa_type mantissa_; + exponent_type exponent_; /** Adjusts the mantissa and exponent to the proper range. @@ -38,11 +40,14 @@ private: void normalize(); + static IOUAmount + fromNumber(Number const& number); + public: IOUAmount() = default; explicit IOUAmount(Number const& other); IOUAmount(beast::Zero); - IOUAmount(std::int64_t mantissa, int exponent); + IOUAmount(mantissa_type mantissa, exponent_type exponent); IOUAmount& operator=(beast::Zero); @@ -71,10 +76,10 @@ public: int signum() const noexcept; - int + exponent_type exponent() const noexcept; - std::int64_t + mantissa_type mantissa() const noexcept; static IOUAmount @@ -92,7 +97,7 @@ inline IOUAmount::IOUAmount(beast::Zero) *this = beast::zero; } -inline IOUAmount::IOUAmount(std::int64_t mantissa, int exponent) +inline IOUAmount::IOUAmount(mantissa_type mantissa, exponent_type exponent) : mantissa_(mantissa), exponent_(exponent) { normalize(); @@ -149,13 +154,13 @@ IOUAmount::signum() const noexcept return (mantissa_ < 0) ? -1 : (mantissa_ ? 1 : 0); } -inline int +inline IOUAmount::exponent_type IOUAmount::exponent() const noexcept { return exponent_; } -inline std::int64_t +inline IOUAmount::mantissa_type IOUAmount::mantissa() const noexcept { return mantissa_; diff --git a/include/xrpl/protocol/Issue.h b/include/xrpl/protocol/Issue.h index 519d7a96f3..a76b7a8316 100644 --- a/include/xrpl/protocol/Issue.h +++ b/include/xrpl/protocol/Issue.h @@ -37,6 +37,9 @@ public: bool native() const; + bool + integral() const; + friend constexpr std::weak_ordering operator<=>(Issue const& lhs, Issue const& rhs); }; diff --git a/include/xrpl/protocol/MPTIssue.h b/include/xrpl/protocol/MPTIssue.h index b89b59ee0d..ca81548a29 100644 --- a/include/xrpl/protocol/MPTIssue.h +++ b/include/xrpl/protocol/MPTIssue.h @@ -46,6 +46,12 @@ public: { return false; } + + bool + integral() const + { + return true; + } }; constexpr bool diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 0c72b80de4..43be9d3b45 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -233,6 +233,7 @@ std::size_t constexpr maxMPTokenMetadataLength = 1024; /** The maximum amount of MPTokenIssuance */ std::uint64_t constexpr maxMPTokenAmount = 0x7FFF'FFFF'FFFF'FFFFull; +static_assert(Number::maxRep >= maxMPTokenAmount); /** The maximum length of Data payload */ std::size_t constexpr maxDataPayloadLength = 256; diff --git a/include/xrpl/protocol/SField.h b/include/xrpl/protocol/SField.h index b1d353196d..7f404b4d5f 100644 --- a/include/xrpl/protocol/SField.h +++ b/include/xrpl/protocol/SField.h @@ -135,7 +135,10 @@ public: sMD_Always = 0x10, // value when node containing it is affected at all sMD_BaseTen = 0x20, // value is treated as base 10, overriding behavior sMD_PseudoAccount = 0x40, // if this field is set in an ACCOUNT_ROOT - // _only_, then it is a pseudo-account + // _only_, then it is a pseudo-account + sMD_NeedsAsset = 0x80, // This field needs to be associated with an + // asset before it is serialized as a ledger + // object. Intended for STNumber. sMD_Default = sMD_ChangeOrig | sMD_ChangeNew | sMD_DeleteFinal | sMD_Create }; diff --git a/include/xrpl/protocol/STAmount.h b/include/xrpl/protocol/STAmount.h index 79cbf51436..4d86aed2ec 100644 --- a/include/xrpl/protocol/STAmount.h +++ b/include/xrpl/protocol/STAmount.h @@ -138,7 +138,7 @@ public: template STAmount(A const& asset, Number const& number) - : STAmount(asset, number.mantissa(), number.exponent()) + : STAmount(fromNumber(asset, number)) { } @@ -282,6 +282,10 @@ public: mpt() const; private: + template + static STAmount + fromNumber(A const& asset, Number const& number); + static std::unique_ptr construct(SerialIter&, SField const& name); @@ -345,10 +349,19 @@ STAmount::STAmount( , mIsNegative(negative) { // mValue is uint64, but needs to fit in the range of int64 - XRPL_ASSERT( - mValue <= std::numeric_limits::max(), - "xrpl::STAmount::STAmount(SField, A, std::uint64_t, int, bool) : " - "maximum mantissa input"); + if (Number::getMantissaScale() == MantissaRange::small) + { + XRPL_ASSERT( + mValue <= std::numeric_limits::max(), + "xrpl::STAmount::STAmount(SField, A, std::uint64_t, int, bool) : " + "maximum mantissa input"); + } + else + { + if (integral() && mValue > std::numeric_limits::max()) + throw std::overflow_error( + "STAmount mantissa is too large " + std::to_string(mantissa)); + } canonicalize(); } @@ -542,14 +555,23 @@ STAmount::operator=(XRPAmount const& amount) return *this; } -inline STAmount& -STAmount::operator=(Number const& number) +template +inline STAmount +STAmount::fromNumber(A const& a, Number const& number) { - mIsNegative = number.mantissa() < 0; - mValue = mIsNegative ? -number.mantissa() : number.mantissa(); - mOffset = number.exponent(); - canonicalize(); - return *this; + bool const negative = number.mantissa() < 0; + Number const working{negative ? -number : number}; + Asset asset{a}; + if (asset.integral()) + { + std::uint64_t const intValue = static_cast(working); + return STAmount{asset, intValue, 0, negative}; + } + + auto const [mantissa, exponent] = + working.normalizeToRange(cMinValue, cMaxValue); + + return STAmount{asset, mantissa, exponent, negative}; } inline void @@ -699,17 +721,32 @@ getRate(STAmount const& offerOut, STAmount const& offerIn); * @param rounding Optional Number rounding mode * */ -STAmount +[[nodiscard]] STAmount roundToScale( STAmount const& value, std::int32_t scale, Number::rounding_mode rounding = Number::getround()); +/** Round an arbitrary precision Number IN PLACE to the precision of a given + * Asset. + * + * This is used to ensure that calculations do not collect dust for IOUs, or + * fractional amounts for the integral types XRP and MPT. + * + * @param asset The relevant asset + * @param value The lvalue to be rounded + */ +template +void +roundToAsset(A const& asset, Number& value) +{ + value = STAmount{asset, value}; +} + /** Round an arbitrary precision Number to the precision of a given Asset. * - * This is used to ensure that calculations do not collect dust beyond the - * precision of the reference value for IOUs, or fractional amounts for the - * integral types XRP and MPT. + * This is used to ensure that calculations do not collect dust beyond specified + * scale for IOUs, or fractional amounts for the integral types XRP and MPT. * * @param asset The relevant asset * @param value The value to be rounded @@ -718,7 +755,7 @@ roundToScale( * @param rounding Optional Number rounding mode */ template -Number +[[nodiscard]] Number roundToAsset( A const& asset, Number const& value, diff --git a/include/xrpl/protocol/STNumber.h b/include/xrpl/protocol/STNumber.h index dfdb16af93..39b0c3b042 100644 --- a/include/xrpl/protocol/STNumber.h +++ b/include/xrpl/protocol/STNumber.h @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -19,8 +20,19 @@ namespace xrpl { * it can represent a value of any token type (XRP, IOU, or MPT) * without paying the storage cost of duplicating asset information * that may be deduced from the context. + * + * STNumber derives from STTakesAsset, so that it can be associated with the + * related Asset during transaction processing. Which asset is relevant depends + * on the object and transaction. As of this writing, only Vault, LoanBroker, + * and Loan objects use STNumber fields. All of those fields represent amounts + * of the Vault's Asset, so they should be associated with the Vault's Asset. + * + * e.g. + * associateAsset(*loanSle, asset); + * associateAsset(*brokerSle, asset); + * associateAsset(*vaultSle, asset); */ -class STNumber : public STBase, public CountedObject +class STNumber : public STTakesAsset, public CountedObject { private: Number value_; @@ -56,6 +68,9 @@ public: bool isDefault() const override; + void + associateAsset(Asset const& a) override; + operator Number() const { return value_; diff --git a/include/xrpl/protocol/STTakesAsset.h b/include/xrpl/protocol/STTakesAsset.h new file mode 100644 index 0000000000..767223b97d --- /dev/null +++ b/include/xrpl/protocol/STTakesAsset.h @@ -0,0 +1,63 @@ +#ifndef XRPL_PROTOCOL_STTAKESASSET_H_INCLUDED +#define XRPL_PROTOCOL_STTAKESASSET_H_INCLUDED + +#include +#include + +namespace xrpl { + +/** Intermediate class for any STBase-derived class to store an Asset. + * + * In the class definition, this class should be specified as a base class + * _instead_ of STBase. + * + * Specifically, the Asset is only stored and used at runtime. It should not be + * serialized to the ledger. + * + * The derived class decides what to do with the Asset, and when. It will not + * necessarily be set at any given time. As of this writing, only STNumber uses + * it to round the stored Number to the Asset's precision both when associated, + * and when serializing the Number. + */ +class STTakesAsset : public STBase +{ +protected: + std::optional asset_; + +public: + using STBase::STBase; + using STBase::operator=; + + virtual void + associateAsset(Asset const& a); +}; + +inline void +STTakesAsset::associateAsset(Asset const& a) +{ + asset_.emplace(a); +} + +class STLedgerEntry; + +/** Associate an Asset with all sMD_NeedsAsset fields in a ledger entry. + * + * This function iterates over all fields in the given ledger entry. For each + * field that is set and has the SField::sMD_NeedsAsset metadata flag, it calls + * `associateAsset` on that field with the given Asset. Such field must be + * derived from STTakesAsset - if it is not, the conversion will throw. + * + * Typically, associateAsset should be called near the end of doApply() of any + * Transactor classes on the SLEs of any new or modified ledger entries + * containing STNumber fields, after doing all of the modifications t the SLEs. + * + * @param sle The ledger entry whose fields will be updated. + * @param asset The Asset to associate with the relevant fields. + * + */ +void +associateAsset(STLedgerEntry& sle, Asset const& asset); + +} // namespace xrpl + +#endif diff --git a/include/xrpl/protocol/SystemParameters.h b/include/xrpl/protocol/SystemParameters.h index c0732bc9fe..c2f66e9ea1 100644 --- a/include/xrpl/protocol/SystemParameters.h +++ b/include/xrpl/protocol/SystemParameters.h @@ -23,6 +23,8 @@ systemName() /** Number of drops in the genesis account. */ constexpr XRPAmount INITIAL_XRP{100'000'000'000 * DROPS_PER_XRP}; +static_assert(INITIAL_XRP.drops() == 100'000'000'000'000'000); +static_assert(Number::maxRep >= INITIAL_XRP.drops()); /** Returns true if the amount does not exceed the initial XRP in existence. */ inline bool diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index a7fde7994e..cbeaacc4ef 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -17,7 +17,8 @@ // Keep it sorted in reverse chronological order. XRPL_FEATURE(Sponsor, Supported::no, VoteBehavior::DefaultNo) -XRPL_FEATURE(LendingProtocol, Supported::no, VoteBehavior::DefaultNo) +XRPL_FIX (BatchInnerSigs, Supported::yes, VoteBehavior::DefaultNo) +XRPL_FEATURE(LendingProtocol, Supported::yes, VoteBehavior::DefaultNo) XRPL_FEATURE(PermissionDelegationV1_1, Supported::no, VoteBehavior::DefaultNo) XRPL_FIX (DirectoryLimit, Supported::yes, VoteBehavior::DefaultNo) XRPL_FIX (IncludeKeyletFields, Supported::yes, VoteBehavior::DefaultNo) @@ -31,7 +32,7 @@ XRPL_FIX (EnforceNFTokenTrustlineV2, Supported::yes, VoteBehavior::DefaultNo XRPL_FIX (AMMv1_3, Supported::yes, VoteBehavior::DefaultNo) XRPL_FEATURE(PermissionedDEX, Supported::yes, VoteBehavior::DefaultNo) XRPL_FEATURE(Batch, Supported::yes, VoteBehavior::DefaultNo) -XRPL_FEATURE(SingleAssetVault, Supported::no, VoteBehavior::DefaultNo) +XRPL_FEATURE(SingleAssetVault, Supported::yes, VoteBehavior::DefaultNo) XRPL_FIX (PayChanCancelAfter, Supported::yes, VoteBehavior::DefaultNo) // Check flags in Credential transactions XRPL_FIX (InvalidTxFlags, Supported::yes, VoteBehavior::DefaultNo) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 8f48a4389e..d846c6129b 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -546,7 +546,7 @@ LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({ {sfStartDate, soeREQUIRED}, {sfPaymentInterval, soeREQUIRED}, {sfGracePeriod, soeDEFAULT}, - {sfPreviousPaymentDate, soeDEFAULT}, + {sfPreviousPaymentDueDate, soeDEFAULT}, {sfNextPaymentDueDate, soeDEFAULT}, // The loan object tracks these values: // diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 33f7b8e728..69216e05ef 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -102,7 +102,7 @@ TYPED_SFIELD(sfMutableFlags, UINT32, 53) TYPED_SFIELD(sfStartDate, UINT32, 54) TYPED_SFIELD(sfPaymentInterval, UINT32, 55) TYPED_SFIELD(sfGracePeriod, UINT32, 56) -TYPED_SFIELD(sfPreviousPaymentDate, UINT32, 57) +TYPED_SFIELD(sfPreviousPaymentDueDate, UINT32, 57) TYPED_SFIELD(sfNextPaymentDueDate, UINT32, 58) TYPED_SFIELD(sfPaymentRemaining, UINT32, 59) TYPED_SFIELD(sfPaymentTotal, UINT32, 60) @@ -213,22 +213,22 @@ TYPED_SFIELD(sfObjectID, UINT256, 39) // number (common) TYPED_SFIELD(sfNumber, NUMBER, 1) -TYPED_SFIELD(sfAssetsAvailable, NUMBER, 2) -TYPED_SFIELD(sfAssetsMaximum, NUMBER, 3) -TYPED_SFIELD(sfAssetsTotal, NUMBER, 4) -TYPED_SFIELD(sfLossUnrealized, NUMBER, 5) -TYPED_SFIELD(sfDebtTotal, NUMBER, 6) -TYPED_SFIELD(sfDebtMaximum, NUMBER, 7) -TYPED_SFIELD(sfCoverAvailable, NUMBER, 8) +TYPED_SFIELD(sfAssetsAvailable, NUMBER, 2, SField::sMD_NeedsAsset | SField::sMD_Default) +TYPED_SFIELD(sfAssetsMaximum, NUMBER, 3, SField::sMD_NeedsAsset | SField::sMD_Default) +TYPED_SFIELD(sfAssetsTotal, NUMBER, 4, SField::sMD_NeedsAsset | SField::sMD_Default) +TYPED_SFIELD(sfLossUnrealized, NUMBER, 5, SField::sMD_NeedsAsset | SField::sMD_Default) +TYPED_SFIELD(sfDebtTotal, NUMBER, 6, SField::sMD_NeedsAsset | SField::sMD_Default) +TYPED_SFIELD(sfDebtMaximum, NUMBER, 7, SField::sMD_NeedsAsset | SField::sMD_Default) +TYPED_SFIELD(sfCoverAvailable, NUMBER, 8, SField::sMD_NeedsAsset | SField::sMD_Default) TYPED_SFIELD(sfLoanOriginationFee, NUMBER, 9) TYPED_SFIELD(sfLoanServiceFee, NUMBER, 10) TYPED_SFIELD(sfLatePaymentFee, NUMBER, 11) TYPED_SFIELD(sfClosePaymentFee, NUMBER, 12) -TYPED_SFIELD(sfPrincipalOutstanding, NUMBER, 13) +TYPED_SFIELD(sfPrincipalOutstanding, NUMBER, 13, SField::sMD_NeedsAsset | SField::sMD_Default) TYPED_SFIELD(sfPrincipalRequested, NUMBER, 14) -TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15) +TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15, SField::sMD_NeedsAsset | SField::sMD_Default) TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16) -TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17) +TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17, SField::sMD_NeedsAsset | SField::sMD_Default) // int32 TYPED_SFIELD(sfLoanScale, INT32, 1) diff --git a/src/libxrpl/basics/Number.cpp b/src/libxrpl/basics/Number.cpp index 9984b26ffe..436ebf6779 100644 --- a/src/libxrpl/basics/Number.cpp +++ b/src/libxrpl/basics/Number.cpp @@ -1,4 +1,6 @@ #include +// Keep Number.h first to ensure it can build without hidden dependencies +#include #include #include @@ -13,16 +15,20 @@ #include #ifdef _MSC_VER -#pragma message("Using boost::multiprecision::uint128_t") +#pragma message("Using boost::multiprecision::uint128_t and int128_t") #include using uint128_t = boost::multiprecision::uint128_t; +using int128_t = boost::multiprecision::int128_t; #else // !defined(_MSC_VER) using uint128_t = __uint128_t; +using int128_t = __int128_t; #endif // !defined(_MSC_VER) namespace xrpl { thread_local Number::rounding_mode Number::mode_ = Number::to_nearest; +thread_local std::reference_wrapper Number::range_ = + largeRange; Number::rounding_mode Number::getround() @@ -36,12 +42,30 @@ Number::setround(rounding_mode mode) return std::exchange(mode_, mode); } +MantissaRange::mantissa_scale +Number::getMantissaScale() +{ + return range_.get().scale; +} + +void +Number::setMantissaScale(MantissaRange::mantissa_scale scale) +{ + if (scale != MantissaRange::small && scale != MantissaRange::large) + LogicError("Unknown mantissa scale"); + range_ = scale == MantissaRange::small ? smallRange : largeRange; +} + // Guard // The Guard class is used to temporarily add extra digits of // precision to an operation. This enables the final result // to be correctly rounded to the internal precision of Number. +template +concept UnsignedMantissa = + std::is_unsigned_v || std::is_same_v; + class Number::Guard { std::uint64_t digits_; // 16 decimal guard digits @@ -62,8 +86,9 @@ public: is_negative() const noexcept; // add a digit + template void - push(unsigned d) noexcept; + push(T d) noexcept; // recover a digit unsigned @@ -76,16 +101,40 @@ public: round() noexcept; // Modify the result to the correctly rounded value + template void - doRoundUp(rep& mantissa, int& exponent, std::string location); + doRoundUp( + bool& negative, + T& mantissa, + int& exponent, + internalrep const& minMantissa, + internalrep const& maxMantissa, + std::string location); + + // Modify the result to the correctly rounded value + template + void + doRoundDown( + bool& negative, + T& mantissa, + int& exponent, + internalrep const& minMantissa); // Modify the result to the correctly rounded value void - doRoundDown(rep& mantissa, int& exponent); + doRound(rep& drops, std::string location); - // Modify the result to the correctly rounded value +private: void - doRound(rep& drops); + doPush(unsigned d) noexcept; + + template + void + bringIntoRange( + bool& negative, + T& mantissa, + int& exponent, + internalrep const& minMantissa); }; inline void @@ -107,13 +156,20 @@ Number::Guard::is_negative() const noexcept } inline void -Number::Guard::push(unsigned d) noexcept +Number::Guard::doPush(unsigned d) noexcept { - xbit_ = xbit_ || (digits_ & 0x0000'0000'0000'000F) != 0; + xbit_ = xbit_ || ((digits_ & 0x0000'0000'0000'000F) != 0); digits_ >>= 4; digits_ |= (d & 0x0000'0000'0000'000FULL) << 60; } +template +inline void +Number::Guard::push(T d) noexcept +{ + doPush(static_cast(d)); +} + inline unsigned Number::Guard::pop() noexcept { @@ -163,30 +219,65 @@ Number::Guard::round() noexcept return 0; } +template void -Number::Guard::doRoundUp(rep& mantissa, int& exponent, std::string location) +Number::Guard::bringIntoRange( + bool& negative, + T& mantissa, + int& exponent, + internalrep const& minMantissa) +{ + // Bring mantissa back into the minMantissa / maxMantissa range AFTER + // rounding + if (mantissa < minMantissa) + { + mantissa *= 10; + --exponent; + } + if (exponent < minExponent) + { + constexpr Number zero = Number{}; + + negative = zero.negative_; + mantissa = zero.mantissa_; + exponent = zero.exponent_; + } +} + +template +void +Number::Guard::doRoundUp( + bool& negative, + T& mantissa, + int& exponent, + internalrep const& minMantissa, + internalrep const& maxMantissa, + std::string location) { auto r = round(); if (r == 1 || (r == 0 && (mantissa & 1) == 1)) { ++mantissa; - if (mantissa > maxMantissa) + // Ensure mantissa after incrementing fits within both the + // min/maxMantissa range and is a valid "rep". + if (mantissa > maxMantissa || mantissa > maxRep) { mantissa /= 10; ++exponent; } } - if (exponent < minExponent) - { - mantissa = 0; - exponent = Number{}.exponent_; - } + bringIntoRange(negative, mantissa, exponent, minMantissa); if (exponent > maxExponent) throw std::overflow_error(location); } +template void -Number::Guard::doRoundDown(rep& mantissa, int& exponent) +Number::Guard::doRoundDown( + bool& negative, + T& mantissa, + int& exponent, + internalrep const& minMantissa) { auto r = round(); if (r == 1 || (r == 0 && (mantissa & 1) == 1)) @@ -198,20 +289,27 @@ Number::Guard::doRoundDown(rep& mantissa, int& exponent) --exponent; } } - if (exponent < minExponent) - { - mantissa = 0; - exponent = Number{}.exponent_; - } + bringIntoRange(negative, mantissa, exponent, minMantissa); } // Modify the result to the correctly rounded value void -Number::Guard::doRound(rep& drops) +Number::Guard::doRound(rep& drops, std::string location) { auto r = round(); if (r == 1 || (r == 0 && (drops & 1) == 1)) { + if (drops >= maxRep) + { + static_assert(sizeof(internalrep) == sizeof(rep)); + // This should be impossible, because it's impossible to represent + // "maxRep + 0.6" in Number, regardless of the scale. There aren't + // enough digits available. You'd either get a mantissa of "maxRep" + // or "(maxRep + 1) / 10", neither of which will round up when + // converting to rep, though the latter might overflow _before_ + // rounding. + throw std::overflow_error(location); // LCOV_EXCL_LINE + } ++drops; } if (is_negative()) @@ -220,20 +318,88 @@ Number::Guard::doRound(rep& drops) // Number -constexpr Number one{1000000000000000, -15, Number::unchecked{}}; - -void -Number::normalize() +// Safely convert rep (int64) mantissa to internalrep (uint64). If the rep is +// negative, returns the positive value. This takes a little extra work because +// converting std::numeric_limits::min() flirts with UB, and can +// vary across compilers. +Number::internalrep +Number::externalToInternal(rep mantissa) { + // If the mantissa is already positive, just return it + if (mantissa >= 0) + return mantissa; + // If the mantissa is negative, but fits within the positive range of rep, + // return it negated + if (mantissa >= -std::numeric_limits::max()) + return -mantissa; + + // If the mantissa doesn't fit within the positive range, convert to + // int128_t, negate that, and cast it back down to the internalrep + // In practice, this is only going to cover the case of + // std::numeric_limits::min(). + int128_t temp = mantissa; + return static_cast(-temp); +} + +constexpr Number +Number::oneSmall() +{ + return Number{ + false, + Number::smallRange.min, + -Number::smallRange.log, + Number::unchecked{}}; +}; + +constexpr Number oneSml = Number::oneSmall(); + +constexpr Number +Number::oneLarge() +{ + return Number{ + false, + Number::largeRange.min, + -Number::largeRange.log, + Number::unchecked{}}; +}; + +constexpr Number oneLrg = Number::oneLarge(); + +Number +Number::one() +{ + if (&range_.get() == &smallRange) + return oneSml; + XRPL_ASSERT(&range_.get() == &largeRange, "Number::one() : valid range_"); + return oneLrg; +} + +// Use the member names in this static function for now so the diff is cleaner +// TODO: Rename the function parameters to get rid of the "_" suffix +template +void +doNormalize( + bool& negative, + T& mantissa_, + int& exponent_, + MantissaRange::rep const& minMantissa, + MantissaRange::rep const& maxMantissa) +{ + auto constexpr minExponent = Number::minExponent; + auto constexpr maxExponent = Number::maxExponent; + auto constexpr maxRep = Number::maxRep; + + using Guard = Number::Guard; + + constexpr Number zero = Number{}; if (mantissa_ == 0) { - *this = Number{}; + mantissa_ = zero.mantissa_; + exponent_ = zero.exponent_; + negative = zero.negative_; return; } - bool const negative = (mantissa_ < 0); - auto m = static_cast>(mantissa_); - if (negative) - m = -m; + auto m = mantissa_; while ((m < minMantissa) && (exponent_ > minExponent)) { m *= 10; @@ -250,57 +416,161 @@ Number::normalize() m /= 10; ++exponent_; } - mantissa_ = m; - if ((exponent_ < minExponent) || (mantissa_ < minMantissa)) + if ((exponent_ < minExponent) || (m < minMantissa)) { - *this = Number{}; + mantissa_ = zero.mantissa_; + exponent_ = zero.exponent_; + negative = zero.negative_; return; } - g.doRoundUp(mantissa_, exponent_, "Number::normalize 2"); + // When using the largeRange, "m" needs fit within an int64, even if + // the final mantissa_ is going to end up larger to fit within the + // MantissaRange. Cut it down here so that the rounding will be done while + // it's smaller. + // + // Example: 9,900,000,000,000,123,456 > 9,223,372,036,854,775,807, + // so "m" will be modified to 990,000,000,000,012,345. Then that value + // will be rounded to 990,000,000,000,012,345 or + // 990,000,000,000,012,346, depending on the rounding mode. Finally, + // mantissa_ will be "m*10" so it fits within the range, and end up as + // 9,900,000,000,000,123,450 or 9,900,000,000,000,123,460. + // mantissa() will return mantissa_ / 10, and exponent() will return + // exponent_ + 1. + if (m > maxRep) + { + if (exponent_ >= maxExponent) + throw std::overflow_error("Number::normalize 1.5"); + g.push(m % 10); + m /= 10; + ++exponent_; + } + // Before modification, m should be within the min/max range. After + // modification, it must be less than maxRep. In other words, the original + // value should have been no more than maxRep * 10. + // (maxRep * 10 > maxMantissa) + XRPL_ASSERT_PARTS( + m <= maxRep, + "xrpl::doNormalize", + "intermediate mantissa fits in int64"); + mantissa_ = m; - if (negative) - mantissa_ = -mantissa_; + g.doRoundUp( + negative, + mantissa_, + exponent_, + minMantissa, + maxMantissa, + "Number::normalize 2"); + XRPL_ASSERT_PARTS( + mantissa_ >= minMantissa && mantissa_ <= maxMantissa, + "xrpl::doNormalize", + "final mantissa fits in range"); +} + +template <> +void +Number::normalize( + bool& negative, + uint128_t& mantissa, + int& exponent, + internalrep const& minMantissa, + internalrep const& maxMantissa) +{ + doNormalize(negative, mantissa, exponent, minMantissa, maxMantissa); +} + +template <> +void +Number::normalize( + bool& negative, + unsigned long long& mantissa, + int& exponent, + internalrep const& minMantissa, + internalrep const& maxMantissa) +{ + doNormalize(negative, mantissa, exponent, minMantissa, maxMantissa); +} + +template <> +void +Number::normalize( + bool& negative, + unsigned long& mantissa, + int& exponent, + internalrep const& minMantissa, + internalrep const& maxMantissa) +{ + doNormalize(negative, mantissa, exponent, minMantissa, maxMantissa); +} + +void +Number::normalize() +{ + auto const& range = range_.get(); + normalize(negative_, mantissa_, exponent_, range.min, range.max); +} + +// Copy the number, but set a new exponent. Because the mantissa doesn't change, +// the result will be "mostly" normalized, but the exponent could go out of +// range. +Number +Number::shiftExponent(int exponentDelta) const +{ + XRPL_ASSERT_PARTS(isnormal(), "xrpl::Number::shiftExponent", "normalized"); + auto const newExponent = exponent_ + exponentDelta; + if (newExponent >= maxExponent) + throw std::overflow_error("Number::shiftExponent"); + if (newExponent < minExponent) + { + return Number{}; + } + Number const result{negative_, mantissa_, newExponent, unchecked{}}; + XRPL_ASSERT_PARTS( + result.isnormal(), + "xrpl::Number::shiftExponent", + "result is normalized"); + return result; } Number& Number::operator+=(Number const& y) { - if (y == Number{}) + constexpr Number zero = Number{}; + if (y == zero) return *this; - if (*this == Number{}) + if (*this == zero) { *this = y; return *this; } if (*this == -y) { - *this = Number{}; + *this = zero; return *this; } + XRPL_ASSERT( isnormal() && y.isnormal(), "xrpl::Number::operator+=(Number) : is normal"); - auto xm = mantissa(); - auto xe = exponent(); - int xn = 1; - if (xm < 0) - { - xm = -xm; - xn = -1; - } - auto ym = y.mantissa(); - auto ye = y.exponent(); - int yn = 1; - if (ym < 0) - { - ym = -ym; - yn = -1; - } + // *n = negative + // *s = sign + // *m = mantissa + // *e = exponent + + // Need to use uint128_t, because large mantissas can overflow when added + // together. + bool xn = negative_; + uint128_t xm = mantissa_; + auto xe = exponent_; + + bool yn = y.negative_; + uint128_t ym = y.mantissa_; + auto ye = y.exponent_; Guard g; if (xe < ye) { - if (xn == -1) + if (xn) g.set_negative(); do { @@ -311,7 +581,7 @@ Number::operator+=(Number const& y) } else if (xe > ye) { - if (yn == -1) + if (yn) g.set_negative(); do { @@ -320,16 +590,22 @@ Number::operator+=(Number const& y) ++ye; } while (xe > ye); } + + auto const& range = range_.get(); + auto const& minMantissa = range.min; + auto const& maxMantissa = range.max; + if (xn == yn) { xm += ym; - if (xm > maxMantissa) + if (xm > maxMantissa || xm > maxRep) { g.push(xm % 10); xm /= 10; ++xe; } - g.doRoundUp(xm, xe, "Number::addition overflow"); + g.doRoundUp( + xn, xm, xe, minMantissa, maxMantissa, "Number::addition overflow"); } else { @@ -343,16 +619,19 @@ Number::operator+=(Number const& y) xe = ye; xn = yn; } - while (xm < minMantissa) + while (xm < minMantissa && xm * 10 <= maxRep) { xm *= 10; xm -= g.pop(); --xe; } - g.doRoundDown(xm, xe); + g.doRoundDown(xn, xm, xe, minMantissa); } - mantissa_ = xm * xn; + + negative_ = xn; + mantissa_ = static_cast(xm); exponent_ = xe; + normalize(); return *this; } @@ -387,39 +666,42 @@ divu10(uint128_t& u) Number& Number::operator*=(Number const& y) { - if (*this == Number{}) + constexpr Number zero = Number{}; + if (*this == zero) return *this; - if (y == Number{}) + if (y == zero) { *this = y; return *this; } - XRPL_ASSERT( - isnormal() && y.isnormal(), - "xrpl::Number::operator*=(Number) : is normal"); - auto xm = mantissa(); - auto xe = exponent(); - int xn = 1; - if (xm < 0) - { - xm = -xm; - xn = -1; - } - auto ym = y.mantissa(); - auto ye = y.exponent(); - int yn = 1; - if (ym < 0) - { - ym = -ym; - yn = -1; - } + // *n = negative + // *s = sign + // *m = mantissa + // *e = exponent + + bool xn = negative_; + int xs = xn ? -1 : 1; + internalrep xm = mantissa_; + auto xe = exponent_; + + bool yn = y.negative_; + int ys = yn ? -1 : 1; + internalrep ym = y.mantissa_; + auto ye = y.exponent_; + auto zm = uint128_t(xm) * uint128_t(ym); auto ze = xe + ye; - auto zn = xn * yn; + auto zs = xs * ys; + bool zn = (zs == -1); Guard g; - if (zn == -1) + if (zn) g.set_negative(); - while (zm > maxMantissa) + + auto const& range = range_.get(); + auto const& minMantissa = range.min; + auto const& maxMantissa = range.max; + + while (zm > maxMantissa || zm > maxRep) { // The following is optimization for: // g.push(static_cast(zm % 10)); @@ -427,61 +709,129 @@ Number::operator*=(Number const& y) g.push(divu10(zm)); ++ze; } - xm = static_cast(zm); + xm = static_cast(zm); xe = ze; g.doRoundUp( + zn, xm, xe, + minMantissa, + maxMantissa, "Number::multiplication overflow : exponent is " + std::to_string(xe)); - mantissa_ = xm * zn; + negative_ = zn; + mantissa_ = xm; exponent_ = xe; - XRPL_ASSERT( - isnormal() || *this == Number{}, - "xrpl::Number::operator*=(Number) : result is normal"); + + normalize(); return *this; } Number& Number::operator/=(Number const& y) { - if (y == Number{}) + constexpr Number zero = Number{}; + if (y == zero) throw std::overflow_error("Number: divide by 0"); - if (*this == Number{}) + if (*this == zero) return *this; - int np = 1; - auto nm = mantissa(); - auto ne = exponent(); - if (nm < 0) + // n* = numerator + // d* = denominator + // *p = negative (positive?) + // *s = sign + // *m = mantissa + // *e = exponent + + bool np = negative_; + int ns = (np ? -1 : 1); + auto nm = mantissa_; + auto ne = exponent_; + + bool dp = y.negative_; + int ds = (dp ? -1 : 1); + auto dm = y.mantissa_; + auto de = y.exponent_; + + auto const& range = range_.get(); + auto const& minMantissa = range.min; + auto const& maxMantissa = range.max; + + // Shift by 10^17 gives greatest precision while not overflowing + // uint128_t or the cast back to int64_t + // TODO: Can/should this be made bigger for largeRange? + // log(2^128,10) ~ 38.5 + // largeRange.log = 18, fits in 10^19 + // f can be up to 10^(38-19) = 10^19 safely + static_assert(smallRange.log == 15); + static_assert(largeRange.log == 18); + bool small = Number::getMantissaScale() == MantissaRange::small; + uint128_t const f = + small ? 100'000'000'000'000'000 : 10'000'000'000'000'000'000ULL; + XRPL_ASSERT_PARTS( + f >= minMantissa * 10, "Number::operator/=", "factor expected size"); + + // unsigned denominator + auto const dmu = static_cast(dm); + // correctionFactor can be anything between 10 and f, depending on how much + // extra precision we want to only use for rounding with the + // largeRange. Three digits seems like plenty, and is more than + // the smallRange uses. + uint128_t const correctionFactor = 1'000; + + auto const numerator = uint128_t(nm) * f; + + auto zm = numerator / dmu; + auto ze = ne - de - (small ? 17 : 19); + bool zn = (ns * ds) < 0; + if (!small) { - nm = -nm; - np = -1; + // Virtually multiply numerator by correctionFactor. Since that would + // overflow in the existing uint128_t, we'll do that part separately. + // The math for this would work for small mantissas, but we need to + // preserve existing behavior. + // + // Consider: + // ((numerator * correctionFactor) / dmu) / correctionFactor + // = ((numerator / dmu) * correctionFactor) / correctionFactor) + // + // But that assumes infinite precision. With integer math, this is + // equivalent to + // + // = ((numerator / dmu * correctionFactor) + // + ((numerator % dmu) * correctionFactor) / dmu) / correctionFactor + // + // We have already set `mantissa_ = numerator / dmu`. Now we + // compute `remainder = numerator % dmu`, and if it is + // nonzero, we do the rest of the arithmetic. If it's zero, we can skip + // it. + auto const remainder = (numerator % dmu); + if (remainder != 0) + { + zm *= correctionFactor; + auto const correction = remainder * correctionFactor / dmu; + zm += correction; + // divide by 1000 by moving the exponent, so we don't lose the + // integer value we just computed + ze -= 3; + } } - int dp = 1; - auto dm = y.mantissa(); - auto de = y.exponent(); - if (dm < 0) - { - dm = -dm; - dp = -1; - } - // Shift by 10^17 gives greatest precision while not overflowing uint128_t - // or the cast back to int64_t - uint128_t const f = 100'000'000'000'000'000; - mantissa_ = static_cast(uint128_t(nm) * f / uint128_t(dm)); - exponent_ = ne - de - 17; - mantissa_ *= np * dp; - normalize(); + normalize(zn, zm, ze, minMantissa, maxMantissa); + negative_ = zn; + mantissa_ = static_cast(zm); + exponent_ = ze; + XRPL_ASSERT_PARTS( + isnormal(), "xrpl::Number::operator/=", "result is normalized"); + return *this; } Number::operator rep() const { - rep drops = mantissa_; - int offset = exponent_; + rep drops = mantissa(); + int offset = exponent(); Guard g; if (drops != 0) { - if (drops < 0) + if (negative_) { g.set_negative(); drops = -drops; @@ -493,11 +843,11 @@ Number::operator rep() const } for (; offset > 0; --offset) { - if (drops > std::numeric_limits::max() / 10) + if (drops > maxRep / 10) throw std::overflow_error("Number::operator rep() overflow"); drops *= 10; } - g.doRound(drops); + g.doRound(drops, "Number::operator rep() rounding overflow"); } return drops; } @@ -524,34 +874,37 @@ std::string to_string(Number const& amount) { // keep full internal accuracy, but make more human friendly if possible - if (amount == Number{}) + constexpr Number zero = Number{}; + if (amount == zero) return "0"; - auto const exponent = amount.exponent(); - auto mantissa = amount.mantissa(); + auto exponent = amount.exponent_; + auto mantissa = amount.mantissa_; + bool const negative = amount.negative_; // Use scientific notation for exponents that are too small or too large - if (((exponent != 0) && ((exponent < -25) || (exponent > -5)))) + auto const rangeLog = Number::mantissaLog(); + if (((exponent != 0) && + ((exponent < -(rangeLog + 10)) || (exponent > -(rangeLog - 10))))) { - std::string ret = std::to_string(mantissa); + while (mantissa != 0 && mantissa % 10 == 0 && + exponent < Number::maxExponent) + { + mantissa /= 10; + ++exponent; + } + std::string ret = negative ? "-" : ""; + ret.append(std::to_string(mantissa)); ret.append(1, 'e'); ret.append(std::to_string(exponent)); return ret; } - bool negative = false; - - if (mantissa < 0) - { - mantissa = -mantissa; - negative = true; - } - XRPL_ASSERT( exponent + 43 > 0, "xrpl::to_string(Number) : minimum exponent"); - ptrdiff_t const pad_prefix = 27; - ptrdiff_t const pad_suffix = 23; + ptrdiff_t const pad_prefix = rangeLog + 12; + ptrdiff_t const pad_suffix = rangeLog + 8; std::string const raw_value(std::to_string(mantissa)); std::string val; @@ -561,7 +914,7 @@ to_string(Number const& amount) val.append(raw_value); val.append(pad_suffix, '0'); - ptrdiff_t const offset(exponent + 43); + ptrdiff_t const offset(exponent + pad_prefix + rangeLog + 1); auto pre_from(val.begin()); auto const pre_to(val.begin() + offset); @@ -621,7 +974,7 @@ Number power(Number const& f, unsigned n) { if (n == 0) - return one; + return Number::one(); if (n == 1) return f; auto r = power(f, n / 2); @@ -643,6 +996,9 @@ power(Number const& f, unsigned n) Number root(Number f, unsigned d) { + constexpr Number zero = Number{}; + auto const one = Number::one(); + if (f == one || d == 1) return f; if (d == 0) @@ -650,16 +1006,16 @@ root(Number f, unsigned d) if (f == -one) return one; if (abs(f) < one) - return Number{}; + return zero; throw std::overflow_error("Number::root infinity"); } - if (f < Number{} && d % 2 == 0) + if (f < zero && d % 2 == 0) throw std::overflow_error("Number::root nan"); - if (f == Number{}) + if (f == zero) return f; // Scale f into the range (0, 1) such that f's exponent is a multiple of d - auto e = f.exponent() + 16; + auto e = f.exponent_ + Number::mantissaLog() + 1; auto const di = static_cast(d); auto ex = [e = e, di = di]() // Euclidean remainder of e/d { @@ -670,9 +1026,12 @@ root(Number f, unsigned d) return di - k2; }(); e += ex; - f = Number{f.mantissa(), f.exponent() - e}; // f /= 10^e; + f = f.shiftExponent(-e); // f /= 10^e; + + XRPL_ASSERT_PARTS( + f.isnormal(), "xrpl::root(Number, unsigned)", "f is normalized"); bool neg = false; - if (f < Number{}) + if (f < zero) { neg = true; f = -f; @@ -702,24 +1061,33 @@ root(Number f, unsigned d) } while (r != rm1 && r != rm2); // return r * 10^(e/d) to reverse scaling - return Number{r.mantissa(), r.exponent() + e / di}; + auto const result = r.shiftExponent(e / di); + XRPL_ASSERT_PARTS( + result.isnormal(), + "xrpl::root(Number, unsigned)", + "result is normalized"); + return result; } Number root2(Number f) { + constexpr Number zero = Number{}; + auto const one = Number::one(); + if (f == one) return f; - if (f < Number{}) + if (f < zero) throw std::overflow_error("Number::root nan"); - if (f == Number{}) + if (f == zero) return f; // Scale f into the range (0, 1) such that f's exponent is a multiple of d - auto e = f.exponent() + 16; + auto e = f.exponent_ + Number::mantissaLog() + 1; if (e % 2 != 0) ++e; - f = Number{f.mantissa(), f.exponent() - e}; // f /= 10^e; + f = f.shiftExponent(-e); // f /= 10^e; + XRPL_ASSERT_PARTS(f.isnormal(), "xrpl::root2(Number)", "f is normalized"); // Quadratic least squares curve fit of f^(1/d) in the range [0, 1] auto const D = 105; @@ -740,7 +1108,11 @@ root2(Number f) } while (r != rm1 && r != rm2); // return r * 10^(e/2) to reverse scaling - return Number{r.mantissa(), r.exponent() + e / 2}; + auto const result = r.shiftExponent(e / 2); + XRPL_ASSERT_PARTS( + result.isnormal(), "xrpl::root2(Number)", "result is normalized"); + + return result; } // Returns f^(n/d) @@ -748,6 +1120,9 @@ root2(Number f) Number power(Number const& f, unsigned n, unsigned d) { + constexpr Number zero = Number{}; + auto const one = Number::one(); + if (f == one) return f; auto g = std::gcd(n, d); @@ -758,7 +1133,7 @@ power(Number const& f, unsigned n, unsigned d) if (f == -one) return one; if (abs(f) < one) - return Number{}; + return zero; // abs(f) > one throw std::overflow_error("Number::power infinity"); } @@ -766,7 +1141,7 @@ power(Number const& f, unsigned n, unsigned d) return one; n /= g; d /= g; - if ((n % 2) == 1 && (d % 2) == 0 && f < Number{}) + if ((n % 2) == 1 && (d % 2) == 0 && f < zero) throw std::overflow_error("Number::power nan"); return root(power(f, n), d); } diff --git a/src/libxrpl/beast/core/CurrentThreadName.cpp b/src/libxrpl/beast/core/CurrentThreadName.cpp index 42dbb062b4..e8f7b629a7 100644 --- a/src/libxrpl/beast/core/CurrentThreadName.cpp +++ b/src/libxrpl/beast/core/CurrentThreadName.cpp @@ -1,7 +1,5 @@ #include -#include - #include #include @@ -73,12 +71,32 @@ setCurrentThreadNameImpl(std::string_view name) #if BOOST_OS_LINUX #include +#include + namespace beast::detail { inline void setCurrentThreadNameImpl(std::string_view name) { - pthread_setname_np(pthread_self(), name.data()); + // truncate and set the thread name. + char boundedName[maxThreadNameLength + 1]; + std::snprintf( + boundedName, + sizeof(boundedName), + "%.*s", + static_cast(maxThreadNameLength), + name.data()); + + pthread_setname_np(pthread_self(), boundedName); + +#ifdef TRUNCATED_THREAD_NAME_LOGS + if (name.size() > maxThreadNameLength) + { + std::cerr << "WARNING: Thread name \"" << name << "\" (length " + << name.size() << ") exceeds maximum of " + << maxThreadNameLength << " characters on Linux.\n"; + } +#endif } } // namespace beast::detail diff --git a/src/xrpld/app/paths/Credit.cpp b/src/libxrpl/ledger/Credit.cpp similarity index 100% rename from src/xrpld/app/paths/Credit.cpp rename to src/libxrpl/ledger/Credit.cpp diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index 256241f569..5e63c78ebf 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -464,7 +465,8 @@ accountHolds( Currency const& currency, AccountID const& issuer, FreezeHandling zeroIfFrozen, - beast::Journal j) + beast::Journal j, + SpendableHandling includeFullBalance) { STAmount amount; if (isXRP(currency)) @@ -472,11 +474,19 @@ accountHolds( return {xrpLiquid(view, account, 0, j)}; } + bool const returnSpendable = (includeFullBalance == shFULL_BALANCE); + if (returnSpendable && account == issuer) + // If the account is the issuer, then their limit is effectively + // infinite + return STAmount{ + Issue{currency, issuer}, STAmount::cMaxValue, STAmount::cMaxOffset}; + // IOU: Return balance on trust line modulo freeze SLE::const_pointer const sle = getLineIfUsable(view, account, currency, issuer, zeroIfFrozen, j); - return getTrustLineBalance(view, sle, account, currency, issuer, false, j); + return getTrustLineBalance( + view, sle, account, currency, issuer, returnSpendable, j); } STAmount @@ -485,10 +495,17 @@ accountHolds( AccountID const& account, Issue const& issue, FreezeHandling zeroIfFrozen, - beast::Journal j) + beast::Journal j, + SpendableHandling includeFullBalance) { return accountHolds( - view, account, issue.currency, issue.account, zeroIfFrozen, j); + view, + account, + issue.currency, + issue.account, + zeroIfFrozen, + j, + includeFullBalance); } STAmount @@ -498,8 +515,28 @@ accountHolds( MPTIssue const& mptIssue, FreezeHandling zeroIfFrozen, AuthHandling zeroIfUnauthorized, - beast::Journal j) + beast::Journal j, + SpendableHandling includeFullBalance) { + bool const returnSpendable = (includeFullBalance == shFULL_BALANCE); + + if (returnSpendable && account == mptIssue.getIssuer()) + { + // if the account is the issuer, and the issuance exists, their limit is + // the issuance limit minus the outstanding value + auto const issuance = + view.read(keylet::mptIssuance(mptIssue.getMptID())); + + if (!issuance) + { + return STAmount{mptIssue}; + } + return STAmount{ + mptIssue, + issuance->at(~sfMaximumAmount).value_or(maxMPTokenAmount) - + issuance->at(sfOutstandingAmount)}; + } + STAmount amount; auto const sleMpt = @@ -547,108 +584,27 @@ accountHolds( Asset const& asset, FreezeHandling zeroIfFrozen, AuthHandling zeroIfUnauthorized, - beast::Journal j) + beast::Journal j, + SpendableHandling includeFullBalance) { return std::visit( - [&](auto const& value) { - if constexpr (std::is_same_v< - std::remove_cvref_t, - Issue>) + [&](TIss const& value) { + if constexpr (std::is_same_v) { - return accountHolds(view, account, value, zeroIfFrozen, j); + return accountHolds( + view, account, value, zeroIfFrozen, j, includeFullBalance); } - return accountHolds( - view, account, value, zeroIfFrozen, zeroIfUnauthorized, j); - }, - asset.value()); -} - -STAmount -accountSpendable( - ReadView const& view, - AccountID const& account, - Currency const& currency, - AccountID const& issuer, - FreezeHandling zeroIfFrozen, - beast::Journal j) -{ - if (isXRP(currency)) - return accountHolds(view, account, currency, issuer, zeroIfFrozen, j); - - if (account == issuer) - // If the account is the issuer, then their limit is effectively - // infinite - return STAmount{ - Issue{currency, issuer}, STAmount::cMaxValue, STAmount::cMaxOffset}; - - // IOU: Return balance on trust line modulo freeze - SLE::const_pointer const sle = - getLineIfUsable(view, account, currency, issuer, zeroIfFrozen, j); - - return getTrustLineBalance(view, sle, account, currency, issuer, true, j); -} - -STAmount -accountSpendable( - ReadView const& view, - AccountID const& account, - Issue const& issue, - FreezeHandling zeroIfFrozen, - beast::Journal j) -{ - return accountSpendable( - view, account, issue.currency, issue.account, zeroIfFrozen, j); -} - -STAmount -accountSpendable( - ReadView const& view, - AccountID const& account, - MPTIssue const& mptIssue, - FreezeHandling zeroIfFrozen, - AuthHandling zeroIfUnauthorized, - beast::Journal j) -{ - if (account == mptIssue.getIssuer()) - { - // if the account is the issuer, and the issuance exists, their limit is - // the issuance limit minus the outstanding value - auto const issuance = - view.read(keylet::mptIssuance(mptIssue.getMptID())); - - if (!issuance) - { - return STAmount{mptIssue}; - } - return STAmount{ - mptIssue, - issuance->at(~sfMaximumAmount).value_or(maxMPTokenAmount) - - issuance->at(sfOutstandingAmount)}; - } - - return accountHolds( - view, account, mptIssue, zeroIfFrozen, zeroIfUnauthorized, j); -} - -[[nodiscard]] STAmount -accountSpendable( - ReadView const& view, - AccountID const& account, - Asset const& asset, - FreezeHandling zeroIfFrozen, - AuthHandling zeroIfUnauthorized, - beast::Journal j) -{ - return std::visit( - [&](auto const& value) { - if constexpr (std::is_same_v< - std::remove_cvref_t, - Issue>) + else if constexpr (std::is_same_v) { - return accountSpendable(view, account, value, zeroIfFrozen, j); + return accountHolds( + view, + account, + value, + zeroIfFrozen, + zeroIfUnauthorized, + j, + includeFullBalance); } - return accountSpendable( - view, account, value, zeroIfFrozen, zeroIfUnauthorized, j); }, asset.value()); } @@ -1466,8 +1422,7 @@ getPseudoAccountFields() // LCOV_EXCL_START LogicError( "xrpl::getPseudoAccountFields : unable to find account root " - "ledger " - "format"); + "ledger format"); // LCOV_EXCL_STOP } auto const& soTemplate = ar->getSOTemplate(); @@ -1603,12 +1558,58 @@ checkDestinationAndTag(SLE::const_ref toSle, bool hasDestinationTag) return tesSUCCESS; } +/* + * Checks if a withdrawal amount into the destination account exceeds + * any applicable receiving limit. + * Called by VaultWithdraw and LoanBrokerCoverWithdraw. + * + * IOU : Performs the trustline check against the destination account's + * credit limit to ensure the account's trust maximum is not exceeded. + * + * MPT: The limit check is effectively skipped (returns true). This is + * because MPT MaximumAmount relates to token supply, and withdrawal does not + * involve minting new tokens that could exceed the global cap. + * On withdrawal, tokens are simply transferred from the vault's pseudo-account + * to the destination account. Since no new MPT tokens are minted during this + * transfer, the withdrawal cannot violate the MPT MaximumAmount/supply cap + * even if `from` is the issuer. + */ +static TER +withdrawToDestExceedsLimit( + ReadView const& view, + AccountID const& from, + AccountID const& to, + STAmount const& amount) +{ + auto const& issuer = amount.getIssuer(); + if (from == to || to == issuer || isXRP(issuer)) + return tesSUCCESS; + + return std::visit( + [&](TIss const& issue) -> TER { + if constexpr (std::is_same_v) + { + auto const& currency = issue.currency; + auto const owed = creditBalance(view, to, issuer, currency); + if (owed <= beast::zero) + { + auto const limit = creditLimit(view, to, issuer, currency); + if (-owed >= limit || amount > (limit + owed)) + return tecNO_LINE; + } + } + return tesSUCCESS; + }, + amount.asset().value()); +} + [[nodiscard]] TER canWithdraw( - AccountID const& from, ReadView const& view, + AccountID const& from, AccountID const& to, SLE::const_ref toSle, + STAmount const& amount, bool hasDestinationTag) { if (auto const ret = checkDestinationAndTag(toSle, hasDestinationTag)) @@ -1623,19 +1624,20 @@ canWithdraw( return tecNO_PERMISSION; } - return tesSUCCESS; + return withdrawToDestExceedsLimit(view, from, to, amount); } [[nodiscard]] TER canWithdraw( - AccountID const& from, ReadView const& view, + AccountID const& from, AccountID const& to, + STAmount const& amount, bool hasDestinationTag) { auto const toSle = view.read(keylet::account(to)); - return canWithdraw(from, view, to, toSle, hasDestinationTag); + return canWithdraw(view, from, to, toSle, amount, hasDestinationTag); } [[nodiscard]] TER @@ -1644,7 +1646,8 @@ canWithdraw(ReadView const& view, STTx const& tx) auto const from = tx[sfAccount]; auto const to = tx[~sfDestination].value_or(from); - return canWithdraw(from, view, to, tx.isFieldPresent(sfDestinationTag)); + return canWithdraw( + view, from, to, tx[sfAmount], tx.isFieldPresent(sfDestinationTag)); } TER diff --git a/src/libxrpl/protocol/IOUAmount.cpp b/src/libxrpl/protocol/IOUAmount.cpp index 5c9ab1febc..297c2bac12 100644 --- a/src/libxrpl/protocol/IOUAmount.cpp +++ b/src/libxrpl/protocol/IOUAmount.cpp @@ -1,8 +1,11 @@ +#include +// Do not remove. Forces IOUAmount.h to stay first, to verify it can compile +// without any hidden dependencies #include #include #include #include -#include +#include #include @@ -40,11 +43,24 @@ setSTNumberSwitchover(bool v) } /* The range for the mantissa when normalized */ -static std::int64_t constexpr minMantissa = 1000000000000000ull; -static std::int64_t constexpr maxMantissa = 9999999999999999ull; +// log(2^63,10) ~ 18.96 +// +static std::int64_t constexpr minMantissa = STAmount::cMinValue; +static std::int64_t constexpr maxMantissa = STAmount::cMaxValue; /* The range for the exponent when normalized */ -static int constexpr minExponent = -96; -static int constexpr maxExponent = 80; +static int constexpr minExponent = STAmount::cMinOffset; +static int constexpr maxExponent = STAmount::cMaxOffset; + +IOUAmount +IOUAmount::fromNumber(Number const& number) +{ + // Need to create a default IOUAmount and assign directly so it doesn't try + // to normalize, which calls fromNumber + IOUAmount result{}; + std::tie(result.mantissa_, result.exponent_) = + number.normalizeToRange(minMantissa, maxMantissa); + return result; +} IOUAmount IOUAmount::minPositiveAmount() @@ -64,8 +80,7 @@ IOUAmount::normalize() if (getSTNumberSwitchover()) { Number const v{mantissa_, exponent_}; - mantissa_ = v.mantissa(); - exponent_ = v.exponent(); + *this = fromNumber(v); if (exponent_ > maxExponent) Throw("value overflow"); if (exponent_ < minExponent) @@ -106,8 +121,7 @@ IOUAmount::normalize() mantissa_ = -mantissa_; } -IOUAmount::IOUAmount(Number const& other) - : mantissa_(other.mantissa()), exponent_(other.exponent()) +IOUAmount::IOUAmount(Number const& other) : IOUAmount(fromNumber(other)) { if (exponent_ > maxExponent) Throw("value overflow"); diff --git a/src/libxrpl/protocol/Issue.cpp b/src/libxrpl/protocol/Issue.cpp index b858a31e3e..ca5bf35e8b 100644 --- a/src/libxrpl/protocol/Issue.cpp +++ b/src/libxrpl/protocol/Issue.cpp @@ -49,6 +49,12 @@ Issue::native() const return *this == xrpIssue(); } +bool +Issue::integral() const +{ + return native(); +} + bool isConsistent(Issue const& ac) { diff --git a/src/libxrpl/protocol/Rules.cpp b/src/libxrpl/protocol/Rules.cpp index b1f2c2d631..3710322699 100644 --- a/src/libxrpl/protocol/Rules.cpp +++ b/src/libxrpl/protocol/Rules.cpp @@ -1,10 +1,13 @@ +#include +// Do not remove. Forces Rules.h to stay first, to verify it can compile +// without any hidden dependencies #include +#include #include #include #include #include #include -#include #include #include @@ -33,6 +36,15 @@ getCurrentTransactionRules() void setCurrentTransactionRules(std::optional r) { + // Make global changes associated with the rules before the value is moved. + // Push the appropriate setting, instead of having the class pull every time + // the value is needed. That could get expensive fast. + bool enableLargeNumbers = !r || + (r->enabled(featureSingleAssetVault) || + r->enabled(featureLendingProtocol)); + Number::setMantissaScale( + enableLargeNumbers ? MantissaRange::large : MantissaRange::small); + *getCurrentTransactionRulesRef() = std::move(r); } diff --git a/src/libxrpl/protocol/STAmount.cpp b/src/libxrpl/protocol/STAmount.cpp index ebccfb3e64..ec60971e63 100644 --- a/src/libxrpl/protocol/STAmount.cpp +++ b/src/libxrpl/protocol/STAmount.cpp @@ -11,11 +11,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -310,7 +312,8 @@ STAmount& STAmount::operator=(IOUAmount const& iou) { XRPL_ASSERT( - native() == false, "xrpl::STAmount::operator=(IOUAmount) : is not XRP"); + integral() == false, + "xrpl::STAmount::operator=(IOUAmount) : is not integral"); mOffset = iou.exponent(); mIsNegative = iou < beast::zero; if (mIsNegative) @@ -320,6 +323,26 @@ STAmount::operator=(IOUAmount const& iou) return *this; } +STAmount& +STAmount::operator=(Number const& number) +{ + if (!getCurrentTransactionRules() || + isFeatureEnabled(featureSingleAssetVault) || + isFeatureEnabled(featureLendingProtocol)) + { + *this = fromNumber(mAsset, number); + } + else + { + auto const originalMantissa = number.mantissa(); + mIsNegative = originalMantissa < 0; + mValue = mIsNegative ? -originalMantissa : originalMantissa; + mOffset = number.exponent(); + } + canonicalize(); + return *this; +} + //------------------------------------------------------------------------------ // // Operators @@ -849,11 +872,11 @@ STAmount::canonicalize() if (getSTNumberSwitchover()) { - Number num( - mIsNegative ? -mValue : mValue, mOffset, Number::unchecked{}); + Number num(mIsNegative, mValue, mOffset, Number::unchecked{}); auto set = [&](auto const& val) { - mIsNegative = val.value() < 0; - mValue = mIsNegative ? -val.value() : val.value(); + auto const value = val.value(); + mIsNegative = value < 0; + mValue = mIsNegative ? -value : value; }; if (native()) set(XRPAmount{num}); @@ -1323,7 +1346,7 @@ multiply(STAmount const& v1, STAmount const& v2, Asset const& asset) if (getSTNumberSwitchover()) { auto const r = Number{v1} * Number{v2}; - return STAmount{asset, r.mantissa(), r.exponent()}; + return STAmount{asset, r}; } std::uint64_t value1 = v1.mantissa(); @@ -1471,6 +1494,10 @@ roundToScale( if (value.integral()) return value; + // Nothing to do for zero. + if (value == beast::zero) + return value; + // If the value's exponent is greater than or equal to the scale, then // rounding will do nothing, and might even lose precision, so just return // the value. diff --git a/src/libxrpl/protocol/STNumber.cpp b/src/libxrpl/protocol/STNumber.cpp index f85bb48e0a..2f2dae7493 100644 --- a/src/libxrpl/protocol/STNumber.cpp +++ b/src/libxrpl/protocol/STNumber.cpp @@ -1,9 +1,13 @@ +#include +// Do not remove. Keep STNumber.h first #include #include #include +#include #include +#include #include -#include +#include #include #include @@ -17,11 +21,11 @@ namespace xrpl { STNumber::STNumber(SField const& field, Number const& value) - : STBase(field), value_(value) + : STTakesAsset(field), value_(value) { } -STNumber::STNumber(SerialIter& sit, SField const& field) : STBase(field) +STNumber::STNumber(SerialIter& sit, SField const& field) : STTakesAsset(field) { // We must call these methods in separate statements // to guarantee their order of execution. @@ -42,6 +46,19 @@ STNumber::getText() const return to_string(value_); } +void +STNumber::associateAsset(Asset const& a) +{ + STTakesAsset::associateAsset(a); + + XRPL_ASSERT_PARTS( + getFName().shouldMeta(SField::sMD_NeedsAsset), + "STNumber::associateAsset", + "field needs asset"); + + roundToAsset(a, value_); +} + void STNumber::add(Serializer& s) const { @@ -49,8 +66,49 @@ STNumber::add(Serializer& s) const XRPL_ASSERT( getFName().fieldType == getSType(), "xrpl::STNumber::add : field type match"); - s.add64(value_.mantissa()); - s.add32(value_.exponent()); + + auto value = value_; + auto const mantissa = value.mantissa(); + auto const exponent = value.exponent(); + + SField const& field = getFName(); + if (field.shouldMeta(SField::sMD_NeedsAsset)) + { + // asset is defined in the STTakesAsset base class + if (asset_) + { + // The number should be rounded to the asset's precision, but round + // it here if it has an asset assigned. + roundToAsset(*asset_, value); + XRPL_ASSERT_PARTS( + value_ == value, + "xrpl::STNumber::add", + "value is already rounded"); + } + else + { +#if !NDEBUG + // There are circumstances where an already-rounded Number is + // serialized without being touched by a transactor, and thus + // without an asset. We can't know if it's rounded, because it could + // represent _anything_, particularly when serializing user-provided + // Json. Regardless, the only time we should be serializing an + // STNumber is when the scale is large. + XRPL_ASSERT_PARTS( + Number::getMantissaScale() == MantissaRange::large, + "xrpl::STNumber::add", + "STNumber only used with large mantissa scale"); +#endif + } + } + + XRPL_ASSERT_PARTS( + mantissa <= std::numeric_limits::max() && + mantissa >= std::numeric_limits::min(), + "xrpl::STNumber::add", + "mantissa in valid range"); + s.add64(mantissa); + s.add32(exponent); } Number const& @@ -179,20 +237,30 @@ numberFromJson(SField const& field, Json::Value const& value) else if (value.isString()) { parts = partsFromString(value.asString()); - // Only strings can represent out-of-range values. - if (parts.mantissa > std::numeric_limits::max()) - Throw("too high"); + + XRPL_ASSERT_PARTS( + !getCurrentTransactionRules(), + "xrpld::numberFromJson", + "Not in a Transactor context"); + + // Number mantissas are much bigger than the allowable parsed values, so + // it can't be out of range. + static_assert( + std::numeric_limits::max() >= + std::numeric_limits::max()); } else { Throw("not a number"); } - std::int64_t mantissa = parts.mantissa; - if (parts.negative) - mantissa = -mantissa; - - return STNumber{field, Number{mantissa, parts.exponent}}; + return STNumber{ + field, + Number{ + parts.negative, + parts.mantissa, + parts.exponent, + Number::normalized{}}}; } } // namespace xrpl diff --git a/src/libxrpl/protocol/STTakesAsset.cpp b/src/libxrpl/protocol/STTakesAsset.cpp new file mode 100644 index 0000000000..d43e7b04a1 --- /dev/null +++ b/src/libxrpl/protocol/STTakesAsset.cpp @@ -0,0 +1,29 @@ +#include +// Do not remove. Force STTakesAsset.h first +#include + +namespace xrpl { + +void +associateAsset(SLE& sle, Asset const& asset) +{ + // Iterating by offset is the only way to get non-const references + for (int i = 0; i < sle.getCount(); ++i) + { + STBase& entry = sle.getIndex(i); + SField const& field = entry.getFName(); + if (field.shouldMeta(SField::sMD_NeedsAsset)) + { + auto const type = entry.getSType(); + // If the field is not set or present, skip it. + if (type == STI_NOTPRESENT) + continue; + // If the type doesn't downcast, then the flag shouldn't be on the + // SField + auto& ta = entry.downcast(); + ta.associateAsset(asset); + } + } +} + +} // namespace xrpl diff --git a/src/libxrpl/protocol/SecretKey.cpp b/src/libxrpl/protocol/SecretKey.cpp index 88404a88a5..2507269407 100644 --- a/src/libxrpl/protocol/SecretKey.cpp +++ b/src/libxrpl/protocol/SecretKey.cpp @@ -77,7 +77,7 @@ deriveDeterministicRootKey(Seed const& seed) std::array buf; std::copy(seed.begin(), seed.end(), buf.begin()); - // The odds that this loop executes more than once are neglible + // The odds that this loop executes more than once are negligible // but *just* in case someone managed to generate a key that required // more iterations loop a few times. for (std::uint32_t seq = 0; seq != 128; ++seq) @@ -137,7 +137,7 @@ private: std::copy(generator_.begin(), generator_.end(), buf.begin()); copy_uint32(buf.data() + 33, seq); - // The odds that this loop executes more than once are neglible + // The odds that this loop executes more than once are negligible // but we impose a maximum limit just in case. for (std::uint32_t subseq = 0; subseq != 128; ++subseq) { diff --git a/src/libxrpl/resource/ResourceManager.cpp b/src/libxrpl/resource/ResourceManager.cpp index 8582836611..15d31a558e 100644 --- a/src/libxrpl/resource/ResourceManager.cpp +++ b/src/libxrpl/resource/ResourceManager.cpp @@ -140,7 +140,7 @@ private: void run() { - beast::setCurrentThreadName("Resource::Manager"); + beast::setCurrentThreadName("Resource::Mngr"); for (;;) { logic_.periodicActivity(); diff --git a/src/test/app/AMMClawback_test.cpp b/src/test/app/AMMClawback_test.cpp index 93fda8fe34..52f05f9ed5 100644 --- a/src/test/app/AMMClawback_test.cpp +++ b/src/test/app/AMMClawback_test.cpp @@ -2425,7 +2425,10 @@ class AMMClawback_test : public beast::unit_test::suite void run() override { - FeatureBitset const all = jtx::testable_amendments(); + // For now, just disable SAV entirely, which locks in the small Number + // mantissas + FeatureBitset const all = jtx::testable_amendments() - + featureSingleAssetVault - featureLendingProtocol; testInvalidRequest(); testFeatureDisabled(all - featureAMMClawback); diff --git a/src/test/app/AMMExtended_test.cpp b/src/test/app/AMMExtended_test.cpp index 317f6cb63d..d1816df51b 100644 --- a/src/test/app/AMMExtended_test.cpp +++ b/src/test/app/AMMExtended_test.cpp @@ -26,6 +26,9 @@ namespace test { */ struct AMMExtended_test : public jtx::AMMTest { + // Use small Number mantissas for the life of this test. + NumberMantissaScaleGuard const sg_{xrpl::MantissaRange::small}; + private: void testRmFundedOffer(FeatureBitset features) @@ -42,6 +45,7 @@ private: // funded and not used for the payment. using namespace jtx; + Env env{*this, features}; fund( @@ -1418,7 +1422,12 @@ private: testOffers() { using namespace jtx; - FeatureBitset const all{testable_amendments()}; + // For now, just disable SAV entirely, which locks in the small Number + // mantissas + FeatureBitset const all{ + testable_amendments() - featureSingleAssetVault - + featureLendingProtocol}; + testRmFundedOffer(all); testRmFundedOffer(all - fixAMMv1_1 - fixAMMv1_3); testEnforceNoRipple(all); @@ -3746,7 +3755,11 @@ private: testFlow() { using namespace jtx; - FeatureBitset const all{testable_amendments()}; + // For now, just disable SAV entirely, which locks in the small Number + // mantissas in the transaction engine + FeatureBitset const all{ + testable_amendments() - featureSingleAssetVault - + featureLendingProtocol}; testFalseDry(all); testBookStep(all); @@ -3760,7 +3773,11 @@ private: testCrossingLimits() { using namespace jtx; - FeatureBitset const all{testable_amendments()}; + // For now, just disable SAV entirely, which locks in the small Number + // mantissas in the transaction engine + FeatureBitset const all{ + testable_amendments() - featureSingleAssetVault - + featureLendingProtocol}; testStepLimit(all); testStepLimit(all - fixAMMv1_1 - fixAMMv1_3); } @@ -3769,7 +3786,11 @@ private: testDeliverMin() { using namespace jtx; - FeatureBitset const all{testable_amendments()}; + // For now, just disable SAV entirely, which locks in the small Number + // mantissas in the transaction engine + FeatureBitset const all{ + testable_amendments() - featureSingleAssetVault - + featureLendingProtocol}; test_convert_all_of_an_asset(all); test_convert_all_of_an_asset(all - fixAMMv1_1 - fixAMMv1_3); } @@ -3777,7 +3798,12 @@ private: void testDepositAuth() { - testPayment(jtx::testable_amendments()); + // For now, just disable SAV entirely, which locks in the small Number + // mantissas in the transaction engine + FeatureBitset const all{ + jtx::testable_amendments() - featureSingleAssetVault - + featureLendingProtocol}; + testPayment(all); testPayIOU(); } @@ -3785,7 +3811,11 @@ private: testFreeze() { using namespace test::jtx; - auto const sa = testable_amendments(); + // For now, just disable SAV entirely, which locks in the small Number + // mantissas in the transaction engine + FeatureBitset const sa{ + testable_amendments() - featureSingleAssetVault - + featureLendingProtocol}; testRippleState(sa); testGlobalFreeze(sa); testOffersWhenFrozen(sa); diff --git a/src/test/app/AMM_test.cpp b/src/test/app/AMM_test.cpp index 468d5b3ffd..55bf4aa0a3 100644 --- a/src/test/app/AMM_test.cpp +++ b/src/test/app/AMM_test.cpp @@ -30,7 +30,19 @@ namespace test { */ struct AMM_test : public jtx::AMMTest { + // Use small Number mantissas for the life of this test. + NumberMantissaScaleGuard const sg_{xrpl::MantissaRange::small}; + private: + static FeatureBitset + testable_amendments() + { + // For now, just disable SAV entirely, which locks in the small Number + // mantissas + return jtx::testable_amendments() - featureSingleAssetVault - + featureLendingProtocol; + } + void testInstanceCreate() { @@ -38,6 +50,7 @@ private: using namespace jtx; +#if NUMBERTODO // XRP to IOU, with featureSingleAssetVault testAMM( [&](AMM& ammAlice, Env&) { @@ -48,6 +61,7 @@ private: 0, {}, {testable_amendments() | featureSingleAssetVault}); +#endif // XRP to IOU, without featureSingleAssetVault testAMM( @@ -1365,8 +1379,8 @@ private: { testcase("Deposit"); - using namespace jtx; auto const all = testable_amendments(); + using namespace jtx; // Equal deposit: 1000000 tokens, 10% of the current pool testAMM([&](AMM& ammAlice, Env& env) { @@ -1384,15 +1398,14 @@ private: // equal asset deposit: unit test to exercise the rounding-down of // LPTokens in the AMMHelpers.cpp: adjustLPTokens calculations // The LPTokens need to have 16 significant digits and a fractional part - for (Number const deltaLPTokens : + for (Number const& deltaLPTokens : {Number{UINT64_C(100000'0000000009), -10}, Number{UINT64_C(100000'0000000001), -10}}) { testAMM([&](AMM& ammAlice, Env& env) { // initial LPToken balance IOUAmount const initLPToken = ammAlice.getLPTokensBalance(); - IOUAmount const newLPTokens{ - deltaLPTokens.mantissa(), deltaLPTokens.exponent()}; + IOUAmount const newLPTokens{deltaLPTokens}; // carol performs a two-asset deposit ammAlice.deposit( @@ -1417,11 +1430,9 @@ private: Number const deltaXRP = fr * 1e10; Number const deltaUSD = fr * 1e4; - STAmount const depositUSD = - STAmount{USD, deltaUSD.mantissa(), deltaUSD.exponent()}; + STAmount const depositUSD = STAmount{USD, deltaUSD}; - STAmount const depositXRP = - STAmount{XRP, deltaXRP.mantissa(), deltaXRP.exponent()}; + STAmount const depositXRP = STAmount{XRP, deltaXRP}; // initial LPTokens (1e7) + newLPTokens BEAST_EXPECT(ammAlice.expectBalances( @@ -1487,7 +1498,7 @@ private: }); // Single deposit: 100000 tokens worth of XRP - testAMM([&](AMM& ammAlice, Env&) { + testAMM([&](AMM& ammAlice, Env& env) { ammAlice.deposit(carol, 100'000, XRP(205)); BEAST_EXPECT(ammAlice.expectBalances( XRP(10'201), USD(10'000), IOUAmount{10'100'000, 0})); @@ -1668,8 +1679,8 @@ private: { testcase("Invalid Withdraw"); - using namespace jtx; auto const all = testable_amendments(); + using namespace jtx; testAMM( [&](AMM& ammAlice, Env& env) { @@ -2248,8 +2259,8 @@ private: { testcase("Withdraw"); - using namespace jtx; auto const all = testable_amendments(); + using namespace jtx; // Equal withdrawal by Carol: 1000000 of tokens, 10% of the current // pool @@ -2669,8 +2680,8 @@ private: testFeeVote() { testcase("Fee Vote"); - using namespace jtx; auto const all = testable_amendments(); + using namespace jtx; // One vote sets fee to 1%. testAMM([&](AMM& ammAlice, Env& env) { @@ -3014,6 +3025,10 @@ private: using namespace jtx; using namespace std::chrono; + // For now, just disable SAV entirely, which locks in the small Number + // mantissas + features = features - featureSingleAssetVault - featureLendingProtocol; + // Auction slot initially is owned by AMM creator, who pays 0 price. // Bid 110 tokens. Pay bidMin. @@ -3758,6 +3773,11 @@ private: testcase("Basic Payment"); using namespace jtx; + // For now, just disable SAV entirely, which locks in the small Number + // mantissas + features = features - featureSingleAssetVault - featureLendingProtocol - + featureLendingProtocol; + // Payment 100USD for 100XRP. // Force one path with tfNoRippleDirect. testAMM( @@ -4836,12 +4856,12 @@ private: testAmendment() { testcase("Amendment"); - using namespace jtx; FeatureBitset const all{testable_amendments()}; FeatureBitset const noAMM{all - featureAMM}; FeatureBitset const noNumber{all - fixUniversalNumber}; FeatureBitset const noAMMAndNumber{ all - featureAMM - fixUniversalNumber}; + using namespace jtx; for (auto const& feature : {noAMM, noNumber, noAMMAndNumber}) { @@ -6476,6 +6496,8 @@ private: Env env(*this, features, std::make_unique(&logs)); auto rules = env.current()->rules(); CurrentTransactionRulesGuard rg(rules); + NumberMantissaScaleGuard sg(MantissaRange::small); + for (auto const& t : tests) { auto getPool = [&](std::string const& v, bool isXRP) { @@ -7025,7 +7047,7 @@ private: {{xrpPool, iouPool}}, 889, std::nullopt, - {jtx::testable_amendments() | fixAMMv1_1}); + {testable_amendments() | fixAMMv1_1}); } void @@ -7566,6 +7588,7 @@ private: { auto const [amount, amount2, lptBalance] = amm.balances(GBP, EUR); + NumberMantissaScaleGuard sg(MantissaRange::small); NumberRoundModeGuard g( env.enabled(fixAMMv1_3) ? Number::upward : Number::getround()); auto const res = root2(amount * amount2); @@ -7880,7 +7903,7 @@ private: void run() override { - FeatureBitset const all{jtx::testable_amendments()}; + FeatureBitset const all{testable_amendments()}; testInvalidInstance(); testInstanceCreate(); testInvalidDeposit(all); diff --git a/src/test/app/Batch_test.cpp b/src/test/app/Batch_test.cpp index 6fbec52a93..67b0933ae2 100644 --- a/src/test/app/Batch_test.cpp +++ b/src/test/app/Batch_test.cpp @@ -148,15 +148,21 @@ class Batch_test : public beast::unit_test::suite void testEnable(FeatureBitset features) { - testcase("enabled"); - using namespace test::jtx; using namespace std::literals; + bool const withInnerSigFix = features[fixBatchInnerSigs]; + for (bool const withBatch : {true, false}) { + testcase << "enabled: Batch " + << (withBatch ? "enabled" : "disabled") + << ", Inner Sig Fix: " + << (withInnerSigFix ? "enabled" : "disabled"); + auto const amend = withBatch ? features : features - featureBatch; - test::jtx::Env env{*this, envconfig(), amend}; + + test::jtx::Env env{*this, amend}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -179,7 +185,7 @@ class Batch_test : public beast::unit_test::suite // tfInnerBatchTxn // If the feature is disabled, the transaction fails with - // temINVALID_FLAG If the feature is enabled, the transaction fails + // temINVALID_FLAG. If the feature is enabled, the transaction fails // early in checkValidity() { auto const txResult = @@ -205,7 +211,7 @@ class Batch_test : public beast::unit_test::suite //---------------------------------------------------------------------- // preflight - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -421,6 +427,7 @@ class Batch_test : public beast::unit_test::suite auto const batchFee = batch::calcBatchFee(env, 0, 2); auto tx1 = batch::inner(pay(alice, bob, XRP(1)), seq + 1); tx1[jss::Fee] = "1.5"; + env.set_parse_failure_expected(true); try { env(batch::outer(alice, seq, batchFee, tfAllOrNothing), @@ -432,6 +439,7 @@ class Batch_test : public beast::unit_test::suite { BEAST_EXPECT(true); } + env.set_parse_failure_expected(false); } // temSEQ_AND_TICKET: Batch: inner txn cannot have both Sequence @@ -617,7 +625,7 @@ class Batch_test : public beast::unit_test::suite //---------------------------------------------------------------------- // preclaim - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -858,7 +866,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -949,7 +957,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1187,7 +1195,7 @@ class Batch_test : public beast::unit_test::suite // Bad Fee Without Signer { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1209,7 +1217,7 @@ class Batch_test : public beast::unit_test::suite // Bad Fee With MultiSign { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1236,7 +1244,7 @@ class Batch_test : public beast::unit_test::suite // Bad Fee With MultiSign + BatchSigners { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1265,7 +1273,7 @@ class Batch_test : public beast::unit_test::suite // Bad Fee With MultiSign + BatchSigners.Signers { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1297,7 +1305,7 @@ class Batch_test : public beast::unit_test::suite // Bad Fee With BatchSigners { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1321,7 +1329,7 @@ class Batch_test : public beast::unit_test::suite // Bad Fee Dynamic Fee Calculation { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1361,7 +1369,7 @@ class Batch_test : public beast::unit_test::suite // telENV_RPC_FAILED: Batch: txns array exceeds 8 entries. { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1386,7 +1394,7 @@ class Batch_test : public beast::unit_test::suite // temARRAY_TOO_LARGE: Batch: txns array exceeds 8 entries. { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1419,7 +1427,7 @@ class Batch_test : public beast::unit_test::suite // telENV_RPC_FAILED: Batch: signers array exceeds 8 entries. { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1438,7 +1446,7 @@ class Batch_test : public beast::unit_test::suite // temARRAY_TOO_LARGE: Batch: signers array exceeds 8 entries. { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1472,7 +1480,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1608,7 +1616,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1840,7 +1848,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2062,7 +2070,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2248,14 +2256,26 @@ class Batch_test : public beast::unit_test::suite } void - testInnerSubmitRPC(FeatureBitset features) + doTestInnerSubmitRPC(FeatureBitset features, bool withBatch) { - testcase("inner submit rpc"); + bool const withInnerSigFix = features[fixBatchInnerSigs]; + + std::string const testName = [&]() { + std::stringstream ss; + ss << "inner submit rpc: batch " + << (withBatch ? "enabled" : "disabled") << ", inner sig fix: " + << (withInnerSigFix ? "enabled" : "disabled") << ": "; + return ss.str(); + }(); + + auto const amend = withBatch ? features : features - featureBatch; using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, amend}; + if (!BEAST_EXPECT(amend[featureBatch] == withBatch)) + return; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2263,75 +2283,170 @@ class Batch_test : public beast::unit_test::suite env.fund(XRP(10000), alice, bob); env.close(); - auto submitAndValidate = [&](Slice const& slice) { - auto const jrr = env.rpc("submit", strHex(slice))[jss::result]; - BEAST_EXPECT( - jrr[jss::status] == "error" && - jrr[jss::error] == "invalidTransaction" && - jrr[jss::error_exception] == - "fails local checks: Malformed: Invalid inner batch " - "transaction."); - env.close(); - }; + auto submitAndValidate = + [&](std::string caseName, + Slice const& slice, + int line, + std::optional expectedEnabled = std::nullopt, + std::optional expectedDisabled = std::nullopt, + bool expectInvalidFlag = false) { + testcase << testName << caseName + << (expectInvalidFlag + ? " - Expected to reach tx engine!" + : ""); + auto const jrr = env.rpc("submit", strHex(slice))[jss::result]; + auto const expected = withBatch + ? expectedEnabled.value_or( + "fails local checks: Malformed: Invalid inner batch " + "transaction.") + : expectedDisabled.value_or( + "fails local checks: Empty SigningPubKey."); + if (expectInvalidFlag) + { + expect( + jrr[jss::status] == "success" && + jrr[jss::engine_result] == "temINVALID_FLAG", + pretty(jrr), + __FILE__, + line); + } + else + { + expect( + jrr[jss::status] == "error" && + jrr[jss::error] == "invalidTransaction" && + jrr[jss::error_exception] == expected, + pretty(jrr), + __FILE__, + line); + } + env.close(); + }; // Invalid RPC Submission: TxnSignature - // - has `TxnSignature` field + // + has `TxnSignature` field // - has no `SigningPubKey` field // - has no `Signers` field - // - has `tfInnerBatchTxn` flag + // + has `tfInnerBatchTxn` flag { auto txn = batch::inner(pay(alice, bob, XRP(1)), env.seq(alice)); txn[sfTxnSignature] = "DEADBEEF"; STParsedJSONObject parsed("test", txn.getTxn()); Serializer s; parsed.object->add(s); - submitAndValidate(s.slice()); + submitAndValidate("TxnSignature set", s.slice(), __LINE__); } // Invalid RPC Submission: SigningPubKey // - has no `TxnSignature` field - // - has `SigningPubKey` field + // + has `SigningPubKey` field // - has no `Signers` field - // - has `tfInnerBatchTxn` flag + // + has `tfInnerBatchTxn` flag { auto txn = batch::inner(pay(alice, bob, XRP(1)), env.seq(alice)); txn[sfSigningPubKey] = strHex(alice.pk()); STParsedJSONObject parsed("test", txn.getTxn()); Serializer s; parsed.object->add(s); - submitAndValidate(s.slice()); + submitAndValidate( + "SigningPubKey set", + s.slice(), + __LINE__, + std::nullopt, + "fails local checks: Invalid signature."); } // Invalid RPC Submission: Signers // - has no `TxnSignature` field - // - has empty `SigningPubKey` field - // - has `Signers` field - // - has `tfInnerBatchTxn` flag + // + has empty `SigningPubKey` field + // + has `Signers` field + // + has `tfInnerBatchTxn` flag { auto txn = batch::inner(pay(alice, bob, XRP(1)), env.seq(alice)); txn[sfSigners] = Json::arrayValue; STParsedJSONObject parsed("test", txn.getTxn()); Serializer s; parsed.object->add(s); - submitAndValidate(s.slice()); + submitAndValidate( + "Signers set", + s.slice(), + __LINE__, + std::nullopt, + "fails local checks: Invalid Signers array size."); + } + + { + // Fully signed inner batch transaction + auto const txn = + batch::inner(pay(alice, bob, XRP(1)), env.seq(alice)); + auto const jt = env.jt(txn.getTxn()); + + STParsedJSONObject parsed("test", jt.jv); + Serializer s; + parsed.object->add(s); + submitAndValidate( + "Fully signed", + s.slice(), + __LINE__, + std::nullopt, + std::nullopt, + !withBatch); } // Invalid RPC Submission: tfInnerBatchTxn // - has no `TxnSignature` field - // - has empty `SigningPubKey` field + // + has empty `SigningPubKey` field // - has no `Signers` field - // - has `tfInnerBatchTxn` flag + // + has `tfInnerBatchTxn` flag { auto txn = batch::inner(pay(alice, bob, XRP(1)), env.seq(alice)); STParsedJSONObject parsed("test", txn.getTxn()); Serializer s; parsed.object->add(s); - auto const jrr = env.rpc("submit", strHex(s.slice()))[jss::result]; - BEAST_EXPECT( - jrr[jss::status] == "success" && - jrr[jss::engine_result] == "temINVALID_FLAG"); + submitAndValidate( + "No signing fields set", + s.slice(), + __LINE__, + "fails local checks: Empty SigningPubKey.", + "fails local checks: Empty SigningPubKey.", + withBatch && !withInnerSigFix); + } - env.close(); + // Invalid RPC Submission: tfInnerBatchTxn pseudo-transaction + // - has no `TxnSignature` field + // + has empty `SigningPubKey` field + // - has no `Signers` field + // + has `tfInnerBatchTxn` flag + { + STTx amendTx( + ttAMENDMENT, [seq = env.closed()->header().seq + 1](auto& obj) { + obj.setAccountID(sfAccount, AccountID()); + obj.setFieldH256(sfAmendment, fixBatchInnerSigs); + obj.setFieldU32(sfLedgerSequence, seq); + obj.setFieldU32(sfFlags, tfInnerBatchTxn); + }); + auto txn = batch::inner( + amendTx.getJson(JsonOptions::none), env.seq(alice)); + STParsedJSONObject parsed("test", txn.getTxn()); + Serializer s; + parsed.object->add(s); + submitAndValidate( + "Pseudo-transaction", + s.slice(), + __LINE__, + withInnerSigFix + ? "fails local checks: Empty SigningPubKey." + : "fails local checks: Cannot submit pseudo transactions.", + "fails local checks: Empty SigningPubKey."); + } + } + + void + testInnerSubmitRPC(FeatureBitset features) + { + for (bool const withBatch : {true, false}) + { + doTestInnerSubmitRPC(features, withBatch); } } @@ -2343,7 +2458,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2390,7 +2505,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2443,7 +2558,7 @@ class Batch_test : public beast::unit_test::suite // tfIndependent: account delete success { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2484,7 +2599,7 @@ class Batch_test : public beast::unit_test::suite // tfIndependent: account delete fails { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2529,7 +2644,7 @@ class Batch_test : public beast::unit_test::suite // tfAllOrNothing: account delete fails { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2581,7 +2696,6 @@ class Batch_test : public beast::unit_test::suite test::jtx::Env env{ *this, - envconfig(), features | featureSingleAssetVault | featureLendingProtocol | featureMPTokensV1}; @@ -2776,7 +2890,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2889,7 +3003,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2947,7 +3061,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -3009,7 +3123,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -3058,7 +3172,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -3106,7 +3220,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -3169,7 +3283,7 @@ class Batch_test : public beast::unit_test::suite // overwritten by the payment in the batch transaction. Because the // terPRE_SEQ is outside of the batch this noop transaction will ge // reapplied in the following ledger - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob, carol); env.close(); @@ -3216,7 +3330,7 @@ class Batch_test : public beast::unit_test::suite // IMPORTANT: The batch txn is applied first, then the noop txn. // Because of this ordering, the noop txn is not applied and is // overwritten by the payment in the batch transaction. - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob); env.close(); @@ -3258,7 +3372,7 @@ class Batch_test : public beast::unit_test::suite // IMPORTANT: The batch txn is applied first, then the noop txn. // Because of this ordering, the noop txn is not applied and is // overwritten by the payment in the batch transaction. - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob); env.close(); @@ -3295,7 +3409,7 @@ class Batch_test : public beast::unit_test::suite // Outer Batch terPRE_SEQ { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob, carol); env.close(); @@ -3353,7 +3467,7 @@ class Batch_test : public beast::unit_test::suite // IMPORTANT: The batch txn is applied first, then the noop txn. // Because of this ordering, the noop txn is not applied and is // overwritten by the payment in the batch transaction. - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob); env.close(); @@ -3402,7 +3516,7 @@ class Batch_test : public beast::unit_test::suite // IMPORTANT: The batch txn is applied first, then the noop txn. // Because of this ordering, the noop txn is not applied and is // overwritten by the payment in the batch transaction. - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob); env.close(); @@ -3464,7 +3578,7 @@ class Batch_test : public beast::unit_test::suite // batch will run in the close ledger process. The batch will be // allied and then retry this transaction in the current ledger. - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob); env.close(); @@ -3511,7 +3625,7 @@ class Batch_test : public beast::unit_test::suite // Create Object Before Batch Txn { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob); env.close(); @@ -3558,7 +3672,7 @@ class Batch_test : public beast::unit_test::suite // batch will run in the close ledger process. The batch will be // applied and then retry this transaction in the current ledger. - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob); env.close(); @@ -3605,7 +3719,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -3644,7 +3758,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; XRPAmount const baseFee = env.current()->fees().base; auto const alice = Account("alice"); @@ -3725,6 +3839,7 @@ class Batch_test : public beast::unit_test::suite *this, makeSmallQueueConfig( {{"minimum_txn_in_ledger_standalone", "2"}}), + features, nullptr, beast::severities::kError}; @@ -3785,6 +3900,7 @@ class Batch_test : public beast::unit_test::suite *this, makeSmallQueueConfig( {{"minimum_txn_in_ledger_standalone", "2"}}), + features, nullptr, beast::severities::kError}; @@ -3892,7 +4008,7 @@ class Batch_test : public beast::unit_test::suite // delegated non atomic inner { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -3937,7 +4053,7 @@ class Batch_test : public beast::unit_test::suite // delegated atomic inner { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -3989,7 +4105,7 @@ class Batch_test : public beast::unit_test::suite // this also makes sure tfInnerBatchTxn won't block delegated AccountSet // with granular permission { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -4038,7 +4154,7 @@ class Batch_test : public beast::unit_test::suite // this also makes sure tfInnerBatchTxn won't block delegated // MPTokenIssuanceSet with granular permission { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; Account alice{"alice"}; Account bob{"bob"}; env.fund(XRP(100000), alice, bob); @@ -4094,7 +4210,7 @@ class Batch_test : public beast::unit_test::suite // this also makes sure tfInnerBatchTxn won't block delegated TrustSet // with granular permission { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; Account gw{"gw"}; Account alice{"alice"}; Account bob{"bob"}; @@ -4134,7 +4250,7 @@ class Batch_test : public beast::unit_test::suite // inner transaction not authorized by the delegating account. { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; Account gw{"gw"}; Account alice{"alice"}; Account bob{"bob"}; @@ -4182,7 +4298,7 @@ class Batch_test : public beast::unit_test::suite testcase("Validate RPC response"); using namespace jtx; - Env env(*this); + Env env(*this, features); Account const alice("alice"); Account const bob("bob"); env.fund(XRP(10000), alice, bob); @@ -4259,7 +4375,7 @@ class Batch_test : public beast::unit_test::suite testBatchCalculateBaseFee(FeatureBitset features) { using namespace jtx; - Env env(*this); + Env env(*this, features); Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); @@ -4384,6 +4500,7 @@ public: { using namespace test::jtx; auto const sa = testable_amendments(); + testWithFeats(sa - fixBatchInnerSigs); testWithFeats(sa); } }; diff --git a/src/test/app/EscrowToken_test.cpp b/src/test/app/EscrowToken_test.cpp index 955ca8f449..589a8b474e 100644 --- a/src/test/app/EscrowToken_test.cpp +++ b/src/test/app/EscrowToken_test.cpp @@ -559,12 +559,15 @@ struct EscrowToken_test : public beast::unit_test::suite env(pay(gw, bob, USD(1))); env.close(); + bool const largeMantissa = features[featureSingleAssetVault] || + features[featureLendingProtocol]; + // alice cannot create escrow for 1/10 iou - precision loss env(escrow::create(alice, bob, USD(1)), escrow::condition(escrow::cb1), escrow::finish_time(env.now() + 1s), fee(baseFee * 150), - ter(tecPRECISION_LOSS)); + ter(largeMantissa ? (TER)tesSUCCESS : (TER)tecPRECISION_LOSS)); env.close(); } } @@ -2076,12 +2079,15 @@ struct EscrowToken_test : public beast::unit_test::suite env(pay(gw, bob, USD(1))); env.close(); + bool const largeMantissa = features[featureSingleAssetVault] || + features[featureLendingProtocol]; + // alice cannot create escrow for 1/10 iou - precision loss env(escrow::create(alice, bob, USD(1)), escrow::condition(escrow::cb1), escrow::finish_time(env.now() + 1s), fee(baseFee * 150), - ter(tecPRECISION_LOSS)); + ter(largeMantissa ? (TER)tesSUCCESS : (TER)tecPRECISION_LOSS)); env.close(); auto const seq1 = env.seq(alice); @@ -3924,9 +3930,13 @@ public: { using namespace test::jtx; FeatureBitset const all{testable_amendments()}; - testIOUWithFeats(all); - testMPTWithFeats(all); - testMPTWithFeats(all - fixTokenEscrowV1); + for (FeatureBitset const& feats : + {all - featureSingleAssetVault - featureLendingProtocol, all}) + { + testIOUWithFeats(feats); + testMPTWithFeats(feats); + testMPTWithFeats(feats - fixTokenEscrowV1); + } } }; diff --git a/src/test/app/HashRouter_test.cpp b/src/test/app/HashRouter_test.cpp index c428917fdc..2d1d37c3e3 100644 --- a/src/test/app/HashRouter_test.cpp +++ b/src/test/app/HashRouter_test.cpp @@ -349,7 +349,7 @@ class HashRouter_test : public beast::unit_test::suite h.set("hold_time", "alice"); h.set("relay_time", "bob"); auto const setup = setup_HashRouter(cfg); - // The set function ignores values that don't covert, so the + // The set function ignores values that don't convert, so the // defaults are left unchanged BEAST_EXPECT(setup.holdTime == 300s); BEAST_EXPECT(setup.relayTime == 30s); diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp new file mode 100644 index 0000000000..55fffad6b0 --- /dev/null +++ b/src/test/app/LendingHelpers_test.cpp @@ -0,0 +1,1352 @@ +#include +// DO NOT REMOVE +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace xrpl { +namespace test { + +class LendingHelpers_test : public beast::unit_test::suite +{ + void + testComputeRaisedRate() + { + using namespace jtx; + using namespace xrpl::detail; + struct TestCase + { + std::string name; + Number periodicRate; + std::uint32_t paymentsRemaining; + Number expectedRaisedRate; + }; + + auto const testCases = std::vector{ + { + .name = "Zero payments remaining", + .periodicRate = Number{5, -2}, + .paymentsRemaining = 0, + .expectedRaisedRate = Number{1}, // (1 + r)^0 = 1 + }, + { + .name = "One payment remaining", + .periodicRate = Number{5, -2}, + .paymentsRemaining = 1, + .expectedRaisedRate = Number{105, -2}, + }, // 1.05^1 + { + .name = "Multiple payments remaining", + .periodicRate = Number{5, -2}, + .paymentsRemaining = 3, + .expectedRaisedRate = Number{1157625, -6}, + }, // 1.05^3 + { + .name = "Zero periodic rate", + .periodicRate = Number{0}, + .paymentsRemaining = 5, + .expectedRaisedRate = Number{1}, // (1 + 0)^5 = 1 + }}; + + for (auto const& tc : testCases) + { + testcase("computeRaisedRate: " + tc.name); + + auto const computedRaisedRate = + computeRaisedRate(tc.periodicRate, tc.paymentsRemaining); + BEAST_EXPECTS( + computedRaisedRate == tc.expectedRaisedRate, + "Raised rate mismatch: expected " + + to_string(tc.expectedRaisedRate) + ", got " + + to_string(computedRaisedRate)); + } + } + + void + testComputePaymentFactor() + { + using namespace jtx; + using namespace xrpl::detail; + struct TestCase + { + std::string name; + Number periodicRate; + std::uint32_t paymentsRemaining; + Number expectedPaymentFactor; + }; + + auto const testCases = std::vector{ + { + .name = "Zero periodic rate", + .periodicRate = Number{0}, + .paymentsRemaining = 4, + .expectedPaymentFactor = Number{25, -2}, + }, // 1/4 = 0.25 + { + .name = "One payment remaining", + .periodicRate = Number{5, -2}, + .paymentsRemaining = 1, + .expectedPaymentFactor = Number{105, -2}, + }, // 0.05/1 = 1.05 + { + .name = "Multiple payments remaining", + .periodicRate = Number{5, -2}, + .paymentsRemaining = 3, + .expectedPaymentFactor = Number{3672085646312450436, -19}, + }, // from calc + { + .name = "Zero payments remaining", + .periodicRate = Number{5, -2}, + .paymentsRemaining = 0, + .expectedPaymentFactor = Number{0}, + } // edge case + }; + + for (auto const& tc : testCases) + { + testcase("computePaymentFactor: " + tc.name); + + auto const computedPaymentFactor = + computePaymentFactor(tc.periodicRate, tc.paymentsRemaining); + BEAST_EXPECTS( + computedPaymentFactor == tc.expectedPaymentFactor, + "Payment factor mismatch: expected " + + to_string(tc.expectedPaymentFactor) + ", got " + + to_string(computedPaymentFactor)); + } + } + + void + testLoanPeriodicPayment() + { + using namespace jtx; + using namespace xrpl::detail; + + struct TestCase + { + std::string name; + Number principalOutstanding; + Number periodicRate; + std::uint32_t paymentsRemaining; + Number expectedPeriodicPayment; + }; + + auto const testCases = std::vector{ + { + .name = "Zero principal outstanding", + .principalOutstanding = Number{0}, + .periodicRate = Number{5, -2}, + .paymentsRemaining = 5, + .expectedPeriodicPayment = Number{0}, + }, + { + .name = "Zero payments remaining", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .paymentsRemaining = 0, + .expectedPeriodicPayment = Number{0}, + }, + { + .name = "Zero periodic rate", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{0}, + .paymentsRemaining = 4, + .expectedPeriodicPayment = Number{250}, + }, + { + .name = "Standard case", + .principalOutstanding = Number{1'000}, + .periodicRate = + loanPeriodicRate(TenthBips32(100'000), 30 * 24 * 60 * 60), + .paymentsRemaining = 3, + .expectedPeriodicPayment = + Number{389569066396123265, -15}, // from calc + }, + }; + + for (auto const& tc : testCases) + { + testcase("loanPeriodicPayment: " + tc.name); + + auto const computedPeriodicPayment = loanPeriodicPayment( + tc.principalOutstanding, tc.periodicRate, tc.paymentsRemaining); + BEAST_EXPECTS( + computedPeriodicPayment == tc.expectedPeriodicPayment, + "Periodic payment mismatch: expected " + + to_string(tc.expectedPeriodicPayment) + ", got " + + to_string(computedPeriodicPayment)); + } + } + + void + testLoanPrincipalFromPeriodicPayment() + { + using namespace jtx; + using namespace xrpl::detail; + + struct TestCase + { + std::string name; + Number periodicPayment; + Number periodicRate; + std::uint32_t paymentsRemaining; + Number expectedPrincipalOutstanding; + }; + + auto const testCases = std::vector{ + { + .name = "Zero periodic payment", + .periodicPayment = Number{0}, + .periodicRate = Number{5, -2}, + .paymentsRemaining = 5, + .expectedPrincipalOutstanding = Number{0}, + }, + { + .name = "Zero payments remaining", + .periodicPayment = Number{1'000}, + .periodicRate = Number{5, -2}, + .paymentsRemaining = 0, + .expectedPrincipalOutstanding = Number{0}, + }, + { + .name = "Zero periodic rate", + .periodicPayment = Number{250}, + .periodicRate = Number{0}, + .paymentsRemaining = 4, + .expectedPrincipalOutstanding = Number{1'000}, + }, + { + .name = "Standard case", + .periodicPayment = + Number{389569066396123265, -15}, // from calc + .periodicRate = + loanPeriodicRate(TenthBips32(100'000), 30 * 24 * 60 * 60), + .paymentsRemaining = 3, + .expectedPrincipalOutstanding = Number{1'000}, + }, + }; + + for (auto const& tc : testCases) + { + testcase("loanPrincipalFromPeriodicPayment: " + tc.name); + + auto const computedPrincipalOutstanding = + loanPrincipalFromPeriodicPayment( + tc.periodicPayment, tc.periodicRate, tc.paymentsRemaining); + BEAST_EXPECTS( + computedPrincipalOutstanding == tc.expectedPrincipalOutstanding, + "Principal outstanding mismatch: expected " + + to_string(tc.expectedPrincipalOutstanding) + ", got " + + to_string(computedPrincipalOutstanding)); + } + } + + void + testComputeOverpaymentComponents() + { + testcase("computeOverpaymentComponents"); + using namespace jtx; + using namespace xrpl::detail; + + Account const issuer{"issuer"}; + PrettyAsset const IOU = issuer["IOU"]; + int32_t const loanScale = 1; + auto const overpayment = Number{1'000}; + auto const overpaymentInterestRate = TenthBips32{10'000}; // 10% + auto const overpaymentFeeRate = TenthBips32{50'000}; // 50% + auto const managementFeeRate = TenthBips16{10'000}; // 10% + + auto const expectedOverpaymentFee = Number{500}; // 50% of 1,000 + auto const expectedOverpaymentInterestGross = + Number{100}; // 10% of 1,000 + auto const expectedOverpaymentInterestNet = + Number{90}; // 100 - 10% of 100 + auto const expectedOverpaymentManagementFee = Number{10}; // 10% of 100 + auto const expectedPrincipalPortion = Number{400}; // 1,000 - 100 - 500 + + auto const components = detail::computeOverpaymentComponents( + IOU, + loanScale, + overpayment, + overpaymentInterestRate, + overpaymentFeeRate, + managementFeeRate); + + BEAST_EXPECT( + components.untrackedManagementFee == expectedOverpaymentFee); + + BEAST_EXPECT( + components.untrackedInterest == expectedOverpaymentInterestNet); + + BEAST_EXPECT( + components.trackedInterestPart() == expectedOverpaymentInterestNet); + + BEAST_EXPECT( + components.trackedManagementFeeDelta == + expectedOverpaymentManagementFee); + BEAST_EXPECT( + components.trackedPrincipalDelta == expectedPrincipalPortion); + BEAST_EXPECT( + components.trackedManagementFeeDelta + + components.untrackedInterest == + expectedOverpaymentInterestGross); + + BEAST_EXPECT( + components.trackedManagementFeeDelta + + components.untrackedInterest + + components.trackedPrincipalDelta + + components.untrackedManagementFee == + overpayment); + } + + void + testComputeInterestAndFeeParts() + { + using namespace jtx; + using namespace xrpl::detail; + + struct TestCase + { + std::string name; + Number interest; + TenthBips16 managementFeeRate; + Number expectedInterestPart; + Number expectedFeePart; + }; + + Account const issuer{"issuer"}; + PrettyAsset const IOU = issuer["IOU"]; + std::int32_t const loanScale = 1; + + auto const testCases = std::vector{ + {.name = "Zero interest", + .interest = Number{0}, + .managementFeeRate = TenthBips16{10'000}, + .expectedInterestPart = Number{0}, + .expectedFeePart = Number{0}}, + {.name = "Zero fee rate", + .interest = Number{1'000}, + .managementFeeRate = TenthBips16{0}, + .expectedInterestPart = Number{1'000}, + .expectedFeePart = Number{0}}, + {.name = "10% fee rate", + .interest = Number{1'000}, + .managementFeeRate = TenthBips16{10'000}, + .expectedInterestPart = Number{900}, + .expectedFeePart = Number{100}}, + }; + + for (auto const& tc : testCases) + { + testcase("computeInterestAndFeeParts: " + tc.name); + + auto const [computedInterestPart, computedFeePart] = + computeInterestAndFeeParts( + IOU, tc.interest, tc.managementFeeRate, loanScale); + BEAST_EXPECTS( + computedInterestPart == tc.expectedInterestPart, + "Interest part mismatch: expected " + + to_string(tc.expectedInterestPart) + ", got " + + to_string(computedInterestPart)); + BEAST_EXPECTS( + computedFeePart == tc.expectedFeePart, + "Fee part mismatch: expected " + to_string(tc.expectedFeePart) + + ", got " + to_string(computedFeePart)); + } + } + + void + testLoanLatePaymentInterest() + { + using namespace jtx; + using namespace xrpl::detail; + struct TestCase + { + std::string name; + Number principalOutstanding; + TenthBips32 lateInterestRate; + NetClock::time_point parentCloseTime; + std::uint32_t nextPaymentDueDate; + Number expectedLateInterest; + }; + + auto const testCases = std::vector{ + { + .name = "On-time payment", + .principalOutstanding = Number{1'000}, + .lateInterestRate = TenthBips32{10'000}, // 10% + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .nextPaymentDueDate = 3'000, + .expectedLateInterest = Number{0}, + }, + { + .name = "Early payment", + .principalOutstanding = Number{1'000}, + .lateInterestRate = TenthBips32{10'000}, // 10% + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .nextPaymentDueDate = 4'000, + .expectedLateInterest = Number{0}, + }, + { + .name = "No principal outstanding", + .principalOutstanding = Number{0}, + .lateInterestRate = TenthBips32{10'000}, // 10% + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .nextPaymentDueDate = 2'000, + .expectedLateInterest = Number{0}, + }, + { + .name = "No late interest rate", + .principalOutstanding = Number{1'000}, + .lateInterestRate = TenthBips32{0}, // 0% + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .nextPaymentDueDate = 2'000, + .expectedLateInterest = Number{0}, + }, + { + .name = "Late payment", + .principalOutstanding = Number{1'000}, + .lateInterestRate = TenthBips32{100'000}, // 100% + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .nextPaymentDueDate = 2'000, + .expectedLateInterest = + Number{317097919837645865, -19}, // from calc + }, + }; + + for (auto const& tc : testCases) + { + testcase("loanLatePaymentInterest: " + tc.name); + + auto const computedLateInterest = loanLatePaymentInterest( + tc.principalOutstanding, + tc.lateInterestRate, + tc.parentCloseTime, + tc.nextPaymentDueDate); + BEAST_EXPECTS( + computedLateInterest == tc.expectedLateInterest, + "Late interest mismatch: expected " + + to_string(tc.expectedLateInterest) + ", got " + + to_string(computedLateInterest)); + } + } + + void + testLoanAccruedInterest() + { + using namespace jtx; + using namespace xrpl::detail; + struct TestCase + { + std::string name; + Number principalOutstanding; + Number periodicRate; + NetClock::time_point parentCloseTime; + std::uint32_t startDate; + std::uint32_t prevPaymentDate; + std::uint32_t paymentInterval; + Number expectedAccruedInterest; + }; + + auto const testCases = std::vector{ + { + .name = "Zero principal outstanding", + .principalOutstanding = Number{0}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .startDate = 2'000, + .prevPaymentDate = 2'500, + .paymentInterval = 30 * 24 * 60 * 60, + .expectedAccruedInterest = Number{0}, + }, + { + .name = "Before start date", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{1'000}}, + .startDate = 2'000, + .prevPaymentDate = 1'500, + .paymentInterval = 30 * 24 * 60 * 60, + .expectedAccruedInterest = Number{0}, + }, + { + .name = "Zero periodic rate", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{0}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .startDate = 2'000, + .prevPaymentDate = 2'500, + .paymentInterval = 30 * 24 * 60 * 60, + .expectedAccruedInterest = Number{0}, + }, + { + .name = "Zero payment interval", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .startDate = 2'000, + .prevPaymentDate = 2'500, + .paymentInterval = 0, + .expectedAccruedInterest = Number{0}, + }, + { + .name = "Standard case", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .startDate = 1'000, + .prevPaymentDate = 2'000, + .paymentInterval = 30 * 24 * 60 * 60, + .expectedAccruedInterest = + Number{1929012345679012346, -20}, // from calc + }, + }; + + for (auto const& tc : testCases) + { + testcase("loanAccruedInterest: " + tc.name); + + auto const computedAccruedInterest = loanAccruedInterest( + tc.principalOutstanding, + tc.periodicRate, + tc.parentCloseTime, + tc.startDate, + tc.prevPaymentDate, + tc.paymentInterval); + BEAST_EXPECTS( + computedAccruedInterest == tc.expectedAccruedInterest, + "Accrued interest mismatch: expected " + + to_string(tc.expectedAccruedInterest) + ", got " + + to_string(computedAccruedInterest)); + } + } + + // This test overlaps with testLoanAccruedInterest, the test cases only + // exercise the computeFullPaymentInterest parts unique to it. + void + testComputeFullPaymentInterest() + { + using namespace jtx; + using namespace xrpl::detail; + + struct TestCase + { + std::string name; + Number rawPrincipalOutstanding; + Number periodicRate; + NetClock::time_point parentCloseTime; + std::uint32_t paymentInterval; + std::uint32_t prevPaymentDate; + std::uint32_t startDate; + TenthBips32 closeInterestRate; + Number expectedFullPaymentInterest; + }; + + auto const testCases = std::vector{ + { + .name = "Zero principal outstanding", + .rawPrincipalOutstanding = Number{0}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .paymentInterval = 30 * 24 * 60 * 60, + .prevPaymentDate = 2'000, + .startDate = 1'000, + .closeInterestRate = TenthBips32{10'000}, + .expectedFullPaymentInterest = Number{0}, + }, + { + .name = "Zero close interest rate", + .rawPrincipalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .paymentInterval = 30 * 24 * 60 * 60, + .prevPaymentDate = 2'000, + .startDate = 1'000, + .closeInterestRate = TenthBips32{0}, + .expectedFullPaymentInterest = + Number{1929012345679012346, -20}, // from calc + }, + { + .name = "Standard case", + .rawPrincipalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .paymentInterval = 30 * 24 * 60 * 60, + .prevPaymentDate = 2'000, + .startDate = 1'000, + .closeInterestRate = TenthBips32{10'000}, + .expectedFullPaymentInterest = + Number{1000192901234567901, -16}, // from calc + }, + }; + + for (auto const& tc : testCases) + { + testcase("computeFullPaymentInterest: " + tc.name); + + auto const computedFullPaymentInterest = computeFullPaymentInterest( + tc.rawPrincipalOutstanding, + tc.periodicRate, + tc.parentCloseTime, + tc.paymentInterval, + tc.prevPaymentDate, + tc.startDate, + tc.closeInterestRate); + BEAST_EXPECTS( + computedFullPaymentInterest == tc.expectedFullPaymentInterest, + "Full payment interest mismatch: expected " + + to_string(tc.expectedFullPaymentInterest) + ", got " + + to_string(computedFullPaymentInterest)); + } + } + + void + testTryOverpaymentNoInterestNoFee() + { + // This test ensures that overpayment with no interest works correctly. + testcase("tryOverpayment - No Interest No Fee"); + + using namespace jtx; + using namespace xrpl::detail; + + Env env{*this}; + Account const issuer{"issuer"}; + PrettyAsset const asset = issuer["USD"]; + std::int32_t const loanScale = -5; + TenthBips16 const managementFeeRate{0}; // 0% + TenthBips32 const loanInterestRate{0}; // 0% + Number const loanPrincipal{1'000}; + std::uint32_t const paymentInterval = 30 * 24 * 60 * 60; + std::uint32_t const paymentsRemaining = 10; + auto const periodicRate = + loanPeriodicRate(loanInterestRate, paymentInterval); + Number const overpaymentAmount{50}; + + ExtendedPaymentComponents const overpaymentComponents = + computeOverpaymentComponents( + asset, + loanScale, + overpaymentAmount, + TenthBips32(0), + TenthBips32(0), + managementFeeRate); + + auto const loanProperites = computeLoanProperties( + asset, + loanPrincipal, + loanInterestRate, + paymentInterval, + paymentsRemaining, + managementFeeRate, + loanScale); + + Number const periodicPayment = loanProperites.periodicPayment; + + auto const ret = tryOverpayment( + asset, + loanScale, + overpaymentComponents, + loanProperites.loanState, + periodicPayment, + periodicRate, + paymentsRemaining, + managementFeeRate, + env.journal); + + BEAST_EXPECT(ret); + + auto const& [actualPaymentParts, newLoanProperties] = *ret; + auto const& newState = newLoanProperties.loanState; + + // =========== VALIDATE PAYMENT PARTS =========== + BEAST_EXPECTS( + actualPaymentParts.valueChange == 0, + " valueChange mismatch: expected 0, got " + + to_string(actualPaymentParts.valueChange)); + + BEAST_EXPECTS( + actualPaymentParts.feePaid == 0, + " feePaid mismatch: expected 0, got " + + to_string(actualPaymentParts.feePaid)); + + BEAST_EXPECTS( + actualPaymentParts.interestPaid == 0, + " interestPaid mismatch: expected 0, got " + + to_string(actualPaymentParts.interestPaid)); + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == overpaymentAmount, + " principalPaid mismatch: expected " + + to_string(overpaymentAmount) + ", got " + + to_string(actualPaymentParts.principalPaid)); + + // =========== VALIDATE STATE CHANGES =========== + BEAST_EXPECTS( + loanProperites.loanState.interestDue - newState.interestDue == 0, + " interest change mismatch: expected 0, got " + + to_string( + loanProperites.loanState.interestDue - + newState.interestDue)); + + BEAST_EXPECTS( + loanProperites.loanState.managementFeeDue - + newState.managementFeeDue == + 0, + " management fee change mismatch: expected 0, got " + + to_string( + loanProperites.loanState.managementFeeDue - + newState.managementFeeDue)); + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding, + " principalPaid mismatch: expected " + + to_string( + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding) + + ", got " + to_string(actualPaymentParts.principalPaid)); + } + + void + testTryOverpaymentNoInterestOverpaymentFee() + { + testcase("tryOverpayment - No Interest With Overpayment Fee"); + + using namespace jtx; + using namespace xrpl::detail; + + Env env{*this}; + Account const issuer{"issuer"}; + PrettyAsset const asset = issuer["USD"]; + std::int32_t const loanScale = -5; + TenthBips16 const managementFeeRate{0}; // 0% + TenthBips32 const loanInterestRate{0}; // 0% + Number const loanPrincipal{1'000}; + std::uint32_t const paymentInterval = 30 * 24 * 60 * 60; + std::uint32_t const paymentsRemaining = 10; + auto const periodicRate = + loanPeriodicRate(loanInterestRate, paymentInterval); + + ExtendedPaymentComponents const overpaymentComponents = + computeOverpaymentComponents( + asset, + loanScale, + Number{50, 0}, + TenthBips32(0), + TenthBips32(10'000), // 10% overpayment fee + managementFeeRate); + + auto const loanProperites = computeLoanProperties( + asset, + loanPrincipal, + loanInterestRate, + paymentInterval, + paymentsRemaining, + managementFeeRate, + loanScale); + + Number const periodicPayment = loanProperites.periodicPayment; + + auto const ret = tryOverpayment( + asset, + loanScale, + overpaymentComponents, + loanProperites.loanState, + periodicPayment, + periodicRate, + paymentsRemaining, + managementFeeRate, + env.journal); + + BEAST_EXPECT(ret); + + auto const& [actualPaymentParts, newLoanProperties] = *ret; + auto const& newState = newLoanProperties.loanState; + + // =========== VALIDATE PAYMENT PARTS =========== + BEAST_EXPECTS( + actualPaymentParts.valueChange == 0, + " valueChange mismatch: expected 0, got " + + to_string(actualPaymentParts.valueChange)); + + BEAST_EXPECTS( + actualPaymentParts.feePaid == 5, + " feePaid mismatch: expected 5, got " + + to_string(actualPaymentParts.feePaid)); + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == 45, + " principalPaid mismatch: expected 45, got `" + + to_string(actualPaymentParts.principalPaid)); + + BEAST_EXPECTS( + actualPaymentParts.interestPaid == 0, + " interestPaid mismatch: expected 0, got " + + to_string(actualPaymentParts.interestPaid)); + + // =========== VALIDATE STATE CHANGES =========== + // With no Loan interest, interest outstanding should not change + BEAST_EXPECTS( + loanProperites.loanState.interestDue - newState.interestDue == 0, + " interest change mismatch: expected 0, got " + + to_string( + loanProperites.loanState.interestDue - + newState.interestDue)); + + // With no Loan management fee, management fee due should not change + BEAST_EXPECTS( + loanProperites.loanState.managementFeeDue - + newState.managementFeeDue == + 0, + " management fee change mismatch: expected 0, got " + + to_string( + loanProperites.loanState.managementFeeDue - + newState.managementFeeDue)); + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding, + " principalPaid mismatch: expected " + + to_string( + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding) + + ", got " + to_string(actualPaymentParts.principalPaid)); + } + + void + testTryOverpaymentLoanInterestNoOverpaymentFees() + { + testcase("tryOverpayment - Loan Interest, No Overpayment Fees"); + + using namespace jtx; + using namespace xrpl::detail; + + Env env{*this}; + Account const issuer{"issuer"}; + PrettyAsset const asset = issuer["USD"]; + std::int32_t const loanScale = -5; + TenthBips16 const managementFeeRate{0}; // 0% + TenthBips32 const loanInterestRate{10'000}; // 10% + Number const loanPrincipal{1'000}; + std::uint32_t const paymentInterval = 30 * 24 * 60 * 60; + std::uint32_t const paymentsRemaining = 10; + auto const periodicRate = + loanPeriodicRate(loanInterestRate, paymentInterval); + + ExtendedPaymentComponents const overpaymentComponents = + computeOverpaymentComponents( + asset, + loanScale, + Number{50, 0}, + TenthBips32(0), // no overpayment interest + TenthBips32(0), // 0% overpayment fee + managementFeeRate); + + auto const loanProperites = computeLoanProperties( + asset, + loanPrincipal, + loanInterestRate, + paymentInterval, + paymentsRemaining, + managementFeeRate, + loanScale); + + Number const periodicPayment = loanProperites.periodicPayment; + + auto const ret = tryOverpayment( + asset, + loanScale, + overpaymentComponents, + loanProperites.loanState, + periodicPayment, + periodicRate, + paymentsRemaining, + managementFeeRate, + env.journal); + + BEAST_EXPECT(ret); + + auto const& [actualPaymentParts, newLoanProperties] = *ret; + auto const& newState = newLoanProperties.loanState; + + // =========== VALIDATE PAYMENT PARTS =========== + // with no overpayment interest portion, value change should equal + // interest decrease + BEAST_EXPECTS( + (actualPaymentParts.valueChange == Number{-228802, -5}), + " valueChange mismatch: expected " + + to_string(Number{-228802, -5}) + ", got " + + to_string(actualPaymentParts.valueChange)); + + // with no fee portion, fee paid should be zero + BEAST_EXPECTS( + actualPaymentParts.feePaid == 0, + " feePaid mismatch: expected 0, got " + + to_string(actualPaymentParts.feePaid)); + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == 50, + " principalPaid mismatch: expected 50, got `" + + to_string(actualPaymentParts.principalPaid)); + + // with no interest portion, interest paid should be zero + BEAST_EXPECTS( + actualPaymentParts.interestPaid == 0, + " interestPaid mismatch: expected 0, got " + + to_string(actualPaymentParts.interestPaid)); + + // =========== VALIDATE STATE CHANGES =========== + BEAST_EXPECTS( + actualPaymentParts.principalPaid == + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding, + " principalPaid mismatch: expected " + + to_string( + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding) + + ", got " + to_string(actualPaymentParts.principalPaid)); + + BEAST_EXPECTS( + actualPaymentParts.valueChange == + newState.interestDue - loanProperites.loanState.interestDue, + " valueChange mismatch: expected " + + to_string( + newState.interestDue - + loanProperites.loanState.interestDue) + + ", got " + to_string(actualPaymentParts.valueChange)); + + // With no Loan management fee, management fee due should not change + BEAST_EXPECTS( + loanProperites.loanState.managementFeeDue - + newState.managementFeeDue == + 0, + " management fee change mismatch: expected 0, got " + + to_string( + loanProperites.loanState.managementFeeDue - + newState.managementFeeDue)); + } + + void + testTryOverpaymentLoanInterestOverpaymentInterest() + { + testcase( + "tryOverpayment - Loan Interest, Overpayment Interest, No Fee"); + + using namespace jtx; + using namespace xrpl::detail; + + Env env{*this}; + Account const issuer{"issuer"}; + PrettyAsset const asset = issuer["USD"]; + std::int32_t const loanScale = -5; + TenthBips16 const managementFeeRate{0}; // 0% + TenthBips32 const loanInterestRate{10'000}; // 10% + Number const loanPrincipal{1'000}; + std::uint32_t const paymentInterval = 30 * 24 * 60 * 60; + std::uint32_t const paymentsRemaining = 10; + auto const periodicRate = + loanPeriodicRate(loanInterestRate, paymentInterval); + + ExtendedPaymentComponents const overpaymentComponents = + computeOverpaymentComponents( + asset, + loanScale, + Number{50, 0}, + TenthBips32(10'000), // 10% overpayment interest + TenthBips32(0), // 0% overpayment fee + managementFeeRate); + + auto const loanProperites = computeLoanProperties( + asset, + loanPrincipal, + loanInterestRate, + paymentInterval, + paymentsRemaining, + managementFeeRate, + loanScale); + + Number const periodicPayment = loanProperites.periodicPayment; + + auto const ret = tryOverpayment( + asset, + loanScale, + overpaymentComponents, + loanProperites.loanState, + periodicPayment, + periodicRate, + paymentsRemaining, + managementFeeRate, + env.journal); + + BEAST_EXPECT(ret); + + auto const& [actualPaymentParts, newLoanProperties] = *ret; + auto const& newState = newLoanProperties.loanState; + + // =========== VALIDATE PAYMENT PARTS =========== + // with overpayment interest portion, interest paid should be 5 + BEAST_EXPECTS( + actualPaymentParts.interestPaid == 5, + " interestPaid mismatch: expected 5, got " + + to_string(actualPaymentParts.interestPaid)); + + // With overpayment interest portion, value change should equal the + // interest decrease plus overpayment interest portion + BEAST_EXPECTS( + (actualPaymentParts.valueChange == + Number{-205922, -5} + actualPaymentParts.interestPaid), + " valueChange mismatch: expected " + + to_string( + actualPaymentParts.valueChange - + actualPaymentParts.interestPaid) + + ", got " + to_string(actualPaymentParts.valueChange)); + + // with no fee portion, fee paid should be zero + BEAST_EXPECTS( + actualPaymentParts.feePaid == 0, + " feePaid mismatch: expected 0, got " + + to_string(actualPaymentParts.feePaid)); + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == 45, + " principalPaid mismatch: expected 45, got `" + + to_string(actualPaymentParts.principalPaid)); + + // =========== VALIDATE STATE CHANGES =========== + BEAST_EXPECTS( + actualPaymentParts.principalPaid == + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding, + " principalPaid mismatch: expected " + + to_string( + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding) + + ", got " + to_string(actualPaymentParts.principalPaid)); + + // The change in interest is equal to the value change sans the + // overpayment interest + BEAST_EXPECTS( + actualPaymentParts.valueChange - actualPaymentParts.interestPaid == + newState.interestDue - loanProperites.loanState.interestDue, + " valueChange mismatch: expected " + + to_string( + newState.interestDue - + loanProperites.loanState.interestDue + + actualPaymentParts.interestPaid) + + ", got " + to_string(actualPaymentParts.valueChange)); + + // With no Loan management fee, management fee due should not change + BEAST_EXPECTS( + loanProperites.loanState.managementFeeDue - + newState.managementFeeDue == + 0, + " management fee change mismatch: expected 0, got " + + to_string( + loanProperites.loanState.managementFeeDue - + newState.managementFeeDue)); + } + + void + testTryOverpaymentLoanInterestFeeOverpaymentInterestNoFee() + { + testcase( + "tryOverpayment - Loan Interest and Fee, Overpayment Interest, No " + "Fee"); + + using namespace jtx; + using namespace xrpl::detail; + + Env env{*this}; + Account const issuer{"issuer"}; + PrettyAsset const asset = issuer["USD"]; + std::int32_t const loanScale = -5; + TenthBips16 const managementFeeRate{10'000}; // 10% + TenthBips32 const loanInterestRate{10'000}; // 10% + Number const loanPrincipal{1'000}; + std::uint32_t const paymentInterval = 30 * 24 * 60 * 60; + std::uint32_t const paymentsRemaining = 10; + auto const periodicRate = + loanPeriodicRate(loanInterestRate, paymentInterval); + + ExtendedPaymentComponents const overpaymentComponents = + computeOverpaymentComponents( + asset, + loanScale, + Number{50, 0}, + TenthBips32(10'000), // 10% overpayment interest + TenthBips32(0), // 0% overpayment fee + managementFeeRate); + + auto const loanProperites = computeLoanProperties( + asset, + loanPrincipal, + loanInterestRate, + paymentInterval, + paymentsRemaining, + managementFeeRate, + loanScale); + + Number const periodicPayment = loanProperites.periodicPayment; + + auto const ret = tryOverpayment( + asset, + loanScale, + overpaymentComponents, + loanProperites.loanState, + periodicPayment, + periodicRate, + paymentsRemaining, + managementFeeRate, + env.journal); + + BEAST_EXPECT(ret); + + auto const& [actualPaymentParts, newLoanProperties] = *ret; + auto const& newState = newLoanProperties.loanState; + + // =========== VALIDATE PAYMENT PARTS =========== + + // Since there is loan management fee, the fee is charged against + // overpayment interest portion first, so interest paid remains 4.5 + BEAST_EXPECTS( + (actualPaymentParts.interestPaid == Number{45, -1}), + " interestPaid mismatch: expected 4.5, got " + + to_string(actualPaymentParts.interestPaid)); + + // With overpayment interest portion, value change should equal the + // interest decrease plus overpayment interest portion + BEAST_EXPECTS( + (actualPaymentParts.valueChange == + Number{-18533, -4} + actualPaymentParts.interestPaid), + " valueChange mismatch: expected " + + to_string( + Number{-18533, -4} + actualPaymentParts.interestPaid) + + ", got " + to_string(actualPaymentParts.valueChange)); + + // While there is no overpayment fee, fee paid should equal the + // management fee charged against the overpayment interest portion + BEAST_EXPECTS( + (actualPaymentParts.feePaid == Number{5, -1}), + " feePaid mismatch: expected 0.5, got " + + to_string(actualPaymentParts.feePaid)); + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == 45, + " principalPaid mismatch: expected 45, got `" + + to_string(actualPaymentParts.principalPaid)); + + // =========== VALIDATE STATE CHANGES =========== + BEAST_EXPECTS( + actualPaymentParts.principalPaid == + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding, + " principalPaid mismatch: expected " + + to_string( + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding) + + ", got " + to_string(actualPaymentParts.principalPaid)); + + // Note that the management fee value change is not captured, as this + // value is not needed to correctly update the Vault state. + BEAST_EXPECTS( + (newState.managementFeeDue - + loanProperites.loanState.managementFeeDue == + Number{-20592, -5}), + " management fee change mismatch: expected " + + to_string(Number{-20592, -5}) + ", got " + + to_string( + newState.managementFeeDue - + loanProperites.loanState.managementFeeDue)); + + BEAST_EXPECTS( + actualPaymentParts.valueChange - actualPaymentParts.interestPaid == + newState.interestDue - loanProperites.loanState.interestDue, + " valueChange mismatch: expected " + + to_string( + newState.interestDue - + loanProperites.loanState.interestDue) + + ", got " + + to_string( + actualPaymentParts.valueChange - + actualPaymentParts.interestPaid)); + } + + void + testTryOverpaymentLoanInterestFeeOverpaymentInterestFee() + { + testcase( + "tryOverpayment - Loan Interest, Fee, Overpayment Interest, Fee"); + + using namespace jtx; + using namespace xrpl::detail; + + Env env{*this}; + Account const issuer{"issuer"}; + PrettyAsset const asset = issuer["USD"]; + std::int32_t const loanScale = -5; + TenthBips16 const managementFeeRate{10'000}; // 10% + TenthBips32 const loanInterestRate{10'000}; // 10% + Number const loanPrincipal{1'000}; + std::uint32_t const paymentInterval = 30 * 24 * 60 * 60; + std::uint32_t const paymentsRemaining = 10; + auto const periodicRate = + loanPeriodicRate(loanInterestRate, paymentInterval); + + ExtendedPaymentComponents const overpaymentComponents = + computeOverpaymentComponents( + asset, + loanScale, + Number{50, 0}, + TenthBips32(10'000), // 10% overpayment interest + TenthBips32(10'000), // 10% overpayment fee + managementFeeRate); + + auto const loanProperites = computeLoanProperties( + asset, + loanPrincipal, + loanInterestRate, + paymentInterval, + paymentsRemaining, + managementFeeRate, + loanScale); + + Number const periodicPayment = loanProperites.periodicPayment; + + auto const ret = tryOverpayment( + asset, + loanScale, + overpaymentComponents, + loanProperites.loanState, + periodicPayment, + periodicRate, + paymentsRemaining, + managementFeeRate, + env.journal); + + BEAST_EXPECT(ret); + + auto const& [actualPaymentParts, newLoanProperties] = *ret; + auto const& newState = newLoanProperties.loanState; + + // =========== VALIDATE PAYMENT PARTS =========== + + // Since there is loan management fee, the fee is charged against + // overpayment interest portion first, so interest paid remains 4.5 + BEAST_EXPECTS( + (actualPaymentParts.interestPaid == Number{45, -1}), + " interestPaid mismatch: expected 4.5, got " + + to_string(actualPaymentParts.interestPaid)); + + // With overpayment interest portion, value change should equal the + // interest decrease plus overpayment interest portion + BEAST_EXPECTS( + (actualPaymentParts.valueChange == + Number{-164737, -5} + actualPaymentParts.interestPaid), + " valueChange mismatch: expected " + + to_string( + Number{-164737, -5} + actualPaymentParts.interestPaid) + + ", got " + to_string(actualPaymentParts.valueChange)); + + // While there is no overpayment fee, fee paid should equal the + // management fee charged against the overpayment interest portion + BEAST_EXPECTS( + (actualPaymentParts.feePaid == Number{55, -1}), + " feePaid mismatch: expected 5.5, got " + + to_string(actualPaymentParts.feePaid)); + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == 40, + " principalPaid mismatch: expected 40, got `" + + to_string(actualPaymentParts.principalPaid)); + + // =========== VALIDATE STATE CHANGES =========== + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding, + " principalPaid mismatch: expected " + + to_string( + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding) + + ", got " + to_string(actualPaymentParts.principalPaid)); + + // Note that the management fee value change is not captured, as this + // value is not needed to correctly update the Vault state. + BEAST_EXPECTS( + (newState.managementFeeDue - + loanProperites.loanState.managementFeeDue == + Number{-18304, -5}), + " management fee change mismatch: expected " + + to_string(Number{-18304, -5}) + ", got " + + to_string( + newState.managementFeeDue - + loanProperites.loanState.managementFeeDue)); + + BEAST_EXPECTS( + actualPaymentParts.valueChange - actualPaymentParts.interestPaid == + newState.interestDue - loanProperites.loanState.interestDue, + " valueChange mismatch: expected " + + to_string( + newState.interestDue - + loanProperites.loanState.interestDue) + + ", got " + + to_string( + actualPaymentParts.valueChange - + actualPaymentParts.interestPaid)); + } + +public: + void + run() override + { + testTryOverpaymentNoInterestNoFee(); + testTryOverpaymentNoInterestOverpaymentFee(); + testTryOverpaymentLoanInterestNoOverpaymentFees(); + testTryOverpaymentLoanInterestOverpaymentInterest(); + testTryOverpaymentLoanInterestFeeOverpaymentInterestNoFee(); + testTryOverpaymentLoanInterestFeeOverpaymentInterestFee(); + + testComputeFullPaymentInterest(); + testLoanAccruedInterest(); + testLoanLatePaymentInterest(); + testLoanPeriodicPayment(); + testLoanPrincipalFromPeriodicPayment(); + testComputeRaisedRate(); + testComputePaymentFactor(); + testComputeOverpaymentComponents(); + testComputeInterestAndFeeParts(); + } +}; + +BEAST_DEFINE_TESTSUITE(LendingHelpers, app, xrpl); + +} // namespace test +} // namespace xrpl diff --git a/src/test/app/LoanBroker_test.cpp b/src/test/app/LoanBroker_test.cpp index 5915ebae91..769ed40321 100644 --- a/src/test/app/LoanBroker_test.cpp +++ b/src/test/app/LoanBroker_test.cpp @@ -752,30 +752,36 @@ class LoanBroker_test : public beast::unit_test::suite // LoanBrokerID env(set(alice, vault.vaultID), loanBrokerID(nextKeylet.key), - ter(tecNO_ENTRY)); + ter(tecNO_ENTRY), + THISLINE); // VaultID env(set(alice, nextKeylet.key), loanBrokerID(broker->key()), - ter(tecNO_PERMISSION)); + ter(tecNO_ENTRY), + THISLINE); // Owner env(set(evan, vault.vaultID), loanBrokerID(broker->key()), - ter(tecNO_PERMISSION)); + ter(tecNO_PERMISSION), + THISLINE); // ManagementFeeRate env(set(alice, vault.vaultID), loanBrokerID(broker->key()), managementFeeRate(maxManagementFeeRate), - ter(temINVALID)); + ter(temINVALID), + THISLINE); // CoverRateMinimum env(set(alice, vault.vaultID), loanBrokerID(broker->key()), coverRateMinimum(maxManagementFeeRate), - ter(temINVALID)); + ter(temINVALID), + THISLINE); // CoverRateLiquidation env(set(alice, vault.vaultID), loanBrokerID(broker->key()), coverRateLiquidation(maxManagementFeeRate), - ter(temINVALID)); + ter(temINVALID), + THISLINE); // fields that can be changed testData = "Test Data 1234"; @@ -783,23 +789,43 @@ class LoanBroker_test : public beast::unit_test::suite env(set(alice, vault.vaultID), loanBrokerID(broker->key()), data(std::string(maxDataPayloadLength + 1, 'W')), - ter(temINVALID)); + ter(temINVALID), + THISLINE); // Bad debt maximum env(set(alice, vault.vaultID), loanBrokerID(broker->key()), debtMaximum(Number(-175, -1)), - ter(temINVALID)); + ter(temINVALID), + THISLINE); + Number debtMax{175, -1}; + if (vault.asset.integral()) + { + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + data(testData), + debtMaximum(debtMax), + ter(tecPRECISION_LOSS), + THISLINE); + roundToAsset(vault.asset, debtMax); + } // Data & Debt maximum env(set(alice, vault.vaultID), loanBrokerID(broker->key()), data(testData), - debtMaximum(Number(175, -1))); + debtMaximum(debtMax), + THISLINE); }, [&](SLE::const_ref broker) { // Check the updated fields BEAST_EXPECT(checkVL(broker->at(sfData), testData)); - BEAST_EXPECT(broker->at(sfDebtMaximum) == Number(175, -1)); + Number const expected = + STAmount{vault.asset, Number(175, -1)}; + auto const actual = broker->at(sfDebtMaximum); + BEAST_EXPECTS( + actual == expected, + "Expected: " + to_string(expected) + + ", Actual: " + to_string(actual)); }); lifecycle( @@ -1024,6 +1050,12 @@ class LoanBroker_test : public beast::unit_test::suite destination(dest), ter(tecFROZEN), THISLINE); + + // preclaim: tecPSEUDO_ACCOUNT + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(vaultInfo.pseudoAccount), + ter(tecPSEUDO_ACCOUNT), + THISLINE); } if (brokerTest == CoverClawback) @@ -1436,10 +1468,506 @@ class LoanBroker_test : public beast::unit_test::suite }); } + void + testLoanBrokerSetDebtMaximum() + { + testcase("testLoanBrokerSetDebtMaximum"); + using namespace jtx; + using namespace loanBroker; + Account const issuer{"issuer"}; + Account const alice{"alice"}; + Env env(*this); + Vault vault{env}; + + env.fund(XRP(100'000), issuer, alice); + env.close(); + + PrettyAsset const asset = [&]() { + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + env.close(); + PrettyAsset const mptAsset = mptt["MPT"]; + mptt.authorize({.account = alice}); + env.close(); + return mptAsset; + }(); + + env(pay(issuer, alice, asset(100'000)), THISLINE); + env.close(); + + auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = asset}); + env(tx, THISLINE); + env.close(); + auto const le = env.le(vaultKeylet); + VaultInfo vaultInfo = [&]() { + if (BEAST_EXPECT(le)) + return VaultInfo{asset, vaultKeylet.key, le->at(sfAccount)}; + return VaultInfo{asset, {}, {}}; + }(); + if (vaultInfo.vaultID == uint256{}) + return; + + env(vault.deposit( + {.depositor = alice, + .id = vaultKeylet.key, + .amount = asset(50)}), + THISLINE); + env.close(); + + auto const brokerKeylet = + keylet::loanbroker(alice.id(), env.seq(alice)); + env(set(alice, vaultInfo.vaultID), THISLINE); + env.close(); + + Account const borrower{"borrower"}; + env.fund(XRP(1'000), borrower); + env(loan::set(borrower, brokerKeylet.key, asset(50).value()), + sig(sfCounterpartySignature, alice), + fee(env.current()->fees().base * 2), + THISLINE); + auto const broker = env.le(brokerKeylet); + if (!BEAST_EXPECT(broker)) + return; + + BEAST_EXPECT(broker->at(sfDebtTotal) == 50); + auto debtTotal = broker->at(sfDebtTotal); + + auto tx2 = set(alice, vaultInfo.vaultID); + tx2[sfLoanBrokerID] = to_string(brokerKeylet.key); + tx2[sfDebtMaximum] = debtTotal - 1; + env(tx2, ter(tecLIMIT_EXCEEDED), THISLINE); + + tx2[sfDebtMaximum] = debtTotal + 1; + env(tx2, ter(tesSUCCESS), THISLINE); + + tx2[sfDebtMaximum] = 0; + env(tx2, ter(tesSUCCESS), THISLINE); + + tx2[sfDebtMaximum] = Json::Value::maxInt; + env(tx2, ter(tesSUCCESS), THISLINE); + + { + auto const dm = power(2, 64) - 1; + BEAST_EXPECT(dm > maxMPTokenAmount); + tx2[sfDebtMaximum] = dm; + env(tx2, ter(temINVALID), THISLINE); + } + + { + auto const dm = power(2, 63) - 1; + BEAST_EXPECTS(dm > maxMPTokenAmount, to_string(dm)); + tx2[sfDebtMaximum] = dm; + env(tx2, ter(temINVALID), THISLINE); + } + + { + auto const dm = power(2, 63) - 3; + BEAST_EXPECTS(dm == maxMPTokenAmount, to_string(dm)); + tx2[sfDebtMaximum] = dm; + env(tx2, ter(tesSUCCESS), THISLINE); + } + + { + auto const dm = 2 * (power(2, 62) - 1) + 1; + BEAST_EXPECTS(dm == maxMPTokenAmount, to_string(dm)); + tx2[sfDebtMaximum] = dm; + env(tx2, ter(tesSUCCESS), THISLINE); + } + + tx2[sfDebtMaximum] = Number{9223372036854775807, 0}; + env(tx2, ter(tesSUCCESS), THISLINE); + } + + void + testRIPD4323() + { + testcase << "RIPD-4323"; + using namespace jtx; + Account const issuer("issuer"); + Account const holder("holder"); + Account const& broker = issuer; + + auto test = [&](auto&& getToken) { + Env env(*this); + + env.fund(XRP(1'000), issuer, holder); + env.close(); + + auto const [token, deposit, err] = getToken(env); + + Vault vault(env); + auto const [tx, keylet] = + vault.create({.owner = broker, .asset = token.asset()}); + env(tx); + env.close(); + + env(vault.deposit( + {.depositor = broker, .id = keylet.key, .amount = deposit}), + ter(err)); + env.close(); + + auto const brokerKeylet = + keylet::loanbroker(broker, env.seq(broker)); + + env(loanBroker::set(broker, keylet.key)); + env.close(); + + env(loanBroker::coverDeposit(broker, brokerKeylet.key, deposit), + ter(err)); + env.close(); + }; + + test([&](Env&) { + // issuer can issue any amount + auto const token = issuer["IOU"]; + return std::make_tuple(token, token(1'000), tesSUCCESS); + }); + std::vector, // max amount + std::uint64_t, // deposit amount + TER>> // expected error + mptTests = { + // issuer can issue up to 2'000 tokens + {2'000, 4'000, 1'000, tesSUCCESS}, + // issuer can issue 500 tokens (250 VaultDeposit + + // 250 LoanBrokerCoverDeposit) + {2'000, 2'500, 250, tesSUCCESS}, + // issuer can issue 500 tokens (250 VaultDeposit + + // 250 LoanBrokerCoverDeposit). MaximumAmount is default. + {maxMPTokenAmount - 500, std::nullopt, 250, tesSUCCESS}, + // issuer can issue 500, and fails on depositing 1'000 + {2'000, 2'500, 1'000, tecINSUFFICIENT_FUNDS}, + // issuer has already issued MaximumAmount + {2'000, 2'000, 1'000, tecINSUFFICIENT_FUNDS}, + // issuer has already issued MaximumAmount. MaximumAmount is + // default. + {maxMPTokenAmount, std::nullopt, 250, tecINSUFFICIENT_FUNDS}, + }; + for (auto const& [pay, max, deposit, err] : mptTests) + { + test([&](Env& env) -> std::tuple { + MPT const token = MPTTester( + {.env = env, + .issuer = issuer, + .holders = {holder}, + .pay = pay, + .flags = MPTDEXFlags, + .maxAmt = max}); + return std::make_tuple(token, token(deposit), err); + }); + } + } + + void + testAMB06_VaultFreezeCheckMissing() + { + testcase << "RIPD-4466 - LoanBrokerSet disallows frozen vaults"; + using namespace jtx; + Env env(*this); + + Account const issuer{"issuer"}, lender{"lender"}, borrower{"borrower"}; + env.fund(XRP(20'000), issuer, lender, borrower); + auto const IOU = issuer["IOU"]; + + Vault vault{env}; + auto [tx, vaultKeylet] = + vault.create({.owner = lender, .asset = IOU.asset()}); + env(tx); + env.close(); + + // Get vault pseudo-account and FREEZE it + auto const vaultSle = env.le(vaultKeylet); + auto const vaultPseudo = vaultSle->at(sfAccount); + auto const vaultPseudoAcct = Account("VaultPseudo", vaultPseudo); + env(trust(issuer, vaultPseudoAcct["IOU"](0), tfSetFreeze)); + + env(loanBroker::set(lender, vaultKeylet.key), ter(tecFROZEN)); + } + + void + testRIPD4274IOU() + { + using namespace jtx; + Account issuer("broker"); + Account broker("issuer"); + Account dest("destination"); + auto const token = issuer["IOU"]; + + enum TrustState { + RequireAuth, + ZeroLimit, + ReachedLimit, + NearLimit, + NoTrustLine, + }; + + auto test = [&](TrustState trustState) { + Env env(*this); + + testcase << "RIPD-4274 IOU with state: " + << static_cast(trustState); + + auto setTrustLine = [&](Account const& acct, TrustState state) { + switch (state) + { + case RequireAuth: + env(trust(issuer, token(0), acct, tfSetfAuth)); + break; + case ZeroLimit: { + auto jv = trust(acct, token(0)); + // set QualityIn so that the trustline is not + // auto-deleted + jv[sfQualityIn] = 10'000'000; + env(jv); + } + break; + case ReachedLimit: { + env(trust(acct, token(1'000))); + env(pay(issuer, acct, token(1'000))); + env.close(); + } + break; + case NearLimit: { + env(trust(acct, token(1'000))); + env(pay(issuer, acct, token(950))); + env.close(); + } + break; + case NoTrustLine: + // don't create a trustline + break; + default: + BEAST_EXPECT(false); + } + env.close(); + }; + + env.fund(XRP(1'000), issuer, broker, dest); + env.close(); + + if (trustState == RequireAuth) + { + env(fset(issuer, asfRequireAuth)); + env.close(); + + setTrustLine(broker, RequireAuth); + } + + setTrustLine(dest, trustState); + + env(trust(broker, token(2'000), 0)); + env(pay(issuer, broker, token(2'000))); + env.close(); + + Vault vault(env); + auto const [tx, keylet] = + vault.create({.owner = broker, .asset = token.asset()}); + env(tx); + env.close(); + + // Test Vault withdraw + env(vault.deposit( + {.depositor = broker, + .id = keylet.key, + .amount = token(1'000)})); + env.close(); + + env(vault.withdraw( + {.depositor = broker, + .id = keylet.key, + .amount = token(1'000)}), + loanBroker::destination(dest), + ter(std::ignore)); + BEAST_EXPECT(env.ter() == tecNO_LINE); + env.close(); + + env(vault.withdraw( + {.depositor = broker, + .id = keylet.key, + .amount = token(1'000)})); + + // Test LoanBroker withdraw + auto const brokerKeylet = + keylet::loanbroker(broker, env.seq(broker)); + + env(loanBroker::set(broker, keylet.key)); + env.close(); + + env(loanBroker::coverDeposit( + broker, brokerKeylet.key, token(1'000))); + env.close(); + + env(loanBroker::coverWithdraw(broker, brokerKeylet.key, token(100)), + loanBroker::destination(dest), + ter(std::ignore)); + BEAST_EXPECT(env.ter() == tecNO_LINE); + env.close(); + + // Clearing RequireAuth shouldn't change the result + if (trustState == RequireAuth) + { + env(fclear(issuer, asfRequireAuth)); + env.close(); + + env(loanBroker::coverWithdraw( + broker, brokerKeylet.key, token(100)), + loanBroker::destination(dest), + ter(std::ignore)); + BEAST_EXPECT(env.ter() == tecNO_LINE); + env.close(); + } + }; + + test(RequireAuth); + test(ZeroLimit); + test(ReachedLimit); + test(NearLimit); + test(NoTrustLine); + } + + void + testRIPD4274MPT() + { + using namespace jtx; + Account issuer("broker"); + Account broker("issuer"); + Account dest("destination"); + + enum MPTState { + RequireAuth, + ReachedMAX, + NoMPT, + }; + + auto test = [&](MPTState MPTState) { + Env env(*this); + + testcase << "RIPD-4274 MPT with state: " + << static_cast(MPTState); + + env.fund(XRP(1'000), issuer, broker, dest); + env.close(); + + auto const maybeToken = [&]() -> std::optional { + switch (MPTState) + { + case RequireAuth: { + auto tester = MPTTester( + {.env = env, + .issuer = issuer, + .holders = {broker, dest}, + .pay = 2'000, + .flags = MPTDEXFlags | tfMPTRequireAuth, + .authHolder = true, + .maxAmt = 5'000}); + // unauthorize dest + tester.authorize( + {.account = issuer, + .holder = dest, + .flags = tfMPTUnauthorize}); + return tester; + } + case ReachedMAX: { + auto tester = MPTTester( + {.env = env, + .issuer = issuer, + .holders = {broker, dest}, + .pay = 2'000, + .flags = MPTDEXFlags, + .maxAmt = 4'000}); + BEAST_EXPECT( + env.balance(issuer, tester) == tester(-4'000)); + return tester; + } + case NoMPT: { + return MPTTester( + {.env = env, + .issuer = issuer, + .holders = {broker}, + .pay = 2'000, + .flags = MPTDEXFlags, + .maxAmt = 4'000}); + } + default: + return std::nullopt; + } + }(); + if (!BEAST_EXPECT(maybeToken)) + return; + + auto const& token = *maybeToken; + + Vault vault(env); + auto const [tx, keylet] = + vault.create({.owner = broker, .asset = token.asset()}); + env(tx); + env.close(); + + // Test Vault withdraw + env(vault.deposit( + {.depositor = broker, + .id = keylet.key, + .amount = token(1'000)})); + env.close(); + + env(vault.withdraw( + {.depositor = broker, + .id = keylet.key, + .amount = token(1'000)}), + loanBroker::destination(dest), + ter(std::ignore)); + + // Shouldn't fail if at MaximumAmount since no new tokens are issued + TER const err = + MPTState == ReachedMAX ? TER(tesSUCCESS) : tecNO_AUTH; + BEAST_EXPECT(env.ter() == err); + env.close(); + + if (err != tesSUCCESS) + { + env(vault.withdraw( + {.depositor = broker, + .id = keylet.key, + .amount = token(1'000)})); + } + + // Test LoanBroker withdraw + auto const brokerKeylet = + keylet::loanbroker(broker, env.seq(broker)); + + env(loanBroker::set(broker, keylet.key)); + env.close(); + + env(loanBroker::coverDeposit( + broker, brokerKeylet.key, token(1'000))); + env.close(); + + env(loanBroker::coverWithdraw(broker, brokerKeylet.key, token(100)), + loanBroker::destination(dest), + ter(std::ignore)); + BEAST_EXPECT(env.ter() == err); + env.close(); + }; + + test(RequireAuth); + test(ReachedMAX); + test(NoMPT); + } + + void + testRIPD4274() + { + testRIPD4274IOU(); + testRIPD4274MPT(); + } + public: void run() override { + testLoanBrokerSetDebtMaximum(); testLoanBrokerCoverDepositNullVault(); testDisabled(); @@ -1451,6 +1979,11 @@ public: testInvalidLoanBrokerSet(); testRequireAuth(); + testRIPD4323(); + testAMB06_VaultFreezeCheckMissing(); + + testRIPD4274(); + // TODO: Write clawback failure tests with an issuer / MPT that doesn't // have the right flags set. } diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 7c2e83aa19..e9780211de 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -11,6 +11,8 @@ #include #include +#include + namespace xrpl { namespace test { @@ -141,7 +143,7 @@ protected: using namespace jtx; auto const vaultSle = env.le(keylet::vault(vaultID)); - return getVaultScale(vaultSle); + return getAssetsTotalScale(vaultSle); } }; @@ -350,8 +352,14 @@ protected: env.balance(account, broker.asset) - (balanceBefore - balanceChangeAmount), borrowerScale); - env.test.BEAST_EXPECT( - roundToScale(difference, loanScale) >= beast::zero); + env.test.expect( + roundToScale(difference, loanScale) >= beast::zero, + "Balance before: " + to_string(balanceBefore.value()) + + ", expected change: " + to_string(balanceChangeAmount) + + ", difference (balance after - expected): " + + to_string(difference), + __FILE__, + __LINE__); } } @@ -372,7 +380,7 @@ protected: if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) { env.test.BEAST_EXPECT( - loan->at(sfPreviousPaymentDate) == previousPaymentDate); + loan->at(sfPreviousPaymentDueDate) == previousPaymentDate); env.test.BEAST_EXPECT( loan->at(sfPaymentRemaining) == paymentRemaining); env.test.BEAST_EXPECT( @@ -507,7 +515,7 @@ protected: if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) { return LoanState{ - .previousPaymentDate = loan->at(sfPreviousPaymentDate), + .previousPaymentDate = loan->at(sfPreviousPaymentDueDate), .startDate = tp{d{loan->at(sfStartDate)}}, .nextPaymentDate = loan->at(sfNextPaymentDueDate), .paymentRemaining = loan->at(sfPaymentRemaining), @@ -551,12 +559,15 @@ protected: broker.vaultScale(env), state.principalOutstanding.exponent()))); BEAST_EXPECT(state.paymentInterval == 600); - BEAST_EXPECT( - state.totalValue == - roundToAsset( - broker.asset, - state.periodicPayment * state.paymentRemaining, - state.loanScale)); + { + NumberRoundModeGuard mg(Number::upward); + BEAST_EXPECT( + state.totalValue == + roundToAsset( + broker.asset, + state.periodicPayment * state.paymentRemaining, + state.loanScale)); + } BEAST_EXPECT( state.managementFeeOutstanding == computeManagementFee( @@ -589,7 +600,7 @@ protected: auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + state.totalValue - state.managementFeeOutstanding; - if (unrealizedLoss > assetsUnavailable) + if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable)) { return false; } @@ -705,8 +716,9 @@ protected: << "\tManagement Fee Rate: " << feeRate << std::endl << "\tTotal Payments: " << total << std::endl << "\tPeriodic Payment: " << props.periodicPayment << std::endl - << "\tTotal Value: " << props.totalValueOutstanding << std::endl - << "\tManagement Fee: " << props.managementFeeOwedToBroker + << "\tTotal Value: " << props.loanState.valueOutstanding + << std::endl + << "\tManagement Fee: " << props.loanState.managementFeeDue << std::endl << "\tLoan Scale: " << props.loanScale << std::endl << "\tFirst payment principal: " << props.firstPaymentPrincipal @@ -856,9 +868,6 @@ protected: using namespace std::chrono_literals; using d = NetClock::duration; - // Account const evan{"evan"}; - // Account const alice{"alice"}; - bool const showStepBalances = paymentParams.showStepBalances; auto const currencyLabel = getCurrencyLabel(broker.asset); @@ -911,7 +920,7 @@ protected: state.principalOutstanding, state.managementFeeOutstanding); { - auto const raw = computeRawLoanState( + auto const raw = computeTheoreticalLoanState( state.periodicPayment, periodicRate, state.paymentRemaining, @@ -964,7 +973,7 @@ protected: Number totalFeesPaid = 0; std::size_t totalPaymentsMade = 0; - xrpl::LoanState currentTrueState = computeRawLoanState( + xrpl::LoanState currentTrueState = computeTheoreticalLoanState( state.periodicPayment, periodicRate, state.paymentRemaining, @@ -1019,7 +1028,7 @@ protected: paymentComponents.trackedInterestPart() + paymentComponents.trackedManagementFeeDelta); - xrpl::LoanState const nextTrueState = computeRawLoanState( + xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( state.periodicPayment, periodicRate, state.paymentRemaining - 1, @@ -1271,7 +1280,8 @@ protected: verifyLoanStatus, issuer, lender, - borrower); + borrower, + PaymentParameters{.showStepBalances = true}); } /** Runs through the complete lifecycle of a loan @@ -1452,7 +1462,7 @@ protected: BEAST_EXPECT( loan->at(sfPaymentInterval) == *loanParams.payInterval); BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd); - BEAST_EXPECT(loan->at(sfPreviousPaymentDate) == 0); + BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == 0); BEAST_EXPECT( loan->at(sfNextPaymentDueDate) == startDate + *loanParams.payInterval); @@ -1484,9 +1494,9 @@ protected: startDate + *loanParams.payInterval, *loanParams.payTotal, state.loanScale, - loanProperties.totalValueOutstanding, + loanProperties.loanState.valueOutstanding, principalRequestAmount, - loanProperties.managementFeeOwedToBroker, + loanProperties.loanState.managementFeeDue, loanProperties.periodicPayment, loanFlags | 0); @@ -1541,9 +1551,9 @@ protected: nextDueDate, *loanParams.payTotal, loanProperties.loanScale, - loanProperties.totalValueOutstanding, + loanProperties.loanState.valueOutstanding, principalRequestAmount, - loanProperties.managementFeeOwedToBroker, + loanProperties.loanState.managementFeeDue, loanProperties.periodicPayment, loanFlags | 0); @@ -2392,7 +2402,7 @@ protected: interval * Number(12, -2) / secondsInYear; BEAST_EXPECT( periodicRate == - Number(2283105022831050, -21, Number::unchecked{})); + Number(2283105022831050228ULL, -24, Number::normalized{})); STAmount const principalOutstanding{ broker.asset, state.principalOutstanding}; STAmount const accruedInterest{ @@ -2445,16 +2455,38 @@ protected: getCurrentState(env, broker, loanKeylet, verifyLoanStatus); env.close(); + BEAST_EXPECT( + STAmount(broker.asset, state.periodicPayment) == + broker.asset(Number(8333457002039338267, -17))); + // Make all the payments in one transaction // service fee is 2 auto const startingPayments = state.paymentRemaining; - auto const rawPayoff = startingPayments * - (state.periodicPayment + broker.asset(2).value()); - STAmount const payoffAmount{broker.asset, rawPayoff}; - BEAST_EXPECT( - payoffAmount == - broker.asset(Number(1024014840139457, -12))); - BEAST_EXPECT(payoffAmount > state.principalOutstanding); + STAmount const payoffAmount = [&]() { + NumberRoundModeGuard mg(Number::upward); + auto const rawPayoff = startingPayments * + (state.periodicPayment + broker.asset(2).value()); + STAmount payoffAmount{broker.asset, rawPayoff}; + BEAST_EXPECTS( + payoffAmount == + broker.asset(Number(1024014840244721, -12)), + to_string(payoffAmount)); + BEAST_EXPECT(payoffAmount > state.principalOutstanding); + + payoffAmount = roundToScale(payoffAmount, state.loanScale); + + return payoffAmount; + }(); + + auto const totalPayoffValue = state.totalValue + + startingPayments * broker.asset(2).value(); + STAmount const totalPayoffAmount{ + broker.asset, totalPayoffValue}; + + BEAST_EXPECTS( + totalPayoffAmount == payoffAmount, + "Payoff amount: " + to_string(payoffAmount) + + ". Total Value: " + to_string(totalPayoffAmount)); singlePayment( loanKeylet, @@ -2624,7 +2656,7 @@ protected: interval * Number(12, -2) / secondsInYear; BEAST_EXPECT( periodicRate == - Number(2283105022831050, -21, Number::unchecked{})); + Number(2283105022831050228, -24, Number::normalized{})); STAmount const roundedPeriodicPayment{ broker.asset, roundPeriodicPayment( @@ -2642,7 +2674,7 @@ protected: roundedPeriodicPayment == roundToScale( broker.asset( - Number(8333457001162141, -14), Number::upward), + Number(8333457002039338267, -17), Number::upward), state.loanScale, Number::upward)); // 83334570.01162141 @@ -2657,12 +2689,12 @@ protected: totalDue == roundToScale( broker.asset( - Number(8533457001162141, -14), Number::upward), + Number(8533457002039338267, -17), Number::upward), state.loanScale, Number::upward)); { - auto const raw = computeRawLoanState( + auto const raw = computeTheoreticalLoanState( state.periodicPayment, periodicRate, state.paymentRemaining, @@ -2693,7 +2725,7 @@ protected: transactionAmount == roundToScale( broker.asset( - Number(9533457001162141, -14), Number::upward), + Number(9533457002039400, -14), Number::upward), state.loanScale, Number::upward)); @@ -2705,7 +2737,7 @@ protected: Number totalInterestPaid = 0; std::size_t totalPaymentsMade = 0; - xrpl::LoanState currentTrueState = computeRawLoanState( + xrpl::LoanState currentTrueState = computeTheoreticalLoanState( state.periodicPayment, periodicRate, state.paymentRemaining, @@ -2726,15 +2758,22 @@ protected: state.paymentRemaining, broker.params.managementFeeRate); - BEAST_EXPECT( - paymentComponents.trackedValueDelta <= - roundedPeriodicPayment); + BEAST_EXPECTS( + paymentComponents.specialCase == + detail::PaymentSpecialCase::final || + paymentComponents.trackedValueDelta <= + roundedPeriodicPayment, + "Delta: " + + to_string(paymentComponents.trackedValueDelta) + + ", periodic payment: " + + to_string(roundedPeriodicPayment)); - xrpl::LoanState const nextTrueState = computeRawLoanState( - state.periodicPayment, - periodicRate, - state.paymentRemaining - 1, - broker.params.managementFeeRate); + xrpl::LoanState const nextTrueState = + computeTheoreticalLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining - 1, + broker.params.managementFeeRate); detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; @@ -2782,8 +2821,10 @@ protected: paymentComponents.trackedInterestPart() + paymentComponents.trackedManagementFeeDelta); BEAST_EXPECT( + paymentComponents.specialCase == + detail::PaymentSpecialCase::final || paymentComponents.trackedValueDelta <= - roundedPeriodicPayment); + roundedPeriodicPayment); BEAST_EXPECT( state.paymentRemaining < 12 || @@ -2794,7 +2835,7 @@ protected: Number::upward) == roundToScale( broker.asset( - Number(8333228695260180, -14), + Number(8333228691531218890, -17), Number::upward), state.loanScale, Number::upward)); @@ -3453,11 +3494,12 @@ protected: ter{tecNO_AUTH}); env.close(); - // Can create loan without origination fee + // Cannot create loan, even without an origination fee env(set(borrower, broker.brokerID, principalRequest), counterparty(lender), sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5)); + fee(env.current()->fees().base * 5), + ter{tecNO_AUTH}); env.close(); // No MPToken for lender - no authorization and no payment @@ -3578,6 +3620,52 @@ protected: fee(env.current()->fees().base * 5)); }, CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + Vault vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit; + env(tx); + env.close(); + + testcase("Vault at maximum value"); + env(set(issuer, broker.brokerID, principalRequest), + counterparty(lender), + interestRate(TenthBips32(10'000)), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + ter(tecLIMIT_EXCEEDED), + THISLINE); + }, + nullptr); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + Vault vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = + BrokerParameters::defaults().vaultDeposit + + broker.asset(1).number(); + env(tx); + env.close(); + + testcase("Vault maximum value exceeded"); + env(set(issuer, broker.brokerID, principalRequest), + counterparty(lender), + interestRate(TenthBips32(100'000)), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + paymentTotal(2), + paymentInterval(3600 * 24), + ter(tecLIMIT_EXCEEDED), + THISLINE); + }, + nullptr); } void @@ -3632,7 +3720,7 @@ protected: env(pay(issuer, borrower, mptAsset(10'000))); env.close(); - std::array const assets{xrpAsset, mptAsset, iouAsset}; + std::array const assets{iouAsset, xrpAsset, mptAsset}; // Create vaults and loan brokers std::vector brokers; @@ -3813,7 +3901,7 @@ protected: BEAST_EXPECT(loan[sfPaymentInterval] == 60); BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000"); BEAST_EXPECT(loan[sfPaymentRemaining] == 1); - BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDate)); + BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDueDate)); BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000"); BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000"); BEAST_EXPECT(!loan.isMember(sfLoanScale)); @@ -3994,7 +4082,6 @@ protected: createJson["CloseInterestRate"] = 55374; createJson["ClosePaymentFee"] = "3825205248"; - createJson["GracePeriod"] = 0; createJson["LatePaymentFee"] = "237"; createJson["LoanOriginationFee"] = "0"; createJson["OverpaymentFee"] = 35167; @@ -4009,7 +4096,7 @@ protected: createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); // Fails in preclaim because principal requested can't be // represented as XRP - env(createJson, ter(tecPRECISION_LOSS)); + env(createJson, ter(tecPRECISION_LOSS), THISLINE); env.close(); BEAST_EXPECT(!env.le(keylet)); @@ -4021,7 +4108,7 @@ protected: createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); // Fails in doApply because the payment is too small to be // represented as XRP. - env(createJson, ter(tecPRECISION_LOSS)); + env(createJson, ter(tecPRECISION_LOSS), THISLINE); env.close(); } @@ -4455,15 +4542,6 @@ protected: }; } - void - testBasicMath() - { - // Test the functions defined in LendingHelpers.h - testcase("Basic Math"); - - pass(); - } - void testIssuerLoan() { @@ -4679,7 +4757,30 @@ protected: jtx::fee const& loanSetFee, Number const& debtMaximumRequest) { // first temBAD_SIGNER: TODO + // invalid grace period + { + // zero grace period + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + gracePeriod(0), + loanSetFee, + ter(temINVALID)); + // grace period less than default minimum + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + gracePeriod(LoanSet::defaultGracePeriod - 1), + loanSetFee, + ter(temINVALID)); + + // grace period greater than payment interval + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + paymentInterval(120), + gracePeriod(121), + loanSetFee, + ter(temINVALID)); + } // empty/zero broker ID { auto jv = set(borrower, uint256{}, debtMaximumRequest); @@ -4971,7 +5072,6 @@ protected: auto const loanSetFee = fee(env.current()->fees().base * 2); Number const principalRequest{1, 3}; - auto const startDate = env.now() + 60s; auto createJson = env.json( set(borrower, broker.brokerID, principalRequest), @@ -4980,7 +5080,6 @@ protected: createJson["CloseInterestRate"] = 47299; createJson["ClosePaymentFee"] = "3985819770"; - createJson["GracePeriod"] = 0; createJson["InterestRate"] = 92; createJson["LatePaymentFee"] = "3866894865"; createJson["LoanOriginationFee"] = "0"; @@ -4996,13 +5095,13 @@ protected: auto const keylet = keylet::loan(broker.brokerID, loanSequence); createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); - env(createJson, ter(tecPRECISION_LOSS)); - env.close(startDate); + env(createJson, THISLINE); + env.close(); auto loanPayTx = env.json( pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); loanPayTx["Amount"]["value"] = "0.000281284125490196"; - env(loanPayTx, ter(tecNO_ENTRY)); + env(loanPayTx, ter(tecINSUFFICIENT_PAYMENT), THISLINE); env.close(); } @@ -5133,7 +5232,6 @@ protected: json(sfCounterpartySignature, Json::objectValue)); createJson["ClosePaymentFee"] = "0"; - createJson["GracePeriod"] = 0; createJson["InterestRate"] = 24346; createJson["LateInterestRate"] = 65535; createJson["LatePaymentFee"] = "0"; @@ -5253,7 +5351,6 @@ protected: json(sfCounterpartySignature, Json::objectValue)); createJson["ClosePaymentFee"] = "0"; - createJson["GracePeriod"] = 0; createJson["InterestRate"] = 12833; createJson["LateInterestRate"] = 77048; createJson["LatePaymentFee"] = "0"; @@ -5347,7 +5444,7 @@ protected: set(borrower, broker.brokerID, Number{55524'81, -2}), fee(loanSetFee), closePaymentFee(0), - gracePeriod(0), + gracePeriod(LoanSet::defaultGracePeriod), interestRate(TenthBips32(12833)), lateInterestRate(TenthBips32(77048)), latePaymentFee(0), @@ -5573,21 +5670,26 @@ protected: BEAST_EXPECT(beforeState.periodicPayment > 0); // pay all but the last payment - Number const payment = beforeState.periodicPayment * (total - 1); - XRPAmount const payFee{ - baseFee * ((total - 1) / loanPaymentsPerFeeIncrement + 1)}; - auto loanPayTx = env.json( - pay(borrower, keylet.key, STAmount{broker.asset, payment}), - fee(payFee)); - env(loanPayTx, ter(tesSUCCESS)); - env.close(); + { + NumberRoundModeGuard mg{Number::upward}; + Number const payment = + beforeState.periodicPayment * (total - 1); + XRPAmount const payFee{ + baseFee * ((total - 1) / loanPaymentsPerFeeIncrement + 1)}; + STAmount const paymentAmount = roundToScale( + STAmount{broker.asset, payment}, beforeState.loanScale); + auto loanPayTx = env.json( + pay(borrower, keylet.key, paymentAmount), fee(payFee)); + env(loanPayTx, ter(tesSUCCESS)); + env.close(); + } // The loan is on the last payment auto const afterState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(afterState.paymentRemaining == 1); BEAST_EXPECT(afterState.nextPaymentDate == maxTime - grace); BEAST_EXPECT( afterState.previousPaymentDate == maxTime - grace - interval); - BEAST_EXPECT(afterState.paymentRemaining == 1); } } @@ -5851,7 +5953,7 @@ protected: auto const periodicRate = loanPeriodicRate(interestRateValue, state.paymentInterval); - auto const rawLoanState = computeRawLoanState( + auto const rawLoanState = computeTheoreticalLoanState( state.periodicPayment, periodicRate, state.paymentRemaining, @@ -6029,7 +6131,7 @@ protected: { // --- PoC Summary ---------------------------------------------------- // Scenario: Borrower makes one periodic payment early (before next due) - // so doPayment sets sfPreviousPaymentDate to the (future) + // so doPayment sets sfPreviousPaymentDueDate to the (future) // sfNextPaymentDueDate and advances sfNextPaymentDueDate by one // interval. Borrower then immediately performs a full-payment // (tfLoanFullPayment). Why it matters: Full-payment interest accrual @@ -6144,15 +6246,16 @@ protected: // Accrued + prepayment-penalty interest based on current periodic // schedule auto const fullPaymentInterest = computeFullPaymentInterest( - after.periodicPayment, + detail::loanPrincipalFromPeriodicPayment( + after.periodicPayment, periodicRate2, after.paymentRemaining), periodicRate2, - after.paymentRemaining, env.current()->parentCloseTime(), after.paymentInterval, after.previousPaymentDate, static_cast( after.startDate.time_since_epoch().count()), closeInterestRate); + // Round to asset scale and split interest/fee parts auto const roundedInterest = roundToAsset(asset.raw(), fullPaymentInterest, after.loanScale); @@ -6180,9 +6283,9 @@ protected: // window by clamping prevPaymentDate to 'now' for the full-pay path. auto const prevClamped = std::min(after.previousPaymentDate, nowSecs); auto const fullPaymentInterestClamped = computeFullPaymentInterest( - after.periodicPayment, + detail::loanPrincipalFromPeriodicPayment( + after.periodicPayment, periodicRate2, after.paymentRemaining), periodicRate2, - after.paymentRemaining, env.current()->parentCloseTime(), after.paymentInterval, prevClamped, @@ -6436,8 +6539,7 @@ protected: .lateFee = Number{200, -6}, .interest = TenthBips32{50'000}, .payTotal = 10, - .payInterval = 150, - .gracePd = 0}; + .payInterval = 150}; auto const assetType = AssetType::XRP; @@ -6458,9 +6560,6 @@ protected: auto state = getCurrentState(env, broker, loanKeylet); if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) { - // log << "loan after create: " << to_string(loan->getJson()) - // << std::endl; - env.close(tp{d{ loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}}); } @@ -6475,16 +6574,10 @@ protected: { auto const submitParam = to_string(jv); - // log << "about to submit: " << submitParam << std::endl; auto const jr = env.rpc("submit", borrower.name(), submitParam); - // log << jr << std::endl; BEAST_EXPECT(jr.isMember(jss::result)); auto const jResult = jr[jss::result]; - // BEAST_EXPECT(jResult[jss::error] == "invalidTransaction"); - // BEAST_EXPECT( - // jResult[jss::error_exception] == - // "fails local checks: Transaction has bad signature."); } env.close(); @@ -6520,8 +6613,7 @@ protected: .counter = borrower, .principalRequest = Number{100'000, -4}, .interest = TenthBips32{100'000}, - .payTotal = 10, - .gracePd = 0}; + .payTotal = 10}; auto const assetType = AssetType::MPT; @@ -7007,11 +7099,8 @@ protected: env.close(); PaymentParameters paymentParams{ - //.overpaymentFactor = Number{15, -1}, - //.overpaymentExtra = Number{1, -6}, - //.flags = tfLoanOverpayment, - .showStepBalances = true, - //.validateBalances = false, + .showStepBalances = false, + .validateBalances = true, }; makeLoanPayments( @@ -7026,6 +7115,532 @@ protected: paymentParams); } + void + testOverpaymentManagementFee() + { + testcase("testOverpaymentManagementFee"); + + using namespace jtx; + using namespace loan; + + Env env(*this, all); + + Account const lender{"lender"}, borrower{"borrower"}; + + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + PrettyAsset const asset{xrpIssue(), 1000}; + + auto const result = createVaultAndBroker( + env, + asset, + lender, + { + .vaultDeposit = asset(100'000).value(), + .managementFeeRate = TenthBips16(10'000), + }); + + auto const loanSetFee = fee(env.current()->fees().base * 2); + + auto const loanKeylet = keylet::loan( + result.brokerKeylet().key, + (env.le(result.brokerKeylet()))->at(sfLoanSequence)); + env(loan::set( + borrower, + result.brokerKeylet().key, + asset(10'000).value(), + tfLoanOverpayment), + sig(sfCounterpartySignature, lender), + loan::paymentInterval(86400 * 30), + loan::paymentTotal(3), + loan::overpaymentInterestRate( + TenthBips32(percentageToTenthBips(20))), + loanSetFee); + + // From calculator + auto const expectedOverpaymentManagementFee = Number{33333, 0}; + auto const loanBrokerBalanceBefore = env.balance(lender); + + auto const loanPayFee = fee(env.current()->fees().base * 2); + env(pay(borrower, + loanKeylet.key, + asset(5'000).value(), + tfLoanOverpayment), + loanPayFee); + env.close(); + + BEAST_EXPECTS( + env.balance(lender) - loanBrokerBalanceBefore == + expectedOverpaymentManagementFee, + "overpayment management fee missmatch; expected:" + + to_string(expectedOverpaymentManagementFee) + " got: " + + to_string(env.balance(lender) - loanBrokerBalanceBefore)); + } + + void + testLoanPayBrokerOwnerMissingTrustline() + { + testcase << "LoanPay Broker Owner Missing Trustline (PoC)"; + using namespace jtx; + using namespace loan; + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + auto const IOU = issuer["IOU"]; + Env env(*this, all); + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + // Set up trustlines and fund accounts + env(trust(broker, IOU(20'000'000))); + env(trust(borrower, IOU(20'000'000))); + env(pay(issuer, broker, IOU(10'000'000))); + env(pay(issuer, borrower, IOU(1'000))); + env.close(); + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, IOU, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + sig(sfCounterpartySignature, broker), + loanServiceFee(IOU(100).value()), + paymentInterval(100), + fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = IOU(50'000).value(); + env(loanBroker::coverDeposit( + broker, brokerInfo.brokerID, STAmount{IOU, additionalCover})); + env.close(); + // Verify broker owner has a trustline + auto const brokerTrustline = keylet::line(broker, IOU); + BEAST_EXPECT(env.le(brokerTrustline) != nullptr); + // Broker owner deletes their trustline + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, IOU); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Remove the trustline by setting limit to 0 + env(trust(broker, IOU(0))); + env.close(); + // Verify trustline is deleted + BEAST_EXPECT(env.le(brokerTrustline) == nullptr); + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_LINE. + env(pay(borrower, keylet.key, IOU(10'100)), + fee(XRP(100)), + ter(tesSUCCESS)); + env.close(); + // Verify trustline is still deleted + BEAST_EXPECT(env.le(brokerTrustline) == nullptr); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = + env.le(keylet::loanbroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, IOU); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS( + balance == IOU(51'100), to_string(Json::Value(balance))); + } + } + + void + testLoanPayBrokerOwnerUnauthorizedMPT() + { + testcase << "LoanPay Broker Owner MPT unauthorized"; + using namespace jtx; + using namespace loan; + + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + + Env env(*this, all); + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + + PrettyAsset const MPT{mptt.issuanceID()}; + + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); + + env.close(); + + // Fund accounts + env(pay(issuer, broker, MPT(10'000'000))); + env(pay(issuer, borrower, MPT(1'000))); + env.close(); + + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, MPT, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + sig(sfCounterpartySignature, broker), + loanServiceFee(MPT(100).value()), + paymentInterval(100), + fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = MPT(50'000).value(); + env(loanBroker::coverDeposit( + broker, brokerInfo.brokerID, STAmount{MPT, additionalCover})); + env.close(); + // Verify broker owner is authorized + auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); + BEAST_EXPECT(env.le(brokerMpt) != nullptr); + // Broker owner unauthorizes. + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, MPT); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Then, unauthorize the MPT. + mptt.authorize({.account = broker, .flags = tfMPTUnauthorize}); + env.close(); + // Verify the MPT is unauthorized. + BEAST_EXPECT(env.le(brokerMpt) == nullptr); + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_AUTH. + auto const borrowerBalance = env.balance(borrower, MPT); + env(pay(borrower, keylet.key, MPT(10'100)), + fee(XRP(100)), + ter(tesSUCCESS)); + env.close(); + // Verify the MPT is still unauthorized. + BEAST_EXPECT(env.le(brokerMpt) == nullptr); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = + env.le(keylet::loanbroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, MPT); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS( + balance == MPT(51'100), to_string(Json::Value(balance))); + } + } + + void + testLoanPayBrokerOwnerNoPermissionedDomainMPT() + { + testcase + << "LoanPay Broker Owner without permissioned domain of the MPT"; + using namespace jtx; + using namespace loan; + + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + + Env env(*this, all); + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + auto credType = "credential1"; + + pdomain::Credentials const credentials1{{issuer, credType}}; + env(pdomain::setTx(issuer, credentials1)); + env.close(); + + auto domainID = pdomain::getNewDomain(env.meta()); + + env(credentials::create(broker, issuer, credType)); + env(credentials::accept(broker, issuer, credType)); + env.close(); + + env(credentials::create(borrower, issuer, credType)); + env(credentials::accept(borrower, issuer, credType)); + env.close(); + + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create({ + .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | + tfMPTCanLock, + .domainID = domainID, + }); + + PrettyAsset const MPT{mptt.issuanceID()}; + + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); + + env.close(); + + // Fund accounts + env(pay(issuer, broker, MPT(10'000'000))); + env(pay(issuer, borrower, MPT(1'000))); + env.close(); + + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, MPT, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + sig(sfCounterpartySignature, broker), + loanServiceFee(MPT(100).value()), + paymentInterval(100), + fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = MPT(50'000).value(); + env(loanBroker::coverDeposit( + broker, brokerInfo.brokerID, STAmount{MPT, additionalCover})); + env.close(); + // Verify broker owner is authorized + auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); + BEAST_EXPECT(env.le(brokerMpt) != nullptr); + // Remove the credentials for the Broker owner. + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, MPT); + env(pay(broker, issuer, brokerBalance)); + env.close(); + + env(credentials::deleteCred(broker, broker, issuer, credType)); + env.close(); + + // Make sure the broker is not authorized to hold the MPT after we + // deleted the credentials + env(pay(issuer, broker, MPT(1'000)), ter(tecNO_AUTH)); + + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_AUTH. + auto const borrowerBalance = env.balance(borrower, MPT); + env(pay(borrower, keylet.key, MPT(10'100)), + fee(XRP(100)), + ter(tesSUCCESS)); + env.close(); + // Verify broker is still not authorized + env(pay(issuer, broker, MPT(1'000)), ter(tecNO_AUTH)); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = + env.le(keylet::loanbroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, MPT); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS( + balance == MPT(51'100), to_string(Json::Value(balance))); + } + } + + void + testLoanSetBrokerOwnerNoPermissionedDomainMPT() + { + testcase + << "LoanSet Broker Owner without permissioned domain of the MPT"; + using namespace jtx; + using namespace loan; + + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + + Env env(*this, all); + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + auto credType = "credential1"; + + pdomain::Credentials const credentials1{{issuer, credType}}; + env(pdomain::setTx(issuer, credentials1)); + env.close(); + + auto domainID = pdomain::getNewDomain(env.meta()); + + // Add credentials for the broker and borrower + env(credentials::create(broker, issuer, credType)); + env(credentials::accept(broker, issuer, credType)); + env.close(); + + env(credentials::create(borrower, issuer, credType)); + env(credentials::accept(borrower, issuer, credType)); + env.close(); + + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create({ + .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | + tfMPTCanLock, + .domainID = domainID, + }); + + PrettyAsset const MPT{mptt.issuanceID()}; + + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); + env.close(); + + // Fund accounts + env(pay(issuer, broker, MPT(10'000'000))); + env(pay(issuer, borrower, MPT(1'000))); + env.close(); + + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, MPT, broker); + + // Remove the credentials for the Broker owner. + // Clear the balance first. + auto const brokerBalance = env.balance(broker, MPT); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Delete the credentials + env(credentials::deleteCred(broker, broker, issuer, credType)); + env.close(); + + // Create a loan, this should fail for tecNO_AUTH + env(set(borrower, brokerInfo.brokerID, 10'000), + sig(sfCounterpartySignature, broker), + loanServiceFee(MPT(100).value()), + paymentInterval(100), + fee(XRP(100)), + ter(tecNO_AUTH)); + env.close(); + } + + void + testSequentialFLCDepletion() + { + testcase << "First-Loss Capital Depletion on Sequential Defaults"; + + using namespace jtx; + using namespace loan; + using namespace loanBroker; + + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrowerA{"borrowerA"}; + Account const borrowerB{"borrowerB"}; + + env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB); + env.close(); + + PrettyAsset const asset = xrpIssue(); + auto const vaultDepositAmount = + asset(200'000); // Enough for 2 x 50k loans plus interest/fees + + auto const brokerInfo = createVaultAndBroker( + env, + asset, + lender, + { + .vaultDeposit = vaultDepositAmount.value(), + .debtMax = 0, + .coverRateMin = TenthBips32(20000), // 20% + .coverDeposit = 21'000, + .managementFeeRate = TenthBips16(100), // 0.1% + .coverRateLiquidation = TenthBips32(100000), + }); + auto const brokerKeylet = brokerInfo.brokerKeylet(); + + // Create two identical loans: each 50,000 XRP principal (scaled down to + // avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal + // + interest) Formula will calculate cover as: 100% × (20% × 100,000) = + // 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first + // default + auto const principalAmount = Number(50'000); + auto const loanPaymentInterval = 2592000; // 30 days + auto const loanGracePeriod = 604800; // 7 days + + // Create Loan A + auto loanATx = env.jt( + set(borrowerA, brokerKeylet.key, principalAmount), + sig(sfCounterpartySignature, lender), + interestRate(TenthBips32(500)), // 5% + paymentTotal(12), + loan::paymentInterval(loanPaymentInterval), + loan::gracePeriod(loanGracePeriod), + fee(XRP(10))); // Sufficient fee for multi-sig transaction + env(loanATx); + env.close(); + + auto const loanAKeylet = keylet::loan(brokerKeylet.key, 1); + + // Create Loan B + auto loanBTx = env.jt( + set(borrowerB, brokerKeylet.key, principalAmount), + sig(sfCounterpartySignature, lender), + interestRate(TenthBips32(500)), // 5% + paymentTotal(12), + loan::paymentInterval(loanPaymentInterval), + loan::gracePeriod(loanGracePeriod), + fee(XRP(10))); // Sufficient fee for multi-sig transaction + env(loanBTx); + env.close(); + + auto const loanBKeylet = keylet::loan(brokerKeylet.key, 2); + + auto loanASle = env.le(loanAKeylet); + if (!BEAST_EXPECT(loanASle)) + return; + + // Advance time past grace period for both loans to be defaultable + auto const loanANextDue = loanASle->at(sfNextPaymentDueDate); + auto const loanAGrace = loanASle->at(sfGracePeriod); + env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60}); + + env(manage(lender, loanAKeylet.key, tfLoanDefault), ter(tesSUCCESS)); + env.close(); + + // Verify Loan A is defaulted + loanASle = env.le(loanAKeylet); + if (!BEAST_EXPECT(loanASle)) + return; + BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault)); + BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0); + + // Check broker state after first default (from committed ledger) + auto brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return; + auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal); + auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable); + + // DebtTotal should have decreased by Loan A's debt + BEAST_EXPECT(afterFirstDebtTotal == 50'134); + + // CoverAvailable should have decreased significantly + BEAST_EXPECT(afterFirstCoverAvailable == 946); + + env(manage(lender, loanBKeylet.key, tfLoanDefault), ter(tesSUCCESS)); + + brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return; + auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal); + auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable); + + BEAST_EXPECT(afterSecondDebtTotal == 0); + + BEAST_EXPECT(afterSecondCoverAvailable == 0); + } + public: void run() override @@ -7034,6 +7649,8 @@ public: testLoanPayLateFullPaymentBypassesPenalties(); testLoanCoverMinimumRoundingExploit(); #endif + testInvalidLoanSet(); + testCoverDepositWithdrawNonTransferableMPT(); testPoC_UnsignedUnderflowOnFullPayAfterEarlyPeriodic(); @@ -7045,12 +7662,9 @@ public: testServiceFeeOnBrokerDeepFreeze(); testRPC(); - testBasicMath(); - testInvalidLoanDelete(); testInvalidLoanManage(); testInvalidLoanPay(); - testInvalidLoanSet(); testBatchBypassCounterparty(); testLoanPayComputePeriodicPaymentValidRateInvariant(); @@ -7074,6 +7688,12 @@ public: testBorrowerIsBroker(); testIssuerIsBorrower(); testLimitExceeded(); + testOverpaymentManagementFee(); + testLoanPayBrokerOwnerMissingTrustline(); + testLoanPayBrokerOwnerUnauthorizedMPT(); + testLoanPayBrokerOwnerNoPermissionedDomainMPT(); + testLoanSetBrokerOwnerNoPermissionedDomainMPT(); + testSequentialFLCDepletion(); } }; @@ -7193,15 +7813,15 @@ class LoanArbitrary_test : public LoanBatch_test .vaultDeposit = 10000, .debtMax = 0, .coverRateMin = TenthBips32{0}, - // .managementFeeRate = TenthBips16{5919}, + .managementFeeRate = TenthBips16{0}, .coverRateLiquidation = TenthBips32{0}}; LoanParameters const loanParams{ .account = Account("lender"), .counter = Account("borrower"), - .principalRequest = Number{10000, 0}, - // .interest = TenthBips32{0}, - // .payTotal = 5816, - .payInterval = 150}; + .principalRequest = Number{200000, -6}, + .interest = TenthBips32{50000}, + .payTotal = 2, + .payInterval = 200}; runLoan(AssetType::XRP, brokerParams, loanParams); } diff --git a/src/test/app/MPToken_test.cpp b/src/test/app/MPToken_test.cpp index f1a5e10f49..f71e44a7bc 100644 --- a/src/test/app/MPToken_test.cpp +++ b/src/test/app/MPToken_test.cpp @@ -1594,7 +1594,7 @@ class MPToken_test : public beast::unit_test::suite jv[jss::secret] = alice.name(); jv[jss::tx_json] = pay(alice, bob, mpt); jv[jss::tx_json][jss::Amount][jss::value] = - to_string(maxMPTokenAmount + 1); + std::to_string(maxMPTokenAmount + 1); auto const jrr = env.rpc("json", "submit", to_string(jv)); BEAST_EXPECT(jrr[jss::result][jss::error] == "invalidParams"); } @@ -2483,7 +2483,7 @@ class MPToken_test : public beast::unit_test::suite alice.name(), makeMptID(env.seq(alice), alice)); Json::Value jv = claw(alice, mpt(1), bob); - jv[jss::Amount][jss::value] = to_string(maxMPTokenAmount + 1); + jv[jss::Amount][jss::value] = std::to_string(maxMPTokenAmount + 1); Json::Value jv1; jv1[jss::secret] = alice.name(); jv1[jss::tx_json] = jv; @@ -2660,7 +2660,7 @@ class MPToken_test : public beast::unit_test::suite STAmount const amt3{asset3, 10'000}; { - testcase("Test STAmount MPT arithmetics"); + testcase("Test STAmount MPT arithmetic"); using namespace std::string_literals; STAmount res = multiply(amt1, amt2, asset3); BEAST_EXPECT(res == amt3); @@ -2697,7 +2697,7 @@ class MPToken_test : public beast::unit_test::suite } { - testcase("Test MPTAmount arithmetics"); + testcase("Test MPTAmount arithmetic"); MPTAmount mptAmt1{100}; MPTAmount const mptAmt2{100}; BEAST_EXPECT((mptAmt1 += mptAmt2) == MPTAmount{200}); diff --git a/src/test/app/MultiSign_test.cpp b/src/test/app/MultiSign_test.cpp index 6950286b52..5c5404c17e 100644 --- a/src/test/app/MultiSign_test.cpp +++ b/src/test/app/MultiSign_test.cpp @@ -708,7 +708,7 @@ public: void testHeterogeneousSigners(FeatureBitset features) { - testcase("Heterogenous Signers"); + testcase("Heterogeneous Signers"); using namespace jtx; Env env{*this, features}; diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index f8d76623fd..41a4fc2b3b 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -21,7 +21,6 @@ #include #include #include -#include #include #include #include @@ -940,25 +939,6 @@ class Vault_test : public beast::unit_test::suite } }); - testCase([&](Env& env, - Account const& issuer, - Account const& owner, - Asset const& asset, - Vault& vault) { - testcase("clawback from self"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - - { - auto tx = vault.clawback( - {.issuer = issuer, - .id = keylet.key, - .holder = issuer, - .amount = asset(10)}); - env(tx, ter{temMALFORMED}); - } - }); - testCase([&](Env& env, Account const&, Account const& owner, @@ -1197,11 +1177,13 @@ class Vault_test : public beast::unit_test::suite auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + // Preclaim only checks for native assets. + if (asset.native()) { auto tx = vault.clawback( - {.issuer = owner, + {.issuer = issuer, .id = keylet.key, - .holder = issuer, + .holder = owner, .amount = asset(50)}); env(tx, ter(temMALFORMED)); } @@ -1924,8 +1906,20 @@ class Vault_test : public beast::unit_test::suite env.close(); { - auto tx = vault.clawback( - {.issuer = owner, .id = keylet.key, .holder = depositor}); + auto tx = vault.clawback({ + .issuer = depositor, + .id = keylet.key, + .holder = depositor, + }); + env(tx, ter(tecNO_PERMISSION)); + } + + { + auto tx = vault.clawback({ + .issuer = owner, + .id = keylet.key, + .holder = depositor, + }); env(tx, ter(tecNO_PERMISSION)); } }); @@ -2082,7 +2076,7 @@ class Vault_test : public beast::unit_test::suite PrettyAsset const& asset, Vault& vault, MPTTester& mptt) { - testcase("MPT failed reserve to re-create MPToken"); + testcase("MPT fail reserve to re-create MPToken"); auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); @@ -2377,6 +2371,15 @@ class Vault_test : public beast::unit_test::suite env(tx, ter(tecNO_AUTH)); } + { + // Cannot clawback if issuer is the holder + tx = vault.clawback( + {.issuer = issuer, + .id = keylet.key, + .holder = issuer, + .amount = asset(800)}); + env(tx, ter(tecNO_PERMISSION)); + } // Clawback works tx = vault.clawback( {.issuer = issuer, @@ -5243,6 +5246,542 @@ class Vault_test : public beast::unit_test::suite }); } + void + testVaultClawbackBurnShares() + { + using namespace test::jtx; + using namespace loanBroker; + using namespace loan; + Env env(*this, beast::severities::kWarning); + + auto const vaultAssetBalance = [&](Keylet const& vaultKeylet) { + auto const sleVault = env.le(vaultKeylet); + BEAST_EXPECT(sleVault != nullptr); + + return std::make_pair( + sleVault->at(sfAssetsAvailable), sleVault->at(sfAssetsTotal)); + }; + + auto const vaultShareBalance = [&](Keylet const& vaultKeylet) { + auto const sleVault = env.le(vaultKeylet); + BEAST_EXPECT(sleVault != nullptr); + + auto const sleIssuance = + env.le(keylet::mptIssuance(sleVault->at(sfShareMPTID))); + BEAST_EXPECT(sleIssuance != nullptr); + + return sleIssuance->at(sfOutstandingAmount); + }; + + auto const setupVault = + [&](PrettyAsset const& asset, + Account const& owner, + Account const& depositor) -> std::pair { + Vault vault{env}; + + auto const& [tx, vaultKeylet] = + vault.create({.owner = owner, .asset = asset}); + env(tx, ter(tesSUCCESS), THISLINE); + env.close(); + + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + + Asset share = vaultSle->at(sfShareMPTID); + + env(vault.deposit( + {.depositor = depositor, + .id = vaultKeylet.key, + .amount = asset(100)}), + ter(tesSUCCESS), + THISLINE); + env.close(); + + auto const& [availablePreDefault, totalPreDefault] = + vaultAssetBalance(vaultKeylet); + BEAST_EXPECT(availablePreDefault == totalPreDefault); + BEAST_EXPECT(availablePreDefault == asset(100).value()); + + // attempt to clawback shares while there are assets fails + env(vault.clawback( + {.issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(0).value()}), + ter(tecNO_PERMISSION), + THISLINE); + env.close(); + + auto const& sharesAvailable = vaultShareBalance(vaultKeylet); + auto const& brokerKeylet = + keylet::loanbroker(owner.id(), env.seq(owner)); + + env(set(owner, vaultKeylet.key), THISLINE); + env.close(); + + auto const& loanKeylet = keylet::loan(brokerKeylet.key, 1); + + // Create a simple Loan for the full amount of Vault assets + env(set(depositor, brokerKeylet.key, asset(100).value()), + loan::interestRate(TenthBips32(0)), + gracePeriod(60), + paymentInterval(120), + paymentTotal(10), + sig(sfCounterpartySignature, owner), + fee(env.current()->fees().base * 2), + ter(tesSUCCESS), + THISLINE); + env.close(); + + // attempt to clawback shares while there assetsAvailable == 0 and + // assetsTotal > 0 fails + env(vault.clawback( + {.issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(0).value()}), + ter(tecNO_PERMISSION), + THISLINE); + env.close(); + + env.close(std::chrono::seconds{120 + 60}); + + env(manage(owner, loanKeylet.key, tfLoanDefault), + ter(tesSUCCESS), + THISLINE); + + auto const& [availablePostDefault, totalPostDefault] = + vaultAssetBalance(vaultKeylet); + + BEAST_EXPECT(availablePostDefault == totalPostDefault); + BEAST_EXPECT(availablePostDefault == asset(0).value()); + BEAST_EXPECT(vaultShareBalance(vaultKeylet) == sharesAvailable); + + return std::make_pair(vault, vaultKeylet); + }; + + auto const testCase = [&](PrettyAsset const& asset, + std::string const& prefix, + Account const& owner, + Account const& depositor) { + { + testcase( + "VaultClawback (share) - " + prefix + + " owner asset clawback fails"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(100).value(), + }), + // when asset is XRP or owner is not issuer clawback fail + // when owner is issuer precision loss occurs as vault is + // empty + asset.native() ? ter(temMALFORMED) + : asset.raw().getIssuer() != owner.id() + ? ter(tecNO_PERMISSION) + : ter(tecPRECISION_LOSS), + THISLINE); + env.close(); + } + + { + testcase( + "VaultClawback (share) - " + prefix + + " owner incomplete share clawback fails"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + if (!vaultSle) + return; + Asset share = vaultSle->at(sfShareMPTID); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(1).value(), + }), + ter(tecLIMIT_EXCEEDED), + THISLINE); + env.close(); + } + + { + testcase( + "VaultClawback (share) - " + prefix + + " owner implicit complete share clawback"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + }), + // when owner is issuer implicit clawback fails + asset.native() || asset.raw().getIssuer() != owner.id() + ? ter(tesSUCCESS) + : ter(tecWRONG_ASSET), + THISLINE); + env.close(); + } + + { + testcase( + "VaultClawback (share) - " + prefix + + " owner explicit complete share clawback succeeds"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + if (!vaultSle) + return; + Asset share = vaultSle->at(sfShareMPTID); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(vaultShareBalance(vaultKeylet)).value(), + }), + ter(tesSUCCESS), + THISLINE); + env.close(); + } + { + testcase( + "VaultClawback (share) - " + prefix + + " owner can clawback own shares"); + auto [vault, vaultKeylet] = setupVault(asset, owner, owner); + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + if (!vaultSle) + return; + Asset share = vaultSle->at(sfShareMPTID); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = owner, + .amount = share(vaultShareBalance(vaultKeylet)).value(), + }), + ter(tesSUCCESS), + THISLINE); + env.close(); + } + + { + testcase( + "VaultClawback (share) - " + prefix + + " empty vault share clawback fails"); + auto [vault, vaultKeylet] = setupVault(asset, owner, owner); + auto const& vaultSle = env.le(vaultKeylet); + if (BEAST_EXPECT(vaultSle != nullptr)) + return; + Asset share = vaultSle->at(sfShareMPTID); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = owner, + .amount = share(vaultShareBalance(vaultKeylet)).value(), + }), + ter(tesSUCCESS), + THISLINE); + + // Now the vault is empty, clawback again fails + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = owner, + }), + ter(tecNO_PERMISSION), + THISLINE); + env.close(); + } + }; + + Account owner{"alice"}; + Account depositor{"bob"}; + Account issuer{"issuer"}; + + env.fund(XRP(10000), issuer, owner, depositor); + env.close(); + + // Test XRP + PrettyAsset xrp = xrpIssue(); + testCase(xrp, "XRP", owner, depositor); + testCase(xrp, "XRP (depositor is owner)", owner, owner); + + // Test IOU + PrettyAsset IOU = issuer["IOU"]; + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + env.trust(IOU(1000), owner); + env.trust(IOU(1000), depositor); + env(pay(issuer, owner, IOU(100))); + env(pay(issuer, depositor, IOU(100))); + env.close(); + testCase(IOU, "IOU", owner, depositor); + testCase(IOU, "IOU (owner is issuer)", issuer, depositor); + + // Test MPT + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset MPT = mptt.issuanceID(); + mptt.authorize({.account = owner}); + mptt.authorize({.account = depositor}); + env(pay(issuer, owner, MPT(1000))); + env(pay(issuer, depositor, MPT(1000))); + env.close(); + testCase(MPT, "MPT", owner, depositor); + testCase(MPT, "MPT (owner is issuer)", issuer, depositor); + } + + void + testVaultClawbackAssets() + { + using namespace test::jtx; + using namespace loanBroker; + using namespace loan; + Env env(*this); + + auto const setupVault = + [&](PrettyAsset const& asset, + Account const& owner, + Account const& depositor, + Account const& issuer) -> std::pair { + Vault vault{env}; + + auto const& [tx, vaultKeylet] = + vault.create({.owner = owner, .asset = asset}); + env(tx, ter(tesSUCCESS), THISLINE); + env.close(); + + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + env(vault.deposit( + {.depositor = depositor, + .id = vaultKeylet.key, + .amount = asset(100)}), + ter(tesSUCCESS), + THISLINE); + env.close(); + + return std::make_pair(vault, vaultKeylet); + }; + + auto const testCase = [&](PrettyAsset const& asset, + std::string const& prefix, + Account const& owner, + Account const& depositor, + Account const& issuer) { + if (asset.native()) + { + testcase( + "VaultClawback (asset) - " + prefix + + " issuer XRP clawback fails"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, depositor, issuer); + // If the asset is XRP, clawback with amount fails as malfored + // when asset is specified. + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = issuer, + .amount = asset(1).value(), + }), + ter(temMALFORMED), + THISLINE); + // When asset is implicit, clawback fails as no permission. + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = issuer, + }), + ter(tecNO_PERMISSION), + THISLINE); + return; + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " clawback for different asset fails"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, depositor, issuer); + + Account issuer2{"issuer2"}; + PrettyAsset asset2 = issuer2["FOO"]; + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset2(1).value(), + }), + ter(tecWRONG_ASSET), + THISLINE); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " ambiguous owner/issuer asset clawback fails"); + auto [vault, vaultKeylet] = + setupVault(asset, issuer, depositor, issuer); + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = issuer, + }), + ter(tecWRONG_ASSET), + THISLINE); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " non-issuer asset clawback fails"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, depositor, issuer); + + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + }), + ter(tecNO_PERMISSION), + THISLINE); + + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(1).value(), + }), + ter(tecNO_PERMISSION), + THISLINE); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " issuer clawback from self fails"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, issuer, issuer); + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = issuer, + }), + ter(tecNO_PERMISSION), + THISLINE); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " issuer share clawback fails"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, depositor, issuer); + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + if (!vaultSle) + return; + Asset share = vaultSle->at(sfShareMPTID); + + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(1).value(), + }), + ter(tecNO_PERMISSION), + THISLINE); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " partial issuer asset clawback succeeds"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, depositor, issuer); + + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(1).value(), + }), + ter(tesSUCCESS), + THISLINE); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " full issuer asset clawback succeeds"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, depositor, issuer); + + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(100).value(), + }), + ter(tesSUCCESS), + THISLINE); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " implicit full issuer asset clawback succeeds"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, depositor, issuer); + + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + }), + ter(tesSUCCESS), + THISLINE); + } + }; + + Account owner{"alice"}; + Account depositor{"bob"}; + Account issuer{"issuer"}; + + env.fund(XRP(10000), issuer, owner, depositor); + env.close(); + + // Test XRP + PrettyAsset xrp = xrpIssue(); + testCase(xrp, "XRP", owner, depositor, issuer); + + // Test IOU + PrettyAsset IOU = issuer["IOU"]; + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + env.trust(IOU(1000), owner); + env.trust(IOU(1000), depositor); + env(pay(issuer, owner, IOU(1000))); + env(pay(issuer, depositor, IOU(1000))); + env.close(); + testCase(IOU, "IOU", owner, depositor, issuer); + + // Test MPT + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset MPT = mptt.issuanceID(); + mptt.authorize({.account = owner}); + mptt.authorize({.account = depositor}); + env(pay(issuer, depositor, MPT(1000))); + env.close(); + testCase(MPT, "MPT", owner, depositor, issuer); + } + public: void run() override @@ -5261,6 +5800,8 @@ public: testScaleIOU(); testRPC(); testDelegate(); + testVaultClawbackBurnShares(); + testVaultClawbackAssets(); } }; diff --git a/src/test/basics/IOUAmount_test.cpp b/src/test/basics/IOUAmount_test.cpp index 305f2c83a1..d299f439d4 100644 --- a/src/test/basics/IOUAmount_test.cpp +++ b/src/test/basics/IOUAmount_test.cpp @@ -141,15 +141,28 @@ public: { testcase("IOU strings"); - BEAST_EXPECT(to_string(IOUAmount(-2, 0)) == "-2"); - BEAST_EXPECT(to_string(IOUAmount(0, 0)) == "0"); - BEAST_EXPECT(to_string(IOUAmount(2, 0)) == "2"); - BEAST_EXPECT(to_string(IOUAmount(25, -3)) == "0.025"); - BEAST_EXPECT(to_string(IOUAmount(-25, -3)) == "-0.025"); - BEAST_EXPECT(to_string(IOUAmount(25, 1)) == "250"); - BEAST_EXPECT(to_string(IOUAmount(-25, 1)) == "-250"); - BEAST_EXPECT(to_string(IOUAmount(2, 20)) == "2000000000000000e5"); - BEAST_EXPECT(to_string(IOUAmount(-2, -20)) == "-2000000000000000e-35"); + auto test = [this](IOUAmount const& n, std::string const& expected) { + auto const result = to_string(n); + std::stringstream ss; + ss << "to_string(" << result << "). Expected: " << expected; + BEAST_EXPECTS(result == expected, ss.str()); + }; + + for (auto const mantissaSize : + {MantissaRange::small, MantissaRange::large}) + { + NumberMantissaScaleGuard mg(mantissaSize); + + test(IOUAmount(-2, 0), "-2"); + test(IOUAmount(0, 0), "0"); + test(IOUAmount(2, 0), "2"); + test(IOUAmount(25, -3), "0.025"); + test(IOUAmount(-25, -3), "-0.025"); + test(IOUAmount(25, 1), "250"); + test(IOUAmount(-25, 1), "-250"); + test(IOUAmount(2, 20), "2e20"); + test(IOUAmount(-2, -20), "-2e-20"); + } } void diff --git a/src/test/basics/IntrusiveShared_test.cpp b/src/test/basics/IntrusiveShared_test.cpp index b77325efa9..500b6e7e39 100644 --- a/src/test/basics/IntrusiveShared_test.cpp +++ b/src/test/basics/IntrusiveShared_test.cpp @@ -396,7 +396,7 @@ public: // This checks that partialDelete has run to completion // before the destructor is called. A sleep is inserted // inside the partial delete to make sure the destructor is - // given an opportunity to run durring partial delete. + // given an opportunity to run during partial delete. BEAST_EXPECT(cur == partiallyDeleted); } if (next == partiallyDeletedStarted) diff --git a/src/test/basics/Number_test.cpp b/src/test/basics/Number_test.cpp index b7c5ee45b7..1fa5ae6e8f 100644 --- a/src/test/basics/Number_test.cpp +++ b/src/test/basics/Number_test.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -14,46 +15,84 @@ public: void testZero() { - testcase("zero"); + testcase << "zero " << to_string(Number::getMantissaScale()); - Number const z{0, 0}; + for (Number const& z : {Number{0, 0}, Number{0}}) + { + BEAST_EXPECT(z.mantissa() == 0); + BEAST_EXPECT(z.exponent() == Number{}.exponent()); - BEAST_EXPECT(z.mantissa() == 0); - BEAST_EXPECT(z.exponent() == Number{}.exponent()); - - BEAST_EXPECT((z + z) == z); - BEAST_EXPECT((z - z) == z); - BEAST_EXPECT(z == -z); + BEAST_EXPECT((z + z) == z); + BEAST_EXPECT((z - z) == z); + BEAST_EXPECT(z == -z); + } } void test_limits() { - testcase("test_limits"); + auto const scale = Number::getMantissaScale(); + testcase << "test_limits " << to_string(scale); bool caught = false; + auto const minMantissa = Number::minMantissa(); try { - Number x{10'000'000'000'000'000, 32768}; + Number x = + Number{false, minMantissa * 10, 32768, Number::normalized{}}; } catch (std::overflow_error const&) { caught = true; } BEAST_EXPECT(caught); - Number x{10'000'000'000'000'000, 32767}; - BEAST_EXPECT((x == Number{1'000'000'000'000'000, 32768})); - Number z{1'000'000'000'000'000, -32769}; - BEAST_EXPECT(z == Number{}); - Number y{1'000'000'000'000'001'500, 32000}; - BEAST_EXPECT((y == Number{1'000'000'000'000'002, 32003})); - Number m{std::numeric_limits::min()}; - BEAST_EXPECT((m == Number{-9'223'372'036'854'776, 3})); - Number M{std::numeric_limits::max()}; - BEAST_EXPECT((M == Number{9'223'372'036'854'776, 3})); + + auto test = [this](auto const& x, auto const& y, int line) { + auto const result = x == y; + std::stringstream ss; + ss << x << " == " << y << " -> " << (result ? "true" : "false"); + expect(result, ss.str(), __FILE__, line); + }; + + test( + Number{false, minMantissa * 10, 32767, Number::normalized{}}, + Number{false, minMantissa, 32768, Number::normalized{}}, + __LINE__); + test( + Number{false, minMantissa, -32769, Number::normalized{}}, + Number{}, + __LINE__); + test( + Number{false, minMantissa, 32000, Number::normalized{}} * 1'000 + + Number{false, 1'500, 32000, Number::normalized{}}, + Number{false, minMantissa + 2, 32003, Number::normalized{}}, + __LINE__); + // 9,223,372,036,854,775,808 + + test( + Number{std::numeric_limits::min()}, + scale == MantissaRange::small + ? Number{-9'223'372'036'854'776, 3} + : Number{true, 9'223'372'036'854'775'808ULL, 0, Number::normalized{}}, + __LINE__); + test( + Number{std::numeric_limits::min() + 1}, + scale == MantissaRange::small ? Number{-9'223'372'036'854'776, 3} + : Number{-9'223'372'036'854'775'807}, + __LINE__); + test( + Number{std::numeric_limits::max()}, + Number{ + scale == MantissaRange::small + ? 9'223'372'036'854'776 + : std::numeric_limits::max(), + 18 - Number::mantissaLog()}, + __LINE__); caught = false; try { - Number q{99'999'999'999'999'999, 32767}; + [[maybe_unused]] + Number q = + Number{false, minMantissa, 32767, Number::normalized{}} * 100; } catch (std::overflow_error const&) { @@ -65,76 +104,307 @@ public: void test_add() { - testcase("test_add"); + auto const scale = Number::getMantissaScale(); + testcase << "test_add " << to_string(scale); + using Case = std::tuple; - Case c[]{ - {Number{1'000'000'000'000'000, -15}, - Number{6'555'555'555'555'555, -29}, - Number{1'000'000'000'000'066, -15}}, - {Number{-1'000'000'000'000'000, -15}, - Number{-6'555'555'555'555'555, -29}, - Number{-1'000'000'000'000'066, -15}}, - {Number{-1'000'000'000'000'000, -15}, - Number{6'555'555'555'555'555, -29}, - Number{-9'999'999'999'999'344, -16}}, - {Number{-6'555'555'555'555'555, -29}, - Number{1'000'000'000'000'000, -15}, - Number{9'999'999'999'999'344, -16}}, - {Number{}, Number{5}, Number{5}}, - {Number{5'555'555'555'555'555, -32768}, - Number{-5'555'555'555'555'554, -32768}, - Number{0}}, - {Number{-9'999'999'999'999'999, -31}, - Number{1'000'000'000'000'000, -15}, - Number{9'999'999'999'999'990, -16}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x + y == z); - bool caught = false; - try + auto const cSmall = std::to_array( + {{Number{1'000'000'000'000'000, -15}, + Number{6'555'555'555'555'555, -29}, + Number{1'000'000'000'000'066, -15}}, + {Number{-1'000'000'000'000'000, -15}, + Number{-6'555'555'555'555'555, -29}, + Number{-1'000'000'000'000'066, -15}}, + {Number{-1'000'000'000'000'000, -15}, + Number{6'555'555'555'555'555, -29}, + Number{-9'999'999'999'999'344, -16}}, + {Number{-6'555'555'555'555'555, -29}, + Number{1'000'000'000'000'000, -15}, + Number{9'999'999'999'999'344, -16}}, + {Number{}, Number{5}, Number{5}}, + {Number{5}, Number{}, Number{5}}, + {Number{5'555'555'555'555'555, -32768}, + Number{-5'555'555'555'555'554, -32768}, + Number{0}}, + {Number{-9'999'999'999'999'999, -31}, + Number{1'000'000'000'000'000, -15}, + Number{9'999'999'999'999'990, -16}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items from C + // with larger mantissa + { + {Number{1'000'000'000'000'000, -15}, + Number{6'555'555'555'555'555, -29}, + Number{1'000'000'000'000'065'556, -18}}, + {Number{-1'000'000'000'000'000, -15}, + Number{-6'555'555'555'555'555, -29}, + Number{-1'000'000'000'000'065'556, -18}}, + {Number{-1'000'000'000'000'000, -15}, + Number{6'555'555'555'555'555, -29}, + Number{ + true, + 9'999'999'999'999'344'444ULL, + -19, + Number::normalized{}}}, + {Number{-6'555'555'555'555'555, -29}, + Number{1'000'000'000'000'000, -15}, + Number{ + false, + 9'999'999'999'999'344'444ULL, + -19, + Number::normalized{}}}, + {Number{}, Number{5}, Number{5}}, + {Number{5}, Number{}, Number{5}}, + {Number{5'555'555'555'555'555'000, -32768}, + Number{-5'555'555'555'555'554'000, -32768}, + Number{0}}, + {Number{-9'999'999'999'999'999, -31}, + Number{1'000'000'000'000'000, -15}, + Number{9'999'999'999'999'990, -16}}, + // Items from cSmall expanded for the larger mantissa + {Number{1'000'000'000'000'000'000, -18}, + Number{6'555'555'555'555'555'555, -35}, + Number{1'000'000'000'000'000'066, -18}}, + {Number{-1'000'000'000'000'000'000, -18}, + Number{-6'555'555'555'555'555'555, -35}, + Number{-1'000'000'000'000'000'066, -18}}, + {Number{-1'000'000'000'000'000'000, -18}, + Number{6'555'555'555'555'555'555, -35}, + Number{ + true, + 9'999'999'999'999'999'344ULL, + -19, + Number::normalized{}}}, + {Number{-6'555'555'555'555'555'555, -35}, + Number{1'000'000'000'000'000'000, -18}, + Number{ + false, + 9'999'999'999'999'999'344ULL, + -19, + Number::normalized{}}}, + {Number{}, Number{5}, Number{5}}, + {Number{5'555'555'555'555'555'555, -32768}, + Number{-5'555'555'555'555'555'554, -32768}, + Number{0}}, + {Number{ + true, + 9'999'999'999'999'999'999ULL, + -37, + Number::normalized{}}, + Number{1'000'000'000'000'000'000, -18}, + Number{ + false, + 9'999'999'999'999'999'990ULL, + -19, + Number::normalized{}}}, + {Number{Number::maxRep}, + Number{6, -1}, + Number{Number::maxRep / 10, 1}}, + {Number{Number::maxRep - 1}, + Number{1, 0}, + Number{Number::maxRep}}, + // Test extremes + { + // Each Number operand rounds up, so the actual mantissa is + // minMantissa + Number{ + false, + 9'999'999'999'999'999'999ULL, + 0, + Number::normalized{}}, + Number{ + false, + 9'999'999'999'999'999'999ULL, + 0, + Number::normalized{}}, + Number{2, 19}, + }, + { + // Does not round. Mantissas are going to be > maxRep, so if + // added together as uint64_t's, the result will overflow. + // With addition using uint128_t, there's no problem. After + // normalizing, the resulting mantissa ends up less than + // maxRep. + Number{ + false, + 9'999'999'999'999'999'990ULL, + 0, + Number::normalized{}}, + Number{ + false, + 9'999'999'999'999'999'990ULL, + 0, + Number::normalized{}}, + Number{ + false, + 1'999'999'999'999'999'998ULL, + 1, + Number::normalized{}}, + }, + }); + auto test = [this](auto const& c) { + for (auto const& [x, y, z] : c) + { + auto const result = x + y; + std::stringstream ss; + ss << x << " + " << y << " = " << result << ". Expected: " << z; + BEAST_EXPECTS(result == z, ss.str()); + } + }; + if (scale == MantissaRange::small) + test(cSmall); + else + test(cLarge); { - Number{9'999'999'999'999'999, 32768} + - Number{5'000'000'000'000'000, 32767}; + bool caught = false; + try + { + Number{ + false, Number::maxMantissa(), 32768, Number::normalized{}} + + Number{ + false, + Number::minMantissa(), + 32767, + Number::normalized{}} * + 5; + } + catch (std::overflow_error const&) + { + caught = true; + } + BEAST_EXPECT(caught); } - catch (std::overflow_error const&) - { - caught = true; - } - BEAST_EXPECT(caught); } void test_sub() { - testcase("test_sub"); + auto const scale = Number::getMantissaScale(); + testcase << "test_sub " << to_string(scale); + using Case = std::tuple; - Case c[]{ - {Number{1'000'000'000'000'000, -15}, - Number{6'555'555'555'555'555, -29}, - Number{9'999'999'999'999'344, -16}}, - {Number{6'555'555'555'555'555, -29}, - Number{1'000'000'000'000'000, -15}, - Number{-9'999'999'999'999'344, -16}}, - {Number{1'000'000'000'000'000, -15}, - Number{1'000'000'000'000'000, -15}, - Number{0}}, - {Number{1'000'000'000'000'000, -15}, - Number{1'000'000'000'000'001, -15}, - Number{-1'000'000'000'000'000, -30}}, - {Number{1'000'000'000'000'001, -15}, - Number{1'000'000'000'000'000, -15}, - Number{1'000'000'000'000'000, -30}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x - y == z); + auto const cSmall = std::to_array( + {{Number{1'000'000'000'000'000, -15}, + Number{6'555'555'555'555'555, -29}, + Number{9'999'999'999'999'344, -16}}, + {Number{6'555'555'555'555'555, -29}, + Number{1'000'000'000'000'000, -15}, + Number{-9'999'999'999'999'344, -16}}, + {Number{1'000'000'000'000'000, -15}, + Number{1'000'000'000'000'000, -15}, + Number{0}}, + {Number{1'000'000'000'000'000, -15}, + Number{1'000'000'000'000'001, -15}, + Number{-1'000'000'000'000'000, -30}}, + {Number{1'000'000'000'000'001, -15}, + Number{1'000'000'000'000'000, -15}, + Number{1'000'000'000'000'000, -30}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items from C + // with larger mantissa + { + {Number{1'000'000'000'000'000, -15}, + Number{6'555'555'555'555'555, -29}, + Number{ + false, + 9'999'999'999'999'344'444ULL, + -19, + Number::normalized{}}}, + {Number{6'555'555'555'555'555, -29}, + Number{1'000'000'000'000'000, -15}, + Number{ + true, + 9'999'999'999'999'344'444ULL, + -19, + Number::normalized{}}}, + {Number{1'000'000'000'000'000, -15}, + Number{1'000'000'000'000'000, -15}, + Number{0}}, + {Number{1'000'000'000'000'000, -15}, + Number{1'000'000'000'000'001, -15}, + Number{-1'000'000'000'000'000, -30}}, + {Number{1'000'000'000'000'001, -15}, + Number{1'000'000'000'000'000, -15}, + Number{1'000'000'000'000'000, -30}}, + // Items from cSmall expanded for the larger mantissa + {Number{1'000'000'000'000'000'000, -18}, + Number{6'555'555'555'555'555'555, -32}, + Number{ + false, + 9'999'999'999'999'344'444ULL, + -19, + Number::normalized{}}}, + {Number{6'555'555'555'555'555'555, -32}, + Number{1'000'000'000'000'000'000, -18}, + Number{ + true, + 9'999'999'999'999'344'444ULL, + -19, + Number::normalized{}}}, + {Number{1'000'000'000'000'000'000, -18}, + Number{1'000'000'000'000'000'000, -18}, + Number{0}}, + {Number{1'000'000'000'000'000'000, -18}, + Number{1'000'000'000'000'000'001, -18}, + Number{-1'000'000'000'000'000'000, -36}}, + {Number{1'000'000'000'000'000'001, -18}, + Number{1'000'000'000'000'000'000, -18}, + Number{1'000'000'000'000'000'000, -36}}, + {Number{Number::maxRep}, + Number{6, -1}, + Number{Number::maxRep - 1}}, + {Number{false, Number::maxRep + 1, 0, Number::normalized{}}, + Number{1, 0}, + Number{Number::maxRep / 10 + 1, 1}}, + {Number{false, Number::maxRep + 1, 0, Number::normalized{}}, + Number{3, 0}, + Number{Number::maxRep}}, + {power(2, 63), Number{3, 0}, Number{Number::maxRep}}, + }); + auto test = [this](auto const& c) { + for (auto const& [x, y, z] : c) + { + auto const result = x - y; + std::stringstream ss; + ss << x << " - " << y << " = " << result << ". Expected: " << z; + BEAST_EXPECTS(result == z, ss.str()); + } + }; + if (scale == MantissaRange::small) + test(cSmall); + else + test(cLarge); } void test_mul() { - testcase("test_mul"); + auto const scale = Number::getMantissaScale(); + testcase << "test_mul " << to_string(scale); + using Case = std::tuple; + auto test = [this](auto const& c) { + for (auto const& [x, y, z] : c) + { + auto const result = x * y; + std::stringstream ss; + ss << x << " * " << y << " = " << result << ". Expected: " << z; + BEAST_EXPECTS(result == z, ss.str()); + } + }; + auto tests = [&](auto const& cSmall, auto const& cLarge) { + if (scale == MantissaRange::small) + test(cSmall); + else + test(cLarge); + }; + auto const maxMantissa = Number::maxMantissa(); + saveNumberRoundMode save{Number::setround(Number::to_nearest)}; { - Case c[]{ + auto const cSmall = std::to_array({ {Number{7}, Number{8}, Number{56}}, {Number{1414213562373095, -15}, Number{1414213562373095, -15}, @@ -150,166 +420,520 @@ public: Number{1000000000000000, -14}}, {Number{1000000000000000, -32768}, Number{1000000000000000, -32768}, - Number{0}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x * y == z); + Number{0}}, + // Maximum mantissa range + {Number{9'999'999'999'999'999, 0}, + Number{9'999'999'999'999'999, 0}, + Number{9'999'999'999'999'998, 16}}, + }); + auto const cLarge = std::to_array({ + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items + // from C with larger mantissa + {Number{7}, Number{8}, Number{56}}, + {Number{1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{1999999999999999862, -18}}, + {Number{-1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{-1999999999999999862, -18}}, + {Number{-1414213562373095, -15}, + Number{-1414213562373095, -15}, + Number{1999999999999999862, -18}}, + {Number{3214285714285706, -15}, + Number{3111111111111119, -15}, + Number{ + false, + 9'999'999'999'999'999'579ULL, + -18, + Number::normalized{}}}, + {Number{1000000000000000000, -32768}, + Number{1000000000000000000, -32768}, + Number{0}}, + // Items from cSmall expanded for the larger mantissa, + // except duplicates. Sadly, it looks like sqrt(2)^2 != 2 + // with higher precision + {Number{1414213562373095049, -18}, + Number{1414213562373095049, -18}, + Number{2000000000000000001, -18}}, + {Number{-1414213562373095048, -18}, + Number{1414213562373095048, -18}, + Number{-1999999999999999998, -18}}, + {Number{-1414213562373095048, -18}, + Number{-1414213562373095049, -18}, + Number{1999999999999999999, -18}}, + {Number{3214285714285714278, -18}, + Number{3111111111111111119, -18}, + Number{10, 0}}, + // Maximum mantissa range - rounds up to 1e19 + {Number{false, maxMantissa, 0, Number::normalized{}}, + Number{false, maxMantissa, 0, Number::normalized{}}, + Number{1, 38}}, + // Maximum int64 range + {Number{Number::maxRep, 0}, + Number{Number::maxRep, 0}, + Number{85'070'591'730'234'615'85, 19}}, + }); + tests(cSmall, cLarge); } Number::setround(Number::towards_zero); + testcase << "test_mul " << to_string(Number::getMantissaScale()) + << " towards_zero"; { - Case c[]{ - {Number{7}, Number{8}, Number{56}}, - {Number{1414213562373095, -15}, - Number{1414213562373095, -15}, - Number{1999999999999999, -15}}, - {Number{-1414213562373095, -15}, - Number{1414213562373095, -15}, - Number{-1999999999999999, -15}}, - {Number{-1414213562373095, -15}, - Number{-1414213562373095, -15}, - Number{1999999999999999, -15}}, - {Number{3214285714285706, -15}, - Number{3111111111111119, -15}, - Number{9999999999999999, -15}}, - {Number{1000000000000000, -32768}, - Number{1000000000000000, -32768}, - Number{0}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x * y == z); + auto const cSmall = std::to_array( + {{Number{7}, Number{8}, Number{56}}, + {Number{1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{1999999999999999, -15}}, + {Number{-1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{-1999999999999999, -15}}, + {Number{-1414213562373095, -15}, + Number{-1414213562373095, -15}, + Number{1999999999999999, -15}}, + {Number{3214285714285706, -15}, + Number{3111111111111119, -15}, + Number{9999999999999999, -15}}, + {Number{1000000000000000, -32768}, + Number{1000000000000000, -32768}, + Number{0}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items + // from C with larger mantissa + { + {Number{7}, Number{8}, Number{56}}, + {Number{1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{1999999999999999861, -18}}, + {Number{-1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{-1999999999999999861, -18}}, + {Number{-1414213562373095, -15}, + Number{-1414213562373095, -15}, + Number{1999999999999999861, -18}}, + {Number{3214285714285706, -15}, + Number{3111111111111119, -15}, + Number{ + false, + 9999999999999999579ULL, + -18, + Number::normalized{}}}, + {Number{1000000000000000000, -32768}, + Number{1000000000000000000, -32768}, + Number{0}}, + // Items from cSmall expanded for the larger mantissa, + // except duplicates. Sadly, it looks like sqrt(2)^2 != 2 + // with higher precision + {Number{1414213562373095049, -18}, + Number{1414213562373095049, -18}, + Number{2, 0}}, + {Number{-1414213562373095048, -18}, + Number{1414213562373095048, -18}, + Number{-1999999999999999997, -18}}, + {Number{-1414213562373095048, -18}, + Number{-1414213562373095049, -18}, + Number{1999999999999999999, -18}}, + {Number{3214285714285714278, -18}, + Number{3111111111111111119, -18}, + Number{10, 0}}, + // Maximum mantissa range - rounds down to maxMantissa/10e1 + // 99'999'999'999'999'999'800'000'000'000'000'000'100 + {Number{false, maxMantissa, 0, Number::normalized{}}, + Number{false, maxMantissa, 0, Number::normalized{}}, + Number{ + false, + maxMantissa / 10 - 1, + 20, + Number::normalized{}}}, + // Maximum int64 range + // 85'070'591'730'234'615'847'396'907'784'232'501'249 + {Number{Number::maxRep, 0}, + Number{Number::maxRep, 0}, + Number{85'070'591'730'234'615'84, 19}}, + }); + tests(cSmall, cLarge); } Number::setround(Number::downward); + testcase << "test_mul " << to_string(Number::getMantissaScale()) + << " downward"; { - Case c[]{ - {Number{7}, Number{8}, Number{56}}, - {Number{1414213562373095, -15}, - Number{1414213562373095, -15}, - Number{1999999999999999, -15}}, - {Number{-1414213562373095, -15}, - Number{1414213562373095, -15}, - Number{-2000000000000000, -15}}, - {Number{-1414213562373095, -15}, - Number{-1414213562373095, -15}, - Number{1999999999999999, -15}}, - {Number{3214285714285706, -15}, - Number{3111111111111119, -15}, - Number{9999999999999999, -15}}, - {Number{1000000000000000, -32768}, - Number{1000000000000000, -32768}, - Number{0}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x * y == z); + auto const cSmall = std::to_array( + {{Number{7}, Number{8}, Number{56}}, + {Number{1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{1999999999999999, -15}}, + {Number{-1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{-2000000000000000, -15}}, + {Number{-1414213562373095, -15}, + Number{-1414213562373095, -15}, + Number{1999999999999999, -15}}, + {Number{3214285714285706, -15}, + Number{3111111111111119, -15}, + Number{9999999999999999, -15}}, + {Number{1000000000000000, -32768}, + Number{1000000000000000, -32768}, + Number{0}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items + // from C with larger mantissa + { + {Number{7}, Number{8}, Number{56}}, + {Number{1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{1999999999999999861, -18}}, + {Number{-1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{-1999999999999999862, -18}}, + {Number{-1414213562373095, -15}, + Number{-1414213562373095, -15}, + Number{1999999999999999861, -18}}, + {Number{3214285714285706, -15}, + Number{3111111111111119, -15}, + Number{ + false, + 9'999'999'999'999'999'579ULL, + -18, + Number::normalized{}}}, + {Number{1000000000000000000, -32768}, + Number{1000000000000000000, -32768}, + Number{0}}, + // Items from cSmall expanded for the larger mantissa, + // except duplicates. Sadly, it looks like sqrt(2)^2 != 2 + // with higher precision + {Number{1414213562373095049, -18}, + Number{1414213562373095049, -18}, + Number{2, 0}}, + {Number{-1414213562373095048, -18}, + Number{1414213562373095048, -18}, + Number{-1999999999999999998, -18}}, + {Number{-1414213562373095048, -18}, + Number{-1414213562373095049, -18}, + Number{1999999999999999999, -18}}, + {Number{3214285714285714278, -18}, + Number{3111111111111111119, -18}, + Number{10, 0}}, + // Maximum mantissa range - rounds down to maxMantissa/10e1 + // 99'999'999'999'999'999'800'000'000'000'000'000'100 + {Number{false, maxMantissa, 0, Number::normalized{}}, + Number{false, maxMantissa, 0, Number::normalized{}}, + Number{ + false, + maxMantissa / 10 - 1, + 20, + Number::normalized{}}}, + // Maximum int64 range + // 85'070'591'730'234'615'847'396'907'784'232'501'249 + {Number{Number::maxRep, 0}, + Number{Number::maxRep, 0}, + Number{85'070'591'730'234'615'84, 19}}, + }); + tests(cSmall, cLarge); } Number::setround(Number::upward); + testcase << "test_mul " << to_string(Number::getMantissaScale()) + << " upward"; { - Case c[]{ - {Number{7}, Number{8}, Number{56}}, - {Number{1414213562373095, -15}, - Number{1414213562373095, -15}, - Number{2000000000000000, -15}}, - {Number{-1414213562373095, -15}, - Number{1414213562373095, -15}, - Number{-1999999999999999, -15}}, - {Number{-1414213562373095, -15}, - Number{-1414213562373095, -15}, - Number{2000000000000000, -15}}, - {Number{3214285714285706, -15}, - Number{3111111111111119, -15}, - Number{1000000000000000, -14}}, - {Number{1000000000000000, -32768}, - Number{1000000000000000, -32768}, - Number{0}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x * y == z); + auto const cSmall = std::to_array( + {{Number{7}, Number{8}, Number{56}}, + {Number{1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{2000000000000000, -15}}, + {Number{-1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{-1999999999999999, -15}}, + {Number{-1414213562373095, -15}, + Number{-1414213562373095, -15}, + Number{2000000000000000, -15}}, + {Number{3214285714285706, -15}, + Number{3111111111111119, -15}, + Number{1000000000000000, -14}}, + {Number{1000000000000000, -32768}, + Number{1000000000000000, -32768}, + Number{0}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items + // from C with larger mantissa + { + {Number{7}, Number{8}, Number{56}}, + {Number{1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{1999999999999999862, -18}}, + {Number{-1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{-1999999999999999861, -18}}, + {Number{-1414213562373095, -15}, + Number{-1414213562373095, -15}, + Number{1999999999999999862, -18}}, + {Number{3214285714285706, -15}, + Number{3111111111111119, -15}, + Number{999999999999999958, -17}}, + {Number{1000000000000000000, -32768}, + Number{1000000000000000000, -32768}, + Number{0}}, + // Items from cSmall expanded for the larger mantissa, + // except duplicates. Sadly, it looks like sqrt(2)^2 != 2 + // with higher precision + {Number{1414213562373095049, -18}, + Number{1414213562373095049, -18}, + Number{2000000000000000001, -18}}, + {Number{-1414213562373095048, -18}, + Number{1414213562373095048, -18}, + Number{-1999999999999999997, -18}}, + {Number{-1414213562373095048, -18}, + Number{-1414213562373095049, -18}, + Number{2, 0}}, + {Number{3214285714285714278, -18}, + Number{3111111111111111119, -18}, + Number{1000000000000000001, -17}}, + // Maximum mantissa range - rounds up to minMantissa*10 + // 1e19*1e19=1e38 + {Number{false, maxMantissa, 0, Number::normalized{}}, + Number{false, maxMantissa, 0, Number::normalized{}}, + Number{1, 38}}, + // Maximum int64 range + // 85'070'591'730'234'615'847'396'907'784'232'501'249 + {Number{Number::maxRep, 0}, + Number{Number::maxRep, 0}, + Number{85'070'591'730'234'615'85, 19}}, + }); + tests(cSmall, cLarge); } - bool caught = false; - try + testcase << "test_mul " << to_string(Number::getMantissaScale()) + << " overflow"; { - Number{9'999'999'999'999'999, 32768} * - Number{5'000'000'000'000'000, 32767}; + bool caught = false; + try + { + Number{false, maxMantissa, 32768, Number::normalized{}} * + Number{ + false, + Number::minMantissa() * 5, + 32767, + Number::normalized{}}; + } + catch (std::overflow_error const&) + { + caught = true; + } + BEAST_EXPECT(caught); } - catch (std::overflow_error const&) - { - caught = true; - } - BEAST_EXPECT(caught); } void test_div() { - testcase("test_div"); + auto const scale = Number::getMantissaScale(); + testcase << "test_div " << to_string(scale); + using Case = std::tuple; + auto test = [this](auto const& c) { + for (auto const& [x, y, z] : c) + { + auto const result = x / y; + std::stringstream ss; + ss << x << " / " << y << " = " << result << ". Expected: " << z; + BEAST_EXPECTS(result == z, ss.str()); + } + }; + auto const maxMantissa = Number::maxMantissa(); + auto tests = [&](auto const& cSmall, auto const& cLarge) { + if (scale == MantissaRange::small) + test(cSmall); + else + test(cLarge); + }; saveNumberRoundMode save{Number::setround(Number::to_nearest)}; { - Case c[]{ - {Number{1}, Number{2}, Number{5, -1}}, - {Number{1}, Number{10}, Number{1, -1}}, - {Number{1}, Number{-10}, Number{-1, -1}}, - {Number{0}, Number{100}, Number{0}}, - {Number{1414213562373095, -10}, - Number{1414213562373095, -10}, - Number{1}}, - {Number{9'999'999'999'999'999}, - Number{1'000'000'000'000'000}, - Number{9'999'999'999'999'999, -15}}, - {Number{2}, Number{3}, Number{6'666'666'666'666'667, -16}}, - {Number{-2}, Number{3}, Number{-6'666'666'666'666'667, -16}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x / y == z); + auto const cSmall = std::to_array( + {{Number{1}, Number{2}, Number{5, -1}}, + {Number{1}, Number{10}, Number{1, -1}}, + {Number{1}, Number{-10}, Number{-1, -1}}, + {Number{0}, Number{100}, Number{0}}, + {Number{1414213562373095, -10}, + Number{1414213562373095, -10}, + Number{1}}, + {Number{9'999'999'999'999'999}, + Number{1'000'000'000'000'000}, + Number{9'999'999'999'999'999, -15}}, + {Number{2}, Number{3}, Number{6'666'666'666'666'667, -16}}, + {Number{-2}, Number{3}, Number{-6'666'666'666'666'667, -16}}, + {Number{1}, Number{7}, Number{1'428'571'428'571'428, -16}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items + // from C with larger mantissa + {{Number{1}, Number{2}, Number{5, -1}}, + {Number{1}, Number{10}, Number{1, -1}}, + {Number{1}, Number{-10}, Number{-1, -1}}, + {Number{0}, Number{100}, Number{0}}, + {Number{1414213562373095, -10}, + Number{1414213562373095, -10}, + Number{1}}, + {Number{9'999'999'999'999'999}, + Number{1'000'000'000'000'000}, + Number{9'999'999'999'999'999, -15}}, + {Number{2}, Number{3}, Number{6'666'666'666'666'666'667, -19}}, + {Number{-2}, + Number{3}, + Number{-6'666'666'666'666'666'667, -19}}, + {Number{1}, Number{7}, Number{1'428'571'428'571'428'571, -19}}, + // Items from cSmall expanded for the larger mantissa, except + // duplicates. + {Number{1414213562373095049, -13}, + Number{1414213562373095049, -13}, + Number{1}}, + {Number{false, maxMantissa, 0, Number::normalized{}}, + Number{1'000'000'000'000'000'000}, + Number{false, maxMantissa, -18, Number::normalized{}}}}); + tests(cSmall, cLarge); } + testcase << "test_div " << to_string(Number::getMantissaScale()) + << " towards_zero"; Number::setround(Number::towards_zero); { - Case c[]{ - {Number{1}, Number{2}, Number{5, -1}}, - {Number{1}, Number{10}, Number{1, -1}}, - {Number{1}, Number{-10}, Number{-1, -1}}, - {Number{0}, Number{100}, Number{0}}, - {Number{1414213562373095, -10}, - Number{1414213562373095, -10}, - Number{1}}, - {Number{9'999'999'999'999'999}, - Number{1'000'000'000'000'000}, - Number{9'999'999'999'999'999, -15}}, - {Number{2}, Number{3}, Number{6'666'666'666'666'666, -16}}, - {Number{-2}, Number{3}, Number{-6'666'666'666'666'666, -16}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x / y == z); + auto const cSmall = std::to_array( + {{Number{1}, Number{2}, Number{5, -1}}, + {Number{1}, Number{10}, Number{1, -1}}, + {Number{1}, Number{-10}, Number{-1, -1}}, + {Number{0}, Number{100}, Number{0}}, + {Number{1414213562373095, -10}, + Number{1414213562373095, -10}, + Number{1}}, + {Number{9'999'999'999'999'999}, + Number{1'000'000'000'000'000}, + Number{9'999'999'999'999'999, -15}}, + {Number{2}, Number{3}, Number{6'666'666'666'666'666, -16}}, + {Number{-2}, Number{3}, Number{-6'666'666'666'666'666, -16}}, + {Number{1}, Number{7}, Number{1'428'571'428'571'428, -16}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items + // from C with larger mantissa + {{Number{1}, Number{2}, Number{5, -1}}, + {Number{1}, Number{10}, Number{1, -1}}, + {Number{1}, Number{-10}, Number{-1, -1}}, + {Number{0}, Number{100}, Number{0}}, + {Number{1414213562373095, -10}, + Number{1414213562373095, -10}, + Number{1}}, + {Number{9'999'999'999'999'999}, + Number{1'000'000'000'000'000}, + Number{9'999'999'999'999'999, -15}}, + {Number{2}, Number{3}, Number{6'666'666'666'666'666'666, -19}}, + {Number{-2}, + Number{3}, + Number{-6'666'666'666'666'666'666, -19}}, + {Number{1}, Number{7}, Number{1'428'571'428'571'428'571, -19}}, + // Items from cSmall expanded for the larger mantissa, except + // duplicates. + {Number{1414213562373095049, -13}, + Number{1414213562373095049, -13}, + Number{1}}, + {Number{false, maxMantissa, 0, Number::normalized{}}, + Number{1'000'000'000'000'000'000}, + Number{false, maxMantissa, -18, Number::normalized{}}}}); + tests(cSmall, cLarge); } + testcase << "test_div " << to_string(Number::getMantissaScale()) + << " downward"; Number::setround(Number::downward); { - Case c[]{ - {Number{1}, Number{2}, Number{5, -1}}, - {Number{1}, Number{10}, Number{1, -1}}, - {Number{1}, Number{-10}, Number{-1, -1}}, - {Number{0}, Number{100}, Number{0}}, - {Number{1414213562373095, -10}, - Number{1414213562373095, -10}, - Number{1}}, - {Number{9'999'999'999'999'999}, - Number{1'000'000'000'000'000}, - Number{9'999'999'999'999'999, -15}}, - {Number{2}, Number{3}, Number{6'666'666'666'666'666, -16}}, - {Number{-2}, Number{3}, Number{-6'666'666'666'666'667, -16}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x / y == z); + auto const cSmall = std::to_array( + {{Number{1}, Number{2}, Number{5, -1}}, + {Number{1}, Number{10}, Number{1, -1}}, + {Number{1}, Number{-10}, Number{-1, -1}}, + {Number{0}, Number{100}, Number{0}}, + {Number{1414213562373095, -10}, + Number{1414213562373095, -10}, + Number{1}}, + {Number{9'999'999'999'999'999}, + Number{1'000'000'000'000'000}, + Number{9'999'999'999'999'999, -15}}, + {Number{2}, Number{3}, Number{6'666'666'666'666'666, -16}}, + {Number{-2}, Number{3}, Number{-6'666'666'666'666'667, -16}}, + {Number{1}, Number{7}, Number{1'428'571'428'571'428, -16}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items + // from C with larger mantissa + {{Number{1}, Number{2}, Number{5, -1}}, + {Number{1}, Number{10}, Number{1, -1}}, + {Number{1}, Number{-10}, Number{-1, -1}}, + {Number{0}, Number{100}, Number{0}}, + {Number{1414213562373095, -10}, + Number{1414213562373095, -10}, + Number{1}}, + {Number{9'999'999'999'999'999}, + Number{1'000'000'000'000'000}, + Number{9'999'999'999'999'999, -15}}, + {Number{2}, Number{3}, Number{6'666'666'666'666'666'666, -19}}, + {Number{-2}, + Number{3}, + Number{-6'666'666'666'666'666'667, -19}}, + {Number{1}, Number{7}, Number{1'428'571'428'571'428'571, -19}}, + // Items from cSmall expanded for the larger mantissa, except + // duplicates. + {Number{1414213562373095049, -13}, + Number{1414213562373095049, -13}, + Number{1}}, + {Number{false, maxMantissa, 0, Number::normalized{}}, + Number{1'000'000'000'000'000'000}, + Number{false, maxMantissa, -18, Number::normalized{}}}}); + tests(cSmall, cLarge); } + testcase << "test_div " << to_string(Number::getMantissaScale()) + << " upward"; Number::setround(Number::upward); { - Case c[]{ - {Number{1}, Number{2}, Number{5, -1}}, - {Number{1}, Number{10}, Number{1, -1}}, - {Number{1}, Number{-10}, Number{-1, -1}}, - {Number{0}, Number{100}, Number{0}}, - {Number{1414213562373095, -10}, - Number{1414213562373095, -10}, - Number{1}}, - {Number{9'999'999'999'999'999}, - Number{1'000'000'000'000'000}, - Number{9'999'999'999'999'999, -15}}, - {Number{2}, Number{3}, Number{6'666'666'666'666'667, -16}}, - {Number{-2}, Number{3}, Number{-6'666'666'666'666'666, -16}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x / y == z); + auto const cSmall = std::to_array( + {{Number{1}, Number{2}, Number{5, -1}}, + {Number{1}, Number{10}, Number{1, -1}}, + {Number{1}, Number{-10}, Number{-1, -1}}, + {Number{0}, Number{100}, Number{0}}, + {Number{1414213562373095, -10}, + Number{1414213562373095, -10}, + Number{1}}, + {Number{9'999'999'999'999'999}, + Number{1'000'000'000'000'000}, + Number{9'999'999'999'999'999, -15}}, + {Number{2}, Number{3}, Number{6'666'666'666'666'667, -16}}, + {Number{-2}, Number{3}, Number{-6'666'666'666'666'666, -16}}, + {Number{1}, Number{7}, Number{1'428'571'428'571'429, -16}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items + // from C with larger mantissa + {{Number{1}, Number{2}, Number{5, -1}}, + {Number{1}, Number{10}, Number{1, -1}}, + {Number{1}, Number{-10}, Number{-1, -1}}, + {Number{0}, Number{100}, Number{0}}, + {Number{1414213562373095, -10}, + Number{1414213562373095, -10}, + Number{1}}, + {Number{9'999'999'999'999'999}, + Number{1'000'000'000'000'000}, + Number{9'999'999'999'999'999, -15}}, + {Number{2}, Number{3}, Number{6'666'666'666'666'666'667, -19}}, + {Number{-2}, + Number{3}, + Number{-6'666'666'666'666'666'666, -19}}, + {Number{1}, Number{7}, Number{1'428'571'428'571'428'572, -19}}, + // Items from cSmall expanded for the larger mantissa, except + // duplicates. + {Number{1414213562373095049, -13}, + Number{1414213562373095049, -13}, + Number{1}}, + {Number{false, maxMantissa, 0, Number::normalized{}}, + Number{1'000'000'000'000'000'000}, + Number{false, maxMantissa, -18, Number::normalized{}}}}); + tests(cSmall, cLarge); } + testcase << "test_div " << to_string(Number::getMantissaScale()) + << " overflow"; bool caught = false; try { @@ -325,20 +949,59 @@ public: void test_root() { - testcase("test_root"); + auto const scale = Number::getMantissaScale(); + testcase << "test_root " << to_string(scale); + using Case = std::tuple; - Case c[]{ - {Number{2}, 2, Number{1414213562373095, -15}}, - {Number{2'000'000}, 2, Number{1414213562373095, -12}}, - {Number{2, -30}, 2, Number{1414213562373095, -30}}, - {Number{-27}, 3, Number{-3}}, - {Number{1}, 5, Number{1}}, - {Number{-1}, 0, Number{1}}, - {Number{5, -1}, 0, Number{0}}, - {Number{0}, 5, Number{0}}, - {Number{5625, -4}, 2, Number{75, -2}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT((root(x, y) == z)); + auto test = [this](auto const& c) { + for (auto const& [x, y, z] : c) + { + auto const result = root(x, y); + std::stringstream ss; + ss << "root(" << x << ", " << y << ") = " << result + << ". Expected: " << z; + BEAST_EXPECTS(result == z, ss.str()); + } + }; + /* + auto tests = [&](auto const& cSmall, auto const& cLarge) { + test(cSmall); + if (scale != MantissaRange::small) + test(cLarge); + }; + */ + + auto const cSmall = std::to_array( + {{Number{2}, 2, Number{1414213562373095049, -18}}, + {Number{2'000'000}, 2, Number{1414213562373095049, -15}}, + {Number{2, -30}, 2, Number{1414213562373095049, -33}}, + {Number{-27}, 3, Number{-3}}, + {Number{1}, 5, Number{1}}, + {Number{-1}, 0, Number{1}}, + {Number{5, -1}, 0, Number{0}}, + {Number{0}, 5, Number{0}}, + {Number{5625, -4}, 2, Number{75, -2}}}); + auto const cLarge = std::to_array({ + {Number{false, Number::maxMantissa() - 9, -1, Number::normalized{}}, + 2, + Number{false, 999'999'999'999'999'999, -9, Number::normalized{}}}, + {Number{false, Number::maxMantissa() - 9, 0, Number::normalized{}}, + 2, + Number{ + false, 3'162'277'660'168'379'330, -9, Number::normalized{}}}, + {Number{Number::maxRep}, + 2, + Number{false, 3'037'000'499'976049692, -9, Number::normalized{}}}, + {Number{Number::maxRep}, + 4, + Number{false, 55'108'98747006743627, -14, Number::normalized{}}}, + }); + test(cSmall); + if (Number::getMantissaScale() != MantissaRange::small) + { + NumberRoundModeGuard mg(Number::towards_zero); + test(cLarge); + } bool caught = false; try { @@ -361,10 +1024,52 @@ public: BEAST_EXPECT(caught); } + void + test_root2() + { + auto const scale = Number::getMantissaScale(); + testcase << "test_root2 " << to_string(scale); + + auto test = [this](auto const& c) { + for (auto const& x : c) + { + auto const expected = root(x, 2); + auto const result = root2(x); + std::stringstream ss; + ss << "root2(" << x << ") = " << result + << ". Expected: " << expected; + BEAST_EXPECTS(result == expected, ss.str()); + } + }; + + auto const cSmall = std::to_array({ + Number{2}, + Number{2'000'000}, + Number{2, -30}, + Number{27}, + Number{1}, + Number{5, -1}, + Number{0}, + Number{5625, -4}, + Number{Number::maxRep}, + }); + test(cSmall); + bool caught = false; + try + { + (void)root2(Number{-2}); + } + catch (std::overflow_error const&) + { + caught = true; + } + BEAST_EXPECT(caught); + } + void test_power1() { - testcase("test_power1"); + testcase << "test_power1 " << to_string(Number::getMantissaScale()); using Case = std::tuple; Case c[]{ {Number{64}, 0, Number{1}}, @@ -372,7 +1077,13 @@ public: {Number{64}, 2, Number{4096}}, {Number{-64}, 2, Number{4096}}, {Number{64}, 3, Number{262144}}, - {Number{-64}, 3, Number{-262144}}}; + {Number{-64}, 3, Number{-262144}}, + {Number{64}, + 11, + Number{false, 7378697629483820646ULL, 1, Number::normalized{}}}, + {Number{-64}, + 11, + Number{true, 7378697629483820646ULL, 1, Number::normalized{}}}}; for (auto const& [x, y, z] : c) BEAST_EXPECT((power(x, y) == z)); } @@ -380,7 +1091,7 @@ public: void test_power2() { - testcase("test_power2"); + testcase << "test_power2 " << to_string(Number::getMantissaScale()); using Case = std::tuple; Case c[]{ {Number{1}, 3, 7, Number{1}}, @@ -426,7 +1137,7 @@ public: void testConversions() { - testcase("testConversions"); + testcase << "testConversions " << to_string(Number::getMantissaScale()); IOUAmount x{5, 6}; Number y = x; @@ -452,7 +1163,7 @@ public: void test_to_integer() { - testcase("test_to_integer"); + testcase << "test_to_integer " << to_string(Number::getMantissaScale()); using Case = std::tuple; saveNumberRoundMode save{Number::setround(Number::to_nearest)}; { @@ -620,7 +1331,7 @@ public: void test_squelch() { - testcase("test_squelch"); + testcase << "test_squelch " << to_string(Number::getMantissaScale()); Number limit{1, -6}; BEAST_EXPECT((squelch(Number{2, -6}, limit) == Number{2, -6})); BEAST_EXPECT((squelch(Number{1, -6}, limit) == Number{1, -6})); @@ -633,22 +1344,129 @@ public: void testToString() { - testcase("testToString"); - BEAST_EXPECT(to_string(Number(-2, 0)) == "-2"); - BEAST_EXPECT(to_string(Number(0, 0)) == "0"); - BEAST_EXPECT(to_string(Number(2, 0)) == "2"); - BEAST_EXPECT(to_string(Number(25, -3)) == "0.025"); - BEAST_EXPECT(to_string(Number(-25, -3)) == "-0.025"); - BEAST_EXPECT(to_string(Number(25, 1)) == "250"); - BEAST_EXPECT(to_string(Number(-25, 1)) == "-250"); - BEAST_EXPECT(to_string(Number(2, 20)) == "2000000000000000e5"); - BEAST_EXPECT(to_string(Number(-2, -20)) == "-2000000000000000e-35"); + auto const scale = Number::getMantissaScale(); + testcase << "testToString " << to_string(scale); + + auto test = [this](Number const& n, std::string const& expected) { + auto const result = to_string(n); + std::stringstream ss; + ss << "to_string(" << result << "). Expected: " << expected; + BEAST_EXPECTS(result == expected, ss.str()); + }; + + test(Number(-2, 0), "-2"); + test(Number(0, 0), "0"); + test(Number(2, 0), "2"); + test(Number(25, -3), "0.025"); + test(Number(-25, -3), "-0.025"); + test(Number(25, 1), "250"); + test(Number(-25, 1), "-250"); + test(Number(2, 20), "2e20"); + test(Number(-2, -20), "-2e-20"); + // Test the edges + // ((exponent < -(25)) || (exponent > -(5))))) + // or ((exponent < -(28)) || (exponent > -(8))))) + test(Number(2, -10), "0.0000000002"); + test(Number(2, -11), "2e-11"); + + test(Number(-2, 10), "-20000000000"); + test(Number(-2, 11), "-2e11"); + + switch (scale) + { + case MantissaRange::small: + + test(Number::min(), "1e-32753"); + test(Number::max(), "9999999999999999e32768"); + test(Number::lowest(), "-9999999999999999e32768"); + { + NumberRoundModeGuard mg(Number::towards_zero); + + auto const maxMantissa = Number::maxMantissa(); + BEAST_EXPECT(maxMantissa == 9'999'999'999'999'999); + test( + Number{ + false, + maxMantissa * 1000 + 999, + -3, + Number::normalized()}, + "9999999999999999"); + test( + Number{ + true, + maxMantissa * 1000 + 999, + -3, + Number::normalized()}, + "-9999999999999999"); + + test( + Number{std::numeric_limits::max(), -3}, + "9223372036854775"); + test( + -(Number{std::numeric_limits::max(), -3}), + "-9223372036854775"); + + test( + Number{std::numeric_limits::min(), 0}, + "-9223372036854775e3"); + test( + -(Number{std::numeric_limits::min(), 0}), + "9223372036854775e3"); + } + break; + case MantissaRange::large: + // Test the edges + // ((exponent < -(28)) || (exponent > -(8))))) + test(Number::min(), "1e-32750"); + test(Number::max(), "9223372036854775807e32768"); + test(Number::lowest(), "-9223372036854775807e32768"); + { + NumberRoundModeGuard mg(Number::towards_zero); + + auto const maxMantissa = Number::maxMantissa(); + BEAST_EXPECT(maxMantissa == 9'999'999'999'999'999'999ULL); + test( + Number{false, maxMantissa, 0, Number::normalized{}}, + "9999999999999999990"); + test( + Number{true, maxMantissa, 0, Number::normalized{}}, + "-9999999999999999990"); + + test( + Number{std::numeric_limits::max(), 0}, + "9223372036854775807"); + test( + -(Number{std::numeric_limits::max(), 0}), + "-9223372036854775807"); + + // Because the absolute value of min is larger than max, it + // will be scaled down to fit under max. Since we're + // rounding towards zero, the 8 at the end is dropped. + test( + Number{std::numeric_limits::min(), 0}, + "-9223372036854775800"); + test( + -(Number{std::numeric_limits::min(), 0}), + "9223372036854775800"); + } + + test( + Number{std::numeric_limits::max(), 0} + 1, + "9223372036854775810"); + test( + -(Number{std::numeric_limits::max(), 0} + 1), + "-9223372036854775810"); + break; + default: + BEAST_EXPECT(false); + } } void test_relationals() { - testcase("test_relationals"); + testcase << "test_relationals " + << to_string(Number::getMantissaScale()); BEAST_EXPECT(!(Number{100} < Number{10})); BEAST_EXPECT(Number{100} > Number{10}); BEAST_EXPECT(Number{100} >= Number{10}); @@ -658,7 +1476,7 @@ public: void test_stream() { - testcase("test_stream"); + testcase << "test_stream " << to_string(Number::getMantissaScale()); Number x{100}; std::ostringstream os; os << x; @@ -668,7 +1486,7 @@ public: void test_inc_dec() { - testcase("test_inc_dec"); + testcase << "test_inc_dec " << to_string(Number::getMantissaScale()); Number x{100}; Number y = +x; BEAST_EXPECT(x == y); @@ -685,19 +1503,19 @@ public: Issue const issue; Number const n{7'518'783'80596, -5}; saveNumberRoundMode const save{Number::setround(Number::to_nearest)}; - auto res2 = STAmount{issue, n.mantissa(), n.exponent()}; + auto res2 = STAmount{issue, n}; BEAST_EXPECT(res2 == STAmount{7518784}); Number::setround(Number::towards_zero); - res2 = STAmount{issue, n.mantissa(), n.exponent()}; + res2 = STAmount{issue, n}; BEAST_EXPECT(res2 == STAmount{7518783}); Number::setround(Number::downward); - res2 = STAmount{issue, n.mantissa(), n.exponent()}; + res2 = STAmount{issue, n}; BEAST_EXPECT(res2 == STAmount{7518783}); Number::setround(Number::upward); - res2 = STAmount{issue, n.mantissa(), n.exponent()}; + res2 = STAmount{issue, n}; BEAST_EXPECT(res2 == STAmount{7518784}); } @@ -834,28 +1652,94 @@ public: } } + void + testInt64() + { + auto const scale = Number::getMantissaScale(); + testcase << "std::int64_t " << to_string(scale); + + // Control case + BEAST_EXPECT(Number::maxMantissa() > 10); + Number ten{10}; + BEAST_EXPECT(ten.exponent() <= 0); + + if (scale == MantissaRange::small) + { + BEAST_EXPECT( + std::numeric_limits::max() > INITIAL_XRP.drops()); + BEAST_EXPECT(Number::maxMantissa() < INITIAL_XRP.drops()); + Number const initalXrp{INITIAL_XRP}; + BEAST_EXPECT(initalXrp.exponent() > 0); + + Number const maxInt64{Number::maxRep}; + BEAST_EXPECT(maxInt64.exponent() > 0); + // 85'070'591'730'234'615'865'843'651'857'942'052'864 - 38 digits + BEAST_EXPECT( + (power(maxInt64, 2) == Number{85'070'591'730'234'62, 22})); + + Number const max = + Number{false, Number::maxMantissa(), 0, Number::normalized{}}; + BEAST_EXPECT(max.exponent() <= 0); + // 99'999'999'999'999'980'000'000'000'000'001 - 32 digits + BEAST_EXPECT((power(max, 2) == Number{99'999'999'999'999'98, 16})); + } + else + { + BEAST_EXPECT( + std::numeric_limits::max() > INITIAL_XRP.drops()); + BEAST_EXPECT(Number::maxMantissa() > INITIAL_XRP.drops()); + Number const initalXrp{INITIAL_XRP}; + BEAST_EXPECT(initalXrp.exponent() <= 0); + + Number const maxInt64{Number::maxRep}; + BEAST_EXPECT(maxInt64.exponent() <= 0); + // 85'070'591'730'234'615'847'396'907'784'232'501'249 - 38 digits + BEAST_EXPECT( + (power(maxInt64, 2) == Number{85'070'591'730'234'615'85, 19})); + + NumberRoundModeGuard mg(Number::towards_zero); + + auto const maxMantissa = Number::maxMantissa(); + Number const max = + Number{false, maxMantissa, 0, Number::normalized{}}; + BEAST_EXPECT(max.mantissa() == maxMantissa / 10); + BEAST_EXPECT(max.exponent() == 1); + // 99'999'999'999'999'999'800'000'000'000'000'000'100 - also 38 + // digits + BEAST_EXPECT(( + power(max, 2) == + Number{false, maxMantissa / 10 - 1, 20, Number::normalized{}})); + } + } + void run() override { - testZero(); - test_limits(); - test_add(); - test_sub(); - test_mul(); - test_div(); - test_root(); - test_power1(); - test_power2(); - testConversions(); - test_to_integer(); - test_squelch(); - testToString(); - test_relationals(); - test_stream(); - test_inc_dec(); - test_toSTAmount(); - test_truncate(); - testRounding(); + for (auto const scale : {MantissaRange::small, MantissaRange::large}) + { + NumberMantissaScaleGuard sg(scale); + testZero(); + test_limits(); + testToString(); + test_add(); + test_sub(); + test_mul(); + test_div(); + test_root(); + test_root2(); + test_power1(); + test_power2(); + testConversions(); + test_to_integer(); + test_squelch(); + test_relationals(); + test_stream(); + test_inc_dec(); + test_toSTAmount(); + test_truncate(); + testRounding(); + testInt64(); + } } }; diff --git a/src/test/beast/beast_CurrentThreadName_test.cpp b/src/test/beast/beast_CurrentThreadName_test.cpp index 3d33ecb602..dc12883a63 100644 --- a/src/test/beast/beast_CurrentThreadName_test.cpp +++ b/src/test/beast/beast_CurrentThreadName_test.cpp @@ -1,6 +1,8 @@ #include #include +#include + #include namespace xrpl { @@ -37,33 +39,71 @@ private: if (beast::getCurrentThreadName() == myName) *state = 2; } +#if BOOST_OS_LINUX + // Helper function to test a specific name. + // It creates a thread, sets the name, and checks if the OS-level + // name matches the expected (potentially truncated) name. + void + testName(std::string const& nameToSet, std::string const& expectedName) + { + std::thread t([&] { + beast::setCurrentThreadName(nameToSet); + + // Initialize buffer to be safe. + char actualName[beast::maxThreadNameLength + 1] = {}; + pthread_getname_np(pthread_self(), actualName, sizeof(actualName)); + + BEAST_EXPECT(std::string(actualName) == expectedName); + }); + t.join(); + } +#endif public: void run() override { - // Make two different threads with two different names. Make sure - // that the expected thread names are still there when the thread - // exits. - std::atomic stop{false}; + // Make two different threads with two different names. + // Make sure that the expected thread names are still there + // when the thread exits. + { + std::atomic stop{false}; - std::atomic stateA{0}; - std::thread tA(exerciseName, "tA", &stop, &stateA); + std::atomic stateA{0}; + std::thread tA(exerciseName, "tA", &stop, &stateA); - std::atomic stateB{0}; - std::thread tB(exerciseName, "tB", &stop, &stateB); + std::atomic stateB{0}; + std::thread tB(exerciseName, "tB", &stop, &stateB); - // Wait until both threads have set their names. - while (stateA == 0 || stateB == 0) - ; + // Wait until both threads have set their names. + while (stateA == 0 || stateB == 0) + ; - stop = true; - tA.join(); - tB.join(); + stop = true; + tA.join(); + tB.join(); - // Both threads should still have the expected name when they exit. - BEAST_EXPECT(stateA == 2); - BEAST_EXPECT(stateB == 2); + // Both threads should still have the expected name when they exit. + BEAST_EXPECT(stateA == 2); + BEAST_EXPECT(stateB == 2); + } +#if BOOST_OS_LINUX + // On Linux, verify that thread names longer than 15 characters + // are truncated to 15 characters (the 16th character is reserved for + // the null terminator). + { + testName( + "123456789012345", + "123456789012345"); // 15 chars, no truncation + testName( + "1234567890123456", "123456789012345"); // 16 chars, truncated + testName( + "ThisIsAVeryLongThreadNameExceedingLimit", + "ThisIsAVeryLong"); // 39 chars, truncated + testName("", ""); // empty name + testName("short", "short"); // short name, no truncation + } +#endif } }; diff --git a/src/test/jtx/AMMTest.h b/src/test/jtx/AMMTest.h index 83366d61e2..208e3c4e5f 100644 --- a/src/test/jtx/AMMTest.h +++ b/src/test/jtx/AMMTest.h @@ -21,7 +21,12 @@ struct TestAMMArg std::optional> pool = std::nullopt; std::uint16_t tfee = 0; std::optional ter = std::nullopt; - std::vector features = {testable_amendments()}; + std::vector features = { + // For now, just disable SAV entirely, which locks in the small Number + // mantissas + jtx::testable_amendments() - featureSingleAssetVault - + featureLendingProtocol}; + bool noLog = false; }; @@ -66,6 +71,15 @@ protected: public: AMMTestBase(); + static FeatureBitset + testable_amendments() + { + // For now, just disable SAV entirely, which locks in the small Number + // mantissas + return jtx::testable_amendments() - featureSingleAssetVault - + featureLendingProtocol; + } + protected: /** testAMM() funds 30,000XRP and 30,000IOU * for each non-XRP asset to Alice and Carol diff --git a/src/test/jtx/amount.h b/src/test/jtx/amount.h index a310fc5b44..147307f7b7 100644 --- a/src/test/jtx/amount.h +++ b/src/test/jtx/amount.h @@ -261,6 +261,12 @@ struct XRP_t return xrpIssue(); } + bool + integral() const + { + return true; + } + /** Returns an amount of XRP as PrettyAmount, which is trivially convertible to STAmount @@ -400,6 +406,11 @@ public: { return issue(); } + bool + integral() const + { + return issue().integral(); + } /** Implicit conversion to Issue or Asset. @@ -490,6 +501,11 @@ public: { return mptIssue(); } + bool + integral() const + { + return true; + } /** Implicit conversion to MPTIssue or asset. diff --git a/src/test/jtx/impl/AMMTest.cpp b/src/test/jtx/impl/AMMTest.cpp index de7ce5504b..139b9113b9 100644 --- a/src/test/jtx/impl/AMMTest.cpp +++ b/src/test/jtx/impl/AMMTest.cpp @@ -105,9 +105,14 @@ AMMTestBase::testAMM( for (auto const& features : arg.features) { + // Use small Number mantissas for the life of this test. + NumberMantissaScaleGuard const sg{xrpl::MantissaRange::small}; + + // For now, just disable SAV entirely, which locks in the small Number + // mantissas Env env{ *this, - features, + features - featureSingleAssetVault - featureLendingProtocol, arg.noLog ? std::make_unique(&logs) : nullptr}; auto const [asset1, asset2] = diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index adffa8548a..ceb60eb319 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -644,7 +644,7 @@ MPTTester::operator[](std::string const& name) const } PrettyAmount -MPTTester::operator()(std::uint64_t amount) const +MPTTester::operator()(std::int64_t amount) const { return MPT("", issuanceID())(amount); } diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index 2f6bbb9ea8..3eea362b58 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -272,7 +272,7 @@ public: operator[](std::string const& name) const; PrettyAmount - operator()(std::uint64_t amount) const; + operator()(std::int64_t amount) const; operator Asset() const; diff --git a/src/test/protocol/STNumber_test.cpp b/src/test/protocol/STNumber_test.cpp index 1275c756cf..4e7a8388ee 100644 --- a/src/test/protocol/STNumber_test.cpp +++ b/src/test/protocol/STNumber_test.cpp @@ -29,10 +29,8 @@ struct STNumber_test : public beast::unit_test::suite } void - run() override + doRun() { - static_assert(!std::is_convertible_v); - { STNumber const stnum{sfNumber}; BEAST_EXPECT(stnum.getSType() == STI_NUMBER); @@ -127,6 +125,41 @@ struct STNumber_test : public beast::unit_test::suite BEAST_EXPECT( numberFromJson(sfNumber, "-0.000e6") == STNumber(sfNumber, 0)); + { + NumberRoundModeGuard mg(Number::towards_zero); + // maxint64 9,223,372,036,854,775,807 + auto const maxInt = + std::to_string(std::numeric_limits::max()); + // minint64 -9,223,372,036,854,775,808 + auto const minInt = + std::to_string(std::numeric_limits::min()); + if (Number::getMantissaScale() == MantissaRange::small) + { + BEAST_EXPECT( + numberFromJson(sfNumber, maxInt) == + STNumber(sfNumber, Number{9'223'372'036'854'775, 3})); + BEAST_EXPECT( + numberFromJson(sfNumber, minInt) == + STNumber(sfNumber, Number{-9'223'372'036'854'775, 3})); + } + else + { + BEAST_EXPECT( + numberFromJson(sfNumber, maxInt) == + STNumber( + sfNumber, Number{9'223'372'036'854'775'807, 0})); + BEAST_EXPECT( + numberFromJson(sfNumber, minInt) == + STNumber( + sfNumber, + Number{ + true, + 9'223'372'036'854'775'808ULL, + 0, + Number::normalized{}})); + } + } + constexpr auto imin = std::numeric_limits::min(); BEAST_EXPECT( numberFromJson(sfNumber, imin) == @@ -279,15 +312,21 @@ struct STNumber_test : public beast::unit_test::suite } } } + + void + run() override + { + static_assert(!std::is_convertible_v); + + for (auto const scale : {MantissaRange::small, MantissaRange::large}) + { + NumberMantissaScaleGuard sg(scale); + testcase << to_string(Number::getMantissaScale()); + doRun(); + } + } }; BEAST_DEFINE_TESTSUITE(STNumber, protocol, xrpl); -void -testCompile(std::ostream& out) -{ - STNumber number{sfNumber, 42}; - out << number; -} - } // namespace xrpl diff --git a/src/test/rpc/GetAggregatePrice_test.cpp b/src/test/rpc/GetAggregatePrice_test.cpp index 52f82ffc6c..0ffefc6cb6 100644 --- a/src/test/rpc/GetAggregatePrice_test.cpp +++ b/src/test/rpc/GetAggregatePrice_test.cpp @@ -191,18 +191,38 @@ public: // Aggregate data set includes all price oracle instances, no trimming // or time threshold { - Env env(*this); - OraclesData oracles; - prep(env, oracles); - // entire and trimmed stats - auto ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles); - BEAST_EXPECT(ret[jss::entire_set][jss::mean] == "74.45"); - BEAST_EXPECT(ret[jss::entire_set][jss::size].asUInt() == 10); - BEAST_EXPECT( - ret[jss::entire_set][jss::standard_deviation] == - "0.3027650354097492"); - BEAST_EXPECT(ret[jss::median] == "74.45"); - BEAST_EXPECT(ret[jss::time] == 946694900); + auto const all = testable_amendments(); + for (auto const& feats : + {all - featureSingleAssetVault - featureLendingProtocol, all}) + { + for (auto const mantissaSize : + {MantissaRange::small, MantissaRange::large}) + { + // Regardless of the features enabled, RPC is controlled by + // the global mantissa size. And since it's a thread-local, + // overriding it locally won't make a difference either. + // This will mean all RPC will use the default of "large". + NumberMantissaScaleGuard mg(mantissaSize); + + Env env(*this, feats); + OraclesData oracles; + prep(env, oracles); + // entire and trimmed stats + auto ret = + Oracle::aggregatePrice(env, "XRP", "USD", oracles); + BEAST_EXPECT(ret[jss::entire_set][jss::mean] == "74.45"); + BEAST_EXPECT( + ret[jss::entire_set][jss::size].asUInt() == 10); + // Short: 0.3027650354097492 + BEAST_EXPECTS( + ret[jss::entire_set][jss::standard_deviation] == + "0.3027650354097491666", + ret[jss::entire_set][jss::standard_deviation] + .asString()); + BEAST_EXPECT(ret[jss::median] == "74.45"); + BEAST_EXPECT(ret[jss::time] == 946694900); + } + } } // Aggregate data set includes all price oracle instances @@ -215,15 +235,19 @@ public: Oracle::aggregatePrice(env, "XRP", "USD", oracles, 20, 100); BEAST_EXPECT(ret[jss::entire_set][jss::mean] == "74.45"); BEAST_EXPECT(ret[jss::entire_set][jss::size].asUInt() == 10); - BEAST_EXPECT( + // Short: "0.3027650354097492", + BEAST_EXPECTS( ret[jss::entire_set][jss::standard_deviation] == - "0.3027650354097492"); + "0.3027650354097491666", + ret[jss::entire_set][jss::standard_deviation].asString()); BEAST_EXPECT(ret[jss::median] == "74.45"); BEAST_EXPECT(ret[jss::trimmed_set][jss::mean] == "74.45"); BEAST_EXPECT(ret[jss::trimmed_set][jss::size].asUInt() == 6); - BEAST_EXPECT( + // Short: "0.187082869338697", + BEAST_EXPECTS( ret[jss::trimmed_set][jss::standard_deviation] == - "0.187082869338697"); + "0.1870828693386970693", + ret[jss::trimmed_set][jss::standard_deviation].asString()); BEAST_EXPECT(ret[jss::time] == 946694900); } @@ -274,15 +298,19 @@ public: Oracle::aggregatePrice(env, "XRP", "USD", oracles, 20, "200"); BEAST_EXPECT(ret[jss::entire_set][jss::mean] == "74.6"); BEAST_EXPECT(ret[jss::entire_set][jss::size].asUInt() == 7); - BEAST_EXPECT( + // Short: 0.2160246899469287 + BEAST_EXPECTS( ret[jss::entire_set][jss::standard_deviation] == - "0.2160246899469287"); + "0.2160246899469286744", + ret[jss::entire_set][jss::standard_deviation].asString()); BEAST_EXPECT(ret[jss::median] == "74.6"); BEAST_EXPECT(ret[jss::trimmed_set][jss::mean] == "74.6"); BEAST_EXPECT(ret[jss::trimmed_set][jss::size].asUInt() == 5); - BEAST_EXPECT( + // Short: 0.158113883008419 + BEAST_EXPECTS( ret[jss::trimmed_set][jss::standard_deviation] == - "0.158113883008419"); + "0.1581138830084189666", + ret[jss::trimmed_set][jss::standard_deviation].asString()); BEAST_EXPECT(ret[jss::time] == 946694900); } diff --git a/src/xrpld/app/misc/LendingHelpers.h b/src/xrpld/app/misc/LendingHelpers.h index 071466f05c..79fc617569 100644 --- a/src/xrpld/app/misc/LendingHelpers.h +++ b/src/xrpld/app/misc/LendingHelpers.h @@ -84,50 +84,10 @@ struct LoanPaymentParts operator==(LoanPaymentParts const& other) const; }; -/* Describes the initial computed properties of a loan. - * - * This structure contains the fundamental calculated values that define a - * loan's payment structure and amortization schedule. These properties are - * computed: - * - At loan creation (LoanSet transaction) - * - When loan terms change (e.g., after an overpayment that reduces the loan - * balance) - */ -struct LoanProperties -{ - // The unrounded amount to be paid at each regular payment period. - // Calculated using the standard amortization formula based on principal, - // interest rate, and number of payments. - // The actual amount paid in the LoanPay transaction must be rounded up to - // the precision of the asset and loan. - Number periodicPayment; - - // The total amount the borrower will pay over the life of the loan. - // Equal to periodicPayment * paymentsRemaining. - // This includes principal, interest, and management fees. - Number totalValueOutstanding; - - // The total management fee that will be paid to the broker over the - // loan's lifetime. This is a percentage of the total interest (gross) - // as specified by the broker's management fee rate. - Number managementFeeOwedToBroker; - - // The scale (decimal places) used for rounding all loan amounts. - // This is the maximum of: - // - The asset's native scale - // - A minimum scale required to represent the periodic payment accurately - // All loan state values (principal, interest, fees) are rounded to this - // scale. - std::int32_t loanScale; - - // The principal portion of the first payment. - Number firstPaymentPrincipal; -}; - /** This structure captures the parts of a loan state. * - * Whether the values are raw (unrounded) or rounded will depend on how it was - * computed. + * Whether the values are theoretical (unrounded) or rounded will depend on how + * it was computed. * * Many of the fields can be derived from each other, but they're all provided * here to reduce code duplication and possible mistakes. @@ -161,6 +121,39 @@ struct LoanState } }; +/* Describes the initial computed properties of a loan. + * + * This structure contains the fundamental calculated values that define a + * loan's payment structure and amortization schedule. These properties are + * computed: + * - At loan creation (LoanSet transaction) + * - When loan terms change (e.g., after an overpayment that reduces the loan + * balance) + */ +struct LoanProperties +{ + // The unrounded amount to be paid at each regular payment period. + // Calculated using the standard amortization formula based on principal, + // interest rate, and number of payments. + // The actual amount paid in the LoanPay transaction must be rounded up to + // the precision of the asset and loan. + Number periodicPayment; + + // The loan's current state, with all values rounded to the loan's scale. + LoanState loanState; + + // The scale (decimal places) used for rounding all loan amounts. + // This is the maximum of: + // - The asset's native scale + // - A minimum scale required to represent the periodic payment accurately + // All loan state values (principal, interest, fees) are rounded to this + // scale. + std::int32_t loanScale; + + // The principal portion of the first payment. + Number firstPaymentPrincipal; +}; + // Some values get re-rounded to the vault scale any time they are adjusted. In // addition, they are prevented from ever going below zero. This helps avoid // accumulated rounding errors and leftover dust amounts. @@ -179,11 +172,12 @@ adjustImpreciseNumber( } inline int -getVaultScale(SLE::const_ref vaultSle) +getAssetsTotalScale(SLE::const_ref vaultSle) { if (!vaultSle) return Number::minExponent - 1; // LCOV_EXCL_LINE - return vaultSle->at(sfAssetsTotal).exponent(); + return STAmount{vaultSle->at(sfAsset), vaultSle->at(sfAssetsTotal)} + .exponent(); } TER @@ -196,20 +190,12 @@ checkLoanGuards( beast::Journal j); LoanState -computeRawLoanState( +computeTheoreticalLoanState( Number const& periodicPayment, Number const& periodicRate, std::uint32_t const paymentRemaining, TenthBips32 const managementFeeRate); -LoanState -computeRawLoanState( - Number const& periodicPayment, - TenthBips32 interestRate, - std::uint32_t paymentInterval, - std::uint32_t const paymentRemaining, - TenthBips32 const managementFeeRate); - // Constructs a valid LoanState object from arbitrary inputs LoanState constructLoanState( @@ -231,7 +217,7 @@ computeManagementFee( Number computeFullPaymentInterest( - Number const& rawPrincipalOutstanding, + Number const& theoreticalPrincipalOutstanding, Number const& periodicRate, NetClock::time_point parentCloseTime, std::uint32_t paymentInterval, @@ -239,17 +225,6 @@ computeFullPaymentInterest( std::uint32_t startDate, TenthBips32 closeInterestRate); -Number -computeFullPaymentInterest( - Number const& periodicPayment, - Number const& periodicRate, - std::uint32_t paymentRemaining, - NetClock::time_point parentCloseTime, - std::uint32_t paymentInterval, - std::uint32_t prevPaymentDate, - std::uint32_t startDate, - TenthBips32 closeInterestRate); - namespace detail { // These classes and functions should only be accessed by LendingHelper // functions and unit tests @@ -387,6 +362,70 @@ struct LoanStateDeltas nonNegative(); }; +Expected, TER> +tryOverpayment( + Asset const& asset, + std::int32_t loanScale, + ExtendedPaymentComponents const& overpaymentComponents, + LoanState const& roundedLoanState, + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentRemaining, + TenthBips16 const managementFeeRate, + beast::Journal j); + +Number +computeRaisedRate(Number const& periodicRate, std::uint32_t paymentsRemaining); + +Number +computePaymentFactor( + Number const& periodicRate, + std::uint32_t paymentsRemaining); + +std::pair +computeInterestAndFeeParts( + Asset const& asset, + Number const& interest, + TenthBips16 managementFeeRate, + std::int32_t loanScale); + +Number +loanPeriodicPayment( + Number const& principalOutstanding, + Number const& periodicRate, + std::uint32_t paymentsRemaining); + +Number +loanPrincipalFromPeriodicPayment( + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentsRemaining); + +Number +loanLatePaymentInterest( + Number const& principalOutstanding, + TenthBips32 lateInterestRate, + NetClock::time_point parentCloseTime, + std::uint32_t nextPaymentDueDate); + +Number +loanAccruedInterest( + Number const& principalOutstanding, + Number const& periodicRate, + NetClock::time_point parentCloseTime, + std::uint32_t startDate, + std::uint32_t prevPaymentDate, + std::uint32_t paymentInterval); + +ExtendedPaymentComponents +computeOverpaymentComponents( + Asset const& asset, + int32_t const loanScale, + Number const& overpayment, + TenthBips32 const overpaymentInterestRate, + TenthBips32 const overpaymentFeeRate, + TenthBips16 const managementFeeRate); + PaymentComponents computePaymentComponents( Asset const& asset, @@ -413,13 +452,22 @@ operator+(LoanState const& lhs, detail::LoanStateDeltas const& rhs); LoanProperties computeLoanProperties( Asset const& asset, - Number principalOutstanding, + Number const& principalOutstanding, TenthBips32 interestRate, std::uint32_t paymentInterval, std::uint32_t paymentsRemaining, TenthBips32 managementFeeRate, std::int32_t minimumScale); +LoanProperties +computeLoanProperties( + Asset const& asset, + Number const& principalOutstanding, + Number const& periodicRate, + std::uint32_t paymentsRemaining, + TenthBips32 managementFeeRate, + std::int32_t minimumScale); + bool isRounded(Asset const& asset, Number const& value, std::int32_t scale); diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp index 6a00354b15..2422ec4ae6 100644 --- a/src/xrpld/app/misc/NetworkOPs.cpp +++ b/src/xrpld/app/misc/NetworkOPs.cpp @@ -1681,7 +1681,7 @@ NetworkOPsImp::apply(std::unique_lock& batchLock) // only be set if the Batch feature is enabled. If Batch is // not enabled, the flag is always invalid, so don't relay // it regardless. - !sttx.isFlag(tfInnerBatchTxn)) + !(sttx.isFlag(tfInnerBatchTxn))) { protocol::TMTransaction tx; Serializer s; diff --git a/src/xrpld/app/misc/detail/LendingHelpers.cpp b/src/xrpld/app/misc/detail/LendingHelpers.cpp index 37385583e7..a8354ff049 100644 --- a/src/xrpld/app/misc/detail/LendingHelpers.cpp +++ b/src/xrpld/app/misc/detail/LendingHelpers.cpp @@ -100,6 +100,9 @@ computePaymentFactor( Number const& periodicRate, std::uint32_t paymentsRemaining) { + if (paymentsRemaining == 0) + return numZero; + // For zero interest, payment factor is simply 1/paymentsRemaining if (periodicRate == beast::zero) return Number{1} / paymentsRemaining; @@ -132,27 +135,6 @@ loanPeriodicPayment( computePaymentFactor(periodicRate, paymentsRemaining); } -/* Calculates the periodic payment amount from annualized interest rate. - * Converts the annual rate to periodic rate before computing payment. - * - * Equation (7) from XLS-66 spec, Section A-2 Equation Glossary - */ -Number -loanPeriodicPayment( - Number const& principalOutstanding, - TenthBips32 interestRate, - std::uint32_t paymentInterval, - std::uint32_t paymentsRemaining) -{ - if (principalOutstanding == 0 || paymentsRemaining == 0) - return 0; - - Number const periodicRate = loanPeriodicRate(interestRate, paymentInterval); - - return loanPeriodicPayment( - principalOutstanding, periodicRate, paymentsRemaining); -} - /* Reverse-calculates principal from periodic payment amount. * Used to determine theoretical principal at any point in the schedule. * @@ -164,6 +146,9 @@ loanPrincipalFromPeriodicPayment( Number const& periodicRate, std::uint32_t paymentsRemaining) { + if (paymentsRemaining == 0) + return numZero; + if (periodicRate == 0) return periodicPayment * paymentsRemaining; @@ -171,21 +156,6 @@ loanPrincipalFromPeriodicPayment( computePaymentFactor(periodicRate, paymentsRemaining); } -/* Splits gross interest into net interest (to vault) and management fee (to - * broker). Returns pair of (net interest, management fee). - * - * Equation (33) from XLS-66 spec, Section A-2 Equation Glossary - */ -std::pair -computeInterestAndFeeParts( - Number const& interest, - TenthBips16 managementFeeRate) -{ - auto const fee = tenthBipsOfValue(interest, managementFeeRate); - - return std::make_pair(interest - fee, fee); -} - /* * Computes the interest and management fee parts from interest amount. * @@ -216,6 +186,12 @@ loanLatePaymentInterest( NetClock::time_point parentCloseTime, std::uint32_t nextPaymentDueDate) { + if (principalOutstanding == beast::zero) + return numZero; + + if (lateInterestRate == TenthBips32{0}) + return numZero; + auto const now = parentCloseTime.time_since_epoch().count(); // If the payment is not late by any amount of time, then there's no late @@ -248,6 +224,9 @@ loanAccruedInterest( if (periodicRate == beast::zero) return numZero; + if (paymentInterval == 0) + return numZero; + auto const lastPaymentDate = std::max(prevPaymentDate, startDate); auto const now = parentCloseTime.time_since_epoch().count(); @@ -401,42 +380,33 @@ doPayment( * The function preserves accumulated rounding errors across the re-amortization * to ensure the loan state remains consistent with its payment history. */ -Expected +Expected, TER> tryOverpayment( Asset const& asset, std::int32_t loanScale, ExtendedPaymentComponents const& overpaymentComponents, - Number& totalValueOutstanding, - Number& principalOutstanding, - Number& managementFeeOutstanding, - Number& periodicPayment, - TenthBips32 interestRate, - std::uint32_t paymentInterval, + LoanState const& roundedOldState, + Number const& periodicPayment, Number const& periodicRate, std::uint32_t paymentRemaining, - std::uint32_t prevPaymentDate, - std::optional nextDueDate, TenthBips16 const managementFeeRate, beast::Journal j) { // Calculate what the loan state SHOULD be theoretically (at full precision) - auto const raw = computeRawLoanState( + auto const theoreticalState = computeTheoreticalLoanState( periodicPayment, periodicRate, paymentRemaining, managementFeeRate); - // Get the actual loan state (with accumulated rounding from past payments) - auto const rounded = constructLoanState( - totalValueOutstanding, principalOutstanding, managementFeeOutstanding); - // Calculate the accumulated rounding errors. These need to be preserved // across the re-amortization to maintain consistency with the loan's // payment history. Without preserving these errors, the loan could end // up with a different total value than what the borrower has actually paid. - auto const errors = rounded - raw; + auto const errors = roundedOldState - theoreticalState; - // Compute the new principal by applying the overpayment to the raw - // (theoretical) principal. Use max with 0 to ensure we never go negative. - auto const newRawPrincipal = std::max( - raw.principalOutstanding - overpaymentComponents.trackedPrincipalDelta, + // Compute the new principal by applying the overpayment to the theoretical + // principal. Use max with 0 to ensure we never go negative. + auto const newTheoreticalPrincipal = std::max( + theoreticalState.principalOutstanding - + overpaymentComponents.trackedPrincipalDelta, Number{0}); // Compute new loan properties based on the reduced principal. This @@ -444,9 +414,8 @@ tryOverpayment( // for the remaining payment schedule. auto newLoanProperties = computeLoanProperties( asset, - newRawPrincipal, - interestRate, - paymentInterval, + newTheoreticalPrincipal, + periodicRate, paymentRemaining, managementFeeRate, loanScale); @@ -454,56 +423,60 @@ tryOverpayment( JLOG(j.debug()) << "new periodic payment: " << newLoanProperties.periodicPayment << ", new total value: " - << newLoanProperties.totalValueOutstanding + << newLoanProperties.loanState.valueOutstanding << ", first payment principal: " << newLoanProperties.firstPaymentPrincipal; // Calculate what the new loan state should be with the new periodic payment - auto const newRaw = computeRawLoanState( - newLoanProperties.periodicPayment, - periodicRate, - paymentRemaining, - managementFeeRate) + + // including rounding errors + auto const newTheoreticalState = computeTheoreticalLoanState( + newLoanProperties.periodicPayment, + periodicRate, + paymentRemaining, + managementFeeRate) + errors; - JLOG(j.debug()) << "new raw value: " << newRaw.valueOutstanding - << ", principal: " << newRaw.principalOutstanding - << ", interest gross: " << newRaw.interestOutstanding(); - // Update the loan state variables with the new values PLUS the preserved - // rounding errors. This ensures the loan's tracked state remains - // consistent with its payment history. + JLOG(j.debug()) << "new theoretical value: " + << newTheoreticalState.valueOutstanding << ", principal: " + << newTheoreticalState.principalOutstanding + << ", interest gross: " + << newTheoreticalState.interestOutstanding(); - principalOutstanding = std::clamp( - roundToAsset( - asset, newRaw.principalOutstanding, loanScale, Number::upward), - numZero, - rounded.principalOutstanding); - totalValueOutstanding = std::clamp( + // Update the loan state variables with the new values that include the + // preserved rounding errors. This ensures the loan's tracked state remains + // consistent with its payment history. + auto const principalOutstanding = std::clamp( roundToAsset( asset, - principalOutstanding + newRaw.interestOutstanding(), + newTheoreticalState.principalOutstanding, loanScale, Number::upward), numZero, - rounded.valueOutstanding); - managementFeeOutstanding = std::clamp( - roundToAsset(asset, newRaw.managementFeeDue, loanScale), + roundedOldState.principalOutstanding); + auto const totalValueOutstanding = std::clamp( + roundToAsset( + asset, + principalOutstanding + newTheoreticalState.interestOutstanding(), + loanScale, + Number::upward), numZero, - rounded.managementFeeDue); + roundedOldState.valueOutstanding); + auto const managementFeeOutstanding = std::clamp( + roundToAsset(asset, newTheoreticalState.managementFeeDue, loanScale), + numZero, + roundedOldState.managementFeeDue); - auto const newRounded = constructLoanState( + auto const roundedNewState = constructLoanState( totalValueOutstanding, principalOutstanding, managementFeeOutstanding); // Update newLoanProperties so that checkLoanGuards can make an accurate // evaluation. - newLoanProperties.totalValueOutstanding = newRounded.valueOutstanding; + newLoanProperties.loanState = roundedNewState; - JLOG(j.debug()) << "new rounded value: " << newRounded.valueOutstanding - << ", principal: " << newRounded.principalOutstanding - << ", interest gross: " << newRounded.interestOutstanding(); - - // Update the periodic payment to reflect the re-amortized schedule - periodicPayment = newLoanProperties.periodicPayment; + JLOG(j.debug()) << "new rounded value: " << roundedNewState.valueOutstanding + << ", principal: " << roundedNewState.principalOutstanding + << ", interest gross: " + << roundedNewState.interestOutstanding(); // check that the loan is still valid if (auto const ter = checkLoanGuards( @@ -513,7 +486,7 @@ tryOverpayment( // small interest amounts, that may have already been paid // off. Check what's still outstanding. This should // guarantee that the interest checks pass. - newRounded.interestOutstanding() != beast::zero, + roundedNewState.interestOutstanding() != beast::zero, paymentRemaining, newLoanProperties, j)) @@ -527,32 +500,40 @@ tryOverpayment( // Validate that all computed properties are reasonable. These checks should // never fail under normal circumstances, but we validate defensively. if (newLoanProperties.periodicPayment <= 0 || - newLoanProperties.totalValueOutstanding <= 0 || - newLoanProperties.managementFeeOwedToBroker < 0) + newLoanProperties.loanState.valueOutstanding <= 0 || + newLoanProperties.loanState.managementFeeDue < 0) { // LCOV_EXCL_START JLOG(j.warn()) << "Overpayment not allowed: Computed loan " "properties are invalid. Does " "not compute. TotalValueOutstanding: " - << newLoanProperties.totalValueOutstanding + << newLoanProperties.loanState.valueOutstanding << ", PeriodicPayment : " << newLoanProperties.periodicPayment << ", ManagementFeeOwedToBroker: " - << newLoanProperties.managementFeeOwedToBroker; + << newLoanProperties.loanState.managementFeeDue; return Unexpected(tesSUCCESS); // LCOV_EXCL_STOP } - auto const deltas = rounded - newRounded; + auto const deltas = roundedOldState - roundedNewState; - auto const hypotheticalValueOutstanding = - rounded.valueOutstanding - deltas.principal; + // The change in loan management fee is equal to the change between the old + // and the new outstanding management fees + XRPL_ASSERT_PARTS( + deltas.managementFee == + roundedOldState.managementFeeDue - managementFeeOutstanding, + "xrpl::detail::tryOverpayment", + "no fee change"); // Calculate how the loan's value changed due to the overpayment. // This should be negative (value decreased) or zero. A principal // overpayment should never increase the loan's value. - auto const valueChange = - newRounded.valueOutstanding - hypotheticalValueOutstanding; + // The value change is derived from the reduction in interest due to + // the lower principal. + // We do not consider the change in management fee here, since + // management fees are excluded from the valueOutstanding. + auto const valueChange = -deltas.interest; if (valueChange > 0) { JLOG(j.warn()) << "Principal overpayment would increase the value of " @@ -560,21 +541,23 @@ tryOverpayment( return Unexpected(tesSUCCESS); } - return LoanPaymentParts{ - // Principal paid is the reduction in principal outstanding - .principalPaid = deltas.principal, - // Interest paid is the reduction in interest due - .interestPaid = - deltas.interest + overpaymentComponents.untrackedInterest, - // Value change includes both the reduction from paying down principal - // (negative) and any untracked interest penalties (positive, e.g., if - // the overpayment itself incurs a fee) - .valueChange = - valueChange + overpaymentComponents.trackedInterestPart(), - // Fee paid includes both the reduction in tracked management fees and - // any untracked fees on the overpayment itself - .feePaid = deltas.managementFee + - overpaymentComponents.untrackedManagementFee}; + return std::make_pair( + LoanPaymentParts{ + // Principal paid is the reduction in principal outstanding + .principalPaid = deltas.principal, + // Interest paid is the reduction in interest due + .interestPaid = overpaymentComponents.untrackedInterest, + // Value change includes both the reduction from paying down + // principal (negative) and any untracked interest penalties + // (positive, e.g., if the overpayment itself incurs a fee) + .valueChange = + valueChange + overpaymentComponents.untrackedInterest, + // Fee paid includes both the reduction in tracked management fees + // and any untracked fees on the overpayment itself + .feePaid = overpaymentComponents.untrackedManagementFee + + overpaymentComponents.trackedManagementFeeDelta, + }, + newLoanProperties); } /* Validates and applies an overpayment to the loan state. @@ -598,23 +581,16 @@ doOverpayment( NumberProxy& principalOutstandingProxy, NumberProxy& managementFeeOutstandingProxy, NumberProxy& periodicPaymentProxy, - TenthBips32 const interestRate, - std::uint32_t const paymentInterval, Number const& periodicRate, std::uint32_t const paymentRemaining, - std::uint32_t const prevPaymentDate, - std::optional const nextDueDate, TenthBips16 const managementFeeRate, beast::Journal j) { - // Create temporary copies of the loan state that can be safely modified - // and discarded if the overpayment doesn't work out. This prevents - // corrupting the actual ledger data if validation fails. - Number totalValueOutstanding = totalValueOutstandingProxy; - Number principalOutstanding = principalOutstandingProxy; - Number managementFeeOutstanding = managementFeeOutstandingProxy; - Number periodicPayment = periodicPaymentProxy; - + auto const loanState = constructLoanState( + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy); + auto const periodicPayment = periodicPaymentProxy; JLOG(j.debug()) << "overpayment components:" << ", totalValue before: " << *totalValueOutstandingProxy @@ -633,33 +609,28 @@ doOverpayment( asset, loanScale, overpaymentComponents, - totalValueOutstanding, - principalOutstanding, - managementFeeOutstanding, + loanState, periodicPayment, - interestRate, - paymentInterval, periodicRate, paymentRemaining, - prevPaymentDate, - nextDueDate, managementFeeRate, j); if (!ret) return Unexpected(ret.error()); - auto const& loanPaymentParts = *ret; + auto const& [loanPaymentParts, newLoanProperties] = *ret; + auto const newRoundedLoanState = newLoanProperties.loanState; // Safety check: the principal must have decreased. If it didn't (or // increased!), something went wrong in the calculation and we should // reject the overpayment. - if (principalOutstandingProxy <= principalOutstanding) + if (principalOutstandingProxy <= newRoundedLoanState.principalOutstanding) { // LCOV_EXCL_START JLOG(j.warn()) << "Overpayment not allowed: principal " << "outstanding did not decrease. Before: " - << *principalOutstandingProxy - << ". After: " << principalOutstanding; + << *principalOutstandingProxy << ". After: " + << newRoundedLoanState.principalOutstanding; return Unexpected(tesSUCCESS); // LCOV_EXCL_STOP } @@ -670,34 +641,29 @@ doOverpayment( XRPL_ASSERT_PARTS( overpaymentComponents.trackedPrincipalDelta == - principalOutstandingProxy - principalOutstanding, + principalOutstandingProxy - + newRoundedLoanState.principalOutstanding, "xrpl::detail::doOverpayment", "principal change agrees"); - XRPL_ASSERT_PARTS( - overpaymentComponents.trackedManagementFeeDelta == - managementFeeOutstandingProxy - managementFeeOutstanding, - "xrpl::detail::doOverpayment", - "no fee change"); - // I'm not 100% sure the following asserts are correct. If in doubt, and // everything else works, remove any that cause trouble. - JLOG(j.debug()) << "valueChange: " << loanPaymentParts.valueChange - << ", totalValue before: " << *totalValueOutstandingProxy - << ", totalValue after: " << totalValueOutstanding - << ", totalValue delta: " - << (totalValueOutstandingProxy - totalValueOutstanding) - << ", principalDelta: " - << overpaymentComponents.trackedPrincipalDelta - << ", principalPaid: " << loanPaymentParts.principalPaid - << ", Computed difference: " - << overpaymentComponents.trackedPrincipalDelta - - (totalValueOutstandingProxy - totalValueOutstanding); + JLOG(j.debug()) + << "valueChange: " << loanPaymentParts.valueChange + << ", totalValue before: " << *totalValueOutstandingProxy + << ", totalValue after: " << newRoundedLoanState.valueOutstanding + << ", totalValue delta: " + << (totalValueOutstandingProxy - newRoundedLoanState.valueOutstanding) + << ", principalDelta: " << overpaymentComponents.trackedPrincipalDelta + << ", principalPaid: " << loanPaymentParts.principalPaid + << ", Computed difference: " + << overpaymentComponents.trackedPrincipalDelta - + (totalValueOutstandingProxy - newRoundedLoanState.valueOutstanding); XRPL_ASSERT_PARTS( loanPaymentParts.valueChange == - totalValueOutstanding - + newRoundedLoanState.valueOutstanding - (totalValueOutstandingProxy - overpaymentComponents.trackedPrincipalDelta) + overpaymentComponents.trackedInterestPart(), @@ -710,19 +676,12 @@ doOverpayment( "xrpl::detail::doOverpayment", "principal payment matches"); - XRPL_ASSERT_PARTS( - loanPaymentParts.feePaid == - overpaymentComponents.untrackedManagementFee + - overpaymentComponents.trackedManagementFeeDelta, - "xrpl::detail::doOverpayment", - "fee payment matches"); - // All validations passed, so update the proxy objects (which will // modify the actual Loan ledger object) - totalValueOutstandingProxy = totalValueOutstanding; - principalOutstandingProxy = principalOutstanding; - managementFeeOutstandingProxy = managementFeeOutstanding; - periodicPaymentProxy = periodicPayment; + totalValueOutstandingProxy = newRoundedLoanState.valueOutstanding; + principalOutstandingProxy = newRoundedLoanState.principalOutstanding; + managementFeeOutstandingProxy = newRoundedLoanState.managementFeeDue; + periodicPaymentProxy = newLoanProperties.periodicPayment; return loanPaymentParts; } @@ -789,25 +748,21 @@ computeLatePayment( // this to keep the logic clear. This preserves all the other fields without // having to enumerate them. - ExtendedPaymentComponents const late = [&]() { - auto inner = periodic; + ExtendedPaymentComponents const late{ + periodic, + // Untracked management fee includes: + // 1. Regular service fee (from periodic.untrackedManagementFee) + // 2. Late payment fee (fixed penalty) + // 3. Management fee portion of late interest + periodic.untrackedManagementFee + latePaymentFee + + roundedLateManagementFee, - return ExtendedPaymentComponents{ - inner, - // Untracked management fee includes: - // 1. Regular service fee (from periodic.untrackedManagementFee) - // 2. Late payment fee (fixed penalty) - // 3. Management fee portion of late interest - periodic.untrackedManagementFee + latePaymentFee + - roundedLateManagementFee, - - // Untracked interest includes: - // 1. Any untracked interest from the regular payment (usually 0) - // 2. Late penalty interest (increases loan value) - // This positive value indicates the loan's value increased due - // to the late payment. - periodic.untrackedInterest + roundedLateInterest}; - }(); + // Untracked interest includes: + // 1. Any untracked interest from the regular payment (usually 0) + // 2. Late penalty interest (increases loan value) + // This positive value indicates the loan's value increased due + // to the late payment. + periodic.untrackedInterest + roundedLateInterest}; XRPL_ASSERT_PARTS( isRounded(asset, late.totalDue, loanScale), @@ -875,15 +830,16 @@ computeFullPayment( } // Calculate the theoretical principal based on the payment schedule. - // This raw (unrounded) value is used to compute interest and penalties - // accurately. - Number const rawPrincipalOutstanding = loanPrincipalFromPeriodicPayment( - periodicPayment, periodicRate, paymentRemaining); + // This theoretical (unrounded) value is used to compute interest and + // penalties accurately. + Number const theoreticalPrincipalOutstanding = + loanPrincipalFromPeriodicPayment( + periodicPayment, periodicRate, paymentRemaining); // Full payment interest includes both accrued interest (time since last // payment) and prepayment penalty (for closing early). auto const fullPaymentInterest = computeFullPaymentInterest( - rawPrincipalOutstanding, + theoreticalPrincipalOutstanding, periodicRate, view.parentCloseTime(), paymentInterval, @@ -896,9 +852,8 @@ computeFullPayment( auto const [roundedFullInterest, roundedFullManagementFee] = [&]() { auto const interest = roundToAsset( asset, fullPaymentInterest, loanScale, Number::downward); - auto const parts = computeInterestAndFeeParts( + return computeInterestAndFeeParts( asset, interest, managementFeeRate, loanScale); - return std::make_tuple(parts.first, parts.second); }(); ExtendedPaymentComponents const full{ @@ -943,7 +898,8 @@ computeFullPayment( JLOG(j.trace()) << "computeFullPayment result: periodicPayment: " << periodicPayment << ", periodicRate: " << periodicRate << ", paymentRemaining: " << paymentRemaining - << ", rawPrincipalOutstanding: " << rawPrincipalOutstanding + << ", theoreticalPrincipalOutstanding: " + << theoreticalPrincipalOutstanding << ", fullPaymentInterest: " << fullPaymentInterest << ", roundedFullInterest: " << roundedFullInterest << ", roundedFullManagementFee: " @@ -980,6 +936,8 @@ PaymentComponents::trackedInterestPart() const * * Special handling for the final payment: all remaining balances are paid off * regardless of the periodic payment amount. + * + * Implements the pseudo-code function `compute_payment_due()`. */ PaymentComponents computePaymentComponents( @@ -1023,7 +981,7 @@ computePaymentComponents( // Calculate what the loan state SHOULD be after this payment (the target). // This is computed at full precision using the theoretical amortization. - LoanState const trueTarget = computeRawLoanState( + LoanState const trueTarget = computeTheoreticalLoanState( periodicPayment, periodicRate, paymentRemaining - 1, managementFeeRate); // Round the target to the loan's scale to match how actual loan values @@ -1229,17 +1187,12 @@ computeOverpaymentComponents( // This interest doesn't follow the normal amortization schedule - it's // a one-time charge for paying early. // Equation (20) and (21) from XLS-66 spec, Section A-2 Equation Glossary - auto const [rawOverpaymentInterest, _] = [&]() { - Number const interest = - tenthBipsOfValue(overpayment, overpaymentInterestRate); - return detail::computeInterestAndFeeParts(interest, managementFeeRate); - }(); - - // Round the penalty interest components to the loan scale auto const [roundedOverpaymentInterest, roundedOverpaymentManagementFee] = [&]() { - Number const interest = - roundToAsset(asset, rawOverpaymentInterest, loanScale); + auto const interest = roundToAsset( + asset, + tenthBipsOfValue(overpayment, overpaymentInterestRate), + loanScale); return detail::computeInterestAndFeeParts( asset, interest, managementFeeRate, loanScale); }(); @@ -1256,12 +1209,11 @@ computeOverpaymentComponents( .specialCase = detail::PaymentSpecialCase::extra}, // Untracked management fee is the fixed overpayment fee overpaymentFee, - // Untracked interest is the penalty interest charged for - // overpaying. - // This is positive, representing a one-time cost, but it's - // typically - // much smaller than the interest savings from reducing - // principal. + // Untracked interest is the penalty interest charged for overpaying. + // This is positive, representing a one-time cost, but it's typically + // much smaller than the interest savings from reducing principal. + // It is equal to the paymentComponents.trackedInterestPart() + // but is kept separate for clarity. roundedOverpaymentInterest}; XRPL_ASSERT_PARTS( result.trackedInterestPart() == roundedOverpaymentInterest, @@ -1320,7 +1272,7 @@ checkLoanGuards( beast::Journal j) { auto const totalInterestOutstanding = - properties.totalValueOutstanding - principalRequested; + properties.loanState.valueOutstanding - principalRequested; // Guard 1: if there is no computed total interest over the life of the // loan for a non-zero interest rate, we cannot properly amortize the // loan @@ -1375,13 +1327,13 @@ checkLoanGuards( NumberRoundModeGuard mg(Number::upward); if (std::int64_t const computedPayments{ - properties.totalValueOutstanding / roundedPayment}; + properties.loanState.valueOutstanding / roundedPayment}; computedPayments != paymentTotal) { JLOG(j.warn()) << "Loan Periodic payment (" << properties.periodicPayment << ") rounding (" << roundedPayment << ") on a total value of " - << properties.totalValueOutstanding + << properties.loanState.valueOutstanding << " can not complete the loan in the specified " "number of payments (" << computedPayments << " != " << paymentTotal << ")"; @@ -1399,7 +1351,7 @@ checkLoanGuards( */ Number computeFullPaymentInterest( - Number const& rawPrincipalOutstanding, + Number const& theoreticalPrincipalOutstanding, Number const& periodicRate, NetClock::time_point parentCloseTime, std::uint32_t paymentInterval, @@ -1408,7 +1360,7 @@ computeFullPaymentInterest( TenthBips32 closeInterestRate) { auto const accruedInterest = detail::loanAccruedInterest( - rawPrincipalOutstanding, + theoreticalPrincipalOutstanding, periodicRate, parentCloseTime, startDate, @@ -1422,7 +1374,7 @@ computeFullPaymentInterest( // Equation (28) from XLS-66 spec, Section A-2 Equation Glossary auto const prepaymentPenalty = closeInterestRate == beast::zero ? Number{} - : tenthBipsOfValue(rawPrincipalOutstanding, closeInterestRate); + : tenthBipsOfValue(theoreticalPrincipalOutstanding, closeInterestRate); XRPL_ASSERT( prepaymentPenalty >= 0, @@ -1433,42 +1385,17 @@ computeFullPaymentInterest( return accruedInterest + prepaymentPenalty; } -Number -computeFullPaymentInterest( - Number const& periodicPayment, - Number const& periodicRate, - std::uint32_t paymentRemaining, - NetClock::time_point parentCloseTime, - std::uint32_t paymentInterval, - std::uint32_t prevPaymentDate, - std::uint32_t startDate, - TenthBips32 closeInterestRate) -{ - Number const rawPrincipalOutstanding = - detail::loanPrincipalFromPeriodicPayment( - periodicPayment, periodicRate, paymentRemaining); - - return computeFullPaymentInterest( - rawPrincipalOutstanding, - periodicRate, - parentCloseTime, - paymentInterval, - prevPaymentDate, - startDate, - closeInterestRate); -} - /* Calculates the theoretical loan state at maximum precision for a given point * in the amortization schedule. * * This function computes what the loan's outstanding balances should be based * on the periodic payment amount and number of payments remaining, * without considering any rounding that may have been applied to the actual - * Loan object's state. This "raw" (unrounded) state is used as a target for - * computing payment components and validating that the loan's tracked state + * Loan object's state. This "theoretical" (unrounded) state is used as a target + * for computing payment components and validating that the loan's tracked state * hasn't drifted too far from the theoretical values. * - * The raw state serves several purposes: + * The theoretical state serves several purposes: * 1. Computing the expected payment breakdown (principal, interest, fees) * 2. Detecting and correcting rounding errors that accumulate over time * 3. Validating that overpayments are calculated correctly @@ -1476,9 +1403,12 @@ computeFullPaymentInterest( * * If paymentRemaining is 0, returns a fully zeroed-out LoanState, * representing a completely paid-off loan. + * + * Implements the `calculate_true_loan_state` function from the XLS-66 spec + * section 3.2.4.4 Transaction Pseudo-code */ LoanState -computeRawLoanState( +computeTheoreticalLoanState( Number const& periodicPayment, Number const& periodicRate, std::uint32_t const paymentRemaining, @@ -1494,55 +1424,42 @@ computeRawLoanState( } // Equation (30) from XLS-66 spec, Section A-2 Equation Glossary - Number const rawTotalValueOutstanding = periodicPayment * paymentRemaining; + Number const totalValueOutstanding = periodicPayment * paymentRemaining; - Number const rawPrincipalOutstanding = + Number const principalOutstanding = detail::loanPrincipalFromPeriodicPayment( periodicPayment, periodicRate, paymentRemaining); // Equation (31) from XLS-66 spec, Section A-2 Equation Glossary - Number const rawInterestOutstandingGross = - rawTotalValueOutstanding - rawPrincipalOutstanding; + Number const interestOutstandingGross = + totalValueOutstanding - principalOutstanding; // Equation (32) from XLS-66 spec, Section A-2 Equation Glossary - Number const rawManagementFeeOutstanding = - tenthBipsOfValue(rawInterestOutstandingGross, managementFeeRate); + Number const managementFeeOutstanding = + tenthBipsOfValue(interestOutstandingGross, managementFeeRate); // Equation (33) from XLS-66 spec, Section A-2 Equation Glossary - Number const rawInterestOutstandingNet = - rawInterestOutstandingGross - rawManagementFeeOutstanding; + Number const interestOutstandingNet = + interestOutstandingGross - managementFeeOutstanding; return LoanState{ - .valueOutstanding = rawTotalValueOutstanding, - .principalOutstanding = rawPrincipalOutstanding, - .interestDue = rawInterestOutstandingNet, - .managementFeeDue = rawManagementFeeOutstanding}; + .valueOutstanding = totalValueOutstanding, + .principalOutstanding = principalOutstanding, + .interestDue = interestOutstandingNet, + .managementFeeDue = managementFeeOutstanding, + }; }; -LoanState -computeRawLoanState( - Number const& periodicPayment, - TenthBips32 interestRate, - std::uint32_t paymentInterval, - std::uint32_t const paymentRemaining, - TenthBips32 const managementFeeRate) -{ - return computeRawLoanState( - periodicPayment, - loanPeriodicRate(interestRate, paymentInterval), - paymentRemaining, - managementFeeRate); -} - /* Constructs a LoanState from rounded Loan ledger object values. * * This function creates a LoanState structure from the three tracked values - * stored in a Loan ledger object. Unlike calculateRawLoanState(), which + * stored in a Loan ledger object. Unlike calculateTheoreticalLoanState(), which * computes theoretical unrounded values, this function works with values * that have already been rounded to the loan's scale. * - * The key difference from calculateRawLoanState(): - * - calculateRawLoanState: Computes theoretical values at full precision + * The key difference from calculateTheoreticalLoanState(): + * - calculateTheoreticalLoanState: Computes theoretical values at full + * precision * - constructRoundedLoanState: Builds state from actual rounded ledger values * * The interestDue field is derived from the other three values rather than @@ -1600,11 +1517,16 @@ computeManagementFee( /* * Given the loan parameters, compute the derived properties of the loan. + * + * Pulls together several formulas from the XLS-66 spec, which are noted at each + * step, plus the concepts from 3.2.4.3 Conceptual Loan Value. They are used for + * to check some of the conditions in 3.2.1.5 Failure Conditions for the LoanSet + * transaction. */ LoanProperties computeLoanProperties( Asset const& asset, - Number principalOutstanding, + Number const& principalOutstanding, TenthBips32 interestRate, std::uint32_t paymentInterval, std::uint32_t paymentsRemaining, @@ -1615,12 +1537,39 @@ computeLoanProperties( XRPL_ASSERT( interestRate == 0 || periodicRate > 0, "xrpl::computeLoanProperties : valid rate"); + return computeLoanProperties( + asset, + principalOutstanding, + periodicRate, + paymentsRemaining, + managementFeeRate, + minimumScale); +} +/* + * Given the loan parameters, compute the derived properties of the loan. + * + * Pulls together several formulas from the XLS-66 spec, which are noted at each + * step, plus the concepts from 3.2.4.3 Conceptual Loan Value. They are used for + * to check some of the conditions in 3.2.1.5 Failure Conditions for the LoanSet + * transaction. + */ +LoanProperties +computeLoanProperties( + Asset const& asset, + Number const& principalOutstanding, + Number const& periodicRate, + std::uint32_t paymentsRemaining, + TenthBips32 managementFeeRate, + std::int32_t minimumScale) +{ auto const periodicPayment = detail::loanPeriodicPayment( principalOutstanding, periodicRate, paymentsRemaining); auto const [totalValueOutstanding, loanScale] = [&]() { - NumberRoundModeGuard mg(Number::to_nearest); + // only round up if there should be interest + NumberRoundModeGuard mg( + periodicRate == 0 ? Number::to_nearest : Number::upward); // Use STAmount's internal rounding instead of roundToAsset, because // we're going to use this result to determine the scale for all the // other rounding. @@ -1641,7 +1590,7 @@ computeLoanProperties( // We may need to truncate the total value because of the minimum // scale - amount = roundToAsset(asset, amount, loanScale, Number::to_nearest); + amount = roundToAsset(asset, amount, loanScale); return std::make_pair(amount, loanScale); }(); @@ -1649,12 +1598,12 @@ computeLoanProperties( // Since we just figured out the loan scale, we haven't been able to // validate that the principal fits in it, so to allow this function to // succeed, round it here, and let the caller do the validation. - principalOutstanding = roundToAsset( + auto const roundedPrincipalOutstanding = roundToAsset( asset, principalOutstanding, loanScale, Number::to_nearest); // Equation (31) from XLS-66 spec, Section A-2 Equation Glossary auto const totalInterestOutstanding = - totalValueOutstanding - principalOutstanding; + totalValueOutstanding - roundedPrincipalOutstanding; auto const feeOwedToBroker = computeManagementFee( asset, totalInterestOutstanding, managementFeeRate, loanScale); @@ -1664,13 +1613,13 @@ computeLoanProperties( auto const firstPaymentPrincipal = [&]() { // Compute the parts for the first payment. Ensure that the // principal payment will actually change the principal. - auto const startingState = computeRawLoanState( + auto const startingState = computeTheoreticalLoanState( periodicPayment, periodicRate, paymentsRemaining, managementFeeRate); - auto const firstPaymentState = computeRawLoanState( + auto const firstPaymentState = computeTheoreticalLoanState( periodicPayment, periodicRate, paymentsRemaining - 1, @@ -1684,10 +1633,13 @@ computeLoanProperties( return LoanProperties{ .periodicPayment = periodicPayment, - .totalValueOutstanding = totalValueOutstanding, - .managementFeeOwedToBroker = feeOwedToBroker, + .loanState = constructLoanState( + totalValueOutstanding, + roundedPrincipalOutstanding, + feeOwedToBroker), .loanScale = loanScale, - .firstPaymentPrincipal = firstPaymentPrincipal}; + .firstPaymentPrincipal = firstPaymentPrincipal, + }; } /* @@ -1740,7 +1692,7 @@ loanMakePayment( Number const periodicPayment = loan->at(sfPeriodicPayment); - auto prevPaymentDateProxy = loan->at(sfPreviousPaymentDate); + auto prevPaymentDateProxy = loan->at(sfPreviousPaymentDueDate); std::uint32_t const startDate = loan->at(sfStartDate); std::uint32_t const paymentInterval = loan->at(sfPaymentInterval); @@ -2016,12 +1968,8 @@ loanMakePayment( principalOutstandingProxy, managementFeeOutstandingProxy, periodicPaymentProxy, - interestRate, - paymentInterval, periodicRate, paymentRemainingProxy, - prevPaymentDateProxy, - nextDueDateProxy, managementFeeRate, j)) totalParts += *overResult; diff --git a/src/xrpld/app/paths/Flow.cpp b/src/xrpld/app/paths/Flow.cpp index a102e44854..b5088d15b3 100644 --- a/src/xrpld/app/paths/Flow.cpp +++ b/src/xrpld/app/paths/Flow.cpp @@ -1,11 +1,11 @@ #include -#include #include #include #include #include #include +#include #include #include diff --git a/src/xrpld/app/paths/detail/DirectStep.cpp b/src/xrpld/app/paths/detail/DirectStep.cpp index 4e701d348f..3d3a76f42d 100644 --- a/src/xrpld/app/paths/detail/DirectStep.cpp +++ b/src/xrpld/app/paths/detail/DirectStep.cpp @@ -1,8 +1,8 @@ -#include #include #include #include +#include #include #include #include diff --git a/src/xrpld/app/paths/detail/StrandFlow.h b/src/xrpld/app/paths/detail/StrandFlow.h index fab92dca35..ca4b18f0a3 100644 --- a/src/xrpld/app/paths/detail/StrandFlow.h +++ b/src/xrpld/app/paths/detail/StrandFlow.h @@ -3,7 +3,6 @@ #include #include -#include #include #include #include @@ -11,6 +10,7 @@ #include #include +#include #include #include #include diff --git a/src/xrpld/app/paths/detail/XRPEndpointStep.cpp b/src/xrpld/app/paths/detail/XRPEndpointStep.cpp index 83271321be..ed1866bf24 100644 --- a/src/xrpld/app/paths/detail/XRPEndpointStep.cpp +++ b/src/xrpld/app/paths/detail/XRPEndpointStep.cpp @@ -1,9 +1,9 @@ -#include #include #include #include #include +#include #include #include #include diff --git a/src/xrpld/app/tx/detail/InvariantCheck.cpp b/src/xrpld/app/tx/detail/InvariantCheck.cpp index 5aabad3184..cbe1773638 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.cpp +++ b/src/xrpld/app/tx/detail/InvariantCheck.cpp @@ -95,6 +95,7 @@ hasPrivilege(STTx const& tx, Privilege priv) switch (tx.getTxnType()) { #include + // Deprecated types default: return false; @@ -2638,6 +2639,7 @@ ValidVault::Vault::make(SLE const& from) self.key = from.key(); self.asset = from.at(sfAsset); self.pseudoId = from.getAccountID(sfAccount); + self.owner = from.at(sfOwner); self.shareMPTID = from.getFieldH192(sfShareMPTID); self.assetsTotal = from.at(sfAssetsTotal); self.assetsAvailable = from.at(sfAssetsAvailable); @@ -3082,6 +3084,10 @@ ValidVault::finalize( : std::nullopt; }; + auto const vaultHoldsNoAssets = [&](Vault const& vault) { + return vault.assetsAvailable == 0 && vault.assetsTotal == 0; + }; + // Technically this does not need to be a lambda, but it's more // convenient thanks to early "return false"; the not-so-nice // alternatives are several layers of nested if/else or more complex @@ -3464,29 +3470,56 @@ ValidVault::finalize( if (vaultAsset.native() || vaultAsset.getIssuer() != tx[sfAccount]) { - JLOG(j.fatal()) << // - "Invariant failed: clawback may only be performed by " - "the asset issuer"; - return false; // That's all we can do + // The owner can use clawback to force-burn shares when the + // vault is empty but there are outstanding shares + if (!(beforeShares && beforeShares->sharesTotal > 0 && + vaultHoldsNoAssets(beforeVault) && + beforeVault.owner == tx[sfAccount])) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback may only be performed " + "by the asset issuer, or by the vault owner of an " + "empty vault"; + return false; // That's all we can do + } } auto const vaultDeltaAssets = deltaAssets(afterVault.pseudoId); + if (vaultDeltaAssets) + { + if (*vaultDeltaAssets >= zero) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback must decrease vault " + "balance"; + result = false; + } - if (!vaultDeltaAssets) + if (beforeVault.assetsTotal + *vaultDeltaAssets != + afterVault.assetsTotal) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback and assets outstanding " + "must add up"; + result = false; + } + + if (beforeVault.assetsAvailable + *vaultDeltaAssets != + afterVault.assetsAvailable) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback and assets available " + "must add up"; + result = false; + } + } + else if (!vaultHoldsNoAssets(beforeVault)) { JLOG(j.fatal()) << // "Invariant failed: clawback must change vault balance"; return false; // That's all we can do } - if (*vaultDeltaAssets >= zero) - { - JLOG(j.fatal()) << // - "Invariant failed: clawback must decrease vault " - "balance"; - result = false; - } - auto const accountDeltaShares = deltaShares(tx[sfHolder]); if (!accountDeltaShares) { @@ -3519,24 +3552,6 @@ ValidVault::finalize( result = false; } - if (beforeVault.assetsTotal + *vaultDeltaAssets != - afterVault.assetsTotal) - { - JLOG(j.fatal()) << // - "Invariant failed: clawback and assets outstanding " - "must add up"; - result = false; - } - - if (beforeVault.assetsAvailable + *vaultDeltaAssets != - afterVault.assetsAvailable) - { - JLOG(j.fatal()) << // - "Invariant failed: clawback and assets available must " - "add up"; - result = false; - } - return result; } diff --git a/src/xrpld/app/tx/detail/InvariantCheck.h b/src/xrpld/app/tx/detail/InvariantCheck.h index 4eeee9182e..7991161731 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.h +++ b/src/xrpld/app/tx/detail/InvariantCheck.h @@ -918,6 +918,7 @@ class ValidVault uint256 key = beast::zero; Asset asset = {}; AccountID pseudoId = {}; + AccountID owner = {}; uint192 shareMPTID = beast::zero; Number assetsTotal = 0; Number assetsAvailable = 0; diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp index 9f4ecb4a97..a3a4ad305b 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp @@ -2,6 +2,8 @@ // #include +#include + namespace xrpl { bool @@ -270,7 +272,7 @@ LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx) JLOG(ctx.j.warn()) << "LoanBroker cover is already at minimum."; return findClawAmount.error(); } - STAmount const clawAmount = *findClawAmount; + STAmount const& clawAmount = *findClawAmount; // Explicitly check the balance of the trust line / MPT to make sure the // balance is actually there. It should always match `sfCoverAvailable`, so @@ -287,6 +289,14 @@ LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx) // Check if the vault asset issuer has the correct flags auto const sleIssuer = ctx.view.read(keylet::account(vaultAsset.getIssuer())); + if (!sleIssuer) + { + // LCOV_EXCL_START + JLOG(ctx.j.fatal()) << "Issuer account does not exist."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + return std::visit( [&](T const&) { return preclaimHelper(ctx, *sleIssuer, clawAmount); @@ -321,7 +331,7 @@ LoanBrokerCoverClawback::doApply() determineClawAmount(*sleBroker, vaultAsset, amount); if (!findClawAmount) return tecINTERNAL; // LCOV_EXCL_LINE - STAmount const clawAmount = *findClawAmount; + STAmount const& clawAmount = *findClawAmount; // Just for paranoia's sake if (clawAmount.native()) return tecINTERNAL; // LCOV_EXCL_LINE @@ -330,6 +340,8 @@ LoanBrokerCoverClawback::doApply() sleBroker->at(sfCoverAvailable) -= clawAmount; view().update(sleBroker); + associateAsset(*sleBroker, vaultAsset); + // Transfer assets from pseudo-account to depositor. return accountSend( view(), diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp index 5446df0786..6faf8c56de 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp @@ -2,6 +2,8 @@ // #include +#include + namespace xrpl { bool @@ -81,7 +83,8 @@ LoanBrokerCoverDeposit::preclaim(PreclaimContext const& ctx) vaultAsset, FreezeHandling::fhZERO_IF_FROZEN, AuthHandling::ahZERO_IF_UNAUTHORIZED, - ctx.j) < amount) + ctx.j, + SpendableHandling::shFULL_BALANCE) < amount) return tecINSUFFICIENT_FUNDS; return tesSUCCESS; @@ -99,6 +102,12 @@ LoanBrokerCoverDeposit::doApply() if (!broker) return tecINTERNAL; // LCOV_EXCL_LINE + auto const vault = view().read(keylet::vault(broker->at(sfVaultID))); + if (!vault) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const vaultAsset = vault->at(sfAsset); + auto const brokerPseudoID = broker->at(sfAccount); // Transfer assets from depositor to pseudo-account. @@ -116,6 +125,8 @@ LoanBrokerCoverDeposit::doApply() broker->at(sfCoverAvailable) += amount; view().update(broker); + associateAsset(*broker, vaultAsset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp index 4c0b3e9af5..5d4d2053ed 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp @@ -4,6 +4,7 @@ #include #include +#include namespace xrpl { @@ -48,6 +49,11 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx) auto const dstAcct = tx[~sfDestination].value_or(account); + if (isPseudoAccount(ctx.view, dstAcct)) + { + JLOG(ctx.j.warn()) << "Trying to withdraw into a pseudo-account."; + return tecPSEUDO_ACCOUNT; + } auto const sleBroker = ctx.view.read(keylet::loanbroker(brokerID)); if (!sleBroker) { @@ -151,12 +157,20 @@ LoanBrokerCoverWithdraw::doApply() if (!broker) return tecINTERNAL; // LCOV_EXCL_LINE + auto const vault = view().read(keylet::vault(broker->at(sfVaultID))); + if (!vault) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const vaultAsset = vault->at(sfAsset); + auto const brokerPseudoID = *broker->at(sfAccount); // Decrease the LoanBroker's CoverAvailable by Amount broker->at(sfCoverAvailable) -= amount; view().update(broker); + associateAsset(*broker, vaultAsset); + return doWithdraw( view(), tx, diff --git a/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp index 61d49fbaa0..d7bc46a3ea 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp @@ -2,6 +2,8 @@ // #include +#include + namespace xrpl { bool @@ -46,30 +48,6 @@ LoanBrokerDelete::preclaim(PreclaimContext const& ctx) JLOG(ctx.j.warn()) << "LoanBrokerDelete: Owner count is " << ownerCount; return tecHAS_OBLIGATIONS; } - if (auto const debtTotal = sleBroker->at(sfDebtTotal); - debtTotal != beast::zero) - { - // Any remaining debt should have been wiped out by the last Loan - // Delete. This check is purely defensive. - auto const vault = - ctx.view.read(keylet::vault(sleBroker->at(sfVaultID))); - if (!vault) - return tefINTERNAL; // LCOV_EXCL_LINE - auto const asset = vault->at(sfAsset); - auto const scale = getVaultScale(vault); - - auto const rounded = - roundToAsset(asset, debtTotal, scale, Number::towards_zero); - - if (rounded != beast::zero) - { - // LCOV_EXCL_START - JLOG(ctx.j.warn()) << "LoanBrokerDelete: Debt total is " - << debtTotal << ", which rounds to " << rounded; - return tecHAS_OBLIGATIONS; - // LCOV_EXCL_START - } - } auto const vault = ctx.view.read(keylet::vault(sleBroker->at(sfVaultID))); if (!vault) @@ -82,6 +60,26 @@ LoanBrokerDelete::preclaim(PreclaimContext const& ctx) Asset const asset = vault->at(sfAsset); + if (auto const debtTotal = sleBroker->at(sfDebtTotal); + debtTotal != beast::zero) + { + // Any remaining debt should have been wiped out by the last Loan + // Delete. This check is purely defensive. + auto const scale = getAssetsTotalScale(vault); + + auto const rounded = + roundToAsset(asset, debtTotal, scale, Number::towards_zero); + + if (rounded != beast::zero) + { + // LCOV_EXCL_START + JLOG(ctx.j.warn()) << "LoanBrokerDelete: Debt total is " + << debtTotal << ", which rounds to " << rounded; + return tecHAS_OBLIGATIONS; + // LCOV_EXCL_STOP + } + } + auto const coverAvailable = STAmount{asset, sleBroker->at(sfCoverAvailable)}; // If there are assets in the cover, broker will receive them on deletion. @@ -196,6 +194,8 @@ LoanBrokerDelete::doApply() view().erase(broker); + associateAsset(*broker, vaultAsset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/LoanBrokerSet.cpp b/src/xrpld/app/tx/detail/LoanBrokerSet.cpp index e7b88ad70d..06adf32c15 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerSet.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerSet.cpp @@ -2,6 +2,8 @@ // #include +#include + namespace xrpl { bool @@ -62,6 +64,15 @@ LoanBrokerSet::preflight(PreflightContext const& ctx) return tesSUCCESS; } +std::vector> const& +LoanBrokerSet::getValueFields() +{ + static std::vector> const valueFields{ + ~sfDebtMaximum}; + + return valueFields; +} + TER LoanBrokerSet::preclaim(PreclaimContext const& ctx) { @@ -70,8 +81,24 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx) auto const account = tx[sfAccount]; auto const vaultID = tx[sfVaultID]; + auto const sleVault = ctx.view.read(keylet::vault(vaultID)); + if (!sleVault) + { + JLOG(ctx.j.warn()) << "Vault does not exist."; + return tecNO_ENTRY; + } + Asset const asset = sleVault->at(sfAsset); + + if (account != sleVault->at(sfOwner)) + { + JLOG(ctx.j.warn()) << "Account is not the owner of the Vault."; + return tecNO_PERMISSION; + } + if (auto const brokerID = tx[~sfLoanBrokerID]) { + // Updating an existing Broker + auto const sleBroker = ctx.view.read(keylet::loanbroker(*brokerID)); if (!sleBroker) { @@ -89,23 +116,46 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx) JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker."; return tecNO_PERMISSION; } + + if (auto const debtMax = tx[~sfDebtMaximum]) + { + // Can't reduce the debt maximum below the current total debt + auto const currentDebtTotal = sleBroker->at(sfDebtTotal); + if (*debtMax != 0 && *debtMax < currentDebtTotal) + { + JLOG(ctx.j.warn()) + << "Cannot reduce DebtMaximum below current DebtTotal."; + return tecLIMIT_EXCEEDED; + } + } } else { - auto const sleVault = ctx.view.read(keylet::vault(vaultID)); - if (!sleVault) - { - JLOG(ctx.j.warn()) << "Vault does not exist."; - return tecNO_ENTRY; - } - if (account != sleVault->at(sfOwner)) - { - JLOG(ctx.j.warn()) << "Account is not the owner of the Vault."; - return tecNO_PERMISSION; - } - if (auto const ter = canAddHolding(ctx.view, sleVault->at(sfAsset))) + if (auto const ter = canAddHolding(ctx.view, asset)) return ter; + + if (auto const ter = checkFrozen( + ctx.view, sleVault->at(sfAccount), sleVault->at(sfAsset))) + { + JLOG(ctx.j.warn()) << "Vault pseudo-account is frozen."; + return ter; + } } + + // Check that relevant values can be represented as the vault asset + // type. This is mostly only relevant for integral (non-IOU) types + for (auto const& field : getValueFields()) + { + if (auto const value = tx[field]; + value && STAmount{asset, *value} != *value) + { + JLOG(ctx.j.warn()) << field.f->getName() << " (" << *value + << ") can not be represented as a(n) " + << to_string(asset) << "."; + return tecPRECISION_LOSS; + } + } + return tesSUCCESS; } @@ -128,12 +178,20 @@ LoanBrokerSet::doApply() // LCOV_EXCL_STOP } + auto const vault = view.read(keylet::vault(broker->at(sfVaultID))); + if (!vault) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const vaultAsset = vault->at(sfAsset); + if (auto const data = tx[~sfData]) broker->at(sfData) = *data; if (auto const debtMax = tx[~sfDebtMaximum]) broker->at(sfDebtMaximum) = *debtMax; view.update(broker); + + associateAsset(*broker, vaultAsset); } else { @@ -149,6 +207,7 @@ LoanBrokerSet::doApply() // LCOV_EXCL_STOP } auto const vaultPseudoID = sleVault->at(sfAccount); + auto const vaultAsset = sleVault->at(sfAsset); auto const sequence = tx.getSeqValue(); auto owner = view.peek(keylet::account(account_)); @@ -220,6 +279,8 @@ LoanBrokerSet::doApply() broker->at(sfCoverRateLiquidation) = *coverLiq; view.insert(broker); + + associateAsset(*broker, vaultAsset); } return tesSUCCESS; diff --git a/src/xrpld/app/tx/detail/LoanBrokerSet.h b/src/xrpld/app/tx/detail/LoanBrokerSet.h index 625c0adeb2..57170b9cb9 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerSet.h +++ b/src/xrpld/app/tx/detail/LoanBrokerSet.h @@ -20,6 +20,9 @@ public: static NotTEC preflight(PreflightContext const& ctx); + static std::vector> const& + getValueFields(); + static TER preclaim(PreclaimContext const& ctx); diff --git a/src/xrpld/app/tx/detail/LoanDelete.cpp b/src/xrpld/app/tx/detail/LoanDelete.cpp index 0aecd3889c..ccbde2da38 100644 --- a/src/xrpld/app/tx/detail/LoanDelete.cpp +++ b/src/xrpld/app/tx/detail/LoanDelete.cpp @@ -2,6 +2,8 @@ // #include +#include + namespace xrpl { bool @@ -78,9 +80,10 @@ LoanDelete::doApply() return tefBAD_LEDGER; // LCOV_EXCL_LINE auto const brokerPseudoAccount = brokerSle->at(sfAccount); - auto const vaultSle = view.peek(keylet ::vault(brokerSle->at(sfVaultID))); + auto const vaultSle = view.peek(keylet::vault(brokerSle->at(sfVaultID))); if (!vaultSle) return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const vaultAsset = vaultSle->at(sfAsset); // Remove LoanID from Directory of the LoanBroker pseudo-account. if (!view.dirRemove( @@ -113,7 +116,7 @@ LoanDelete::doApply() roundToAsset( vaultSle->at(sfAsset), debtTotalProxy, - getVaultScale(vaultSle), + getAssetsTotalScale(vaultSle), Number::towards_zero) == beast::zero, "xrpl::LoanDelete::doApply", "last loan, remaining debt rounds to zero"); @@ -127,6 +130,11 @@ LoanDelete::doApply() // Delete the Loan object view.erase(loanSle); + // 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/xrpld/app/tx/detail/LoanManage.cpp b/src/xrpld/app/tx/detail/LoanManage.cpp index 5fce2213ba..3ba63db4d1 100644 --- a/src/xrpld/app/tx/detail/LoanManage.cpp +++ b/src/xrpld/app/tx/detail/LoanManage.cpp @@ -2,6 +2,7 @@ // #include +#include #include namespace xrpl { @@ -106,7 +107,7 @@ LoanManage::preclaim(PreclaimContext const& ctx) if (loanBrokerSle->at(sfOwner) != account) { JLOG(ctx.j.warn()) - << "LoanBroker for Loan does not belong to the account. LoanModify " + << "LoanBroker for Loan does not belong to the account. LoanManage " "can only be submitted by the Loan Broker."; return tecNO_PERMISSION; } @@ -158,7 +159,7 @@ LoanManage::defaultLoan( auto const minimumCover = tenthBipsOfValue(brokerDebtTotalProxy.value(), coverRateMinimum); // Round the liquidation amount up, too - return roundToAsset( + auto const covered = roundToAsset( vaultAsset, /* * This formula is from the XLS-66 spec, section 3.2.3.2 (State @@ -169,6 +170,9 @@ LoanManage::defaultLoan( tenthBipsOfValue(minimumCover, coverRateLiquidation), totalDefaultAmount), loanScale); + auto const coverAvailable = *brokerSle->at(sfCoverAvailable); + + return std::min(covered, coverAvailable); }(); auto const vaultDefaultAmount = totalDefaultAmount - defaultCovered; @@ -178,7 +182,7 @@ LoanManage::defaultLoan( // The vault may be at a different scale than the loan. Reduce rounding // errors during the accounting by rounding some of the values to that // scale. - auto const vaultScale = getVaultScale(vaultSle); + auto const vaultScale = getAssetsTotalScale(vaultSle); { // Decrease the Total Value of the Vault: @@ -223,11 +227,13 @@ LoanManage::defaultLoan( } if (*vaultAvailableProxy > *vaultTotalProxy) { - JLOG(j.warn()) << "Vault assets available must not be greater " - "than assets outstanding. Available: " - << *vaultAvailableProxy - << ", Total: " << *vaultTotalProxy; - return tecLIMIT_EXCEEDED; + // LCOV_EXCL_START + JLOG(j.fatal()) + << "Vault assets available must not be greater " + "than assets outstanding. Available: " + << *vaultAvailableProxy << ", Total: " << *vaultTotalProxy; + return tecINTERNAL; + // LCOV_EXCL_STOP } // The loss has been realized @@ -242,7 +248,11 @@ LoanManage::defaultLoan( return tefBAD_LEDGER; // LCOV_EXCL_STOP } - vaultLossUnrealizedProxy -= totalDefaultAmount; + adjustImpreciseNumber( + vaultLossUnrealizedProxy, + -totalDefaultAmount, + vaultAsset, + vaultScale); } view.update(vaultSle); } @@ -250,11 +260,9 @@ LoanManage::defaultLoan( // Update the LoanBroker object: { - auto const asset = *vaultSle->at(sfAsset); - // Decrease the Debt of the LoanBroker: adjustImpreciseNumber( - brokerDebtTotalProxy, -totalDefaultAmount, asset, vaultScale); + brokerDebtTotalProxy, -totalDefaultAmount, vaultAsset, vaultScale); // Decrease the First-Loss Capital Cover Available: auto coverAvailableProxy = brokerSle->at(sfCoverAvailable); if (coverAvailableProxy < defaultCovered) @@ -298,13 +306,20 @@ LoanManage::impairLoan( ApplyView& view, SLE::ref loanSle, SLE::ref vaultSle, + Asset const& vaultAsset, beast::Journal j) { Number const lossUnrealized = owedToVault(loanSle); + // The vault may be at a different scale than the loan. Reduce rounding + // errors during the accounting by rounding some of the values to that + // scale. + auto const vaultScale = getAssetsTotalScale(vaultSle); + // Update the Vault object(set "paper loss") auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); - vaultLossUnrealizedProxy += lossUnrealized; + adjustImpreciseNumber( + vaultLossUnrealizedProxy, lossUnrealized, vaultAsset, vaultScale); if (vaultLossUnrealizedProxy > vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable)) { @@ -330,13 +345,19 @@ LoanManage::impairLoan( return tesSUCCESS; } -TER +[[nodiscard]] TER LoanManage::unimpairLoan( ApplyView& view, SLE::ref loanSle, SLE::ref vaultSle, + Asset const& vaultAsset, beast::Journal j) { + // The vault may be at a different scale than the loan. Reduce rounding + // errors during the accounting by rounding some of the values to that + // scale. + auto const vaultScale = getAssetsTotalScale(vaultSle); + // Update the Vault object(clear "paper loss") auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); Number const lossReversed = owedToVault(loanSle); @@ -348,14 +369,18 @@ LoanManage::unimpairLoan( return tefBAD_LEDGER; // LCOV_EXCL_STOP } - vaultLossUnrealizedProxy -= lossReversed; + // Reverse the "paper loss" + adjustImpreciseNumber( + vaultLossUnrealizedProxy, -lossReversed, vaultAsset, vaultScale); + view.update(vaultSle); // Update the Loan object loanSle->clearFlag(lsfLoanImpaired); auto const paymentInterval = loanSle->at(sfPaymentInterval); auto const normalPaymentDueDate = - std::max(loanSle->at(sfPreviousPaymentDate), loanSle->at(sfStartDate)) + + std::max( + loanSle->at(sfPreviousPaymentDueDate), loanSle->at(sfStartDate)) + paymentInterval; if (!hasExpired(view, normalPaymentDueDate)) { @@ -389,7 +414,7 @@ LoanManage::doApply() if (!brokerSle) return tefBAD_LEDGER; // LCOV_EXCL_LINE - auto const vaultSle = view.peek(keylet ::vault(brokerSle->at(sfVaultID))); + auto const vaultSle = view.peek(keylet::vault(brokerSle->at(sfVaultID))); if (!vaultSle) return tefBAD_LEDGER; // LCOV_EXCL_LINE auto const vaultAsset = vaultSle->at(sfAsset); @@ -397,21 +422,16 @@ LoanManage::doApply() // Valid flag combinations are checked in preflight. No flags is valid - // just a noop. if (tx.isFlag(tfLoanDefault)) - { - if (auto const ter = - defaultLoan(view, loanSle, brokerSle, vaultSle, vaultAsset, j_)) - return ter; - } - else if (tx.isFlag(tfLoanImpair)) - { - if (auto const ter = impairLoan(view, loanSle, vaultSle, j_)) - return ter; - } - else if (tx.isFlag(tfLoanUnimpair)) - { - if (auto const ter = unimpairLoan(view, loanSle, vaultSle, j_)) - return ter; - } + return defaultLoan(view, loanSle, brokerSle, vaultSle, vaultAsset, j_); + if (tx.isFlag(tfLoanImpair)) + return impairLoan(view, loanSle, vaultSle, vaultAsset, j_); + if (tx.isFlag(tfLoanUnimpair)) + return unimpairLoan(view, loanSle, vaultSle, vaultAsset, j_); + // Noop, as described above. + + associateAsset(*loanSle, vaultAsset); + associateAsset(*brokerSle, vaultAsset); + associateAsset(*vaultSle, vaultAsset); return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/LoanManage.h b/src/xrpld/app/tx/detail/LoanManage.h index 7a02c7a16f..155611580f 100644 --- a/src/xrpld/app/tx/detail/LoanManage.h +++ b/src/xrpld/app/tx/detail/LoanManage.h @@ -44,15 +44,17 @@ public: ApplyView& view, SLE::ref loanSle, SLE::ref vaultSle, + Asset const& vaultAsset, beast::Journal j); /** Helper function that might be needed by other transactors */ - static TER + [[nodiscard]] static TER unimpairLoan( ApplyView& view, SLE::ref loanSle, SLE::ref vaultSle, + Asset const& vaultAsset, beast::Journal j); TER diff --git a/src/xrpld/app/tx/detail/LoanPay.cpp b/src/xrpld/app/tx/detail/LoanPay.cpp index 4a0352e47d..d8b35937aa 100644 --- a/src/xrpld/app/tx/detail/LoanPay.cpp +++ b/src/xrpld/app/tx/detail/LoanPay.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -152,9 +153,7 @@ LoanPay::preclaim(PreclaimContext const& ctx) } auto const principalOutstanding = loanSle->at(sfPrincipalOutstanding); - TenthBips32 const interestRate{loanSle->at(sfInterestRate)}; auto const paymentRemaining = loanSle->at(sfPaymentRemaining); - TenthBips32 const lateInterestRate{loanSle->at(sfLateInterestRate)}; if (paymentRemaining == 0 || principalOutstanding == 0) { @@ -211,13 +210,14 @@ LoanPay::preclaim(PreclaimContext const& ctx) // Do not support "partial payments" - if the transaction says to pay X, // then the account must have X available, even if the loan payment takes // less. - if (auto const balance = accountSpendable( + if (auto const balance = accountHolds( ctx.view, account, asset, fhZERO_IF_FROZEN, ahZERO_IF_UNAUTHORIZED, - ctx.j); + ctx.j, + SpendableHandling::shFULL_BALANCE); balance < amount) { JLOG(ctx.j.warn()) << "Payment amount too large. Amount: " @@ -262,11 +262,12 @@ LoanPay::doApply() auto debtTotalProxy = brokerSle->at(sfDebtTotal); // Send the broker fee to the owner if they have sufficient cover available, - // _and_ if the owner can receive funds. If not, so as not to block the - // payment, add it to the cover balance (send it to the broker pseudo - // account). + // _and_ if the owner can receive funds + // _and_ if the broker is authorized to hold funds. If not, so as not to + // block the payment, add it to the cover balance (send it to the broker + // pseudo account). // - // Normally freeze status is checked in preflight, but we do it here to + // Normally freeze status is checked in preclaim, but we do it here to // avoid duplicating the check. It'll claim a fee either way. bool const sendBrokerFeeToOwner = [&]() { // Round the minimum required cover up to be conservative. This ensures @@ -278,7 +279,8 @@ LoanPay::doApply() asset, tenthBipsOfValue(debtTotalProxy.value(), coverRateMinimum), loanScale) && - !isDeepFrozen(view, brokerOwner, asset); + !isDeepFrozen(view, brokerOwner, asset) && + !requireAuth(view, asset, brokerOwner, AuthType::StrongAuth); }(); auto const brokerPayee = @@ -305,7 +307,12 @@ LoanPay::doApply() // change will be discarded. if (loanSle->isFlag(lsfLoanImpaired)) { - LoanManage::unimpairLoan(view, loanSle, vaultSle, j_); + if (auto const ret = + LoanManage::unimpairLoan(view, loanSle, vaultSle, asset, j_)) + { + JLOG(j_.fatal()) << "Failed to unimpair loan before payment."; + return ret; // LCOV_EXCL_LINE + } } LoanPaymentType const paymentType = [&tx]() { @@ -377,7 +384,7 @@ LoanPay::doApply() // The vault may be at a different scale than the loan. Reduce rounding // errors during the payment by rounding some of the values to that scale. - auto const vaultScale = assetsTotalProxy.value().exponent(); + auto const vaultScale = getAssetsTotalScale(vaultSle); auto const totalPaidToVaultRaw = paymentParts->principalPaid + paymentParts->interestPaid; @@ -421,35 +428,41 @@ LoanPay::doApply() // Vault object state changes view.update(vaultSle); - Number const assetsAvailableBefore = *assetsAvailableProxy; - Number const pseudoAccountBalanceBefore = accountHolds( - view, - vaultPseudoAccount, - asset, - FreezeHandling::fhIGNORE_FREEZE, - AuthHandling::ahIGNORE_AUTH, - j_); - +#if !NDEBUG { + Number const assetsAvailableBefore = *assetsAvailableProxy; + Number const pseudoAccountBalanceBefore = accountHolds( + view, + vaultPseudoAccount, + asset, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j_); + XRPL_ASSERT_PARTS( assetsAvailableBefore == pseudoAccountBalanceBefore, "xrpl::LoanPay::doApply", "vault pseudo balance agrees before"); + } +#endif - assetsAvailableProxy += totalPaidToVaultRounded; - assetsTotalProxy += paymentParts->valueChange; + assetsAvailableProxy += totalPaidToVaultRounded; + assetsTotalProxy += paymentParts->valueChange; - XRPL_ASSERT_PARTS( - *assetsAvailableProxy <= *assetsTotalProxy, - "xrpl::LoanPay::doApply", - "assets available must not be greater than assets outstanding"); + XRPL_ASSERT_PARTS( + *assetsAvailableProxy <= *assetsTotalProxy, + "xrpl::LoanPay::doApply", + "assets available must not be greater than assets outstanding"); - if (*assetsAvailableProxy > *assetsTotalProxy) - { - // LCOV_EXCL_START - return tecINTERNAL; - // LCOV_EXCL_STOP - } + if (*assetsAvailableProxy > *assetsTotalProxy) + { + // LCOV_EXCL_START + JLOG(j_.fatal()) << "Vault assets available must not be greater " + "than assets outstanding. Available: " + << *assetsAvailableProxy + << ", Total: " << *assetsTotalProxy; + return tecINTERNAL; + // LCOV_EXCL_STOP } JLOG(j_.debug()) << "total paid to vault raw: " << totalPaidToVaultRaw @@ -473,22 +486,45 @@ LoanPay::doApply() coverAvailableProxy += totalPaidToBroker; } + associateAsset(*loanSle, asset); + associateAsset(*brokerSle, asset); + associateAsset(*vaultSle, asset); + + // Duplicate some checks after rounding + XRPL_ASSERT_PARTS( + *assetsAvailableProxy <= *assetsTotalProxy, + "xrpl::LoanPay::doApply", + "assets available must not be greater than assets outstanding"); + #if !NDEBUG - auto const accountBalanceBefore = accountSpendable( - view, account_, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); + auto const accountBalanceBefore = accountHolds( + view, + account_, + asset, + fhIGNORE_FREEZE, + ahIGNORE_AUTH, + j_, + SpendableHandling::shFULL_BALANCE); auto const vaultBalanceBefore = account_ == vaultPseudoAccount ? STAmount{asset, 0} - : accountSpendable( + : accountHolds( view, vaultPseudoAccount, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, - j_); + j_, + SpendableHandling::shFULL_BALANCE); auto const brokerBalanceBefore = account_ == brokerPayee ? STAmount{asset, 0} - : accountSpendable( - view, brokerPayee, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); + : accountHolds( + view, + brokerPayee, + asset, + fhIGNORE_FREEZE, + ahIGNORE_AUTH, + j_, + SpendableHandling::shFULL_BALANCE); #endif if (totalPaidToVaultRounded != beast::zero) @@ -533,6 +569,7 @@ LoanPay::doApply() WaiveTransferFee::Yes)) return ter; +#if !NDEBUG Number const assetsAvailableAfter = *assetsAvailableProxy; Number const pseudoAccountBalanceAfter = accountHolds( view, @@ -546,22 +583,34 @@ LoanPay::doApply() "xrpl::LoanPay::doApply", "vault pseudo balance agrees after"); -#if !NDEBUG - auto const accountBalanceAfter = accountSpendable( - view, account_, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); + auto const accountBalanceAfter = accountHolds( + view, + account_, + asset, + fhIGNORE_FREEZE, + ahIGNORE_AUTH, + j_, + SpendableHandling::shFULL_BALANCE); auto const vaultBalanceAfter = account_ == vaultPseudoAccount ? STAmount{asset, 0} - : accountSpendable( + : accountHolds( view, vaultPseudoAccount, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, - j_); + j_, + SpendableHandling::shFULL_BALANCE); auto const brokerBalanceAfter = account_ == brokerPayee ? STAmount{asset, 0} - : accountSpendable( - view, brokerPayee, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); + : accountHolds( + view, + brokerPayee, + asset, + fhIGNORE_FREEZE, + ahIGNORE_AUTH, + j_, + SpendableHandling::shFULL_BALANCE); XRPL_ASSERT_PARTS( accountBalanceBefore + vaultBalanceBefore + brokerBalanceBefore == diff --git a/src/xrpld/app/tx/detail/LoanSet.cpp b/src/xrpld/app/tx/detail/LoanSet.cpp index 310a13fa3d..d15dc11fc4 100644 --- a/src/xrpld/app/tx/detail/LoanSet.cpp +++ b/src/xrpld/app/tx/detail/LoanSet.cpp @@ -2,6 +2,7 @@ // #include +#include #include namespace xrpl { @@ -88,10 +89,12 @@ LoanSet::preflight(PreflightContext const& ctx) if (auto const paymentInterval = tx[~sfPaymentInterval]; !validNumericMinimum(paymentInterval, LoanSet::minPaymentInterval)) return temINVALID; - - else if (!validNumericRange( - tx[~sfGracePeriod], - paymentInterval.value_or(LoanSet::defaultPaymentInterval))) + // Grace period is between min default value and payment interval + else if (auto const gracePeriod = tx[~sfGracePeriod]; // + !validNumericRange( + gracePeriod, + paymentInterval.value_or(LoanSet::defaultPaymentInterval), + defaultGracePeriod)) return temINVALID; // Copied from preflight2 @@ -282,6 +285,15 @@ LoanSet::preclaim(PreclaimContext const& ctx) if (!vault) // Should be impossible return tefBAD_LEDGER; // LCOV_EXCL_LINE + + 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."; + return tecLIMIT_EXCEEDED; + } + Asset const asset = vault->at(sfAsset); auto const vaultPseudo = vault->at(sfAccount); @@ -290,17 +302,15 @@ LoanSet::preclaim(PreclaimContext const& ctx) // This check is almost duplicated in doApply, but that check is done after // the overall loan scale is known. This is mostly only relevant for // integral (non-IOU) types + for (auto const& field : getValueFields()) { - for (auto const& field : getValueFields()) + if (auto const value = tx[field]; + value && STAmount{asset, *value} != *value) { - if (auto const value = tx[field]; - value && STAmount{asset, *value} != *value) - { - JLOG(ctx.j.warn()) << field.f->getName() << " (" << *value - << ") can not be represented as a(n) " - << to_string(asset) << "."; - return tecPRECISION_LOSS; - } + JLOG(ctx.j.warn()) << field.f->getName() << " (" << *value + << ") can not be represented as a(n) " + << to_string(asset) << "."; + return tecPRECISION_LOSS; } } @@ -383,7 +393,7 @@ LoanSet::doApply() auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); - auto const vaultScale = getVaultScale(vaultSle); + auto const vaultScale = getAssetsTotalScale(vaultSle); if (vaultAvailableProxy < principalRequested) { JLOG(j_.warn()) @@ -406,21 +416,33 @@ LoanSet::doApply() TenthBips16{brokerSle->at(sfManagementFeeRate)}, vaultScale); + LoanState const state = constructLoanState( + properties.loanState.valueOutstanding, + principalRequested, + properties.loanState.managementFeeDue); + + auto const vaultMaximum = *vaultSle->at(sfAssetsMaximum); + XRPL_ASSERT_PARTS( + vaultMaximum == 0 || vaultMaximum > *vaultTotalProxy, + "xrpl::LoanSet::doApply", + "Vault is below maximum limit"); + if (vaultMaximum != 0 && state.interestDue > vaultMaximum - vaultTotalProxy) + { + JLOG(j_.warn()) << "Loan would exceed the maximum assets of the vault"; + return tecLIMIT_EXCEEDED; + } // Check that relevant values won't lose precision. This is mostly only // relevant for IOU assets. + for (auto const& field : getValueFields()) { - for (auto const& field : getValueFields()) + if (auto const value = tx[field]; + value && !isRounded(vaultAsset, *value, properties.loanScale)) { - if (auto const value = tx[field]; - value && !isRounded(vaultAsset, *value, properties.loanScale)) - { - JLOG(j_.warn()) - << field.f->getName() << " (" << *value - << ") has too much precision. Total loan value is " - << properties.totalValueOutstanding << " with a scale of " - << properties.loanScale; - return tecPRECISION_LOSS; - } + JLOG(j_.warn()) << field.f->getName() << " (" << *value + << ") has too much precision. Total loan value is " + << properties.loanState.valueOutstanding + << " with a scale of " << properties.loanScale; + return tecPRECISION_LOSS; } } @@ -434,22 +456,20 @@ LoanSet::doApply() return ret; // Check that the other computed values are valid - if (properties.managementFeeOwedToBroker < 0 || - properties.totalValueOutstanding <= 0 || + if (properties.loanState.managementFeeDue < 0 || + properties.loanState.valueOutstanding <= 0 || properties.periodicPayment <= 0) { // LCOV_EXCL_START JLOG(j_.warn()) - << "Computed loan properties are invalid. Does not compute."; + << "Computed loan properties are invalid. Does not compute." + << " Management fee: " << properties.loanState.managementFeeDue + << ". Total Value: " << properties.loanState.valueOutstanding + << ". PeriodicPayment: " << properties.periodicPayment; return tecINTERNAL; // LCOV_EXCL_STOP } - LoanState const state = constructLoanState( - properties.totalValueOutstanding, - principalRequested, - properties.managementFeeOwedToBroker); - auto const originationFee = tx[~sfLoanOriginationFee].value_or(Number{}); auto const loanAssetsToBorrower = principalRequested - originationFee; @@ -538,12 +558,12 @@ LoanSet::doApply() // ignore tecDUPLICATE. That means the holding already exists, // and is fine here return ter; - - if (auto const ter = requireAuth( - view, vaultAsset, brokerOwner, AuthType::StrongAuth)) - return ter; } + if (auto const ter = + requireAuth(view, vaultAsset, brokerOwner, AuthType::StrongAuth)) + return ter; + auto const sponsorAccount = getTxReserveSponsorAccountID(tx); if (auto const ter = accountSendMulti( @@ -595,9 +615,10 @@ LoanSet::doApply() // Set dynamic / computed fields to their initial values loan->at(sfPrincipalOutstanding) = principalRequested; loan->at(sfPeriodicPayment) = properties.periodicPayment; - loan->at(sfTotalValueOutstanding) = properties.totalValueOutstanding; - loan->at(sfManagementFeeOutstanding) = properties.managementFeeOwedToBroker; - loan->at(sfPreviousPaymentDate) = 0; + loan->at(sfTotalValueOutstanding) = properties.loanState.valueOutstanding; + loan->at(sfManagementFeeOutstanding) = + properties.loanState.managementFeeDue; + loan->at(sfPreviousPaymentDueDate) = 0; loan->at(sfNextPaymentDueDate) = startDate + paymentInterval; loan->at(sfPaymentRemaining) = paymentTotal; view.insert(loan); @@ -631,6 +652,10 @@ LoanSet::doApply() if (auto const ter = dirLink(view, borrower, loan, sfOwnerNode)) return ter; + associateAsset(*vaultSle, vaultAsset); + associateAsset(*brokerSle, vaultAsset); + associateAsset(*loan, vaultAsset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/Transactor.cpp b/src/xrpld/app/tx/detail/Transactor.cpp index 31a38ec1aa..90032470e4 100644 --- a/src/xrpld/app/tx/detail/Transactor.cpp +++ b/src/xrpld/app/tx/detail/Transactor.cpp @@ -235,8 +235,14 @@ Transactor::preflight2(PreflightContext const& ctx) // regardless of success or failure return *ret; + // It should be impossible for the InnerBatchTxn flag to be set without + // featureBatch being enabled + XRPL_ASSERT_PARTS( + !ctx.tx.isFlag(tfInnerBatchTxn) || ctx.rules.enabled(featureBatch), + "xrpl::Transactor::preflight2", + "InnerBatch flag only set if feature enabled"); // Skip signature check on batch inner transactions - if (ctx.tx.isFlag(tfInnerBatchTxn) && !ctx.rules.enabled(featureBatch)) + if (ctx.tx.isFlag(tfInnerBatchTxn) && ctx.rules.enabled(featureBatch)) return tesSUCCESS; // Do not add any checks after this point that are relevant for // batch inner transactions. They will be skipped. @@ -1293,6 +1299,10 @@ Transactor::operator()() { JLOG(j_.trace()) << "apply: " << ctx_.tx.getTransactionID(); + // These global updates really should have been for every Transaction + // step: preflight, preclaim, and doApply. And even calculateBaseFee. See + // with_txn_type(). + // // raii classes for the current ledger rules. // fixUniversalNumber predate the rulesGuard and should be replaced. NumberSO stNumberSO{view().rules().enabled(fixUniversalNumber)}; @@ -1309,7 +1319,7 @@ Transactor::operator()() { // LCOV_EXCL_START JLOG(j_.fatal()) << "Transaction serdes mismatch"; - JLOG(j_.info()) << to_string(ctx_.tx.getJson(JsonOptions::none)); + JLOG(j_.fatal()) << ctx_.tx.getJson(JsonOptions::none); JLOG(j_.fatal()) << s2.getJson(JsonOptions::none); UNREACHABLE( "xrpl::Transactor::operator() : transaction serdes mismatch"); diff --git a/src/xrpld/app/tx/detail/VaultClawback.cpp b/src/xrpld/app/tx/detail/VaultClawback.cpp index 44c974fdc5..5adfe2b075 100644 --- a/src/xrpld/app/tx/detail/VaultClawback.cpp +++ b/src/xrpld/app/tx/detail/VaultClawback.cpp @@ -1,18 +1,18 @@ #include - +// #include #include #include -#include #include #include #include #include +#include #include -#include + +#include namespace xrpl { - NotTEC VaultClawback::preflight(PreflightContext const& ctx) { @@ -22,15 +22,6 @@ VaultClawback::preflight(PreflightContext const& ctx) return temMALFORMED; } - AccountID const issuer = ctx.tx[sfAccount]; - AccountID const holder = ctx.tx[sfHolder]; - - if (issuer == holder) - { - JLOG(ctx.j.debug()) << "VaultClawback: issuer cannot be holder."; - return temMALFORMED; - } - auto const amount = ctx.tx[~sfAmount]; if (amount) { @@ -42,17 +33,27 @@ VaultClawback::preflight(PreflightContext const& ctx) JLOG(ctx.j.debug()) << "VaultClawback: cannot clawback XRP."; return temMALFORMED; } - else if (amount->asset().getIssuer() != issuer) - { - JLOG(ctx.j.debug()) - << "VaultClawback: only asset issuer can clawback."; - return temMALFORMED; - } } return tesSUCCESS; } +[[nodiscard]] STAmount +clawbackAmount( + std::shared_ptr const& vault, + std::optional const& maybeAmount, + AccountID const& account) +{ + if (maybeAmount) + return *maybeAmount; + + Asset const share = MPTIssue{vault->at(sfShareMPTID)}; + if (account == vault->at(sfOwner)) + return STAmount{share}; + + return STAmount{vault->at(sfAsset)}; +} + TER VaultClawback::preclaim(PreclaimContext const& ctx) { @@ -60,61 +61,264 @@ VaultClawback::preclaim(PreclaimContext const& ctx) if (!vault) return tecNO_ENTRY; - auto account = ctx.tx[sfAccount]; - auto const issuer = ctx.view.read(keylet::account(account)); - if (!issuer) + Asset const vaultAsset = vault->at(sfAsset); + auto const account = ctx.tx[sfAccount]; + auto const holder = ctx.tx[sfHolder]; + auto const maybeAmount = ctx.tx[~sfAmount]; + auto const mptIssuanceID = vault->at(sfShareMPTID); + auto const sleShareIssuance = + ctx.view.read(keylet::mptIssuance(mptIssuanceID)); + if (!sleShareIssuance) { // LCOV_EXCL_START - JLOG(ctx.j.error()) << "VaultClawback: missing issuer account."; + JLOG(ctx.j.error()) + << "VaultClawback: missing issuance of vault shares."; return tefINTERNAL; // LCOV_EXCL_STOP } - Asset const vaultAsset = vault->at(sfAsset); - if (auto const amount = ctx.tx[~sfAmount]; - amount && vaultAsset != amount->asset()) + Asset const share = MPTIssue{mptIssuanceID}; + + // Ambiguous case: If Issuer is Owner they must specify the asset + if (!maybeAmount && !vaultAsset.native() && + vaultAsset.getIssuer() == vault->at(sfOwner)) + { + JLOG(ctx.j.debug()) + << "VaultClawback: must specify amount when issuer is owner."; return tecWRONG_ASSET; - - if (vaultAsset.native()) - { - JLOG(ctx.j.debug()) << "VaultClawback: cannot clawback XRP."; - return tecNO_PERMISSION; // Cannot clawback XRP. - } - else if (vaultAsset.getIssuer() != account) - { - JLOG(ctx.j.debug()) << "VaultClawback: only asset issuer can clawback."; - return tecNO_PERMISSION; // Only issuers can clawback. } - if (vaultAsset.holds()) - { - auto const mpt = vaultAsset.get(); - auto const mptIssue = - ctx.view.read(keylet::mptIssuance(mpt.getMptID())); - if (mptIssue == nullptr) - return tecOBJECT_NOT_FOUND; + auto const amount = clawbackAmount(vault, maybeAmount, account); - std::uint32_t const issueFlags = mptIssue->getFieldU32(sfFlags); - if (!(issueFlags & lsfMPTCanClawback)) + // There is a special case that allows the VaultOwner to use clawback to + // burn shares when Vault assets total and available are zero, but + // shares remain. However, that case is handled in doApply() directly, + // so here we just enforce checks. + if (amount.asset() == share) + { + // Only the Vault Owner may clawback shares + if (account != vault->at(sfOwner)) { JLOG(ctx.j.debug()) - << "VaultClawback: cannot clawback MPT vault asset."; + << "VaultClawback: only vault owner can clawback shares."; return tecNO_PERMISSION; } - } - else if (vaultAsset.holds()) - { - std::uint32_t const issuerFlags = issuer->getFieldU32(sfFlags); - if (!(issuerFlags & lsfAllowTrustLineClawback) || - (issuerFlags & lsfNoFreeze)) + + auto const assetsTotal = vault->at(sfAssetsTotal); + auto const assetsAvailable = vault->at(sfAssetsAvailable); + auto const sharesTotal = sleShareIssuance->at(sfOutstandingAmount); + + // Owner can clawback funds when the vault has shares but no assets + if (sharesTotal == 0 || (assetsTotal != 0 || assetsAvailable != 0)) { JLOG(ctx.j.debug()) - << "VaultClawback: cannot clawback IOU vault asset."; + << "VaultClawback: vault owner can clawback shares only" + " when vault has no assets."; return tecNO_PERMISSION; } + + // If amount is non-zero, the VaultOwner must burn all shares + if (amount != beast::zero) + { + Number const& sharesHeld = accountHolds( + ctx.view, + holder, + share, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + ctx.j); + + // The VaultOwner must burn all shares + if (amount != sharesHeld) + { + JLOG(ctx.j.debug()) + << "VaultClawback: vault owner must clawback all " + "shares."; + return tecLIMIT_EXCEEDED; + } + } + + return tesSUCCESS; } - return tesSUCCESS; + // The asset that is being clawed back is the vault asset + if (amount.asset() == vaultAsset) + { + // XRP cannot be clawed back + if (vaultAsset.native()) + { + JLOG(ctx.j.debug()) << "VaultClawback: cannot clawback XRP."; + return tecNO_PERMISSION; + } + + // Only the Asset Issuer may clawback the asset + if (account != vaultAsset.getIssuer()) + { + JLOG(ctx.j.debug()) + << "VaultClawback: only asset issuer can clawback asset."; + return tecNO_PERMISSION; + } + + // The issuer cannot clawback from itself + if (account == holder) + { + JLOG(ctx.j.debug()) + << "VaultClawback: issuer cannot be the holder."; + return tecNO_PERMISSION; + } + + return std::visit( + [&](TIss const& issue) -> TER { + if constexpr (std::is_same_v) + { + auto const mptIssue = + ctx.view.read(keylet::mptIssuance(issue.getMptID())); + if (mptIssue == nullptr) + return tecOBJECT_NOT_FOUND; + + std::uint32_t const issueFlags = + mptIssue->getFieldU32(sfFlags); + if (!(issueFlags & lsfMPTCanClawback)) + { + JLOG(ctx.j.debug()) << "VaultClawback: cannot clawback " + "MPT vault asset."; + return tecNO_PERMISSION; + } + } + else if constexpr (std::is_same_v) + { + auto const issuerSle = + ctx.view.read(keylet::account(account)); + if (!issuerSle) + { + // LCOV_EXCL_START + JLOG(ctx.j.error()) + << "VaultClawback: missing submitter account."; + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + std::uint32_t const issuerFlags = + issuerSle->getFieldU32(sfFlags); + if (!(issuerFlags & lsfAllowTrustLineClawback) || + (issuerFlags & lsfNoFreeze)) + { + JLOG(ctx.j.debug()) << "VaultClawback: cannot clawback " + "IOU vault asset."; + return tecNO_PERMISSION; + } + } + return tesSUCCESS; + }, + vaultAsset.value()); + } + + // Invalid asset + return tecWRONG_ASSET; +} + +Expected, TER> +VaultClawback::assetsToClawback( + std::shared_ptr const& vault, + std::shared_ptr const& sleShareIssuance, + AccountID const& holder, + STAmount const& clawbackAmount) +{ + if (clawbackAmount.asset() != vault->at(sfAsset)) + { + // preclaim should have blocked this , now it's an internal error + // LCOV_EXCL_START + JLOG(j_.error()) << "VaultClawback: asset mismatch in clawback."; + return Unexpected(tecINTERNAL); + // LCOV_EXCL_STOP + } + + auto const assetsAvailable = vault->at(sfAssetsAvailable); + auto const mptIssuanceID = *vault->at(sfShareMPTID); + MPTIssue const share{mptIssuanceID}; + + if (clawbackAmount == beast::zero) + { + auto const sharesDestroyed = accountHolds( + view(), + holder, + share, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j_); + auto const maybeAssets = + sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed); + if (!maybeAssets) + return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE + + return std::make_pair(*maybeAssets, sharesDestroyed); + } + + STAmount sharesDestroyed; + STAmount assetsRecovered = clawbackAmount; + try + { + { + auto const maybeShares = assetsToSharesWithdraw( + vault, sleShareIssuance, assetsRecovered); + if (!maybeShares) + return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE + sharesDestroyed = *maybeShares; + } + + auto const maybeAssets = + sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed); + if (!maybeAssets) + return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE + assetsRecovered = *maybeAssets; + + // Clamp to maximum. + if (assetsRecovered > *assetsAvailable) + { + assetsRecovered = *assetsAvailable; + // Note, it is important to truncate the number of shares, + // otherwise the corresponding assets might breach the + // AssetsAvailable + { + auto const maybeShares = assetsToSharesWithdraw( + vault, + sleShareIssuance, + assetsRecovered, + TruncateShares::yes); + if (!maybeShares) + return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE + sharesDestroyed = *maybeShares; + } + + auto const maybeAssets = sharesToAssetsWithdraw( + vault, sleShareIssuance, sharesDestroyed); + if (!maybeAssets) + return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE + assetsRecovered = *maybeAssets; + if (assetsRecovered > *assetsAvailable) + { + // LCOV_EXCL_START + JLOG(j_.error()) + << "VaultClawback: invalid rounding of shares."; + return Unexpected(tecINTERNAL); + // LCOV_EXCL_STOP + } + } + } + catch (std::overflow_error const&) + { + // It's easy to hit this exception from Number with large enough + // Scale so we avoid spamming the log and only use debug here. + JLOG(j_.debug()) // + << "VaultClawback: overflow error with" + << " scale=" << (int)vault->at(sfScale).value() // + << ", assetsTotal=" << vault->at(sfAssetsTotal).value() + << ", sharesTotal=" << sleShareIssuance->at(sfOutstandingAmount) + << ", amount=" << clawbackAmount.value(); + return Unexpected(tecPATH_DRY); + } + + return std::make_pair(assetsRecovered, sharesDestroyed); } TER @@ -125,7 +329,7 @@ VaultClawback::doApply() if (!vault) return tefINTERNAL; // LCOV_EXCL_LINE - auto const mptIssuanceID = *((*vault)[sfShareMPTID]); + auto const mptIssuanceID = *vault->at(sfShareMPTID); auto const sleIssuance = view().read(keylet::mptIssuance(mptIssuanceID)); if (!sleIssuance) { @@ -134,105 +338,47 @@ VaultClawback::doApply() return tefINTERNAL; // LCOV_EXCL_STOP } + MPTIssue const share{mptIssuanceID}; Asset const vaultAsset = vault->at(sfAsset); - STAmount const amount = [&]() -> STAmount { - auto const maybeAmount = tx[~sfAmount]; - if (maybeAmount) - return *maybeAmount; - return {sfAmount, vaultAsset, 0}; - }(); - XRPL_ASSERT( - amount.asset() == vaultAsset, - "xrpl::VaultClawback::doApply : matching asset"); + STAmount const amount = clawbackAmount(vault, tx[~sfAmount], account_); auto assetsAvailable = vault->at(sfAssetsAvailable); auto assetsTotal = vault->at(sfAssetsTotal); + [[maybe_unused]] auto const lossUnrealized = vault->at(sfLossUnrealized); XRPL_ASSERT( lossUnrealized <= (assetsTotal - assetsAvailable), "xrpl::VaultClawback::doApply : loss and assets do balance"); AccountID holder = tx[sfHolder]; - MPTIssue const share{mptIssuanceID}; STAmount sharesDestroyed = {share}; - STAmount assetsRecovered; - try + STAmount assetsRecovered = {vault->at(sfAsset)}; + + // The Owner is burning shares + if (account_ == vault->at(sfOwner) && amount.asset() == share) { - if (amount == beast::zero) - { - sharesDestroyed = accountHolds( - view(), - holder, - share, - FreezeHandling::fhIGNORE_FREEZE, - AuthHandling::ahIGNORE_AUTH, - j_); - - auto const maybeAssets = - sharesToAssetsWithdraw(vault, sleIssuance, sharesDestroyed); - if (!maybeAssets) - return tecINTERNAL; // LCOV_EXCL_LINE - assetsRecovered = *maybeAssets; - } - else - { - assetsRecovered = amount; - { - auto const maybeShares = - assetsToSharesWithdraw(vault, sleIssuance, assetsRecovered); - if (!maybeShares) - return tecINTERNAL; // LCOV_EXCL_LINE - sharesDestroyed = *maybeShares; - } - - auto const maybeAssets = - sharesToAssetsWithdraw(vault, sleIssuance, sharesDestroyed); - if (!maybeAssets) - return tecINTERNAL; // LCOV_EXCL_LINE - assetsRecovered = *maybeAssets; - } - - // Clamp to maximum. - if (assetsRecovered > *assetsAvailable) - { - assetsRecovered = *assetsAvailable; - // Note, it is important to truncate the number of shares, otherwise - // the corresponding assets might breach the AssetsAvailable - { - auto const maybeShares = assetsToSharesWithdraw( - vault, sleIssuance, assetsRecovered, TruncateShares::yes); - if (!maybeShares) - return tecINTERNAL; // LCOV_EXCL_LINE - sharesDestroyed = *maybeShares; - } - - auto const maybeAssets = - sharesToAssetsWithdraw(vault, sleIssuance, sharesDestroyed); - if (!maybeAssets) - return tecINTERNAL; // LCOV_EXCL_LINE - assetsRecovered = *maybeAssets; - if (assetsRecovered > *assetsAvailable) - { - // LCOV_EXCL_START - JLOG(j_.error()) - << "VaultClawback: invalid rounding of shares."; - return tecINTERNAL; - // LCOV_EXCL_STOP - } - } + sharesDestroyed = accountHolds( + view(), + holder, + share, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j_); } - catch (std::overflow_error const&) + else // The Issuer is clawbacking vault assets { - // It's easy to hit this exception from Number with large enough Scale - // so we avoid spamming the log and only use debug here. - JLOG(j_.debug()) // - << "VaultClawback: overflow error with" - << " scale=" << (int)vault->at(sfScale).value() // - << ", assetsTotal=" << vault->at(sfAssetsTotal).value() - << ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount) - << ", amount=" << amount.value(); - return tecPATH_DRY; + XRPL_ASSERT( + amount.asset() == vaultAsset, + "xrpl::VaultClawback::doApply : matching asset"); + + auto const clawbackParts = + assetsToClawback(vault, sleIssuance, holder, amount); + if (!clawbackParts) + return clawbackParts.error(); + + assetsRecovered = clawbackParts->first; + sharesDestroyed = clawbackParts->second; } if (sharesDestroyed == beast::zero) @@ -283,33 +429,39 @@ VaultClawback::doApply() // else quietly ignore, holder balance is not zero } - // Transfer assets from vault to issuer. - if (auto const ter = accountSend( - view(), - vaultAccount, - account_, - assetsRecovered, - j_, - std::nullopt, - WaiveTransferFee::Yes); - !isTesSuccess(ter)) - return ter; - - // Sanity check - if (accountHolds( - view(), - vaultAccount, - assetsRecovered.asset(), - FreezeHandling::fhIGNORE_FREEZE, - AuthHandling::ahIGNORE_AUTH, - j_) < beast::zero) + if (assetsRecovered > beast::zero) { - // LCOV_EXCL_START - JLOG(j_.error()) << "VaultClawback: negative balance of vault assets."; - return tefINTERNAL; - // LCOV_EXCL_STOP + // Transfer assets from vault to issuer. + if (auto const ter = accountSend( + view(), + vaultAccount, + account_, + assetsRecovered, + j_, + std::nullopt, + WaiveTransferFee::Yes); + !isTesSuccess(ter)) + return ter; + + // Sanity check + if (accountHolds( + view(), + vaultAccount, + assetsRecovered.asset(), + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j_) < beast::zero) + { + // LCOV_EXCL_START + JLOG(j_.error()) + << "VaultClawback: negative balance of vault assets."; + return tefINTERNAL; + // LCOV_EXCL_STOP + } } + associateAsset(*vault, vaultAsset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/VaultClawback.h b/src/xrpld/app/tx/detail/VaultClawback.h index 80a5f73ad0..d05f280e75 100644 --- a/src/xrpld/app/tx/detail/VaultClawback.h +++ b/src/xrpld/app/tx/detail/VaultClawback.h @@ -22,6 +22,14 @@ public: TER doApply() override; + +private: + Expected, TER> + assetsToClawback( + std::shared_ptr const& vault, + std::shared_ptr const& sleShareIssuance, + AccountID const& holder, + STAmount const& clawbackAmount); }; } // namespace xrpl diff --git a/src/xrpld/app/tx/detail/VaultCreate.cpp b/src/xrpld/app/tx/detail/VaultCreate.cpp index a4a69fe689..7cc4d7ba07 100644 --- a/src/xrpld/app/tx/detail/VaultCreate.cpp +++ b/src/xrpld/app/tx/detail/VaultCreate.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -249,6 +250,8 @@ VaultCreate::doApply() return err; } + associateAsset(*vault, asset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/VaultDelete.cpp b/src/xrpld/app/tx/detail/VaultDelete.cpp index d7234efd44..54e1157384 100644 --- a/src/xrpld/app/tx/detail/VaultDelete.cpp +++ b/src/xrpld/app/tx/detail/VaultDelete.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -85,6 +86,7 @@ VaultDelete::doApply() // Destroy the asset holding. auto asset = vault->at(sfAsset); + if (auto ter = removeEmptyHolding( view(), ctx_.tx, vault->at(sfAccount), asset, j_); !isTesSuccess(ter)) @@ -207,6 +209,8 @@ VaultDelete::doApply() // Destroy the vault. view().erase(vault); + associateAsset(*vault, asset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/VaultDeposit.cpp b/src/xrpld/app/tx/detail/VaultDeposit.cpp index ae14f6800c..31947250af 100644 --- a/src/xrpld/app/tx/detail/VaultDeposit.cpp +++ b/src/xrpld/app/tx/detail/VaultDeposit.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -115,16 +116,14 @@ VaultDeposit::preclaim(PreclaimContext const& ctx) !isTesSuccess(ter)) return ter; - // Asset issuer does not have any balance, they can just create funds by - // depositing in the vault. - if ((vaultAsset.native() || vaultAsset.getIssuer() != account) && - accountHolds( + if (accountHolds( ctx.view, account, vaultAsset, FreezeHandling::fhZERO_IF_FROZEN, AuthHandling::ahZERO_IF_UNAUTHORIZED, - ctx.j) < assets) + ctx.j, + SpendableHandling::shFULL_BALANCE) < assets) return tecINSUFFICIENT_FUNDS; return tesSUCCESS; @@ -136,6 +135,7 @@ VaultDeposit::doApply() auto const vault = view().peek(keylet::vault(ctx_.tx[sfVaultID])); if (!vault) return tefINTERNAL; // LCOV_EXCL_LINE + auto const vaultAsset = vault->at(sfAsset); auto const amount = ctx_.tx[sfAmount]; // Make sure the depositor can hold shares. @@ -295,6 +295,8 @@ VaultDeposit::doApply() !isTesSuccess(ter)) return ter; + associateAsset(*vault, vaultAsset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/VaultSet.cpp b/src/xrpld/app/tx/detail/VaultSet.cpp index 648ac12c3d..13c8ad5db8 100644 --- a/src/xrpld/app/tx/detail/VaultSet.cpp +++ b/src/xrpld/app/tx/detail/VaultSet.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -128,6 +129,8 @@ VaultSet::doApply() if (!vault) return tefINTERNAL; // LCOV_EXCL_LINE + auto const vaultAsset = vault->at(sfAsset); + auto const mptIssuanceID = (*vault)[sfShareMPTID]; auto const sleIssuance = view().peek(keylet::mptIssuance(mptIssuanceID)); if (!sleIssuance) @@ -172,6 +175,8 @@ VaultSet::doApply() // to verify the operation. view().update(vault); + associateAsset(*vault, vaultAsset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/VaultWithdraw.cpp b/src/xrpld/app/tx/detail/VaultWithdraw.cpp index 94a04e8b8c..da2d908697 100644 --- a/src/xrpld/app/tx/detail/VaultWithdraw.cpp +++ b/src/xrpld/app/tx/detail/VaultWithdraw.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -115,6 +116,7 @@ VaultWithdraw::doApply() auto const amount = ctx_.tx[sfAmount]; Asset const vaultAsset = vault->at(sfAsset); + MPTIssue const share{mptIssuanceID}; STAmount sharesRedeemed = {share}; STAmount assetsWithdrawn; @@ -242,6 +244,8 @@ VaultWithdraw::doApply() auto const dstAcct = ctx_.tx[~sfDestination].value_or(account_); + associateAsset(*vault, vaultAsset); + return doWithdraw( view(), ctx_.tx, diff --git a/src/xrpld/app/tx/detail/apply.cpp b/src/xrpld/app/tx/detail/apply.cpp index 5209c46f8f..a75f0cc967 100644 --- a/src/xrpld/app/tx/detail/apply.cpp +++ b/src/xrpld/app/tx/detail/apply.cpp @@ -41,15 +41,22 @@ checkValidity( Validity::SigBad, "Malformed: Invalid inner batch transaction."}; - std::string reason; - if (!passesLocalChecks(tx, reason)) + // This block should probably have never been included in the + // original `Batch` implementation. An inner transaction never + // has a valid signature. + bool const neverValid = rules.enabled(fixBatchInnerSigs); + if (!neverValid) { - router.setFlags(id, SF_LOCALBAD); - return {Validity::SigGoodOnly, reason}; - } + std::string reason; + if (!passesLocalChecks(tx, reason)) + { + router.setFlags(id, SF_LOCALBAD); + return {Validity::SigGoodOnly, reason}; + } - router.setFlags(id, SF_SIGGOOD); - return {Validity::Valid, ""}; + router.setFlags(id, SF_SIGGOOD); + return {Validity::Valid, ""}; + } } if (any(flags & SF_SIGBAD)) diff --git a/src/xrpld/app/tx/detail/applySteps.cpp b/src/xrpld/app/tx/detail/applySteps.cpp index 4bb0d94092..994d434867 100644 --- a/src/xrpld/app/tx/detail/applySteps.cpp +++ b/src/xrpld/app/tx/detail/applySteps.cpp @@ -34,8 +34,38 @@ struct UnknownTxnType : std::exception // throw an "UnknownTxnType" exception on error template auto -with_txn_type(TxType txnType, F&& f) +with_txn_type(Rules const& rules, TxType txnType, F&& f) { + // These global updates really should have been for every Transaction + // step: preflight, preclaim, calculateBaseFee, and doApply. Unfortunately, + // they were only included in doApply (via Transactor::operator()). That may + // have been sufficient when the changes were only related to operations + // that mutated data, but some features will now change how they read data, + // so these need to be more global. + // + // To prevent unintentional side effects on existing checks, they will be + // set for every operation only once SingleAssetVault (or later + // LendingProtocol) are enabled. + // + // See also Transactor::operator(). + // + std::optional stNumberSO; + std::optional rulesGuard; + std::optional mantissaScaleGuard; + if (rules.enabled(featureSingleAssetVault) || + rules.enabled(featureLendingProtocol)) + { + // raii classes for the current ledger rules. + // fixUniversalNumber predates the rulesGuard and should be replaced. + stNumberSO.emplace(rules.enabled(fixUniversalNumber)); + rulesGuard.emplace(rules); + } + else + { + // Without those features enabled, always use the old number rules. + mantissaScaleGuard.emplace(MantissaRange::small); + } + switch (txnType) { #pragma push_macro("TRANSACTION") @@ -99,7 +129,7 @@ invoke_preflight(PreflightContext const& ctx) { try { - return with_txn_type(ctx.tx.getTxnType(), [&]() { + return with_txn_type(ctx.rules, ctx.tx.getTxnType(), [&]() { auto const tec = Transactor::invokePreflight(ctx); return std::make_pair( tec, @@ -126,54 +156,55 @@ invoke_preclaim(PreclaimContext const& ctx) { // use name hiding to accomplish compile-time polymorphism of static // class functions for Transactor and derived classes. - return with_txn_type(ctx.tx.getTxnType(), [&]() -> TER { - // preclaim functionality is divided into two sections: - // 1. Up to and including the signature check: returns NotTEC. - // All transaction checks before and including checkSign - // MUST return NotTEC, or something more restrictive. - // Allowing tec results in these steps risks theft or - // destruction of funds, as a fee will be charged before the - // signature is checked. - // 2. After the signature check: returns TER. + return with_txn_type( + ctx.view.rules(), ctx.tx.getTxnType(), [&]() -> TER { + // preclaim functionality is divided into two sections: + // 1. Up to and including the signature check: returns NotTEC. + // All transaction checks before and including checkSign + // MUST return NotTEC, or something more restrictive. + // Allowing tec results in these steps risks theft or + // destruction of funds, as a fee will be charged before the + // signature is checked. + // 2. After the signature check: returns TER. - // If the transactor requires a valid account and the - // transaction doesn't list one, preflight will have already - // a flagged a failure. - auto const id = ctx.tx.getAccountID(sfAccount); + // If the transactor requires a valid account and the + // transaction doesn't list one, preflight will have already + // a flagged a failure. + auto const id = ctx.tx.getAccountID(sfAccount); - if (id != beast::zero) - { - if (NotTEC const preSigResult = [&]() -> NotTEC { - if (NotTEC const result = - T::checkSeqProxy(ctx.view, ctx.tx, ctx.j)) - return result; + if (id != beast::zero) + { + if (NotTEC const preSigResult = [&]() -> NotTEC { + if (NotTEC const result = + T::checkSeqProxy(ctx.view, ctx.tx, ctx.j)) + return result; - if (NotTEC const result = - T::checkPriorTxAndLastLedger(ctx)) - return result; + if (NotTEC const result = + T::checkPriorTxAndLastLedger(ctx)) + return result; - if (NotTEC const result = - T::checkSponsor(ctx.view, ctx.tx)) - return result; + if (NotTEC const result = + T::checkSponsor(ctx.view, ctx.tx)) + return result; - if (NotTEC const result = - T::checkPermission(ctx.view, ctx.tx)) - return result; + if (NotTEC const result = + T::checkPermission(ctx.view, ctx.tx)) + return result; - if (NotTEC const result = T::checkSign(ctx)) - return result; + if (NotTEC const result = T::checkSign(ctx)) + return result; - return tesSUCCESS; - }()) - return preSigResult; + return tesSUCCESS; + }()) + return preSigResult; - if (TER const result = - T::checkFee(ctx, calculateBaseFee(ctx.view, ctx.tx))) - return result; - } + if (TER const result = T::checkFee( + ctx, calculateBaseFee(ctx.view, ctx.tx))) + return result; + } - return T::preclaim(ctx); - }); + return T::preclaim(ctx); + }); } catch (UnknownTxnType const& e) { @@ -208,7 +239,7 @@ invoke_calculateBaseFee(ReadView const& view, STTx const& tx) { try { - return with_txn_type(tx.getTxnType(), [&]() { + return with_txn_type(view.rules(), tx.getTxnType(), [&]() { return T::calculateBaseFee(view, tx); }); } @@ -267,10 +298,11 @@ invoke_apply(ApplyContext& ctx) { try { - return with_txn_type(ctx.tx.getTxnType(), [&]() { - T p(ctx); - return p(); - }); + return with_txn_type( + ctx.view().rules(), ctx.tx.getTxnType(), [&]() { + T p(ctx); + return p(); + }); } catch (UnknownTxnType const& e) { diff --git a/src/xrpld/rpc/handlers/LedgerEntry.cpp b/src/xrpld/rpc/handlers/LedgerEntry.cpp index 0e2f9da0a0..e6e27b67b6 100644 --- a/src/xrpld/rpc/handlers/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/LedgerEntry.cpp @@ -425,7 +425,7 @@ parseLoan(Json::Value const& params, Json::StaticString const fieldName) } auto const id = LedgerEntryHelpers::requiredUInt256( - params, jss::loan_broker_id, "malformedLoanBrokerID"); + params, jss::loan_broker_id, "malformedBroker"); if (!id) return Unexpected(id.error()); auto const seq = LedgerEntryHelpers::requiredUInt32(