diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py index c762d61ccd0..230c18c2dfc 100755 --- a/.github/scripts/strategy-matrix/generate.py +++ b/.github/scripts/strategy-matrix/generate.py @@ -130,8 +130,8 @@ def generate_strategy_matrix(all: bool, config: Config) -> list: if os['distro_name'] == 'rhel' and architecture['platform'] == 'linux/arm64': continue - # We skip all clang-20 on arm64 due to boost 1.86 build error - if f'{os['compiler_name']}-{os['compiler_version']}' == 'clang-20' and architecture['platform'] == 'linux/arm64': + # We skip all clang 20+ on arm64 due to Boost build error. + if f'{os['compiler_name']}-{os['compiler_version']}' in ['clang-20', 'clang-21'] and architecture['platform'] == 'linux/arm64': continue # Enable code coverage for Debian Bookworm using GCC 15 in Debug and no diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 85a78c96dca..748ee031c97 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -15,168 +15,196 @@ "distro_version": "bookworm", "compiler_name": "gcc", "compiler_version": "12", - "image_sha": "97ba375" + "image_sha": "0525eae" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "gcc", "compiler_version": "13", - "image_sha": "97ba375" + "image_sha": "0525eae" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "97ba375" + "image_sha": "0525eae" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "gcc", "compiler_version": "15", - "image_sha": "97ba375" + "image_sha": "0525eae" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "clang", "compiler_version": "16", - "image_sha": "97ba375" + "image_sha": "0525eae" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "clang", "compiler_version": "17", - "image_sha": "97ba375" + "image_sha": "0525eae" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "clang", "compiler_version": "18", - "image_sha": "97ba375" + "image_sha": "0525eae" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "clang", "compiler_version": "19", - "image_sha": "97ba375" + "image_sha": "0525eae" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "clang", "compiler_version": "20", - "image_sha": "97ba375" + "image_sha": "0525eae" + }, + { + "distro_name": "debian", + "distro_version": "trixie", + "compiler_name": "gcc", + "compiler_version": "14", + "image_sha": "0525eae" + }, + { + "distro_name": "debian", + "distro_version": "trixie", + "compiler_name": "gcc", + "compiler_version": "15", + "image_sha": "0525eae" + }, + { + "distro_name": "debian", + "distro_version": "trixie", + "compiler_name": "clang", + "compiler_version": "20", + "image_sha": "0525eae" + }, + { + "distro_name": "debian", + "distro_version": "trixie", + "compiler_name": "clang", + "compiler_version": "21", + "image_sha": "0525eae" }, { "distro_name": "rhel", "distro_version": "8", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "97ba375" + "image_sha": "e1782cd" }, { "distro_name": "rhel", "distro_version": "8", "compiler_name": "clang", "compiler_version": "any", - "image_sha": "97ba375" + "image_sha": "e1782cd" }, { "distro_name": "rhel", "distro_version": "9", "compiler_name": "gcc", "compiler_version": "12", - "image_sha": "97ba375" + "image_sha": "e1782cd" }, { "distro_name": "rhel", "distro_version": "9", "compiler_name": "gcc", "compiler_version": "13", - "image_sha": "97ba375" + "image_sha": "e1782cd" }, { "distro_name": "rhel", "distro_version": "9", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "97ba375" + "image_sha": "e1782cd" }, { "distro_name": "rhel", "distro_version": "9", "compiler_name": "clang", "compiler_version": "any", - "image_sha": "97ba375" + "image_sha": "e1782cd" }, { "distro_name": "rhel", "distro_version": "10", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "97ba375" + "image_sha": "e1782cd" }, { "distro_name": "rhel", "distro_version": "10", "compiler_name": "clang", "compiler_version": "any", - "image_sha": "97ba375" + "image_sha": "e1782cd" }, { "distro_name": "ubuntu", "distro_version": "jammy", "compiler_name": "gcc", "compiler_version": "12", - "image_sha": "97ba375" + "image_sha": "e1782cd" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "gcc", "compiler_version": "13", - "image_sha": "97ba375" + "image_sha": "e1782cd" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "97ba375" + "image_sha": "e1782cd" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "clang", "compiler_version": "16", - "image_sha": "97ba375" + "image_sha": "e1782cd" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "clang", "compiler_version": "17", - "image_sha": "97ba375" + "image_sha": "e1782cd" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "clang", "compiler_version": "18", - "image_sha": "97ba375" + "image_sha": "e1782cd" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "clang", "compiler_version": "19", - "image_sha": "97ba375" + "image_sha": "e1782cd" } ], "build_type": ["Debug", "Release"], diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml index 6d74486e96a..ae996fc183f 100644 --- a/.github/workflows/on-pr.yml +++ b/.github/workflows/on-pr.yml @@ -115,7 +115,7 @@ jobs: needs: - should-run - build-test - if: ${{ needs.should-run.outputs.go == 'true' && contains(fromJSON('["release", "master"]'), github.ref_name) }} + if: ${{ needs.should-run.outputs.go == 'true' && (startsWith(github.base_ref, 'release') || github.base_ref == 'master') }} uses: ./.github/workflows/reusable-notify-clio.yml secrets: clio_notify_token: ${{ secrets.CLIO_NOTIFY_TOKEN }} diff --git a/.github/workflows/on-trigger.yml b/.github/workflows/on-trigger.yml index 9df6417c077..b5a56fb6717 100644 --- a/.github/workflows/on-trigger.yml +++ b/.github/workflows/on-trigger.yml @@ -14,9 +14,7 @@ on: - "master" paths: # These paths are unique to `on-trigger.yml`. - - ".github/workflows/reusable-check-missing-commits.yml" - ".github/workflows/on-trigger.yml" - - ".github/workflows/publish-docs.yml" # Keep the paths below in sync with those in `on-pr.yml`. - ".github/actions/build-deps/**" @@ -63,10 +61,6 @@ defaults: shell: bash jobs: - check-missing-commits: - if: ${{ github.event_name == 'push' && github.ref_type == 'branch' && contains(fromJSON('["develop", "release"]'), github.ref_name) }} - uses: ./.github/workflows/reusable-check-missing-commits.yml - build-test: uses: ./.github/workflows/reusable-build-test.yml strategy: diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index 7032fa5a437..8ce810aa2ee 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -69,9 +69,9 @@ jobs: ENABLED_VOIDSTAR: ${{ contains(inputs.cmake_args, '-Dvoidstar=ON') }} ENABLED_COVERAGE: ${{ contains(inputs.cmake_args, '-Dcoverage=ON') }} steps: - - name: Cleanup workspace - if: ${{ runner.os == 'macOS' }} - uses: XRPLF/actions/.github/actions/cleanup-workspace@3f044c7478548e3c32ff68980eeb36ece02b364e + - name: Cleanup workspace (macOS and Windows) + if: ${{ runner.os == 'macOS' || runner.os == 'Windows' }} + uses: XRPLF/actions/.github/actions/cleanup-workspace@01b244d2718865d427b499822fbd3f15e7197fcc - name: Checkout repository uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 @@ -130,7 +130,7 @@ jobs: --target "${CMAKE_TARGET}" - name: Upload rippled artifact (Linux) - if: ${{ runner.os == 'Linux' }} + if: ${{ github.repository_owner == 'XRPLF' && runner.os == 'Linux' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 env: BUILD_DIR: ${{ inputs.build_dir }} diff --git a/.github/workflows/reusable-check-missing-commits.yml b/.github/workflows/reusable-check-missing-commits.yml deleted file mode 100644 index 07d29174d84..00000000000 --- a/.github/workflows/reusable-check-missing-commits.yml +++ /dev/null @@ -1,62 +0,0 @@ -# This workflow checks that all commits in the "master" branch are also in the -# "release" and "develop" branches, and that all commits in the "release" branch -# are also in the "develop" branch. -name: Check for missing commits - -# This workflow can only be triggered by other workflows. -on: workflow_call - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-missing-commits - cancel-in-progress: true - -defaults: - run: - shell: bash - -jobs: - check: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - with: - fetch-depth: 0 - - name: Check for missing commits - env: - MESSAGE: | - - If you are reading this, then the commits indicated above are missing - from the "develop" and/or "release" branch. Do a reverse-merge as soon - as possible. See CONTRIBUTING.md for instructions. - run: | - set -o pipefail - # Branches are ordered by how "canonical" they are. Every commit in one - # branch should be in all the branches behind it. - order=(master release develop) - branches=() - for branch in "${order[@]}"; do - # Check that the branches exist so that this job will work on forked - # repos, which don't necessarily have master and release branches. - echo "Checking if ${branch} exists." - if git ls-remote --exit-code --heads origin \ - refs/heads/${branch} > /dev/null; then - branches+=(origin/${branch}) - fi - done - - prior=() - for branch in "${branches[@]}"; do - if [[ ${#prior[@]} -ne 0 ]]; then - echo "Checking ${prior[@]} for commits missing from ${branch}." - git log --oneline --no-merges "${prior[@]}" \ - ^$branch | tee -a "missing-commits.txt" - echo - fi - prior+=("${branch}") - done - - if [[ $(cat missing-commits.txt | wc -l) -ne 0 ]]; then - echo "${MESSAGE}" - exit 1 - fi diff --git a/.github/workflows/upload-conan-deps.yml b/.github/workflows/upload-conan-deps.yml index 357d756fa73..320396c8997 100644 --- a/.github/workflows/upload-conan-deps.yml +++ b/.github/workflows/upload-conan-deps.yml @@ -62,9 +62,9 @@ jobs: runs-on: ${{ matrix.architecture.runner }} container: ${{ contains(matrix.architecture.platform, 'linux') && format('ghcr.io/xrplf/ci/{0}-{1}:{2}-{3}-sha-{4}', matrix.os.distro_name, matrix.os.distro_version, matrix.os.compiler_name, matrix.os.compiler_version, matrix.os.image_sha) || null }} steps: - - name: Cleanup workspace - if: ${{ runner.os == 'macOS' }} - uses: XRPLF/actions/.github/actions/cleanup-workspace@3f044c7478548e3c32ff68980eeb36ece02b364e + - name: Cleanup workspace (macOS and Windows) + if: ${{ runner.os == 'macOS' || runner.os == 'Windows' }} + uses: XRPLF/actions/.github/actions/cleanup-workspace@01b244d2718865d427b499822fbd3f15e7197fcc - name: Checkout repository uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 85568a8b2ed..a032fee75e4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,6 +34,5 @@ repos: exclude: | (?x)^( external/.*| - .github/scripts/levelization/results/.*\.txt| - conan\.lock + .github/scripts/levelization/results/.*\.txt )$ diff --git a/conan.lock b/conan.lock index ebdc9ad9a8a..20456e7fe10 100644 --- a/conan.lock +++ b/conan.lock @@ -53,4 +53,4 @@ ] }, "config_requires": [] -} \ No newline at end of file +} diff --git a/external/secp256k1/include/secp256k1.h b/external/secp256k1/include/secp256k1.h index c6e9417f055..e562cd00e81 100644 --- a/external/secp256k1/include/secp256k1.h +++ b/external/secp256k1/include/secp256k1.h @@ -541,7 +541,7 @@ SECP256K1_API int secp256k1_ecdsa_signature_serialize_compact( /** Verify an ECDSA signature. * * Returns: 1: correct signature - * 0: incorrect or unparseable signature + * 0: incorrect or unparsable signature * Args: ctx: pointer to a context object * In: sig: the signature being verified. * msghash32: the 32-byte message hash being verified. diff --git a/include/xrpl/basics/Number.h b/include/xrpl/basics/Number.h index 41c60d30a13..60ea23a0a58 100644 --- a/include/xrpl/basics/Number.h +++ b/include/xrpl/basics/Number.h @@ -32,6 +32,15 @@ class Number; std::string to_string(Number const& amount); +template +constexpr bool +isPowerOfTen(T value) +{ + while (value >= 10 && value % 10 == 0) + value /= 10; + return value == 1; +} + class Number { using rep = std::int64_t; @@ -41,7 +50,9 @@ class Number public: // The range for the mantissa when normalized constexpr static std::int64_t minMantissa = 1'000'000'000'000'000LL; - constexpr static std::int64_t maxMantissa = 9'999'999'999'999'999LL; + 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; @@ -151,22 +162,7 @@ class Number } Number - truncate() const noexcept - { - if (exponent_ >= 0 || mantissa_ == 0) - return *this; - - Number ret = *this; - while (ret.exponent_ < 0 && ret.mantissa_ != 0) - { - ret.exponent_ += 1; - ret.mantissa_ /= rep(10); - } - // We are guaranteed that normalize() will never throw an exception - // because exponent is either negative or zero at this point. - ret.normalize(); - return ret; - } + truncate() const noexcept; friend constexpr bool operator>(Number const& x, Number const& y) noexcept @@ -211,6 +207,8 @@ class Number class Guard; }; +constexpr static Number numZero{}; + inline constexpr Number::Number(rep mantissa, int exponent, unchecked) noexcept : mantissa_{mantissa}, exponent_{exponent} { diff --git a/include/xrpl/basics/SlabAllocator.h b/include/xrpl/basics/SlabAllocator.h index 5e3a2b5138e..577327f0ef8 100644 --- a/include/xrpl/basics/SlabAllocator.h +++ b/include/xrpl/basics/SlabAllocator.h @@ -176,7 +176,7 @@ class SlabAllocator @param count the number of items the slab allocator can allocate; note that a count of 0 is valid and means that the allocator is, effectively, disabled. This can be very useful in some - contexts (e.g. when mimimal memory usage is needed) and + contexts (e.g. when minimal memory usage is needed) and allows for graceful failure. */ constexpr explicit SlabAllocator( diff --git a/include/xrpl/basics/base_uint.h b/include/xrpl/basics/base_uint.h index b1a4622cc46..dc8c34abad3 100644 --- a/include/xrpl/basics/base_uint.h +++ b/include/xrpl/basics/base_uint.h @@ -565,7 +565,7 @@ operator<=>(base_uint const& lhs, base_uint const& rhs) // This comparison might seem wrong on a casual inspection because it // compares data internally stored as std::uint32_t byte-by-byte. But // note that the underlying data is stored in big endian, even if the - // plaform is little endian. This makes the comparison correct. + // platform is little endian. This makes the comparison correct. // // FIXME: use std::lexicographical_compare_three_way once support is // added to MacOS. diff --git a/include/xrpl/basics/comparators.h b/include/xrpl/basics/comparators.h index 0e5f11e9e5c..2a4d99ded95 100644 --- a/include/xrpl/basics/comparators.h +++ b/include/xrpl/basics/comparators.h @@ -28,7 +28,7 @@ namespace ripple { /* * MSVC 2019 version 16.9.0 added [[nodiscard]] to the std comparison - * operator() functions. boost::bimap checks that the comparitor is a + * operator() functions. boost::bimap checks that the comparator is a * BinaryFunction, in part by calling the function and ignoring the value. * These two things don't play well together. These wrapper classes simply * strip [[nodiscard]] from operator() for use in boost::bimap. diff --git a/include/xrpl/beast/unit_test/runner.h b/include/xrpl/beast/unit_test/runner.h index 977cc45035a..38bec785d05 100644 --- a/include/xrpl/beast/unit_test/runner.h +++ b/include/xrpl/beast/unit_test/runner.h @@ -42,7 +42,7 @@ class runner The argument string is available to suites and allows for customization of the test. Each suite - defines its own syntax for the argumnet string. + defines its own syntax for the argument string. The same argument is passed to all suites. */ void diff --git a/include/xrpl/beast/utility/instrumentation.h b/include/xrpl/beast/utility/instrumentation.h index 3594855eef6..cb7e3ffa0e4 100644 --- a/include/xrpl/beast/utility/instrumentation.h +++ b/include/xrpl/beast/utility/instrumentation.h @@ -32,7 +32,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. // The duplication is because Visual Studio 2019 cannot compile that header // even with the option -Zc:__cplusplus added. #define ALWAYS(cond, message, ...) assert((message) && (cond)) -#define ALWAYS_OR_UNREACHABLE(cond, message, ...) assert((message) && (cond)) +#define ALWAYS_OR_UNREACHABLE(cond, message) assert((message) && (cond)) #define SOMETIMES(cond, message, ...) #define REACHABLE(message, ...) #define UNREACHABLE(message, ...) assert((message) && false) diff --git a/include/xrpl/json/json_value.h b/include/xrpl/json/json_value.h index 272d12d680d..0c372699601 100644 --- a/include/xrpl/json/json_value.h +++ b/include/xrpl/json/json_value.h @@ -24,6 +24,7 @@ #include #include +#include #include #include #include @@ -158,9 +159,9 @@ class Value using ArrayIndex = UInt; static Value const null; - static Int const minInt; - static Int const maxInt; - static UInt const maxUInt; + static constexpr Int minInt = std::numeric_limits::min(); + static constexpr Int maxInt = std::numeric_limits::max(); + static constexpr UInt maxUInt = std::numeric_limits::max(); private: class CZString @@ -263,6 +264,10 @@ class Value bool asBool() const; + /** Correct absolute value from int or unsigned int */ + UInt + asAbsUInt() const; + // TODO: What is the "empty()" method this docstring mentions? /** isNull() tests to see if this field is null. Don't use this method to test for emptiness: use empty(). */ @@ -395,6 +400,9 @@ class Value /// Return true if the object has a member named key. bool isMember(std::string const& key) const; + /// Return true if the object has a member named key. + bool + isMember(StaticString const& key) const; /// \brief Return a list of the member names. /// diff --git a/include/xrpl/ledger/ApplyView.h b/include/xrpl/ledger/ApplyView.h index f90033966a5..c00d040e096 100644 --- a/include/xrpl/ledger/ApplyView.h +++ b/include/xrpl/ledger/ApplyView.h @@ -387,6 +387,45 @@ class ApplyView : public ReadView emptyDirDelete(Keylet const& directory); }; +namespace directory { +/** Helper functions for managing low-level directory operations. + These are not part of the ApplyView interface. + + Don't use them unless you really, really know what you're doing. + Instead use dirAdd, dirInsert, etc. + */ + +std::uint64_t +createRoot( + ApplyView& view, + Keylet const& directory, + uint256 const& key, + std::function const&)> const& describe); + +auto +findPreviousPage(ApplyView& view, Keylet const& directory, SLE::ref start); + +std::uint64_t +insertKey( + ApplyView& view, + SLE::ref node, + std::uint64_t page, + bool preserveOrder, + STVector256& indexes, + uint256 const& key); + +std::optional +insertPage( + ApplyView& view, + std::uint64_t page, + SLE::pointer node, + std::uint64_t nextPage, + SLE::ref next, + uint256 const& key, + Keylet const& directory, + std::function const&)> const& describe); + +} // namespace directory } // namespace ripple #endif diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 9698b4fda39..ece8ef84f98 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -242,6 +243,80 @@ isDeepFrozen( Currency const& currency, AccountID const& issuer); +[[nodiscard]] inline bool +isDeepFrozen( + ReadView const& view, + AccountID const& account, + Issue const& issue, + int = 0 /*ignored*/) +{ + return isDeepFrozen(view, account, issue.currency, issue.account); +} + +[[nodiscard]] inline bool +isDeepFrozen( + ReadView const& view, + AccountID const& account, + MPTIssue const& mptIssue, + int depth = 0) +{ + // Unlike IOUs, frozen / locked MPTs are not allowed to send or receive + // funds, so checking "deep frozen" is the same as checking "frozen". + return isFrozen(view, account, mptIssue, depth); +} + +/** + * isFrozen check is recursive for MPT shares in a vault, descending to + * assets in the vault, up to maxAssetCheckDepth recursion depth. This is + * purely defensive, as we currently do not allow such vaults to be created. + */ +[[nodiscard]] inline bool +isDeepFrozen( + ReadView const& view, + AccountID const& account, + Asset const& asset, + int depth = 0) +{ + return std::visit( + [&](auto const& issue) { + return isDeepFrozen(view, account, issue, depth); + }, + asset.value()); +} + +[[nodiscard]] inline TER +checkDeepFrozen( + ReadView const& view, + AccountID const& account, + Issue const& issue) +{ + return isDeepFrozen(view, account, issue) ? (TER)tecFROZEN + : (TER)tesSUCCESS; +} + +[[nodiscard]] inline TER +checkDeepFrozen( + ReadView const& view, + AccountID const& account, + MPTIssue const& mptIssue) +{ + return isDeepFrozen(view, account, mptIssue) ? (TER)tecLOCKED + : (TER)tesSUCCESS; +} + +[[nodiscard]] inline TER +checkDeepFrozen( + ReadView const& view, + AccountID const& account, + Asset const& asset) +{ + return std::visit( + [&](auto const& issue) { + return checkDeepFrozen(view, account, issue); + }, + asset.value()); +} + [[nodiscard]] bool isLPTokenFrozen( ReadView const& view, @@ -287,6 +362,49 @@ accountHolds( AuthHandling zeroIfUnauthorized, beast::Journal j); +// Returns the amount an account can spend total. +// +// 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 / +// issuance. +// +// <-- saAmount: amount of currency held by account. May be negative. +[[nodiscard]] STAmount +accountSpendable( + ReadView const& view, + AccountID const& account, + Currency const& currency, + AccountID const& issuer, + FreezeHandling zeroIfFrozen, + beast::Journal j); + +[[nodiscard]] STAmount +accountSpendable( + ReadView const& view, + AccountID const& account, + Issue const& issue, + FreezeHandling zeroIfFrozen, + beast::Journal j); + +[[nodiscard]] STAmount +accountSpendable( + ReadView const& view, + AccountID const& account, + MPTIssue const& mptIssue, + FreezeHandling zeroIfFrozen, + AuthHandling zeroIfUnauthorized, + beast::Journal j); + +[[nodiscard]] STAmount +accountSpendable( + ReadView const& view, + AccountID const& account, + Asset const& asset, + FreezeHandling zeroIfFrozen, + AuthHandling zeroIfUnauthorized, + beast::Journal j); + // 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 // question. Should be used in favor of accountHolds when questioning how much @@ -533,7 +651,11 @@ dirNext( describeOwnerDir(AccountID const& account); [[nodiscard]] TER -dirLink(ApplyView& view, AccountID const& owner, std::shared_ptr& object); +dirLink( + ApplyView& view, + AccountID const& owner, + std::shared_ptr& object, + SF_UINT64 const& node = sfOwnerNode); AccountID pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey); @@ -552,14 +674,17 @@ createPseudoAccount( uint256 const& pseudoOwnerKey, SField const& ownerField); -// Returns true iff sleAcct is a pseudo-account. +// Returns true iff sleAcct is a pseudo-account or specific +// pseudo-accounts in pseudoFieldFilter. // // Returns false if sleAcct is // * NOT a pseudo-account OR // * NOT a ltACCOUNT_ROOT OR // * null pointer [[nodiscard]] bool -isPseudoAccount(std::shared_ptr sleAcct); +isPseudoAccount( + std::shared_ptr sleAcct, + std::set const& pseudoFieldFilter = {}); // Returns the list of fields that define an ACCOUNT_ROOT as a pseudo-account if // set @@ -573,14 +698,91 @@ isPseudoAccount(std::shared_ptr sleAcct); getPseudoAccountFields(); [[nodiscard]] inline bool -isPseudoAccount(ReadView const& view, AccountID accountId) +isPseudoAccount( + ReadView const& view, + AccountID const& accountId, + std::set const& pseudoFieldFilter = {}) { - return isPseudoAccount(view.read(keylet::account(accountId))); + return isPseudoAccount( + view.read(keylet::account(accountId)), pseudoFieldFilter); } [[nodiscard]] TER canAddHolding(ReadView const& view, Asset const& asset); +/** Validates that the destination SLE and tag are valid + + - Checks that the SLE is not null. + - If the SLE requires a destination tag, checks that there is a tag. +*/ +[[nodiscard]] TER +checkDestinationAndTag(SLE::const_ref toSle, bool hasDestinationTag); + +/** Checks that can withdraw funds from an object to itself or a destination. + * + * The receiver may be either the submitting account (sfAccount) or a different + * destination account (sfDestination). + * + * - Checks that the receiver account exists. + * - If the receiver requires a destination tag, check that one exists, even + * if withdrawing to self. + * - If withdrawing to self, succeed. + * - If not, checks if the receiver requires deposit authorization, and if + * the sender has it. + */ +[[nodiscard]] TER +canWithdraw( + AccountID const& from, + ReadView const& view, + AccountID const& to, + SLE::const_ref toSle, + bool hasDestinationTag); + +/** Checks that can withdraw funds from an object to itself or a destination. + * + * The receiver may be either the submitting account (sfAccount) or a different + * destination account (sfDestination). + * + * - Checks that the receiver account exists. + * - If the receiver requires a destination tag, check that one exists, even + * if withdrawing to self. + * - If withdrawing to self, succeed. + * - If not, checks if the receiver requires deposit authorization, and if + * the sender has it. + */ +[[nodiscard]] TER +canWithdraw( + AccountID const& from, + ReadView const& view, + AccountID const& to, + bool hasDestinationTag); + +/** Checks that can withdraw funds from an object to itself or a destination. + * + * The receiver may be either the submitting account (sfAccount) or a different + * destination account (sfDestination). + * + * - Checks that the receiver account exists. + * - If the receiver requires a destination tag, check that one exists, even + * if withdrawing to self. + * - If withdrawing to self, succeed. + * - If not, checks if the receiver requires deposit authorization, and if + * the sender has it. + */ +[[nodiscard]] TER +canWithdraw(ReadView const& view, STTx const& tx); + +[[nodiscard]] TER +doWithdraw( + ApplyView& view, + STTx const& tx, + AccountID const& senderAcct, + AccountID const& dstAcct, + AccountID const& sourceAcct, + XRPAmount priorBalance, + STAmount const& amount, + beast::Journal j); + /// Any transactors that call addEmptyHolding() in doApply must call /// canAddHolding() in preflight with the same View and Asset [[nodiscard]] TER @@ -750,6 +952,22 @@ accountSend( beast::Journal j, WaiveTransferFee waiveFee = WaiveTransferFee::No); +using MultiplePaymentDestinations = std::vector>; +/** Like accountSend, except one account is sending multiple payments (with the + * same asset!) simultaneously + * + * Calls static accountSendMultiIOU if saAmount represents Issue. + * Calls static accountSendMultiMPT if saAmount represents MPTIssue. + */ +[[nodiscard]] TER +accountSendMulti( + ApplyView& view, + AccountID const& senderID, + Asset const& asset, + MultiplePaymentDestinations const& receivers, + beast::Journal j, + WaiveTransferFee waiveFee = WaiveTransferFee::No); + [[nodiscard]] TER issueIOU( ApplyView& view, @@ -821,7 +1039,8 @@ requireAuth( * purely defensive, as we currently do not allow such vaults to be created. * * If StrongAuth then return tecNO_AUTH if MPToken doesn't exist or - * lsfMPTRequireAuth is set and MPToken is not authorized. + * lsfMPTRequireAuth is set and MPToken is not authorized. Vault and LoanBroker + * pseudo-accounts are implicitly authorized. * * If WeakAuth then return tecNO_AUTH if lsfMPTRequireAuth is set and MPToken * doesn't exist or is not authorized (explicitly or via credentials, if @@ -894,6 +1113,26 @@ canTransfer( AccountID const& from, AccountID const& to); +[[nodiscard]] TER +canTransfer( + ReadView const& view, + Issue const& issue, + AccountID const& from, + AccountID const& to); + +[[nodiscard]] TER inline canTransfer( + ReadView const& view, + Asset const& asset, + AccountID const& from, + AccountID const& to) +{ + return std::visit( + [&](TIss const& issue) -> TER { + return canTransfer(view, issue, from, to); + }, + asset.value()); +} + /** Deleter function prototype. Returns the status of the entry deletion * (if should not be skipped) and if the entry should be skipped. The status * is always tesSUCCESS if the entry should be skipped. diff --git a/include/xrpl/protocol/Asset.h b/include/xrpl/protocol/Asset.h index 44381067389..8872989c887 100644 --- a/include/xrpl/protocol/Asset.h +++ b/include/xrpl/protocol/Asset.h @@ -100,7 +100,27 @@ class Asset bool native() const { - return holds() && get().native(); + return std::visit( + [&](TIss const& issue) { + if constexpr (std::is_same_v) + return issue.native(); + if constexpr (std::is_same_v) + return false; + }, + issue_); + } + + bool + integral() const + { + return std::visit( + [&](TIss const& issue) { + if constexpr (std::is_same_v) + return issue.native(); + if constexpr (std::is_same_v) + return true; + }, + issue_); } friend constexpr bool diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index 79be15d9065..f74287f9a5c 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -346,6 +346,24 @@ vault(uint256 const& vaultKey) return {ltVAULT, vaultKey}; } +Keylet +loanbroker(AccountID const& owner, std::uint32_t seq) noexcept; + +inline Keylet +loanbroker(uint256 const& key) +{ + return {ltLOAN_BROKER, key}; +} + +Keylet +loan(uint256 const& loanBrokerID, std::uint32_t loanSeq) noexcept; + +inline Keylet +loan(uint256 const& key) +{ + return {ltLOAN, key}; +} + Keylet permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept; diff --git a/include/xrpl/protocol/LedgerFormats.h b/include/xrpl/protocol/LedgerFormats.h index ff4653e5c7d..4041023f7c7 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -205,6 +205,11 @@ enum LedgerSpecificFlags { // ltVAULT lsfVaultPrivate = 0x00010000, + + // ltLOAN + lsfLoanDefault = 0x00010000, + lsfLoanImpaired = 0x00020000, + lsfLoanOverpayment = 0x00040000, // True, loan allows overpayments }; //------------------------------------------------------------------------------ diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index b3e7086d038..3465618c44b 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -22,6 +22,7 @@ #include #include +#include #include @@ -84,6 +85,140 @@ std::size_t constexpr maxDeletableTokenOfferEntries = 500; */ std::uint16_t constexpr maxTransferFee = 50000; +/** There are 10,000 basis points (bips) in 100%. + * + * Basis points represent 0.01%. + * + * Given a value X, to find the amount for B bps, + * use X * B / bipsPerUnity + * + * Example: If a loan broker has 999 XRP of debt, and must maintain 1,000 bps of + * that debt as cover (10%), then the minimum cover amount is 999,000,000 drops + * * 1000 / bipsPerUnity = 99,900,00 drops or 99.9 XRP. + * + * Given a percentage P, to find the number of bps that percentage represents, + * use P * bipsPerUnity. + * + * Example: 50% is 0.50 * bipsPerUnity = 5,000 bps. + */ +Bips32 constexpr bipsPerUnity(100 * 100); +static_assert(bipsPerUnity == Bips32{10'000}); +TenthBips32 constexpr tenthBipsPerUnity(bipsPerUnity.value() * 10); +static_assert(tenthBipsPerUnity == TenthBips32(100'000)); + +constexpr Bips32 +percentageToBips(std::uint32_t percentage) +{ + return Bips32(percentage * bipsPerUnity.value() / 100); +} +constexpr TenthBips32 +percentageToTenthBips(std::uint32_t percentage) +{ + return TenthBips32(percentage * tenthBipsPerUnity.value() / 100); +} +template +constexpr T +bipsOfValue(T value, Bips bips) +{ + return value * bips.value() / bipsPerUnity.value(); +} +template +constexpr T +tenthBipsOfValue(T value, TenthBips bips) +{ + return value * bips.value() / tenthBipsPerUnity.value(); +} + +namespace Lending { +/** The maximum management fee rate allowed by a loan broker in 1/10 bips. + + Valid values are between 0 and 10% inclusive. +*/ +TenthBips16 constexpr maxManagementFeeRate( + unsafe_cast(percentageToTenthBips(10).value())); +static_assert(maxManagementFeeRate == TenthBips16(std::uint16_t(10'000u))); + +/** The maximum coverage rate required of a loan broker in 1/10 bips. + + Valid values are between 0 and 100% inclusive. +*/ +TenthBips32 constexpr maxCoverRate = percentageToTenthBips(100); +static_assert(maxCoverRate == TenthBips32(100'000u)); + +/** The maximum overpayment fee on a loan in 1/10 bips. +* + Valid values are between 0 and 100% inclusive. +*/ +TenthBips32 constexpr maxOverpaymentFee = percentageToTenthBips(100); +static_assert(maxOverpaymentFee == TenthBips32(100'000u)); + +/** Annualized interest rate of the Loan in 1/10 bips. + * + * Valid values are between 0 and 100% inclusive. + */ +TenthBips32 constexpr maxInterestRate = percentageToTenthBips(100); +static_assert(maxInterestRate == TenthBips32(100'000u)); + +/** The maximum premium added to the interest rate for late payments on a loan + * in 1/10 bips. + * + * Valid values are between 0 and 100% inclusive. + */ +TenthBips32 constexpr maxLateInterestRate = percentageToTenthBips(100); +static_assert(maxLateInterestRate == TenthBips32(100'000u)); + +/** The maximum close interest rate charged for repaying a loan early in 1/10 + * bips. + * + * Valid values are between 0 and 100% inclusive. + */ +TenthBips32 constexpr maxCloseInterestRate = percentageToTenthBips(100); +static_assert(maxCloseInterestRate == TenthBips32(100'000u)); + +/** The maximum overpayment interest rate charged on loan overpayments in 1/10 + * bips. + * + * Valid values are between 0 and 100% inclusive. + */ +TenthBips32 constexpr maxOverpaymentInterestRate = percentageToTenthBips(100); +static_assert(maxOverpaymentInterestRate == TenthBips32(100'000u)); + +/** LoanPay transaction cost will be one base fee per X combined payments + * + * The number of payments is estimated based on the Amount paid and the Loan's + * Fixed Payment size. Overpayments (indicated with the tfLoanOverpayment flag) + * count as one more payment. + * + * This number was chosen arbitrarily, but should not be changed once released + * without an amendment + */ +static constexpr int loanPaymentsPerFeeIncrement = 5; + +/** Maximum number of combined payments that a LoanPay transaction will process + * + * This limit is enforced during the loan payment process, and thus is not + * estimated. If the limit is hit, no further payments or overpayments will be + * processed, no matter how much of the transation Amount is left, but the + * transaction will succeed with the payments that have been processed up to + * that point. + * + * This limit is independent of loanPaymentsPerFeeIncrement, so a transaction + * could potentially be charged for many more payments than actually get + * processed. Users should take care not to submit a transaction paying more + * than loanMaximumPaymentsPerTransaction * Loan.PeriodicPayment. Because + * overpayments are charged as a payment, if submitting + * loanMaximumPaymentsPerTransaction * Loan.PeriodicPayment, users should not + * set the tfLoanOverpayment flag. + * + * Even though they're independent, loanMaximumPaymentsPerTransaction should be + * a multiple of loanPaymentsPerFeeIncrement. + * + * This number was chosen arbitrarily, but should not be changed once released + * without an amendment + */ +static constexpr int loanMaximumPaymentsPerTransaction = 100; +} // namespace Lending + /** The maximum length of a URI inside an NFT */ std::size_t constexpr maxTokenURILength = 256; diff --git a/include/xrpl/protocol/SField.h b/include/xrpl/protocol/SField.h index b6ae98b48f5..e199939fa49 100644 --- a/include/xrpl/protocol/SField.h +++ b/include/xrpl/protocol/SField.h @@ -139,8 +139,8 @@ field_code(int id, int index) SFields are created at compile time. Each SField, once constructed, lives until program termination, and there - is only one instance per fieldType/fieldValue pair which serves the entire - application. + is only one instance per fieldType/fieldValue pair which serves the + entire application. */ class SField { diff --git a/include/xrpl/protocol/STAmount.h b/include/xrpl/protocol/STAmount.h index f1e34463b6e..70efb4cd257 100644 --- a/include/xrpl/protocol/STAmount.h +++ b/include/xrpl/protocol/STAmount.h @@ -66,16 +66,18 @@ class STAmount final : public STBase, public CountedObject static int const cMaxOffset = 80; // Maximum native value supported by the code - static std::uint64_t const cMinValue = 1000000000000000ull; - static std::uint64_t const cMaxValue = 9999999999999999ull; - static std::uint64_t const cMaxNative = 9000000000000000000ull; + constexpr static std::uint64_t cMinValue = 1'000'000'000'000'000ull; + static_assert(isPowerOfTen(cMinValue)); + constexpr static std::uint64_t cMaxValue = cMinValue * 10 - 1; + static_assert(cMaxValue == 9'999'999'999'999'999ull); + constexpr static std::uint64_t cMaxNative = 9'000'000'000'000'000'000ull; // Max native value on network. - static std::uint64_t const cMaxNativeN = 100000000000000000ull; - static std::uint64_t const cIssuedCurrency = 0x8000000000000000ull; - static std::uint64_t const cPositive = 0x4000000000000000ull; - static std::uint64_t const cMPToken = 0x2000000000000000ull; - static std::uint64_t const cValueMask = ~(cPositive | cMPToken); + constexpr static std::uint64_t cMaxNativeN = 100'000'000'000'000'000ull; + constexpr static std::uint64_t cIssuedCurrency = 0x8'000'000'000'000'000ull; + constexpr static std::uint64_t cPositive = 0x4'000'000'000'000'000ull; + constexpr static std::uint64_t cMPToken = 0x2'000'000'000'000'000ull; + constexpr static std::uint64_t cValueMask = ~(cPositive | cMPToken); static std::uint64_t const uRateOne; @@ -174,6 +176,9 @@ class STAmount final : public STBase, public CountedObject int exponent() const noexcept; + bool + integral() const noexcept; + bool native() const noexcept; @@ -454,6 +459,12 @@ STAmount::exponent() const noexcept return mOffset; } +inline bool +STAmount::integral() const noexcept +{ + return mAsset.integral(); +} + inline bool STAmount::native() const noexcept { @@ -572,7 +583,7 @@ STAmount::clear() { // The -100 is used to allow 0 to sort less than a small positive values // which have a negative exponent. - mOffset = native() ? 0 : -100; + mOffset = integral() ? 0 : -100; mValue = 0; mIsNegative = false; } @@ -695,6 +706,53 @@ divRoundStrict( std::uint64_t getRate(STAmount const& offerOut, STAmount const& offerIn); +/** Round an arbitrary precision Amount to the precision of an STAmount that has + * a given exponent. + * + * This is used to ensure that calculations involving IOU amounts do not collect + * dust beyond the precision of the reference value. + * + * @param value The value to be rounded + * @param scale An exponent value to establish the precision limit of + * `value`. Should be larger than `value.exponent()`. + * @param rounding Optional Number rounding mode + * + */ +STAmount +roundToScale( + STAmount const& value, + std::int32_t scale, + Number::rounding_mode rounding = Number::getround()); + +/** 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. + * + * @param asset The relevant asset + * @param value The value to be rounded + * @param scale Only relevant to IOU assets. An exponent value to establish the + * precision limit of `value`. Should be larger than `value.exponent()`. + * @param rounding Optional Number rounding mode + */ +template +Number +roundToAsset( + A const& asset, + Number const& value, + std::int32_t scale, + Number::rounding_mode rounding = Number::getround()) +{ + NumberRoundModeGuard mg(rounding); + STAmount const ret{asset, value}; + if (ret.integral()) + return ret; + // Note that the ctor will round integral types (XRP, MPT) via canonicalize, + // so no extra work is needed for those. + return roundToScale(ret, scale); +} + //------------------------------------------------------------------------------ inline bool diff --git a/include/xrpl/protocol/STObject.h b/include/xrpl/protocol/STObject.h index 1c22b08abaf..5c58ce1ba94 100644 --- a/include/xrpl/protocol/STObject.h +++ b/include/xrpl/protocol/STObject.h @@ -244,6 +244,9 @@ class STObject : public STBase, public CountedObject getFieldPathSet(SField const& field) const; STVector256 const& getFieldV256(SField const& field) const; + // If not found, returns an object constructed with the given field + STObject + getFieldObject(SField const& field) const; STArray const& getFieldArray(SField const& field) const; STCurrency const& @@ -390,6 +393,8 @@ class STObject : public STBase, public CountedObject setFieldV256(SField const& field, STVector256 const& v); void setFieldArray(SField const& field, STArray const& v); + void + setFieldObject(SField const& field, STObject const& v); template void @@ -496,6 +501,8 @@ class STObject::Proxy value_type operator*() const; + /// Do not use operator->() unless the field is required, or you've checked + /// that it's set. T const* operator->() const; @@ -519,7 +526,26 @@ class STObject::Proxy // Constraint += and -= ValueProxy operators // to value types that support arithmetic operations template -concept IsArithmetic = std::is_arithmetic_v || std::is_same_v; +concept IsArithmeticNumber = std::is_arithmetic_v || + std::is_same_v || std::is_same_v; +template < + typename U, + typename Value = typename U::value_type, + typename Unit = typename U::unit_type> +concept IsArithmeticValueUnit = + std::is_same_v> && + IsArithmeticNumber && std::is_class_v; +template +concept IsArithmeticST = !IsArithmeticValueUnit && IsArithmeticNumber; +template +concept IsArithmetic = + IsArithmeticNumber || IsArithmeticST || IsArithmeticValueUnit; + +template +concept Addable = requires(T t, U u) { t = t + u; }; +template +concept IsArithmeticCompatible = + IsArithmetic && Addable; template class STObject::ValueProxy : public Proxy @@ -539,10 +565,12 @@ class STObject::ValueProxy : public Proxy // Convenience operators for value types supporting // arithmetic operations template + requires IsArithmeticCompatible ValueProxy& operator+=(U const& u); template + requires IsArithmeticCompatible ValueProxy& operator-=(U const& u); @@ -732,6 +760,8 @@ STObject::Proxy::operator*() const -> value_type return this->value(); } +/// Do not use operator->() unless the field is required, or you've checked that +/// it's set. template T const* STObject::Proxy::operator->() const @@ -778,6 +808,7 @@ STObject::ValueProxy::operator=(U&& u) template template + requires IsArithmeticCompatible STObject::ValueProxy& STObject::ValueProxy::operator+=(U const& u) { @@ -787,6 +818,7 @@ STObject::ValueProxy::operator+=(U const& u) template template + requires IsArithmeticCompatible STObject::ValueProxy& STObject::ValueProxy::operator-=(U const& u) { diff --git a/include/xrpl/protocol/STTx.h b/include/xrpl/protocol/STTx.h index f0d21572831..ac0223ee777 100644 --- a/include/xrpl/protocol/STTx.h +++ b/include/xrpl/protocol/STTx.h @@ -87,8 +87,14 @@ class STTx final : public STObject, public CountedObject getFullText() const override; // Outer transaction functions / signature functions. + static Blob + getSignature(STObject const& sigObject); + Blob - getSignature() const; + getSignature() const + { + return getSignature(*this); + } uint256 getSigningHash() const; @@ -119,13 +125,20 @@ class STTx final : public STObject, public CountedObject getJson(JsonOptions options, bool binary) const; void - sign(PublicKey const& publicKey, SecretKey const& secretKey); + sign( + PublicKey const& publicKey, + SecretKey const& secretKey, + std::optional> signatureTarget = + {}); + + enum class RequireFullyCanonicalSig : bool { no, yes }; /** Check the signature. + @param requireCanonicalSig If `true`, check that the signature is fully + canonical. If `false`, only check that the signature is valid. + @param rules The current ledger rules. @return `true` if valid signature. If invalid, the error message string. */ - enum class RequireFullyCanonicalSig : bool { no, yes }; - Expected checkSign(RequireFullyCanonicalSig requireCanonicalSig, Rules const& rules) const; @@ -150,17 +163,34 @@ class STTx final : public STObject, public CountedObject char status, std::string const& escapedMetaData) const; - std::vector + std::vector const& getBatchTransactionIDs() const; private: + /** Check the signature. + @param requireCanonicalSig If `true`, check that the signature is fully + canonical. If `false`, only check that the signature is valid. + @param rules The current ledger rules. + @param sigObject Reference to object that contains the signature fields. + Will be *this more often than not. + @return `true` if valid signature. If invalid, the error message string. + */ Expected - checkSingleSign(RequireFullyCanonicalSig requireCanonicalSig) const; + checkSign( + RequireFullyCanonicalSig requireCanonicalSig, + Rules const& rules, + STObject const& sigObject) const; + + Expected + checkSingleSign( + RequireFullyCanonicalSig requireCanonicalSig, + STObject const& sigObject) const; Expected checkMultiSign( RequireFullyCanonicalSig requireCanonicalSig, - Rules const& rules) const; + Rules const& rules, + STObject const& sigObject) const; Expected checkBatchSingleSign( @@ -179,7 +209,7 @@ class STTx final : public STObject, public CountedObject move(std::size_t n, void* buf) override; friend class detail::STVar; - mutable std::vector batch_txn_ids_; + mutable std::vector batchTxnIds_; }; bool diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index dcbc10b9275..9aece5eb224 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -285,6 +285,32 @@ constexpr std::uint32_t tfIndependent = 0x00080000; constexpr std::uint32_t const tfBatchMask = ~(tfUniversal | tfAllOrNothing | tfOnlyOne | tfUntilFailure | tfIndependent) | tfInnerBatchTxn; +// LoanSet and LoanPay flags: +// LoanSet: True, indicates the loan supports overpayments +// LoanPay: True, indicates any excess in this payment can be used +// as an overpayment. False, no overpayments will be taken. +constexpr std::uint32_t const tfLoanOverpayment = 0x00010000; +// LoanPay exclusive flags: +// tfLoanFullPayment: True, indicates that the payment is an early +// full payment. It must pay the entire loan including close +// interest and fees, or it will fail. False: Not a full payment. +constexpr std::uint32_t const tfLoanFullPayment = 0x00020000; +// tfLoanLatePayment: True, indicates that the payment is late, +// and includes late iterest and fees. If the loan is not late, +// it will fail. False: not a late payment. If the current payment +// is overdue, the transaction will fail. +constexpr std::uint32_t const tfLoanLatePayment = 0x00040000; +constexpr std::uint32_t const tfLoanSetMask = ~(tfUniversal | + tfLoanOverpayment); +constexpr std::uint32_t const tfLoanPayMask = ~(tfUniversal | + tfLoanOverpayment | tfLoanFullPayment | tfLoanLatePayment); + +// LoanManage flags: +constexpr std::uint32_t const tfLoanDefault = 0x00010000; +constexpr std::uint32_t const tfLoanImpair = 0x00020000; +constexpr std::uint32_t const tfLoanUnimpair = 0x00040000; +constexpr std::uint32_t const tfLoanManageMask = ~(tfUniversal | tfLoanDefault | tfLoanImpair | tfLoanUnimpair); + // clang-format on } // namespace ripple diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index c5d46991997..3928fc5f9ec 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -27,9 +27,12 @@ #error "undefined macro: XRPL_RETIRE" #endif +// clang-format off + // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. +XRPL_FEATURE(LendingProtocol, Supported::no, VoteBehavior::DefaultNo) XRPL_FIX (DirectoryLimit, Supported::yes, VoteBehavior::DefaultNo) XRPL_FIX (IncludeKeyletFields, Supported::yes, VoteBehavior::DefaultNo) XRPL_FEATURE(DynamicMPT, Supported::no, VoteBehavior::DefaultNo) @@ -155,3 +158,5 @@ XRPL_RETIRE(fix1512) XRPL_RETIRE(fix1523) XRPL_RETIRE(fix1528) XRPL_RETIRE(FlowCross) + +// clang-format on diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index d3b1b3c6515..9ac49ba27bf 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -168,6 +168,7 @@ LEDGER_ENTRY(ltACCOUNT_ROOT, 0x0061, AccountRoot, account, ({ {sfFirstNFTokenSequence, soeOPTIONAL}, {sfAMMID, soeOPTIONAL}, // pseudo-account designator {sfVaultID, soeOPTIONAL}, // pseudo-account designator + {sfLoanBrokerID, soeOPTIONAL}, // pseudo-account designator })) /** A ledger object which contains a list of object identifiers. @@ -498,10 +499,10 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ {sfAccount, soeREQUIRED}, {sfData, soeOPTIONAL}, {sfAsset, soeREQUIRED}, - {sfAssetsTotal, soeREQUIRED}, - {sfAssetsAvailable, soeREQUIRED}, + {sfAssetsTotal, soeDEFAULT}, + {sfAssetsAvailable, soeDEFAULT}, {sfAssetsMaximum, soeDEFAULT}, - {sfLossUnrealized, soeREQUIRED}, + {sfLossUnrealized, soeDEFAULT}, {sfShareMPTID, soeREQUIRED}, {sfWithdrawalPolicy, soeREQUIRED}, {sfScale, soeDEFAULT}, @@ -509,5 +510,117 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ // no PermissionedDomainID ever (use MPTIssuance.sfDomainID) })) +/** Reserve 0x0084-0x0087 for future Vault-related objects. */ + +/** A ledger object representing a loan broker + + \sa keylet::loanbroker + */ +LEDGER_ENTRY(ltLOAN_BROKER, 0x0088, LoanBroker, loan_broker, ({ + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfSequence, soeREQUIRED}, + {sfOwnerNode, soeREQUIRED}, + {sfVaultNode, soeREQUIRED}, + {sfVaultID, soeREQUIRED}, + {sfAccount, soeREQUIRED}, + {sfOwner, soeREQUIRED}, + {sfLoanSequence, soeREQUIRED}, + {sfData, soeDEFAULT}, + {sfManagementFeeRate, soeDEFAULT}, + {sfOwnerCount, soeDEFAULT}, + {sfDebtTotal, soeDEFAULT}, + {sfDebtMaximum, soeDEFAULT}, + {sfCoverAvailable, soeDEFAULT}, + {sfCoverRateMinimum, soeDEFAULT}, + {sfCoverRateLiquidation, soeDEFAULT}, +})) + +/** A ledger object representing a loan between a Borrower and a Loan Broker + + \sa keylet::loan + */ +LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({ + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfOwnerNode, soeREQUIRED}, + {sfLoanBrokerNode, soeREQUIRED}, + {sfLoanBrokerID, soeREQUIRED}, + {sfLoanSequence, soeREQUIRED}, + {sfBorrower, soeREQUIRED}, + {sfLoanOriginationFee, soeDEFAULT}, + {sfLoanServiceFee, soeDEFAULT}, + {sfLatePaymentFee, soeDEFAULT}, + {sfClosePaymentFee, soeDEFAULT}, + {sfOverpaymentFee, soeDEFAULT}, + {sfInterestRate, soeDEFAULT}, + {sfLateInterestRate, soeDEFAULT}, + {sfCloseInterestRate, soeDEFAULT}, + {sfOverpaymentInterestRate, soeDEFAULT}, + {sfStartDate, soeREQUIRED}, + {sfPaymentInterval, soeREQUIRED}, + {sfGracePeriod, soeDEFAULT}, + {sfPreviousPaymentDate, soeDEFAULT}, + {sfNextPaymentDueDate, soeDEFAULT}, + // The loan object tracks these values: + // + // - PaymentRemaining: The number of payments left in the loan. When it + // reaches 0, the loan is paid off, and all other relevant values + // must also be 0. + // + // - PeriodicPayment: The fixed, unrounded amount to be paid each + // interval. Stored with as much precision as possible. + // Payment transactions must round this value *UP*. + // + // - TotalValueOutstanding: The rounded total amount owed by the + // borrower to the lender / vault. + // + // - PrincipalOutstanding: The rounded portion of the + // TotalValueOutstanding that is from the principal borrowed. + // + // - ManagementFeeOutstanding: The rounded portion of the + // TotalValueOutstanding that represents management fees + // specifically owed to the broker based on the initial + // loan parameters. + // + // There are additional values that can be computed from these: + // + // - InterestOutstanding = TotalValueOutstanding - PrincipalOutstanding + // The total amount of interest still pending on the loan, + // independent of management fees. + // + // - InterestOwedToVault = InterestOutstanding - ManagementFeeOutstanding + // The amount of the total interest that is owed to the vault, and + // will be sent to it as part of a payment. + // + // - TrueTotalLoanValue = PaymentRemaining * PeriodicPayment + // The unrounded true total value of the loan. + // + // - TrueTotalPrincialOutstanding can be computed using the algorithm + // in the ripple::detail::loanPrincipalFromPeriodicPayment function. + // + // - TrueTotalInterestOutstanding = TrueTotalLoanValue - + // TrueTotalPrincipalOutstanding + // The unrounded true total interest remaining. + // + // - TrueTotalManagementFeeOutstanding = TrueTotalInterestOutstanding * + // LoanBroker.ManagementFeeRate + // The unrounded true total fee still owed to the broker. + // + // Note the the "True" values may differ significantly from the tracked + // rounded values. + {sfPaymentRemaining, soeDEFAULT}, + {sfPeriodicPayment, soeREQUIRED}, + {sfPrincipalOutstanding, soeDEFAULT}, + {sfTotalValueOutstanding, soeDEFAULT}, + {sfManagementFeeOutstanding, soeDEFAULT}, + // Based on the computed total value at creation, used for + // rounding calculated values so they are all on a + // consistent scale - that is, they all have the same + // number of digits after the decimal point (excluding + // trailing zeros). + {sfLoanScale, soeDEFAULT}, +})) + #undef EXPAND #undef LEDGER_ENTRY_DUPLICATE diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index f932ae23289..086cda0e08f 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -24,6 +24,8 @@ #error "undefined macro: TYPED_SFIELD" #endif +// clang-format off + // untyped UNTYPED_SFIELD(sfLedgerEntry, LEDGERENTRY, 257) UNTYPED_SFIELD(sfTransaction, TRANSACTION, 257) @@ -59,6 +61,7 @@ TYPED_SFIELD(sfHookEmitCount, UINT16, 18) TYPED_SFIELD(sfHookExecutionIndex, UINT16, 19) TYPED_SFIELD(sfHookApiVersion, UINT16, 20) TYPED_SFIELD(sfLedgerFixType, UINT16, 21) +TYPED_SFIELD(sfManagementFeeRate, UINT16, 22) // 1/10 basis points (bips) // 32-bit integers (common) TYPED_SFIELD(sfNetworkID, UINT32, 1) @@ -115,6 +118,21 @@ TYPED_SFIELD(sfFirstNFTokenSequence, UINT32, 50) TYPED_SFIELD(sfOracleDocumentID, UINT32, 51) TYPED_SFIELD(sfPermissionValue, UINT32, 52) 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(sfNextPaymentDueDate, UINT32, 58) +TYPED_SFIELD(sfPaymentRemaining, UINT32, 59) +TYPED_SFIELD(sfPaymentTotal, UINT32, 60) +TYPED_SFIELD(sfLoanSequence, UINT32, 61) +TYPED_SFIELD(sfCoverRateMinimum, UINT32, 62) // 1/10 basis points (bips) +TYPED_SFIELD(sfCoverRateLiquidation, UINT32, 63) // 1/10 basis points (bips) +TYPED_SFIELD(sfOverpaymentFee, UINT32, 64) // 1/10 basis points (bips) +TYPED_SFIELD(sfInterestRate, UINT32, 65) // 1/10 basis points (bips) +TYPED_SFIELD(sfLateInterestRate, UINT32, 66) // 1/10 basis points (bips) +TYPED_SFIELD(sfCloseInterestRate, UINT32, 67) // 1/10 basis points (bips) +TYPED_SFIELD(sfOverpaymentInterestRate, UINT32, 68) // 1/10 basis points (bips) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) @@ -146,6 +164,8 @@ TYPED_SFIELD(sfMPTAmount, UINT64, 26, SField::sMD_BaseTen|SFie TYPED_SFIELD(sfIssuerNode, UINT64, 27) TYPED_SFIELD(sfSubjectNode, UINT64, 28) TYPED_SFIELD(sfLockedAmount, UINT64, 29, SField::sMD_BaseTen|SField::sMD_Default) +TYPED_SFIELD(sfVaultNode, UINT64, 30) +TYPED_SFIELD(sfLoanBrokerNode, UINT64, 31) // 128-bit TYPED_SFIELD(sfEmailHash, UINT128, 1) @@ -200,6 +220,9 @@ TYPED_SFIELD(sfDomainID, UINT256, 34) TYPED_SFIELD(sfVaultID, UINT256, 35, SField::sMD_PseudoAccount | SField::sMD_Default) TYPED_SFIELD(sfParentBatchID, UINT256, 36) +TYPED_SFIELD(sfLoanBrokerID, UINT256, 37, + SField::sMD_PseudoAccount | SField::sMD_Default) +TYPED_SFIELD(sfLoanID, UINT256, 38) // number (common) TYPED_SFIELD(sfNumber, NUMBER, 1) @@ -207,12 +230,21 @@ 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(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(sfPrincipalRequested, NUMBER, 14) +TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15) +TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16) +TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17) // int32 -// NOTE: Do not use `sfDummyInt32`. It's so far the only use of INT32 -// in this file and has been defined here for test only. -// TODO: Replace `sfDummyInt32` with actually useful field. -TYPED_SFIELD(sfDummyInt32, INT32, 1) // for tests only +TYPED_SFIELD(sfLoanScale, INT32, 1) // currency amount (common) TYPED_SFIELD(sfAmount, AMOUNT, 1) @@ -308,6 +340,8 @@ TYPED_SFIELD(sfAttestationRewardAccount, ACCOUNT, 21) TYPED_SFIELD(sfLockingChainDoor, ACCOUNT, 22) TYPED_SFIELD(sfIssuingChainDoor, ACCOUNT, 23) TYPED_SFIELD(sfSubject, ACCOUNT, 24) +TYPED_SFIELD(sfBorrower, ACCOUNT, 25) +TYPED_SFIELD(sfCounterparty, ACCOUNT, 26) // vector of 256-bit TYPED_SFIELD(sfIndexes, VECTOR256, 1, SField::sMD_Never) @@ -371,6 +405,7 @@ UNTYPED_SFIELD(sfCredential, OBJECT, 33) UNTYPED_SFIELD(sfRawTransaction, OBJECT, 34) UNTYPED_SFIELD(sfBatchSigner, OBJECT, 35) UNTYPED_SFIELD(sfBook, OBJECT, 36) +UNTYPED_SFIELD(sfCounterpartySignature, OBJECT, 37, SField::sMD_Default, SField::notSigning) // array of objects (common) // ARRAY/1 is reserved for end of array @@ -405,3 +440,5 @@ UNTYPED_SFIELD(sfAcceptedCredentials, ARRAY, 28) UNTYPED_SFIELD(sfPermissions, ARRAY, 29) UNTYPED_SFIELD(sfRawTransactions, ARRAY, 30) UNTYPED_SFIELD(sfBatchSigners, ARRAY, 31, SField::sMD_Default, SField::notSigning) + +// clang-format on diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 119c3f8b7b0..36c837c02d0 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -944,6 +944,139 @@ TRANSACTION(ttBATCH, 71, Batch, {sfBatchSigners, soeOPTIONAL}, })) +/** Reserve 72-73 for future Vault-related transactions */ + +/** This transaction creates and updates a Loan Broker */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, + Delegation::delegatable, + featureLendingProtocol, + createPseudoAcct | mayAuthorizeMPT, ({ + {sfVaultID, soeREQUIRED}, + {sfLoanBrokerID, soeOPTIONAL}, + {sfData, soeOPTIONAL}, + {sfManagementFeeRate, soeOPTIONAL}, + {sfDebtMaximum, soeOPTIONAL}, + {sfCoverRateMinimum, soeOPTIONAL}, + {sfCoverRateLiquidation, soeOPTIONAL}, +})) + +/** This transaction deletes a Loan Broker */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_BROKER_DELETE, 75, LoanBrokerDelete, + Delegation::delegatable, + featureLendingProtocol, + mustDeleteAcct | mayAuthorizeMPT, ({ + {sfLoanBrokerID, soeREQUIRED}, +})) + +/** This transaction deposits First Loss Capital into a Loan Broker */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_BROKER_COVER_DEPOSIT, 76, LoanBrokerCoverDeposit, + Delegation::delegatable, + featureLendingProtocol, + noPriv, ({ + {sfLoanBrokerID, soeREQUIRED}, + {sfAmount, soeREQUIRED, soeMPTSupported}, +})) + +/** This transaction withdraws First Loss Capital from a Loan Broker */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, + Delegation::delegatable, + featureLendingProtocol, + mayAuthorizeMPT, ({ + {sfLoanBrokerID, soeREQUIRED}, + {sfAmount, soeREQUIRED, soeMPTSupported}, + {sfDestination, soeOPTIONAL}, + {sfDestinationTag, soeOPTIONAL}, +})) + +/** This transaction claws back First Loss Capital from a Loan Broker to + the issuer of the capital */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_BROKER_COVER_CLAWBACK, 78, LoanBrokerCoverClawback, + Delegation::delegatable, + featureLendingProtocol, + noPriv, ({ + {sfLoanBrokerID, soeOPTIONAL}, + {sfAmount, soeOPTIONAL, soeMPTSupported}, +})) + +/** This transaction creates a Loan */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_SET, 80, LoanSet, + Delegation::delegatable, + featureLendingProtocol, + mayAuthorizeMPT | mustModifyVault, ({ + {sfLoanBrokerID, soeREQUIRED}, + {sfData, soeOPTIONAL}, + {sfCounterparty, soeOPTIONAL}, + {sfCounterpartySignature, soeOPTIONAL}, + {sfLoanOriginationFee, soeOPTIONAL}, + {sfLoanServiceFee, soeOPTIONAL}, + {sfLatePaymentFee, soeOPTIONAL}, + {sfClosePaymentFee, soeOPTIONAL}, + {sfOverpaymentFee, soeOPTIONAL}, + {sfInterestRate, soeOPTIONAL}, + {sfLateInterestRate, soeOPTIONAL}, + {sfCloseInterestRate, soeOPTIONAL}, + {sfOverpaymentInterestRate, soeOPTIONAL}, + {sfPrincipalRequested, soeREQUIRED}, + {sfPaymentTotal, soeOPTIONAL}, + {sfPaymentInterval, soeOPTIONAL}, + {sfGracePeriod, soeOPTIONAL}, +})) + +/** This transaction deletes an existing Loan */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, + Delegation::delegatable, + featureLendingProtocol, + noPriv, ({ + {sfLoanID, soeREQUIRED}, +})) + +/** This transaction is used to change the delinquency status of an existing Loan */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, + Delegation::delegatable, + featureLendingProtocol, + // All of the LoanManage options will modify the vault, but the + // transaction can succeed without options, essentially making it + // a noop. + mayModifyVault, ({ + {sfLoanID, soeREQUIRED}, +})) + +/** The Borrower uses this transaction to make a Payment on the Loan. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_PAY, 84, LoanPay, + Delegation::delegatable, + featureLendingProtocol, + mayAuthorizeMPT | mustModifyVault, ({ + {sfLoanID, soeREQUIRED}, + {sfAmount, soeREQUIRED, soeMPTSupported}, +})) + /** This system-generated transaction type is used to update the status of the various amendments. For details, see: https://xrpl.org/amendments.html diff --git a/include/xrpl/protocol/jss.h b/include/xrpl/protocol/jss.h index 8609aedaefb..733985d3319 100644 --- a/include/xrpl/protocol/jss.h +++ b/include/xrpl/protocol/jss.h @@ -59,6 +59,8 @@ JSS(BaseAsset); // in: Oracle JSS(BidMax); // in: AMM Bid JSS(BidMin); // in: AMM Bid JSS(ClearFlag); // field. +JSS(Counterparty); // field. +JSS(CounterpartySignature);// field. JSS(DeliverMax); // out: alias to Amount JSS(DeliverMin); // in: TransactionSign JSS(Destination); // in: TransactionSign; field. @@ -392,6 +394,8 @@ JSS(load_factor_local); // out: NetworkOPs JSS(load_factor_net); // out: NetworkOPs JSS(load_factor_server); // out: NetworkOPs JSS(load_fee); // out: LoadFeeTrackImp, NetworkOPs +JSS(loan_broker_id); // in: LedgerEntry +JSS(loan_seq); // in: LedgerEntry JSS(local); // out: resource/Logic.h JSS(local_txs); // out: GetCounts JSS(local_static_keys); // out: ValidatorList @@ -504,6 +508,7 @@ JSS(propose_seq); // out: LedgerPropose JSS(proposers); // out: NetworkOPs, LedgerConsensus JSS(protocol); // out: NetworkOPs, PeerImp JSS(proxied); // out: RPC ping +JSS(pseudo_account); // out: AccountInfo JSS(pubkey_node); // out: NetworkOPs JSS(pubkey_publisher); // out: ValidatorList JSS(pubkey_validator); // out: NetworkOPs, ValidatorList @@ -569,6 +574,7 @@ JSS(settle_delay); // out: AccountChannels JSS(severity); // in: LogLevel JSS(shares); // out: VaultInfo JSS(signature); // out: NetworkOPs, ChannelAuthorize +JSS(signature_target); // in: TransactionSign JSS(signature_verified); // out: ChannelVerify JSS(signing_key); // out: NetworkOPs JSS(signing_keys); // out: ValidatorList diff --git a/src/libxrpl/basics/BasicConfig.cpp b/src/libxrpl/basics/BasicConfig.cpp index 7d6f09b391c..e18a4cc8cc8 100644 --- a/src/libxrpl/basics/BasicConfig.cpp +++ b/src/libxrpl/basics/BasicConfig.cpp @@ -49,7 +49,7 @@ Section::append(std::vector const& lines) // '=' static boost::regex const re1( "^" // start of line - "(?:\\s*)" // whitespace (optonal) + "(?:\\s*)" // whitespace (optional) "([a-zA-Z][_a-zA-Z0-9]*)" // "(?:\\s*)" // whitespace (optional) "(?:=)" // '=' diff --git a/src/libxrpl/basics/Number.cpp b/src/libxrpl/basics/Number.cpp index f43288b57b1..228def47205 100644 --- a/src/libxrpl/basics/Number.cpp +++ b/src/libxrpl/basics/Number.cpp @@ -93,6 +93,18 @@ class Number::Guard // tie, round towards even. int round() noexcept; + + // Modify the result to the correctly rounded value + void + doRoundUp(rep& mantissa, int& exponent, std::string location); + + // Modify the result to the correctly rounded value + void + doRoundDown(rep& mantissa, int& exponent); + + // Modify the result to the correctly rounded value + void + doRound(rep& drops); }; inline void @@ -170,6 +182,61 @@ Number::Guard::round() noexcept return 0; } +void +Number::Guard::doRoundUp(rep& mantissa, int& exponent, std::string location) +{ + auto r = round(); + if (r == 1 || (r == 0 && (mantissa & 1) == 1)) + { + ++mantissa; + if (mantissa > maxMantissa) + { + mantissa /= 10; + ++exponent; + } + } + if (exponent < minExponent) + { + mantissa = 0; + exponent = Number{}.exponent_; + } + if (exponent > maxExponent) + throw std::overflow_error(location); +} + +void +Number::Guard::doRoundDown(rep& mantissa, int& exponent) +{ + auto r = round(); + if (r == 1 || (r == 0 && (mantissa & 1) == 1)) + { + --mantissa; + if (mantissa < minMantissa) + { + mantissa *= 10; + --exponent; + } + } + if (exponent < minExponent) + { + mantissa = 0; + exponent = Number{}.exponent_; + } +} + +// Modify the result to the correctly rounded value +void +Number::Guard::doRound(rep& drops) +{ + auto r = round(); + if (r == 1 || (r == 0 && (drops & 1) == 1)) + { + ++drops; + } + if (is_negative()) + drops = -drops; +} + // Number constexpr Number one{1000000000000000, -15, Number::unchecked{}}; @@ -209,18 +276,7 @@ Number::normalize() return; } - auto r = g.round(); - if (r == 1 || (r == 0 && (mantissa_ & 1) == 1)) - { - ++mantissa_; - if (mantissa_ > maxMantissa) - { - mantissa_ /= 10; - ++exponent_; - } - } - if (exponent_ > maxExponent) - throw std::overflow_error("Number::normalize 2"); + g.doRoundUp(mantissa_, exponent_, "Number::normalize 2"); if (negative) mantissa_ = -mantissa_; @@ -292,18 +348,7 @@ Number::operator+=(Number const& y) xm /= 10; ++xe; } - auto r = g.round(); - if (r == 1 || (r == 0 && (xm & 1) == 1)) - { - ++xm; - if (xm > maxMantissa) - { - xm /= 10; - ++xe; - } - } - if (xe > maxExponent) - throw std::overflow_error("Number::addition overflow"); + g.doRoundUp(xm, xe, "Number::addition overflow"); } else { @@ -323,21 +368,7 @@ Number::operator+=(Number const& y) xm -= g.pop(); --xe; } - auto r = g.round(); - if (r == 1 || (r == 0 && (xm & 1) == 1)) - { - --xm; - if (xm < minMantissa) - { - xm *= 10; - --xe; - } - } - if (xe < minExponent) - { - xm = 0; - xe = Number{}.exponent_; - } + g.doRoundDown(xm, xe); } mantissa_ = xm * xn; exponent_ = xe; @@ -417,25 +448,10 @@ Number::operator*=(Number const& y) } xm = static_cast(zm); xe = ze; - auto r = g.round(); - if (r == 1 || (r == 0 && (xm & 1) == 1)) - { - ++xm; - if (xm > maxMantissa) - { - xm /= 10; - ++xe; - } - } - if (xe < minExponent) - { - xm = 0; - xe = Number{}.exponent_; - } - if (xe > maxExponent) - throw std::overflow_error( - "Number::multiplication overflow : exponent is " + - std::to_string(xe)); + g.doRoundUp( + xm, + xe, + "Number::multiplication overflow : exponent is " + std::to_string(xe)); mantissa_ = xm * zn; exponent_ = xe; XRPL_ASSERT( @@ -500,17 +516,29 @@ Number::operator rep() const throw std::overflow_error("Number::operator rep() overflow"); drops *= 10; } - auto r = g.round(); - if (r == 1 || (r == 0 && (drops & 1) == 1)) - { - ++drops; - } - if (g.is_negative()) - drops = -drops; + g.doRound(drops); } return drops; } +Number +Number::truncate() const noexcept +{ + if (exponent_ >= 0 || mantissa_ == 0) + return *this; + + Number ret = *this; + while (ret.exponent_ < 0 && ret.mantissa_ != 0) + { + ret.exponent_ += 1; + ret.mantissa_ /= rep(10); + } + // We are guaranteed that normalize() will never throw an exception + // because exponent is either negative or zero at this point. + ret.normalize(); + return ret; +} + std::string to_string(Number const& amount) { diff --git a/src/libxrpl/json/json_value.cpp b/src/libxrpl/json/json_value.cpp index 7e4d8b6d815..88cc8e84832 100644 --- a/src/libxrpl/json/json_value.cpp +++ b/src/libxrpl/json/json_value.cpp @@ -33,9 +33,6 @@ namespace Json { Value const Value::null; -Int const Value::minInt = Int(~(UInt(-1) / 2)); -Int const Value::maxInt = Int(UInt(-1) / 2); -UInt const Value::maxUInt = UInt(-1); class DefaultValueAllocator : public ValueAllocator { @@ -569,6 +566,69 @@ Value::asInt() const return 0; // unreachable; } +UInt +Value::asAbsUInt() const +{ + switch (type_) + { + case nullValue: + return 0; + + case intValue: { + // Doing this conversion through int64 avoids overflow error for + // value_.int_ = -1 * 2^31 i.e. numeric_limits::min(). + if (value_.int_ < 0) + return static_cast(value_.int_) * -1; + return value_.int_; + } + + case uintValue: + return value_.uint_; + + case realValue: { + if (value_.real_ < 0) + { + JSON_ASSERT_MESSAGE( + -1 * value_.real_ <= maxUInt, + "Real out of unsigned integer range"); + return UInt(-1 * value_.real_); + } + JSON_ASSERT_MESSAGE( + value_.real_ <= maxUInt, "Real out of unsigned integer range"); + return UInt(value_.real_); + } + + case booleanValue: + return value_.bool_ ? 1 : 0; + + case stringValue: { + char const* const str{value_.string_ ? value_.string_ : ""}; + auto const temp = beast::lexicalCastThrow(str); + if (temp < 0) + { + JSON_ASSERT_MESSAGE( + -1 * temp <= maxUInt, + "String out of unsigned integer range"); + return -1 * temp; + } + JSON_ASSERT_MESSAGE( + temp <= maxUInt, "String out of unsigned integer range"); + return temp; + } + + case arrayValue: + case objectValue: + JSON_ASSERT_MESSAGE(false, "Type is not convertible to int"); + + // LCOV_EXCL_START + default: + UNREACHABLE("Json::Value::asAbsInt : invalid type"); + // LCOV_EXCL_STOP + } + + return 0; // unreachable; +} + Value::UInt Value::asUInt() const { @@ -1001,6 +1061,12 @@ Value::isMember(std::string const& key) const return isMember(key.c_str()); } +bool +Value::isMember(StaticString const& key) const +{ + return isMember(key.c_str()); +} + Value::Members Value::getMemberNames() const { diff --git a/src/libxrpl/ledger/ApplyView.cpp b/src/libxrpl/ledger/ApplyView.cpp index bbc8f317ce5..c5cb990a810 100644 --- a/src/libxrpl/ledger/ApplyView.cpp +++ b/src/libxrpl/ledger/ApplyView.cpp @@ -27,73 +27,95 @@ namespace ripple { -std::optional -ApplyView::dirAdd( - bool preserveOrder, +namespace directory { + +std::uint64_t +createRoot( + ApplyView& view, Keylet const& directory, uint256 const& key, std::function const&)> const& describe) { - auto root = peek(directory); - - if (!root) - { - // No root, make it. - root = std::make_shared(directory); - root->setFieldH256(sfRootIndex, directory.key); - describe(root); + auto newRoot = std::make_shared(directory); + newRoot->setFieldH256(sfRootIndex, directory.key); + describe(newRoot); - STVector256 v; - v.push_back(key); - root->setFieldV256(sfIndexes, v); + STVector256 v; + v.push_back(key); + newRoot->setFieldV256(sfIndexes, v); - insert(root); - return std::uint64_t{0}; - } + view.insert(newRoot); + return std::uint64_t{0}; +} - std::uint64_t page = root->getFieldU64(sfIndexPrevious); +auto +findPreviousPage(ApplyView& view, Keylet const& directory, SLE::ref start) +{ + std::uint64_t page = start->getFieldU64(sfIndexPrevious); - auto node = root; + auto node = start; if (page) { - node = peek(keylet::page(directory, page)); + node = view.peek(keylet::page(directory, page)); if (!node) + { // LCOV_EXCL_START LogicError("Directory chain: root back-pointer broken."); + // LCOV_EXCL_STOP + } } auto indexes = node->getFieldV256(sfIndexes); + return std::make_tuple(page, node, indexes); +} - // If there's space, we use it: - if (indexes.size() < dirNodeMaxEntries) +std::uint64_t +insertKey( + ApplyView& view, + SLE::ref node, + std::uint64_t page, + bool preserveOrder, + STVector256& indexes, + uint256 const& key) +{ + if (preserveOrder) { - if (preserveOrder) - { - if (std::find(indexes.begin(), indexes.end(), key) != indexes.end()) - LogicError("dirInsert: double insertion"); - - indexes.push_back(key); - } - else - { - // We can't be sure if this page is already sorted because - // it may be a legacy page we haven't yet touched. Take - // the time to sort it. - std::sort(indexes.begin(), indexes.end()); + if (std::find(indexes.begin(), indexes.end(), key) != indexes.end()) + LogicError("dirInsert: double insertion"); // LCOV_EXCL_LINE - auto pos = std::lower_bound(indexes.begin(), indexes.end(), key); + indexes.push_back(key); + } + else + { + // We can't be sure if this page is already sorted because + // it may be a legacy page we haven't yet touched. Take + // the time to sort it. + std::sort(indexes.begin(), indexes.end()); - if (pos != indexes.end() && key == *pos) - LogicError("dirInsert: double insertion"); + auto pos = std::lower_bound(indexes.begin(), indexes.end(), key); - indexes.insert(pos, key); - } + if (pos != indexes.end() && key == *pos) + LogicError("dirInsert: double insertion"); // LCOV_EXCL_LINE - node->setFieldV256(sfIndexes, indexes); - update(node); - return page; + indexes.insert(pos, key); } + node->setFieldV256(sfIndexes, indexes); + view.update(node); + return page; +} + +std::optional +insertPage( + ApplyView& view, + std::uint64_t page, + SLE::pointer node, + std::uint64_t nextPage, + SLE::ref next, + uint256 const& key, + Keylet const& directory, + std::function const&)> const& describe) +{ // We rely on modulo arithmetic of unsigned integers (guaranteed in // [basic.fundamental] paragraph 2) to detect page representation overflow. // For signed integers this would be UB, hence static_assert here. @@ -107,20 +129,20 @@ ApplyView::dirAdd( // Check whether we're out of pages. if (page == 0) return std::nullopt; - if (!rules().enabled(fixDirectoryLimit) && + if (!view.rules().enabled(fixDirectoryLimit) && page >= dirNodeMaxPages) // Old pages limit return std::nullopt; // We are about to create a new node; we'll link it to // the chain first: node->setFieldU64(sfIndexNext, page); - update(node); + view.update(node); - root->setFieldU64(sfIndexPrevious, page); - update(root); + next->setFieldU64(sfIndexPrevious, page); + view.update(next); // Insert the new key: - indexes.clear(); + STVector256 indexes; indexes.push_back(key); node = std::make_shared(keylet::page(directory, page)); @@ -131,12 +153,52 @@ ApplyView::dirAdd( // it's the default. if (page != 1) node->setFieldU64(sfIndexPrevious, page - 1); + XRPL_ASSERT_PARTS( + !nextPage, + "ripple::directory::insertPage", + "nextPage has default value"); + /* Reserved for future use when directory pages may be inserted in + * between two other pages instead of only at the end of the chain. + if (nextPage) + node->setFieldU64(sfIndexNext, nextPage); + */ describe(node); - insert(node); + view.insert(node); return page; } +} // namespace directory + +std::optional +ApplyView::dirAdd( + bool preserveOrder, + Keylet const& directory, + uint256 const& key, + std::function const&)> const& describe) +{ + auto root = peek(directory); + + if (!root) + { + // No root, make it. + return directory::createRoot(*this, directory, key, describe); + } + + auto [page, node, indexes] = + directory::findPreviousPage(*this, directory, root); + + // If there's space, we use it: + if (indexes.size() < dirNodeMaxEntries) + { + return directory::insertKey( + *this, node, page, preserveOrder, indexes, key); + } + + return directory::insertPage( + *this, page, node, 0, root, key, directory, describe); +} + bool ApplyView::emptyDirDelete(Keylet const& directory) { @@ -164,10 +226,10 @@ ApplyView::emptyDirDelete(Keylet const& directory) auto nextPage = node->getFieldU64(sfIndexNext); if (nextPage == rootPage && prevPage != rootPage) - LogicError("Directory chain: fwd link broken"); + LogicError("Directory chain: fwd link broken"); // LCOV_EXCL_LINE if (prevPage == rootPage && nextPage != rootPage) - LogicError("Directory chain: rev link broken"); + LogicError("Directory chain: rev link broken"); // LCOV_EXCL_LINE // Older versions of the code would, in some cases, allow the last // page to be empty. Remove such pages: @@ -176,7 +238,10 @@ ApplyView::emptyDirDelete(Keylet const& directory) auto last = peek(keylet::page(directory, nextPage)); if (!last) + { // LCOV_EXCL_START LogicError("Directory chain: fwd link broken."); + // LCOV_EXCL_STOP + } if (!last->getFieldV256(sfIndexes).empty()) return false; @@ -248,10 +313,16 @@ ApplyView::dirRemove( if (page == rootPage) { if (nextPage == page && prevPage != page) + { // LCOV_EXCL_START LogicError("Directory chain: fwd link broken"); + // LCOV_EXCL_STOP + } if (prevPage == page && nextPage != page) + { // LCOV_EXCL_START LogicError("Directory chain: rev link broken"); + // LCOV_EXCL_STOP + } // Older versions of the code would, in some cases, // allow the last page to be empty. Remove such @@ -260,7 +331,10 @@ ApplyView::dirRemove( { auto last = peek(keylet::page(directory, nextPage)); if (!last) + { // LCOV_EXCL_START LogicError("Directory chain: fwd link broken."); + // LCOV_EXCL_STOP + } if (last->getFieldV256(sfIndexes).empty()) { @@ -292,10 +366,10 @@ ApplyView::dirRemove( // This can never happen for nodes other than the root: if (nextPage == page) - LogicError("Directory chain: fwd link broken"); + LogicError("Directory chain: fwd link broken"); // LCOV_EXCL_LINE if (prevPage == page) - LogicError("Directory chain: rev link broken"); + LogicError("Directory chain: rev link broken"); // LCOV_EXCL_LINE // This node isn't the root, so it can either be in the // middle of the list, or at the end. Unlink it first @@ -303,14 +377,14 @@ ApplyView::dirRemove( // root: auto prev = peek(keylet::page(directory, prevPage)); if (!prev) - LogicError("Directory chain: fwd link broken."); + LogicError("Directory chain: fwd link broken."); // LCOV_EXCL_LINE // Fix previous to point to its new next. prev->setFieldU64(sfIndexNext, nextPage); update(prev); auto next = peek(keylet::page(directory, nextPage)); if (!next) - LogicError("Directory chain: rev link broken."); + LogicError("Directory chain: rev link broken."); // LCOV_EXCL_LINE // Fix next to point to its new previous. next->setFieldU64(sfIndexPrevious, prevPage); update(next); @@ -334,7 +408,10 @@ ApplyView::dirRemove( // And the root points to the last page: auto root = peek(keylet::page(directory, rootPage)); if (!root) + { // LCOV_EXCL_START LogicError("Directory chain: root link broken."); + // LCOV_EXCL_STOP + } root->setFieldU64(sfIndexPrevious, prevPage); update(root); diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index 1b093b7017c..3db6e296aca 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -383,8 +383,8 @@ isLPTokenFrozen( isFrozen(view, account, asset2.currency, asset2.account); } -STAmount -accountHolds( +static SLE::const_pointer +getLineIfUsable( ReadView const& view, AccountID const& account, Currency const& currency, @@ -392,66 +392,76 @@ accountHolds( FreezeHandling zeroIfFrozen, beast::Journal j) { - STAmount amount; - if (isXRP(currency)) + auto const sle = view.read(keylet::line(account, issuer, currency)); + + if (!sle) { - return {xrpLiquid(view, account, 0, j)}; + return nullptr; } - // IOU: Return balance on trust line modulo freeze - auto const sle = view.read(keylet::line(account, issuer, currency)); - auto const allowBalance = [&]() { - if (!sle) + if (zeroIfFrozen == fhZERO_IF_FROZEN) + { + if (isFrozen(view, account, currency, issuer) || + isDeepFrozen(view, account, currency, issuer)) { - return false; + return nullptr; } - if (zeroIfFrozen == fhZERO_IF_FROZEN) + // when fixFrozenLPTokenTransfer is enabled, if currency is lptoken, + // we need to check if the associated assets have been frozen + if (view.rules().enabled(fixFrozenLPTokenTransfer)) { - if (isFrozen(view, account, currency, issuer) || - isDeepFrozen(view, account, currency, issuer)) + auto const sleIssuer = view.read(keylet::account(issuer)); + if (!sleIssuer) { - return false; + return nullptr; // LCOV_EXCL_LINE } - - // when fixFrozenLPTokenTransfer is enabled, if currency is lptoken, - // we need to check if the associated assets have been frozen - if (view.rules().enabled(fixFrozenLPTokenTransfer)) + else if (sleIssuer->isFieldPresent(sfAMMID)) { - auto const sleIssuer = view.read(keylet::account(issuer)); - if (!sleIssuer) - { - return false; // LCOV_EXCL_LINE - } - else if (sleIssuer->isFieldPresent(sfAMMID)) + auto const sleAmm = + view.read(keylet::amm((*sleIssuer)[sfAMMID])); + + if (!sleAmm || + isLPTokenFrozen( + view, + account, + (*sleAmm)[sfAsset].get(), + (*sleAmm)[sfAsset2].get())) { - auto const sleAmm = - view.read(keylet::amm((*sleIssuer)[sfAMMID])); - - if (!sleAmm || - isLPTokenFrozen( - view, - account, - (*sleAmm)[sfAsset].get(), - (*sleAmm)[sfAsset2].get())) - { - return false; - } + return nullptr; } } } + } - return true; - }(); + return sle; +} - if (allowBalance) +static STAmount +getTrustLineBalance( + ReadView const& view, + SLE::const_ref sle, + AccountID const& account, + Currency const& currency, + AccountID const& issuer, + bool includeOppositeLimit, + beast::Journal j) +{ + STAmount amount; + if (sle) { amount = sle->getFieldAmount(sfBalance); - if (account > issuer) + bool const accountHigh = account > issuer; + auto const& oppositeField = accountHigh ? sfLowLimit : sfHighLimit; + if (accountHigh) { // Put balance in account terms. amount.negate(); } + if (includeOppositeLimit) + { + amount += sle->getFieldAmount(oppositeField); + } amount.setIssuer(issuer); } else @@ -459,13 +469,35 @@ accountHolds( amount.clear(Issue{currency, issuer}); } - JLOG(j.trace()) << "accountHolds:" + JLOG(j.trace()) << "getTrustLineBalance:" << " account=" << to_string(account) << " amount=" << amount.getFullText(); return view.balanceHook(account, issuer, amount); } +STAmount +accountHolds( + ReadView const& view, + AccountID const& account, + Currency const& currency, + AccountID const& issuer, + FreezeHandling zeroIfFrozen, + beast::Journal j) +{ + STAmount amount; + if (isXRP(currency)) + { + return {xrpLiquid(view, account, 0, j)}; + } + + // 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); +} + STAmount accountHolds( ReadView const& view, @@ -550,6 +582,96 @@ accountHolds( 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>) + { + return accountSpendable(view, account, value, zeroIfFrozen, j); + } + return accountSpendable( + view, account, value, zeroIfFrozen, zeroIfUnauthorized, j); + }, + asset.value()); +} + STAmount accountFunds( ReadView const& view, @@ -1055,13 +1177,17 @@ describeOwnerDir(AccountID const& account) } TER -dirLink(ApplyView& view, AccountID const& owner, std::shared_ptr& object) +dirLink( + ApplyView& view, + AccountID const& owner, + std::shared_ptr& object, + SF_UINT64 const& node) { auto const page = view.dirInsert( keylet::ownerDir(owner), object->key(), describeOwnerDir(owner)); if (!page) return tecDIR_FULL; // LCOV_EXCL_LINE - object->setFieldU64(sfOwnerNode, *page); + object->setFieldU64(node, *page); return tesSUCCESS; } @@ -1097,7 +1223,8 @@ getPseudoAccountFields() { // LCOV_EXCL_START LogicError( - "ripple::isPseudoAccount : unable to find account root ledger " + "ripple::getPseudoAccountFields : unable to find account root " + "ledger " "format"); // LCOV_EXCL_STOP } @@ -1115,7 +1242,9 @@ getPseudoAccountFields() } [[nodiscard]] bool -isPseudoAccount(std::shared_ptr sleAcct) +isPseudoAccount( + std::shared_ptr sleAcct, + std::set const& pseudoFieldFilter) { auto const& fields = getPseudoAccountFields(); @@ -1123,8 +1252,12 @@ isPseudoAccount(std::shared_ptr sleAcct) // semantics of true return value clean. return sleAcct && sleAcct->getType() == ltACCOUNT_ROOT && std::count_if( - fields.begin(), fields.end(), [&sleAcct](SField const* sf) -> bool { - return sleAcct->isFieldPresent(*sf); + fields.begin(), + fields.end(), + [&sleAcct, &pseudoFieldFilter](SField const* sf) -> bool { + return sleAcct->isFieldPresent(*sf) && + (pseudoFieldFilter.empty() || + pseudoFieldFilter.contains(sf)); }) > 0; } @@ -1134,7 +1267,8 @@ createPseudoAccount( uint256 const& pseudoOwnerKey, SField const& ownerField) { - [[maybe_unused]] auto const& fields = getPseudoAccountFields(); + [[maybe_unused]] + auto const& fields = getPseudoAccountFields(); XRPL_ASSERT( std::count_if( fields.begin(), @@ -1156,9 +1290,10 @@ createPseudoAccount( // Pseudo-accounts can't submit transactions, so set the sequence number // to 0 to make them easier to spot and verify, and add an extra level // of protection. - std::uint32_t const seqno = // - view.rules().enabled(featureSingleAssetVault) // - ? 0 // + std::uint32_t const seqno = // + view.rules().enabled(featureSingleAssetVault) || // + view.rules().enabled(featureLendingProtocol) // + ? 0 // : view.seq(); account->setFieldU32(sfSequence, seqno); // Ignore reserves requirement, disable the master key, allow default @@ -1212,6 +1347,113 @@ canAddHolding(ReadView const& view, Asset const& asset) asset.value()); } +[[nodiscard]] TER +checkDestinationAndTag(SLE::const_ref toSle, bool hasDestinationTag) +{ + if (toSle == nullptr) + return tecNO_DST; + + // The tag is basically account-specific information we don't + // understand, but we can require someone to fill it in. + if (toSle->isFlag(lsfRequireDestTag) && !hasDestinationTag) + return tecDST_TAG_NEEDED; // Cannot send without a tag + + return tesSUCCESS; +} + +[[nodiscard]] TER +canWithdraw( + AccountID const& from, + ReadView const& view, + AccountID const& to, + SLE::const_ref toSle, + bool hasDestinationTag) +{ + if (auto const ret = checkDestinationAndTag(toSle, hasDestinationTag)) + return ret; + + if (from == to) + return tesSUCCESS; + + if (toSle->isFlag(lsfDepositAuth)) + { + if (!view.exists(keylet::depositPreauth(to, from))) + return tecNO_PERMISSION; + } + + return tesSUCCESS; +} + +[[nodiscard]] TER +canWithdraw( + AccountID const& from, + ReadView const& view, + AccountID const& to, + bool hasDestinationTag) +{ + auto const toSle = view.read(keylet::account(to)); + + return canWithdraw(from, view, to, toSle, hasDestinationTag); +} + +[[nodiscard]] TER +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)); +} + +TER +doWithdraw( + ApplyView& view, + STTx const& tx, + AccountID const& senderAcct, + AccountID const& dstAcct, + AccountID const& sourceAcct, + XRPAmount priorBalance, + STAmount const& amount, + beast::Journal j) +{ + // Create trust line or MPToken for the receiving account + if (dstAcct == senderAcct) + { + if (auto const ter = addEmptyHolding( + view, senderAcct, priorBalance, amount.asset(), j); + !isTesSuccess(ter) && ter != tecDUPLICATE) + return ter; + } + else + { + auto dstSle = view.peek(keylet::account(dstAcct)); + if (auto err = + verifyDepositPreauth(tx, view, senderAcct, dstAcct, dstSle, j)) + return err; + } + + // Sanity check + if (accountHolds( + view, + sourceAcct, + amount.asset(), + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j) < amount) + { + // LCOV_EXCL_START + JLOG(j.error()) << "LoanBrokerCoverWithdraw: negative balance of " + "broker cover assets."; + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + // Move the funds directly from the broker's pseudo-account to the + // dstAcct + return accountSend( + view, sourceAcct, dstAcct, amount, j, WaiveTransferFee::Yes); +} + [[nodiscard]] TER addEmptyHolding( ApplyView& view, @@ -1220,8 +1462,8 @@ addEmptyHolding( Issue const& issue, beast::Journal journal) { - // Every account can hold XRP. - if (issue.native()) + // Every account can hold XRP. An issuer can issue directly. + if (issue.native() || accountID == issue.getIssuer()) return tesSUCCESS; auto const& issuerId = issue.getIssuer(); @@ -1282,6 +1524,8 @@ addEmptyHolding( return tefINTERNAL; // LCOV_EXCL_LINE if (view.peek(keylet::mptoken(mptID, accountID))) return tecDUPLICATE; + if (accountID == mptIssue.getIssuer()) + return tesSUCCESS; return authorizeMPToken(view, priorBalance, mptID, accountID, journal); } @@ -1345,6 +1589,18 @@ authorizeMPToken( if (priorBalance < reserveCreate) return tecINSUFFICIENT_RESERVE; + // Defensive check before we attempt to create MPToken for the issuer + auto const mpt = view.read(keylet::mptIssuance(mptIssuanceID)); + if (!mpt || mpt->getAccountID(sfIssuer) == account) + { + // LCOV_EXCL_START + UNREACHABLE( + "ripple::authorizeMPToken : invalid issuance or issuers token"); + if (view.rules().enabled(featureLendingProtocol)) + return tecINTERNAL; + // LCOV_EXCL_STOP + } + auto const mptokenKey = keylet::mptoken(mptIssuanceID, account); auto mptoken = std::make_shared(mptokenKey); if (auto ter = dirLink(view, account, mptoken)) @@ -1420,6 +1676,14 @@ trustCreate( auto const& uLowAccountID = !bSrcHigh ? uSrcAccountID : uDstAccountID; auto const& uHighAccountID = bSrcHigh ? uSrcAccountID : uDstAccountID; + if (uLowAccountID == uHighAccountID) + { + // LCOV_EXCL_START + UNREACHABLE("ripple::trustCreate : trust line to self"); + if (view.rules().enabled(featureLendingProtocol)) + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const sleRippleState = std::make_shared(ltRIPPLE_STATE, uIndex); view.insert(sleRippleState); @@ -1534,10 +1798,13 @@ removeEmptyHolding( } // `asset` is an IOU. + // If the account is the issuer, then no line should exist. Check anyway. If + // a line does exist, it will get deleted. If not, return success. + bool const accountIsIssuer = accountID == issue.account; auto const line = view.peek(keylet::line(accountID, issue)); if (!line) - return tecOBJECT_NOT_FOUND; - if (line->at(sfBalance)->iou() != beast::zero) + return accountIsIssuer ? (TER)tesSUCCESS : (TER)tecOBJECT_NOT_FOUND; + if (!accountIsIssuer && line->at(sfBalance)->iou() != beast::zero) return tecHAS_OBLIGATIONS; // Adjust the owner count(s) @@ -1586,10 +1853,18 @@ removeEmptyHolding( MPTIssue const& mptIssue, beast::Journal journal) { + // If the account is the issuer, then no token should exist. MPTs do not + // have the legacy ability to create such a situation, but check anyway. If + // a token does exist, it will get deleted. If not, return success. + bool const accountIsIssuer = accountID == mptIssue.getIssuer(); auto const& mptID = mptIssue.getMptID(); auto const mptoken = view.peek(keylet::mptoken(mptID, accountID)); if (!mptoken) - return tecOBJECT_NOT_FOUND; + return accountIsIssuer ? (TER)tesSUCCESS : (TER)tecOBJECT_NOT_FOUND; + // Unlike a trust line, if the account is the issuer, and the token has a + // balance, it can not just be deleted, because that will throw the issuance + // accounting out of balance, so fail. Since this should be impossible + // anyway, I'm not going to put any effort into it. if (mptoken->at(sfMPTAmount) != 0) return tecHAS_OBLIGATIONS; @@ -1868,7 +2143,7 @@ rippleSendIOU( beast::Journal j, WaiveTransferFee waiveFee) { - auto const issuer = saAmount.getIssuer(); + auto const& issuer = saAmount.getIssuer(); XRPL_ASSERT( !isXRP(uSenderID) && !isXRP(uReceiverID), @@ -1910,6 +2185,87 @@ rippleSendIOU( return terResult; } +// Send regardless of limits. +// --> receivers: Amount/currency/issuer to deliver to receivers. +// <-- saActual: Amount actually cost to sender. Sender pays fees. +static TER +rippleSendMultiIOU( + ApplyView& view, + AccountID const& senderID, + Issue const& issue, + MultiplePaymentDestinations const& receivers, + STAmount& actual, + beast::Journal j, + WaiveTransferFee waiveFee) +{ + auto const& issuer = issue.getIssuer(); + + XRPL_ASSERT( + !isXRP(senderID), "ripple::rippleSendMultiIOU : sender is not XRP"); + + // These may diverge + STAmount takeFromSender{issue}; + actual = takeFromSender; + + // Failures return immediately. + for (auto const& r : receivers) + { + auto const& receiverID = r.first; + STAmount amount{issue, r.second}; + + /* If we aren't sending anything or if the sender is the same as the + * receiver then we don't need to do anything. + */ + if (!amount || (senderID == receiverID)) + continue; + + XRPL_ASSERT( + !isXRP(receiverID), + "ripple::rippleSendMultiIOU : receiver is not XRP"); + + if (senderID == issuer || receiverID == issuer || issuer == noAccount()) + { + // Direct send: redeeming IOUs and/or sending own IOUs. + if (auto const ter = rippleCreditIOU( + view, senderID, receiverID, amount, false, j)) + return ter; + actual += amount; + // Do not add amount to takeFromSender, because rippleCreditIOU took + // it. + + continue; + } + + // Sending 3rd party IOUs: transit. + + // Calculate the amount to transfer accounting + // for any transfer fees if the fee is not waived: + STAmount actualSend = (waiveFee == WaiveTransferFee::Yes) + ? amount + : multiply(amount, transferRate(view, issuer)); + actual += actualSend; + takeFromSender += actualSend; + + JLOG(j.debug()) << "rippleSendMultiIOU> " << to_string(senderID) + << " - > " << to_string(receiverID) + << " : deliver=" << amount.getFullText() + << " cost=" << actual.getFullText(); + + if (TER const terResult = + rippleCreditIOU(view, issuer, receiverID, amount, true, j)) + return terResult; + } + + if (senderID != issuer && takeFromSender) + { + if (TER const terResult = rippleCreditIOU( + view, senderID, issuer, takeFromSender, true, j)) + return terResult; + } + + return tesSUCCESS; +} + static TER accountSendIOU( ApplyView& view, @@ -2034,6 +2390,144 @@ accountSendIOU( return terResult; } +static TER +accountSendMultiIOU( + ApplyView& view, + AccountID const& senderID, + Issue const& issue, + MultiplePaymentDestinations const& receivers, + beast::Journal j, + WaiveTransferFee waiveFee) +{ + XRPL_ASSERT_PARTS( + receivers.size() > 1, + "ripple::accountSendMultiIOU", + "multiple recipients provided"); + + if (!issue.native()) + { + STAmount actual; + JLOG(j.trace()) << "accountSendMultiIOU: " << to_string(senderID) + << " sending " << receivers.size() << " IOUs"; + + return rippleSendMultiIOU( + view, senderID, issue, receivers, actual, j, waiveFee); + } + + /* XRP send which does not check reserve and can do pure adjustment. + * Note that sender or receiver may be null and this not a mistake; this + * setup could be used during pathfinding and it is carefully controlled to + * ensure that transfers are balanced. + */ + + SLE::pointer sender = senderID != beast::zero + ? view.peek(keylet::account(senderID)) + : SLE::pointer(); + + if (auto stream = j.trace()) + { + std::string sender_bal("-"); + + if (sender) + sender_bal = sender->getFieldAmount(sfBalance).getFullText(); + + stream << "accountSendMultiIOU> " << to_string(senderID) << " (" + << sender_bal << ") -> " << receivers.size() << " receivers."; + } + + // Failures return immediately. + STAmount takeFromSender{issue}; + for (auto const& r : receivers) + { + auto const& receiverID = r.first; + STAmount amount{issue, r.second}; + + if (amount < beast::zero) + { + return tecINTERNAL; // LCOV_EXCL_LINE + } + + /* If we aren't sending anything or if the sender is the same as the + * receiver then we don't need to do anything. + */ + if (!amount || (senderID == receiverID)) + continue; + + SLE::pointer receiver = receiverID != beast::zero + ? view.peek(keylet::account(receiverID)) + : SLE::pointer(); + + if (auto stream = j.trace()) + { + std::string receiver_bal("-"); + + if (receiver) + receiver_bal = + receiver->getFieldAmount(sfBalance).getFullText(); + + stream << "accountSendMultiIOU> " << to_string(senderID) << " -> " + << to_string(receiverID) << " (" << receiver_bal + << ") : " << amount.getFullText(); + } + + if (receiver) + { + // Increment XRP balance. + auto const rcvBal = receiver->getFieldAmount(sfBalance); + receiver->setFieldAmount(sfBalance, rcvBal + amount); + view.creditHook(xrpAccount(), receiverID, amount, -rcvBal); + + view.update(receiver); + + // Take what is actually sent + takeFromSender += amount; + } + + if (auto stream = j.trace()) + { + std::string receiver_bal("-"); + + if (receiver) + receiver_bal = + receiver->getFieldAmount(sfBalance).getFullText(); + + stream << "accountSendMultiIOU< " << to_string(senderID) << " -> " + << to_string(receiverID) << " (" << receiver_bal + << ") : " << amount.getFullText(); + } + } + + if (sender) + { + if (sender->getFieldAmount(sfBalance) < takeFromSender) + { + return TER{tecFAILED_PROCESSING}; + } + else + { + auto const sndBal = sender->getFieldAmount(sfBalance); + view.creditHook(senderID, xrpAccount(), takeFromSender, sndBal); + + // Decrement XRP balance. + sender->setFieldAmount(sfBalance, sndBal - takeFromSender); + view.update(sender); + } + } + + if (auto stream = j.trace()) + { + std::string sender_bal("-"); + std::string receiver_bal("-"); + + if (sender) + sender_bal = sender->getFieldAmount(sfBalance).getFullText(); + + stream << "accountSendMultiIOU< " << to_string(senderID) << " (" + << sender_bal << ") -> " << receivers.size() << " receivers."; + } + return tesSUCCESS; +} + static TER rippleCreditMPT( ApplyView& view, @@ -2044,7 +2538,7 @@ rippleCreditMPT( { // Do not check MPT authorization here - it must have been checked earlier auto const mptID = keylet::mptIssuance(saAmount.get().getMptID()); - auto const issuer = saAmount.getIssuer(); + auto const& issuer = saAmount.getIssuer(); auto sleIssuance = view.peek(mptID); if (!sleIssuance) return tecOBJECT_NOT_FOUND; @@ -2111,7 +2605,7 @@ rippleSendMPT( "ripple::rippleSendMPT : sender is not receiver"); // Safe to get MPT since rippleSendMPT is only called by accountSendMPT - auto const issuer = saAmount.getIssuer(); + auto const& issuer = saAmount.getIssuer(); auto const sle = view.read(keylet::mptIssuance(saAmount.get().getMptID())); @@ -2162,6 +2656,102 @@ rippleSendMPT( return rippleCreditMPT(view, uSenderID, issuer, saActual, j); } +static TER +rippleSendMultiMPT( + ApplyView& view, + AccountID const& senderID, + MPTIssue const& mptIssue, + MultiplePaymentDestinations const& receivers, + STAmount& actual, + beast::Journal j, + WaiveTransferFee waiveFee) +{ + // Safe to get MPT since rippleSendMultiMPT is only called by + // accountSendMultiMPT + auto const& issuer = mptIssue.getIssuer(); + + auto const sle = view.read(keylet::mptIssuance(mptIssue.getMptID())); + if (!sle) + return tecOBJECT_NOT_FOUND; + + // These may diverge + STAmount takeFromSender{mptIssue}; + actual = takeFromSender; + + for (auto const& r : receivers) + { + auto const& receiverID = r.first; + STAmount amount{mptIssue, r.second}; + + if (amount < beast::zero) + { + return tecINTERNAL; // LCOV_EXCL_LINE + } + + /* If we aren't sending anything or if the sender is the same as the + * receiver then we don't need to do anything. + */ + if (!amount || (senderID == receiverID)) + continue; + + if (senderID == issuer || receiverID == issuer) + { + // if sender is issuer, check that the new OutstandingAmount will + // not exceed MaximumAmount + if (senderID == issuer) + { + XRPL_ASSERT_PARTS( + takeFromSender == beast::zero, + "rippler::rippleSendMultiMPT", + "sender == issuer, takeFromSender == zero"); + auto const sendAmount = amount.mpt().value(); + auto const maximumAmount = + sle->at(~sfMaximumAmount).value_or(maxMPTokenAmount); + if (sendAmount > maximumAmount || + sle->getFieldU64(sfOutstandingAmount) > + maximumAmount - sendAmount) + return tecPATH_DRY; + } + + // Direct send: redeeming MPTs and/or sending own MPTs. + if (auto const ter = + rippleCreditMPT(view, senderID, receiverID, amount, j)) + return ter; + actual += amount; + // Do not add amount to takeFromSender, because rippleCreditMPT took + // it + + continue; + } + + // Sending 3rd party MPTs: transit. + STAmount actualSend = (waiveFee == WaiveTransferFee::Yes) + ? amount + : multiply( + amount, + transferRate(view, amount.get().getMptID())); + actual += actualSend; + takeFromSender += actualSend; + + JLOG(j.debug()) << "rippleSendMultiMPT> " << to_string(senderID) + << " - > " << to_string(receiverID) + << " : deliver=" << amount.getFullText() + << " cost=" << actualSend.getFullText(); + + if (auto const terResult = + rippleCreditMPT(view, issuer, receiverID, amount, j)) + return terResult; + } + if (senderID != issuer && takeFromSender) + { + if (TER const terResult = + rippleCreditMPT(view, senderID, issuer, takeFromSender, j)) + return terResult; + } + + return tesSUCCESS; +} + static TER accountSendMPT( ApplyView& view, @@ -2187,6 +2777,21 @@ accountSendMPT( view, uSenderID, uReceiverID, saAmount, saActual, j, waiveFee); } +static TER +accountSendMultiMPT( + ApplyView& view, + AccountID const& senderID, + MPTIssue const& mptIssue, + MultiplePaymentDestinations const& receivers, + beast::Journal j, + WaiveTransferFee waiveFee) +{ + STAmount actual; + + return rippleSendMultiMPT( + view, senderID, mptIssue, receivers, actual, j, waiveFee); +} + TER accountSend( ApplyView& view, @@ -2208,6 +2813,31 @@ accountSend( saAmount.asset().value()); } +TER +accountSendMulti( + ApplyView& view, + AccountID const& senderID, + Asset const& asset, + MultiplePaymentDestinations const& receivers, + beast::Journal j, + WaiveTransferFee waiveFee) +{ + XRPL_ASSERT_PARTS( + receivers.size() > 1, + "ripple::accountSendMulti", + "multiple recipients provided"); + return std::visit( + [&](TIss const& issue) { + if constexpr (std::is_same_v) + return accountSendMultiIOU( + view, senderID, issue, receivers, j, waiveFee); + else + return accountSendMultiMPT( + view, senderID, issue, receivers, j, waiveFee); + }, + asset.value()); +} + static bool updateTrustLine( ApplyView& view, @@ -2532,7 +3162,10 @@ requireAuth( if (mptIssuer == account) // Issuer won't have MPToken return tesSUCCESS; - if (view.rules().enabled(featureSingleAssetVault)) + bool const featureSAVEnabled = + view.rules().enabled(featureSingleAssetVault); + + if (featureSAVEnabled) { if (depth >= maxAssetCheckDepth) return tecINTERNAL; // LCOV_EXCL_LINE @@ -2591,6 +3224,13 @@ requireAuth( // belong to someone who is explicitly authorized e.g. a vault owner. } + if (featureSAVEnabled) + { + // Implicitly authorize Vault and LoanBroker pseudo-accounts + if (isPseudoAccount(view, account, {&sfVaultID, &sfLoanBrokerID})) + return tesSUCCESS; + } + // mptoken must be authorized if issuance enabled requireAuth if (sleIssuance->isFlag(lsfMPTRequireAuth) && (!sleToken || !sleToken->isFlag(lsfMPTAuthorized))) @@ -2720,6 +3360,42 @@ canTransfer( return tesSUCCESS; } +[[nodiscard]] TER +canTransfer( + ReadView const& view, + Issue const& issue, + AccountID const& from, + AccountID const& to) +{ + if (issue.native()) + return tesSUCCESS; + + auto const& issuerId = issue.getIssuer(); + if (issuerId == from || issuerId == to) + return tesSUCCESS; + auto const sleIssuer = view.read(keylet::account(issuerId)); + if (sleIssuer == nullptr) + return tefINTERNAL; // LCOV_EXCL_LINE + + auto const isRippleDisabled = [&](AccountID account) -> bool { + // Line might not exist, but some transfers can create it. If this + // is the case, just check the default ripple on the issuer account. + auto const line = view.read(keylet::line(account, issue)); + if (line) + { + bool const issuerHigh = issuerId > account; + return line->isFlag(issuerHigh ? lsfHighNoRipple : lsfLowNoRipple); + } + return sleIssuer->isFlag(lsfDefaultRipple) == false; + }; + + // Fail if rippling disabled on both trust lines + if (isRippleDisabled(from) && isRippleDisabled(to)) + return terNO_RIPPLE; + + return tesSUCCESS; +} + TER cleanupOnAccountDelete( ApplyView& view, diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index c5958e6fdc3..6de00962b60 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -36,7 +36,7 @@ namespace BuildInfo { // and follow the format described at http://semver.org/ //------------------------------------------------------------------------------ // clang-format off -char const* const versionString = "3.0.0-rc1" +char const* const versionString = "3.0.0-rc2" // clang-format on #if defined(DEBUG) || defined(SANITIZER) diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index 486945992ab..d87241b7199 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -96,6 +96,8 @@ enum class LedgerNameSpace : std::uint16_t { PERMISSIONED_DOMAIN = 'm', DELEGATE = 'E', VAULT = 'V', + LOAN_BROKER = 'l', // lower-case L + LOAN = 'L', // No longer used or supported. Left here to reserve the space // to avoid accidental reuse. @@ -566,6 +568,18 @@ vault(AccountID const& owner, std::uint32_t seq) noexcept return vault(indexHash(LedgerNameSpace::VAULT, owner, seq)); } +Keylet +loanbroker(AccountID const& owner, std::uint32_t seq) noexcept +{ + return loanbroker(indexHash(LedgerNameSpace::LOAN_BROKER, owner, seq)); +} + +Keylet +loan(uint256 const& loanBrokerID, std::uint32_t loanSeq) noexcept +{ + return loan(indexHash(LedgerNameSpace::LOAN, loanBrokerID, loanSeq)); +} + Keylet permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept { diff --git a/src/libxrpl/protocol/InnerObjectFormats.cpp b/src/libxrpl/protocol/InnerObjectFormats.cpp index 2de5e6624ef..ebb6646f65b 100644 --- a/src/libxrpl/protocol/InnerObjectFormats.cpp +++ b/src/libxrpl/protocol/InnerObjectFormats.cpp @@ -172,6 +172,14 @@ InnerObjectFormats::InnerObjectFormats() {sfBookDirectory, soeREQUIRED}, {sfBookNode, soeREQUIRED}, }); + + add(sfCounterpartySignature.jsonName, + sfCounterpartySignature.getCode(), + { + {sfSigningPubKey, soeOPTIONAL}, + {sfTxnSignature, soeOPTIONAL}, + {sfSigners, soeOPTIONAL}, + }); } InnerObjectFormats const& diff --git a/src/libxrpl/protocol/STAmount.cpp b/src/libxrpl/protocol/STAmount.cpp index 0c722448853..57d33bebfad 100644 --- a/src/libxrpl/protocol/STAmount.cpp +++ b/src/libxrpl/protocol/STAmount.cpp @@ -321,7 +321,7 @@ STAmount::xrp() const IOUAmount STAmount::iou() const { - if (native() || !holds()) + if (integral()) Throw("Cannot return non-IOU STAmount as IOUAmount"); auto mantissa = static_cast(mValue); @@ -872,7 +872,7 @@ STAmount::isDefault() const void STAmount::canonicalize() { - if (native() || mAsset.holds()) + if (integral()) { // native and MPT currency amounts should always have an offset of zero // log(2^64,10) ~ 19.2 @@ -905,8 +905,10 @@ STAmount::canonicalize() }; if (native()) set(XRPAmount{num}); - else + else if (mAsset.holds()) set(MPTAmount{num}); + else + Throw("Unknown integral asset type"); mOffset = 0; } else @@ -1135,7 +1137,7 @@ amountFromJson(SField const& name, Json::Value const& v) } else { - parts.mantissa = -value.asInt(); + parts.mantissa = value.asAbsUInt(); parts.negative = true; } } @@ -1509,6 +1511,33 @@ canonicalizeRoundStrict( } } +STAmount +roundToScale( + STAmount const& value, + std::int32_t scale, + Number::rounding_mode rounding) +{ + // Nothing to do for integral types. + if (value.integral()) + 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. + if (value.exponent() >= scale) + return value; + + STAmount const referenceValue{ + value.asset(), STAmount::cMinValue, scale, value.negative()}; + + NumberRoundModeGuard mg(rounding); + // With an IOU, the the result of addition will be truncated to the + // precision of the larger value, which in this case is referenceValue. Then + // remove the reference value via subtraction, and we're left with the + // rounded value. + return (value + referenceValue) - referenceValue; +} + namespace { // We need a class that has an interface similar to NumberRoundModeGuard diff --git a/src/libxrpl/protocol/STNumber.cpp b/src/libxrpl/protocol/STNumber.cpp index 975fd5723b5..efda102fa47 100644 --- a/src/libxrpl/protocol/STNumber.cpp +++ b/src/libxrpl/protocol/STNumber.cpp @@ -188,7 +188,7 @@ numberFromJson(SField const& field, Json::Value const& value) } else { - parts.mantissa = -value.asInt(); + parts.mantissa = value.asAbsUInt(); parts.negative = true; } } diff --git a/src/libxrpl/protocol/STObject.cpp b/src/libxrpl/protocol/STObject.cpp index 77e5fd1ad95..8fb21a638d4 100644 --- a/src/libxrpl/protocol/STObject.cpp +++ b/src/libxrpl/protocol/STObject.cpp @@ -688,6 +688,16 @@ STObject::getFieldV256(SField const& field) const return getFieldByConstRef(field, empty); } +STObject +STObject::getFieldObject(SField const& field) const +{ + STObject const empty{field}; + auto ret = getFieldByConstRef(field, empty); + if (ret != empty) + ret.applyTemplateFromSField(field); + return ret; +} + STArray const& STObject::getFieldArray(SField const& field) const { @@ -833,6 +843,12 @@ STObject::setFieldArray(SField const& field, STArray const& v) setFieldUsingAssignment(field, v); } +void +STObject::setFieldObject(SField const& field, STObject const& v) +{ + setFieldUsingAssignment(field, v); +} + Json::Value STObject::getJson(JsonOptions options) const { diff --git a/src/libxrpl/protocol/STTx.cpp b/src/libxrpl/protocol/STTx.cpp index 8be8f906a5e..b156ea0901c 100644 --- a/src/libxrpl/protocol/STTx.cpp +++ b/src/libxrpl/protocol/STTx.cpp @@ -200,11 +200,11 @@ STTx::getSigningHash() const } Blob -STTx::getSignature() const +STTx::getSignature(STObject const& sigObject) { try { - return getFieldVL(sfTxnSignature); + return sigObject.getFieldVL(sfTxnSignature); } catch (std::exception const&) { @@ -234,30 +234,43 @@ STTx::getSeqValue() const } void -STTx::sign(PublicKey const& publicKey, SecretKey const& secretKey) +STTx::sign( + PublicKey const& publicKey, + SecretKey const& secretKey, + std::optional> signatureTarget) { auto const data = getSigningData(*this); auto const sig = ripple::sign(publicKey, secretKey, makeSlice(data)); - setFieldVL(sfTxnSignature, sig); + if (signatureTarget) + { + auto& target = peekFieldObject(*signatureTarget); + target.setFieldVL(sfTxnSignature, sig); + } + else + { + setFieldVL(sfTxnSignature, sig); + } tid_ = getHash(HashPrefix::transactionID); } Expected STTx::checkSign( RequireFullyCanonicalSig requireCanonicalSig, - Rules const& rules) const + Rules const& rules, + STObject const& sigObject) const { try { // Determine whether we're single- or multi-signing by looking // at the SigningPubKey. If it's empty we must be // multi-signing. Otherwise we're single-signing. - Blob const& signingPubKey = getFieldVL(sfSigningPubKey); + + Blob const& signingPubKey = sigObject.getFieldVL(sfSigningPubKey); return signingPubKey.empty() - ? checkMultiSign(requireCanonicalSig, rules) - : checkSingleSign(requireCanonicalSig); + ? checkMultiSign(requireCanonicalSig, rules, sigObject) + : checkSingleSign(requireCanonicalSig, sigObject); } catch (std::exception const&) { @@ -265,6 +278,24 @@ STTx::checkSign( return Unexpected("Internal signature check failure."); } +Expected +STTx::checkSign( + RequireFullyCanonicalSig requireCanonicalSig, + Rules const& rules) const +{ + if (auto const ret = checkSign(requireCanonicalSig, rules, *this); !ret) + return ret; + + if (isFieldPresent(sfCounterpartySignature)) + { + auto const counterSig = getFieldObject(sfCounterpartySignature); + if (auto const ret = checkSign(requireCanonicalSig, rules, counterSig); + !ret) + return Unexpected("Counterparty: " + ret.error()); + } + return {}; +} + Expected STTx::checkBatchSign( RequireFullyCanonicalSig requireCanonicalSig, @@ -382,23 +413,23 @@ STTx::getMetaSQL( static Expected singleSignHelper( - STObject const& signer, + STObject const& sigObject, Slice const& data, bool const fullyCanonical) { // We don't allow both a non-empty sfSigningPubKey and an sfSigners. // That would allow the transaction to be signed two ways. So if both // fields are present the signature is invalid. - if (signer.isFieldPresent(sfSigners)) + if (sigObject.isFieldPresent(sfSigners)) return Unexpected("Cannot both single- and multi-sign."); bool validSig = false; try { - auto const spk = signer.getFieldVL(sfSigningPubKey); + auto const spk = sigObject.getFieldVL(sfSigningPubKey); if (publicKeyType(makeSlice(spk))) { - Blob const signature = signer.getFieldVL(sfTxnSignature); + Blob const signature = sigObject.getFieldVL(sfTxnSignature); validSig = verify( PublicKey(makeSlice(spk)), data, @@ -418,12 +449,14 @@ singleSignHelper( } Expected -STTx::checkSingleSign(RequireFullyCanonicalSig requireCanonicalSig) const +STTx::checkSingleSign( + RequireFullyCanonicalSig requireCanonicalSig, + STObject const& sigObject) const { auto const data = getSigningData(*this); bool const fullyCanonical = (getFlags() & tfFullyCanonicalSig) || (requireCanonicalSig == STTx::RequireFullyCanonicalSig::yes); - return singleSignHelper(*this, makeSlice(data), fullyCanonical); + return singleSignHelper(sigObject, makeSlice(data), fullyCanonical); } Expected @@ -440,31 +473,29 @@ STTx::checkBatchSingleSign( Expected multiSignHelper( - STObject const& signerObj, + STObject const& sigObject, + std::optional txnAccountID, bool const fullyCanonical, std::function makeMsg, Rules const& rules) { // Make sure the MultiSigners are present. Otherwise they are not // attempting multi-signing and we just have a bad SigningPubKey. - if (!signerObj.isFieldPresent(sfSigners)) + if (!sigObject.isFieldPresent(sfSigners)) return Unexpected("Empty SigningPubKey."); // We don't allow both an sfSigners and an sfTxnSignature. Both fields // being present would indicate that the transaction is signed both ways. - if (signerObj.isFieldPresent(sfTxnSignature)) + if (sigObject.isFieldPresent(sfTxnSignature)) return Unexpected("Cannot both single- and multi-sign."); - STArray const& signers{signerObj.getFieldArray(sfSigners)}; + STArray const& signers{sigObject.getFieldArray(sfSigners)}; // There are well known bounds that the number of signers must be within. if (signers.size() < STTx::minMultiSigners || signers.size() > STTx::maxMultiSigners(&rules)) return Unexpected("Invalid Signers array size."); - // We also use the sfAccount field inside the loop. Get it once. - auto const txnAccountID = signerObj.getAccountID(sfAccount); - // Signers must be in sorted order by AccountID. AccountID lastAccountID(beast::zero); @@ -472,8 +503,10 @@ multiSignHelper( { auto const accountID = signer.getAccountID(sfAccount); - // The account owner may not multisign for themselves. - if (accountID == txnAccountID) + // The account owner may not usually multisign for themselves. + // If they can, txnAccountID will be unseated, which is not equal to any + // value. + if (txnAccountID == accountID) return Unexpected("Invalid multisigner."); // No duplicate signers allowed. @@ -489,6 +522,7 @@ multiSignHelper( // Verify the signature. bool validSig = false; + std::optional errorWhat; try { auto spk = signer.getFieldVL(sfSigningPubKey); @@ -502,15 +536,16 @@ multiSignHelper( fullyCanonical); } } - catch (std::exception const&) + catch (std::exception const& e) { // We assume any problem lies with the signature. validSig = false; + errorWhat = e.what(); } if (!validSig) return Unexpected( std::string("Invalid signature on account ") + - toBase58(accountID) + "."); + toBase58(accountID) + errorWhat.value_or("") + "."); } // All signatures verified. return {}; @@ -532,8 +567,9 @@ STTx::checkBatchMultiSign( serializeBatch(dataStart, getFlags(), getBatchTransactionIDs()); return multiSignHelper( batchSigner, + std::nullopt, fullyCanonical, - [&dataStart](AccountID const& accountID) mutable -> Serializer { + [&dataStart](AccountID const& accountID) -> Serializer { Serializer s = dataStart; finishMultiSigningData(accountID, s); return s; @@ -544,19 +580,27 @@ STTx::checkBatchMultiSign( Expected STTx::checkMultiSign( RequireFullyCanonicalSig requireCanonicalSig, - Rules const& rules) const + Rules const& rules, + STObject const& sigObject) const { bool const fullyCanonical = (getFlags() & tfFullyCanonicalSig) || (requireCanonicalSig == RequireFullyCanonicalSig::yes); + // Used inside the loop in multiSignHelper to enforce that + // the account owner may not multisign for themselves. + auto const txnAccountID = &sigObject != this + ? std::nullopt + : std::optional(getAccountID(sfAccount)); + // We can ease the computational load inside the loop a bit by // pre-constructing part of the data that we hash. Fill a Serializer // with the stuff that stays constant from signature to signature. Serializer dataStart = startMultiSigningData(*this); return multiSignHelper( - *this, + sigObject, + txnAccountID, fullyCanonical, - [&dataStart](AccountID const& accountID) mutable -> Serializer { + [&dataStart](AccountID const& accountID) -> Serializer { Serializer s = dataStart; finishMultiSigningData(accountID, s); return s; @@ -569,7 +613,7 @@ STTx::checkMultiSign( * * This function returns a vector of transaction IDs by extracting them from * the field array `sfRawTransactions` within the STTx. If the batch - * transaction IDs have already been computed and cached in `batch_txn_ids_`, + * transaction IDs have already been computed and cached in `batchTxnIds_`, * it returns the cached vector. Otherwise, it computes the transaction IDs, * caches them, and then returns the vector. * @@ -579,7 +623,7 @@ STTx::checkMultiSign( * empty and that the size of the computed batch transaction IDs matches the * size of the `sfRawTransactions` field array. */ -std::vector +std::vector const& STTx::getBatchTransactionIDs() const { XRPL_ASSERT( @@ -588,16 +632,20 @@ STTx::getBatchTransactionIDs() const XRPL_ASSERT( getFieldArray(sfRawTransactions).size() != 0, "STTx::getBatchTransactionIDs : empty raw transactions"); - if (batch_txn_ids_.size() != 0) - return batch_txn_ids_; - for (STObject const& rb : getFieldArray(sfRawTransactions)) - batch_txn_ids_.push_back(rb.getHash(HashPrefix::transactionID)); + // The list of inner ids is built once, then reused on subsequent calls. + // After the list is built, it must always have the same size as the array + // `sfRawTransactions`. The assert below verifies that. + if (batchTxnIds_.size() == 0) + { + for (STObject const& rb : getFieldArray(sfRawTransactions)) + batchTxnIds_.push_back(rb.getHash(HashPrefix::transactionID)); + } XRPL_ASSERT( - batch_txn_ids_.size() == getFieldArray(sfRawTransactions).size(), + batchTxnIds_.size() == getFieldArray(sfRawTransactions).size(), "STTx::getBatchTransactionIDs : batch transaction IDs size mismatch"); - return batch_txn_ids_; + return batchTxnIds_; } //------------------------------------------------------------------------------ diff --git a/src/test/app/Batch_test.cpp b/src/test/app/Batch_test.cpp index 92f286ca6a9..1dac56d2d92 100644 --- a/src/test/app/Batch_test.cpp +++ b/src/test/app/Batch_test.cpp @@ -2553,6 +2553,207 @@ class Batch_test : public beast::unit_test::suite } } + void + testLoan(FeatureBitset features) + { + testcase("loan"); + + bool const lendingBatchEnabled = !std::any_of( + Batch::disabledTxTypes.begin(), + Batch::disabledTxTypes.end(), + [](auto const& disabled) { return disabled == ttLOAN_BROKER_SET; }); + + using namespace test::jtx; + + test::jtx::Env env{ + *this, + envconfig(), + features | featureSingleAssetVault | featureLendingProtocol | + featureMPTokensV1}; + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + // Borrower only wants to borrow + Account const borrower{"borrower"}; + + // Fund the accounts and trust lines with the same amount so that tests + // can use the same values regardless of the asset. + env.fund(XRP(100'000), issuer, noripple(lender, borrower)); + env.close(); + + // Just use an XRP asset + PrettyAsset const asset{xrpIssue(), 1'000'000}; + + Vault vault{env}; + + auto const deposit = asset(50'000); + auto const debtMaximumValue = asset(25'000).value(); + auto const coverDepositValue = asset(1000).value(); + + auto [tx, vaultKeylet] = + vault.create({.owner = lender, .asset = asset}); + env(tx); + env.close(); + BEAST_EXPECT(env.le(vaultKeylet)); + + env(vault.deposit( + {.depositor = lender, .id = vaultKeylet.key, .amount = deposit})); + env.close(); + + auto const brokerKeylet = + keylet::loanbroker(lender.id(), env.seq(lender)); + + { + using namespace loanBroker; + env(set(lender, vaultKeylet.key), + managementFeeRate(TenthBips16(100)), + debtMaximum(debtMaximumValue), + coverRateMinimum(TenthBips32(percentageToTenthBips(10))), + coverRateLiquidation(TenthBips32(percentageToTenthBips(25)))); + + env(coverDeposit(lender, brokerKeylet.key, coverDepositValue)); + + env.close(); + } + + { + using namespace loan; + using namespace std::chrono_literals; + + auto const lenderSeq = env.seq(lender); + auto const batchFee = batch::calcBatchFee(env, 0, 2); + + auto const loanKeylet = keylet::loan(brokerKeylet.key, 1); + { + auto const [txIDs, batchID] = submitBatch( + env, + lendingBatchEnabled ? temBAD_SIGNATURE + : temINVALID_INNER_BATCH, + batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing), + batch::inner( + env.json( + set(lender, brokerKeylet.key, asset(1000).value()), + // Not allowed to include the counterparty signature + sig(sfCounterpartySignature, borrower), + sig(none), + fee(none), + seq(none)), + lenderSeq + 1), + batch::inner( + pay(lender, + loanKeylet.key, + STAmount{asset, asset(500).value()}), + lenderSeq + 2)); + } + { + auto const [txIDs, batchID] = submitBatch( + env, + temINVALID_INNER_BATCH, + batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing), + batch::inner( + env.json( + set(lender, brokerKeylet.key, asset(1000).value()), + // Counterparty must be set + sig(none), + fee(none), + seq(none)), + lenderSeq + 1), + batch::inner( + pay(lender, + loanKeylet.key, + STAmount{asset, asset(500).value()}), + lenderSeq + 2)); + } + { + auto const [txIDs, batchID] = submitBatch( + env, + lendingBatchEnabled ? temBAD_SIGNER + : temINVALID_INNER_BATCH, + batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing), + batch::inner( + env.json( + set(lender, brokerKeylet.key, asset(1000).value()), + // Counterparty must sign the outer transaction + counterparty(borrower.id()), + sig(none), + fee(none), + seq(none)), + lenderSeq + 1), + batch::inner( + pay(lender, + loanKeylet.key, + STAmount{asset, asset(500).value()}), + lenderSeq + 2)); + } + { + // LoanSet normally charges at least 2x base fee, but since the + // signature check is done by the batch, it only charges the + // base fee. + auto const batchFee = batch::calcBatchFee(env, 1, 2); + auto const [txIDs, batchID] = submitBatch( + env, + lendingBatchEnabled ? TER(tesSUCCESS) + : TER(temINVALID_INNER_BATCH), + batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing), + batch::inner( + env.json( + set(lender, brokerKeylet.key, asset(1000).value()), + counterparty(borrower.id()), + sig(none), + fee(none), + seq(none)), + lenderSeq + 1), + batch::inner( + pay( + // However, this inner transaction will fail, + // because the lender is not allowed to draw the + // transaction + lender, + loanKeylet.key, + STAmount{asset, asset(500).value()}), + lenderSeq + 2), + batch::sig(borrower)); + } + env.close(); + BEAST_EXPECT(env.le(brokerKeylet)); + BEAST_EXPECT(!env.le(loanKeylet)); + { + // LoanSet normally charges at least 2x base fee, but since the + // signature check is done by the batch, it only charges the + // base fee. + auto const lenderSeq = env.seq(lender); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + auto const [txIDs, batchID] = submitBatch( + env, + lendingBatchEnabled ? TER(tesSUCCESS) + : TER(temINVALID_INNER_BATCH), + batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing), + batch::inner( + env.json( + set(lender, brokerKeylet.key, asset(1000).value()), + counterparty(borrower.id()), + sig(none), + fee(none), + seq(none)), + lenderSeq + 1), + batch::inner( + manage(lender, loanKeylet.key, tfLoanImpair), + lenderSeq + 2), + batch::sig(borrower)); + } + env.close(); + BEAST_EXPECT(env.le(brokerKeylet)); + if (auto const sleLoan = env.le(loanKeylet); lendingBatchEnabled + ? BEAST_EXPECT(sleLoan) + : !BEAST_EXPECT(!sleLoan)) + { + BEAST_EXPECT(sleLoan->isFlag(lsfLoanImpaired)); + } + } + } + void testObjectCreateSequence(FeatureBitset features) { @@ -4147,6 +4348,7 @@ class Batch_test : public beast::unit_test::suite testAccountActivation(features); testAccountSet(features); testAccountDelete(features); + testLoan(features); testObjectCreateSequence(features); testObjectCreateTicket(features); testObjectCreate3rdParty(features); diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 925776a1b46..3237e61e357 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -174,9 +174,13 @@ class Invariants_test : public beast::unit_test::suite {{"an account root was deleted"}}, [](Account const& A1, Account const&, ApplyContext& ac) { // remove an account from the view - auto const sle = ac.view().peek(keylet::account(A1.id())); + auto sle = ac.view().peek(keylet::account(A1.id())); if (!sle) return false; + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sle->at(sfBalance) = beast::zero; ac.view().erase(sle); return true; }); @@ -200,10 +204,15 @@ class Invariants_test : public beast::unit_test::suite {{"account deletion succeeded but deleted multiple accounts"}}, [](Account const& A1, Account const& A2, ApplyContext& ac) { // remove two accounts from the view - auto const sleA1 = ac.view().peek(keylet::account(A1.id())); - auto const sleA2 = ac.view().peek(keylet::account(A2.id())); + auto sleA1 = ac.view().peek(keylet::account(A1.id())); + auto sleA2 = ac.view().peek(keylet::account(A2.id())); if (!sleA1 || !sleA2) return false; + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sleA1->at(sfBalance) = beast::zero; + sleA2->at(sfBalance) = beast::zero; ac.view().erase(sleA1); ac.view().erase(sleA2); return true; @@ -218,6 +227,46 @@ class Invariants_test : public beast::unit_test::suite using namespace test::jtx; testcase << "account root deletion left artifact"; + doInvariantCheck( + {{"account deletion left behind a non-zero balance"}}, + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + // A1 has a balance. Delete A1 + auto const a1 = A1.id(); + auto const sleA1 = ac.view().peek(keylet::account(a1)); + if (!sleA1) + return false; + if (!BEAST_EXPECT(*sleA1->at(sfBalance) != beast::zero)) + return false; + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + + doInvariantCheck( + {{"account deletion left behind a non-zero owner count"}}, + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + // Increment A1's owner count, then delete A1 + auto const a1 = A1.id(); + auto const sleA1 = ac.view().peek(keylet::account(a1)); + if (!sleA1) + return false; + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sleA1->at(sfBalance) = beast::zero; + BEAST_EXPECT(sleA1->at(sfOwnerCount) == 0); + adjustOwnerCount(ac.view(), sleA1, 1, ac.journal); + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + for (auto const& keyletInfo : directAccountKeylets) { // TODO: Use structured binding once LLVM 16 is the minimum @@ -238,29 +287,38 @@ class Invariants_test : public beast::unit_test::suite // Add an object to the ledger for account A1, then delete // A1 auto const a1 = A1.id(); - auto const sleA1 = ac.view().peek(keylet::account(a1)); + auto sleA1 = ac.view().peek(keylet::account(a1)); if (!sleA1) return false; auto const key = std::invoke(keyletfunc, a1); auto const newSLE = std::make_shared(key); ac.view().insert(newSLE); + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the + // desired check. + sleA1->at(sfBalance) = beast::zero; ac.view().erase(sleA1); return true; }, XRPAmount{}, STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - }; + } // NFT special case doInvariantCheck( {{"account deletion left behind a NFTokenPage object"}}, [&](Account const& A1, Account const&, ApplyContext& ac) { // remove an account from the view - auto const sle = ac.view().peek(keylet::account(A1.id())); + auto sle = ac.view().peek(keylet::account(A1.id())); if (!sle) return false; + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sle->at(sfBalance) = beast::zero; + sle->at(sfOwnerCount) = 0; ac.view().erase(sle); return true; }, @@ -284,13 +342,18 @@ class Invariants_test : public beast::unit_test::suite [&](Account const& A1, Account const& A2, ApplyContext& ac) { // Delete the AMM account without cleaning up the directory or // deleting the AMM object - auto const sle = ac.view().peek(keylet::account(ammAcctID)); + auto sle = ac.view().peek(keylet::account(ammAcctID)); if (!sle) return false; BEAST_EXPECT(sle->at(~sfAMMID)); BEAST_EXPECT(sle->at(~sfAMMID) == ammKey); + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sle->at(sfBalance) = beast::zero; + sle->at(sfOwnerCount) = 0; ac.view().erase(sle); return true; @@ -313,7 +376,7 @@ class Invariants_test : public beast::unit_test::suite // Delete all the AMM's trust lines, remove the AMM from the AMM // account's directory (this deletes the directory), and delete // the AMM account. Do not delete the AMM object. - auto const sle = ac.view().peek(keylet::account(ammAcctID)); + auto sle = ac.view().peek(keylet::account(ammAcctID)); if (!sle) return false; @@ -353,6 +416,11 @@ class Invariants_test : public beast::unit_test::suite !ac.view().exists(ownerDirKeylet) || ac.view().emptyDirDelete(ownerDirKeylet)); + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sle->at(sfBalance) = beast::zero; + sle->at(sfOwnerCount) = 0; ac.view().erase(sle); return true; @@ -1741,6 +1809,359 @@ class Invariants_test : public beast::unit_test::suite {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); } + Keylet + createLoanBroker( + jtx::Account const& a, + jtx::Env& env, + jtx::PrettyAsset const& asset) + { + using namespace jtx; + + // Create vault + uint256 vaultID; + Vault vault{env}; + auto [tx, vKeylet] = vault.create({.owner = a, .asset = asset}); + env(tx); + BEAST_EXPECT(env.le(vKeylet)); + + vaultID = vKeylet.key; + + // Create Loan Broker + using namespace loanBroker; + + auto const loanBrokerKeylet = keylet::loanbroker(a.id(), env.seq(a)); + // Create a Loan Broker with all default values. + env(set(a, vaultID), fee(increment)); + + return loanBrokerKeylet; + }; + + void + testNoModifiedUnmodifiableFields() + { + testcase("no modified unmodifiable fields"); + using namespace jtx; + + // Initialize with a placeholder value because there's no default ctor + Keylet loanBrokerKeylet = keylet::amendments(); + Preclose createLoanBroker = + [&, this](Account const& a, Account const& b, Env& env) { + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + loanBrokerKeylet = this->createLoanBroker(a, env, xrpAsset); + return BEAST_EXPECT(env.le(loanBrokerKeylet)); + }; + + { + auto const mods = + std::to_array>({ + [](SLE::pointer& sle) { sle->at(sfSequence) += 1; }, + [](SLE::pointer& sle) { sle->at(sfOwnerNode) += 1; }, + [](SLE::pointer& sle) { sle->at(sfVaultNode) += 1; }, + [](SLE::pointer& sle) { sle->at(sfVaultID) = uint256(1u); }, + [](SLE::pointer& sle) { + sle->at(sfAccount) = sle->at(sfOwner); + }, + [](SLE::pointer& sle) { + sle->at(sfOwner) = sle->at(sfAccount); + }, + [](SLE::pointer& sle) { + sle->at(sfManagementFeeRate) += 1; + }, + [](SLE::pointer& sle) { sle->at(sfCoverRateMinimum) += 1; }, + [](SLE::pointer& sle) { + sle->at(sfCoverRateLiquidation) += 1; + }, + [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; }, + [](SLE::pointer& sle) { + sle->at(sfLedgerIndex) = sle->at(sfVaultID).value(); + }, + }); + + for (auto const& mod : mods) + { + doInvariantCheck( + {{"changed an unchangable field"}}, + [&](Account const& A1, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(loanBrokerKeylet); + if (!sle) + return false; + mod(sle); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + } + } + + // TODO: Loan Object + + { + auto const mods = + std::to_array>({ + [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; }, + [](SLE::pointer& sle) { + sle->at(sfLedgerIndex) = uint256(1u); + }, + }); + + for (auto const& mod : mods) + { + doInvariantCheck( + {{"changed an unchangable field"}}, + [&](Account const& A1, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(keylet::account(A1.id())); + if (!sle) + return false; + mod(sle); + ac.view().update(sle); + return true; + }); + } + } + } + + void + testValidLoanBroker() + { + testcase << "valid loan broker"; + + using namespace jtx; + + enum class Asset { XRP, IOU, MPT }; + auto const assetTypes = + std::to_array({Asset::XRP, Asset::IOU, Asset::MPT}); + + for (auto const assetType : assetTypes) + { + // Initialize with a placeholder value because there's no default + // ctor + Keylet loanBrokerKeylet = keylet::amendments(); + Preclose createLoanBroker = [&, this]( + Account const& alice, + Account const& issuer, + Env& env) { + PrettyAsset const asset = [&]() { + switch (assetType) + { + case Asset::IOU: { + PrettyAsset const iouAsset = issuer["IOU"]; + env(trust(alice, iouAsset(1000))); + env(pay(issuer, alice, iouAsset(1000))); + env.close(); + return iouAsset; + } + + case Asset::MPT: { + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | + tfMPTCanLock}); + PrettyAsset const mptAsset = mptt.issuanceID(); + mptt.authorize({.account = alice}); + env(pay(issuer, alice, mptAsset(1000))); + env.close(); + return mptAsset; + } + + case Asset::XRP: + default: + return PrettyAsset{xrpIssue(), 1'000'000}; + } + }(); + loanBrokerKeylet = this->createLoanBroker(alice, env, asset); + return BEAST_EXPECT(env.le(loanBrokerKeylet)); + }; + + // Ensure the test scenarios are set up completely. The test cases + // will need to recompute any of these values it needs for itself + // rather than trying to return a bunch of items + auto setupTest = + [&, this](Account const& A1, Account const&, ApplyContext& ac) + -> std::optional> { + if (loanBrokerKeylet.type != ltLOAN_BROKER) + return {}; + auto sleBroker = ac.view().peek(loanBrokerKeylet); + if (!sleBroker) + return {}; + if (!BEAST_EXPECT(sleBroker->at(sfOwnerCount) == 0)) + return {}; + // Need to touch sleBroker so that it is included in the + // modified entries for the invariant to find + ac.view().update(sleBroker); + + // The pseudo-account holds the directory, so get it + auto const pseudoAccountID = sleBroker->at(sfAccount); + auto const pseudoAccountKeylet = + keylet::account(pseudoAccountID); + // Strictly speaking, we don't need to load the + // ACCOUNT_ROOT, but check anyway + auto slePseudo = ac.view().peek(pseudoAccountKeylet); + if (!BEAST_EXPECT(slePseudo)) + return {}; + // Make sure the directory doesn't already exist + auto const dirKeylet = keylet::ownerDir(pseudoAccountID); + auto sleDir = ac.view().peek(dirKeylet); + auto const describe = describeOwnerDir(pseudoAccountID); + if (!sleDir) + { + // Create the directory + BEAST_EXPECT( + ::ripple::directory::createRoot( + ac.view(), + dirKeylet, + loanBrokerKeylet.key, + describe) == 0); + + sleDir = ac.view().peek(dirKeylet); + } + + return std::make_pair(slePseudo, sleDir); + }; + + doInvariantCheck( + {{"Loan Broker with zero OwnerCount has multiple directory " + "pages"}}, + [&setupTest, this]( + Account const& A1, Account const& A2, ApplyContext& ac) { + auto test = setupTest(A1, A2, ac); + if (!test || !test->first || !test->second) + return false; + + auto slePseudo = test->first; + auto sleDir = test->second; + auto const describe = + describeOwnerDir(slePseudo->at(sfAccount)); + + BEAST_EXPECT( + ::ripple::directory::insertPage( + ac.view(), + 0, + sleDir, + 0, + sleDir, + slePseudo->key(), + keylet::page(sleDir->key(), 0), + describe) == 1); + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + doInvariantCheck( + {{"Loan Broker with zero OwnerCount has multiple indexes in " + "the Directory root"}}, + [&setupTest]( + Account const& A1, Account const& A2, ApplyContext& ac) { + auto test = setupTest(A1, A2, ac); + if (!test || !test->first || !test->second) + return false; + + auto slePseudo = test->first; + auto sleDir = test->second; + auto indexes = sleDir->getFieldV256(sfIndexes); + + // Put some extra garbage into the directory + for (auto const& key : {slePseudo->key(), sleDir->key()}) + { + ::ripple::directory::insertKey( + ac.view(), sleDir, 0, false, indexes, key); + } + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + doInvariantCheck( + {{"Loan Broker directory corrupt"}}, + [&setupTest]( + Account const& A1, Account const& A2, ApplyContext& ac) { + auto test = setupTest(A1, A2, ac); + if (!test || !test->first || !test->second) + return false; + + auto slePseudo = test->first; + auto sleDir = test->second; + auto const describe = + describeOwnerDir(slePseudo->at(sfAccount)); + // Empty vector will overwrite the existing entry for the + // holding, if any, avoiding the "has multiple indexes" + // failure. + STVector256 indexes; + + // Put one meaningless key into the directory + auto const key = + keylet::account(Account("random").id()).key; + ::ripple::directory::insertKey( + ac.view(), sleDir, 0, false, indexes, key); + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + doInvariantCheck( + {{"Loan Broker with zero OwnerCount has an unexpected entry in " + "the directory"}}, + [&setupTest]( + Account const& A1, Account const& A2, ApplyContext& ac) { + auto test = setupTest(A1, A2, ac); + if (!test || !test->first || !test->second) + return false; + + auto slePseudo = test->first; + auto sleDir = test->second; + // Empty vector will overwrite the existing entry for the + // holding, if any, avoiding the "has multiple indexes" + // failure. + STVector256 indexes; + + ::ripple::directory::insertKey( + ac.view(), sleDir, 0, false, indexes, slePseudo->key()); + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + doInvariantCheck( + {{"Loan Broker sequence number decreased"}}, + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + if (loanBrokerKeylet.type != ltLOAN_BROKER) + return false; + auto sleBroker = ac.view().peek(loanBrokerKeylet); + if (!sleBroker) + return false; + if (!BEAST_EXPECT(sleBroker->at(sfLoanSequence) > 0)) + return false; + // Need to touch sleBroker so that it is included in the + // modified entries for the invariant to find + ac.view().update(sleBroker); + + sleBroker->at(sfLoanSequence) -= 1; + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + } + } + void testVault() { @@ -3504,8 +3925,10 @@ class Invariants_test : public beast::unit_test::suite testValidNewAccountRoot(); testNFTokenPageInvariants(); testPermissionedDomainInvariants(); - testValidPseudoAccounts(); testPermissionedDEX(); + testNoModifiedUnmodifiableFields(); + testValidPseudoAccounts(); + testValidLoanBroker(); testVault(); } }; diff --git a/src/test/app/LoanBroker_test.cpp b/src/test/app/LoanBroker_test.cpp new file mode 100644 index 00000000000..597e2ea75b7 --- /dev/null +++ b/src/test/app/LoanBroker_test.cpp @@ -0,0 +1,1462 @@ +#include + +#include + +#include + +namespace ripple { +namespace test { + +class LoanBroker_test : public beast::unit_test::suite +{ + // Ensure that all the features needed for Lending Protocol are included, + // even if they are set to unsupported. + FeatureBitset const all{ + jtx::testable_amendments() | featureMPTokensV1 | + featureSingleAssetVault | featureLendingProtocol}; + + void + testDisabled() + { + testcase("Disabled"); + // Lending Protocol depends on Single Asset Vault (SAV). Test + // combinations of the two amendments. + // Single Asset Vault depends on MPTokensV1, but don't test every combo + // of that. + using namespace jtx; + auto failAll = [this](FeatureBitset features, bool goodVault = false) { + Env env(*this, features); + + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + + // Try to create a vault + PrettyAsset const asset{xrpIssue(), 1'000'000}; + Vault vault{env}; + auto const [tx, keylet] = + vault.create({.owner = alice, .asset = asset}); + env(tx, ter(goodVault ? ter(tesSUCCESS) : ter(temDISABLED))); + env.close(); + BEAST_EXPECT(static_cast(env.le(keylet)) == goodVault); + + using namespace loanBroker; + // Can't create a loan broker regardless of whether the vault exists + env(set(alice, keylet.key), ter(temDISABLED)); + auto const brokerKeylet = + keylet::loanbroker(alice.id(), env.seq(alice)); + // Other LoanBroker transactions are disabled, too. + // 1. LoanBrokerCoverDeposit + env(coverDeposit(alice, brokerKeylet.key, asset(1000)), + ter(temDISABLED)); + // 2. LoanBrokerCoverWithdraw + env(coverWithdraw(alice, brokerKeylet.key, asset(1000)), + ter(temDISABLED)); + // 3. LoanBrokerCoverClawback + env(coverClawback(alice), ter(temDISABLED)); + env(coverClawback(alice), + loanBrokerID(brokerKeylet.key), + ter(temDISABLED)); + env(coverClawback(alice), amount(asset(0)), ter(temDISABLED)); + env(coverClawback(alice), + loanBrokerID(brokerKeylet.key), + amount(asset(1000)), + ter(temDISABLED)); + // 4. LoanBrokerDelete + env(del(alice, brokerKeylet.key), ter(temDISABLED)); + }; + failAll(all - featureMPTokensV1); + failAll(all - featureSingleAssetVault - featureLendingProtocol); + failAll(all - featureSingleAssetVault); + failAll(all - featureLendingProtocol, true); + } + + struct VaultInfo + { + jtx::PrettyAsset asset; + uint256 vaultID; + jtx::Account pseudoAccount; + VaultInfo( + jtx::PrettyAsset const& asset_, + uint256 const& vaultID_, + AccountID const& pseudo) + : asset(asset_), vaultID(vaultID_), pseudoAccount("vault", pseudo) + { + } + }; + + void + lifecycle( + char const* label, + jtx::Env& env, + jtx::Account const& issuer, + jtx::Account const& alice, + jtx::Account const& evan, + jtx::Account const& bystander, + VaultInfo const& vault, + VaultInfo const& badVault, + std::function modifyJTx, + std::function checkBroker, + std::function changeBroker, + std::function checkChangedBroker) + { + { + auto const& asset = vault.asset.raw(); + testcase << "Lifecycle: " + << (asset.native() ? "XRP " + : asset.holds() ? "IOU " + : asset.holds() ? "MPT " + : "Unknown ") + << label; + } + + using namespace jtx; + using namespace loanBroker; + + // Bogus assets to use in test cases + static PrettyAsset const badMptAsset = [&]() { + MPTTester badMptt{env, evan, mptInitNoFund}; + badMptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + env.close(); + return badMptt["BAD"]; + }(); + static PrettyAsset const badIouAsset = evan["BAD"]; + static Account const nonExistent{"NonExistent"}; + static PrettyAsset const ghostIouAsset = nonExistent["GST"]; + PrettyAsset const vaultPseudoIouAsset = vault.pseudoAccount["PSD"]; + + auto const badKeylet = keylet::loanbroker(alice.id(), env.seq(alice)); + env(set(alice, badVault.vaultID)); + env.close(); + auto const badBrokerPseudo = [&]() { + if (auto const le = env.le(badKeylet); BEAST_EXPECT(le)) + { + return Account{"Bad Broker pseudo-account", le->at(sfAccount)}; + } + // Just to make the build work + return vault.pseudoAccount; + }(); + PrettyAsset const badBrokerPseudoIouAsset = badBrokerPseudo["WAT"]; + + auto const keylet = keylet::loanbroker(alice.id(), env.seq(alice)); + { + // Start with default values + auto jtx = env.jt(set(alice, vault.vaultID)); + // Modify as desired + if (modifyJTx) + jtx = modifyJTx(jtx); + // Successfully create a Loan Broker + env(jtx); + } + + env.close(); + if (auto broker = env.le(keylet); BEAST_EXPECT(broker)) + { + // log << "Broker after create: " << to_string(broker->getJson()) + // << std::endl; + BEAST_EXPECT(broker->at(sfVaultID) == vault.vaultID); + BEAST_EXPECT(broker->at(sfAccount) != alice.id()); + BEAST_EXPECT(broker->at(sfOwner) == alice.id()); + BEAST_EXPECT(broker->at(sfFlags) == 0); + BEAST_EXPECT(broker->at(sfSequence) == env.seq(alice) - 1); + BEAST_EXPECT(broker->at(sfOwnerCount) == 0); + BEAST_EXPECT(broker->at(sfLoanSequence) == 1); + BEAST_EXPECT(broker->at(sfDebtTotal) == 0); + BEAST_EXPECT(broker->at(sfCoverAvailable) == 0); + if (checkBroker) + checkBroker(broker); + + // if (auto const vaultSLE = env.le(keylet::vault(vault.vaultID))) + //{ + // log << "Vault: " << to_string(vaultSLE->getJson()) << + // std::endl; + // } + // Load the pseudo-account + Account const pseudoAccount{ + "Broker pseudo-account", broker->at(sfAccount)}; + + auto const pseudoKeylet = keylet::account(pseudoAccount); + if (auto const pseudo = env.le(pseudoKeylet); BEAST_EXPECT(pseudo)) + { + // log << "Pseudo-account after create: " + // << to_string(pseudo->getJson()) << std::endl + // << std::endl; + BEAST_EXPECT( + pseudo->at(sfFlags) == + (lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth)); + BEAST_EXPECT(pseudo->at(sfSequence) == 0); + BEAST_EXPECT(pseudo->at(sfBalance) == beast::zero); + BEAST_EXPECT( + pseudo->at(sfOwnerCount) == + (vault.asset.raw().native() ? 0 : 1)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfAccountTxnID)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfRegularKey)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfEmailHash)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfWalletLocator)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfWalletSize)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfMessageKey)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfTransferRate)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfDomain)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfTickSize)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfTicketCount)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfNFTokenMinter)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfMintedNFTokens)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfBurnedNFTokens)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfFirstNFTokenSequence)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfAMMID)); + BEAST_EXPECT(!pseudo->isFieldPresent(sfVaultID)); + BEAST_EXPECT(pseudo->at(sfLoanBrokerID) == keylet.key); + } + + { + // Get the AccountInfo RPC result for the broker pseudo-account + std::string const pseudoStr = to_string(pseudoAccount.id()); + auto const accountInfo = env.rpc("account_info", pseudoStr); + if (BEAST_EXPECT(accountInfo.isObject())) + { + auto const& accountData = + accountInfo[jss::result][jss::account_data]; + if (BEAST_EXPECT(accountData.isObject())) + { + BEAST_EXPECT(accountData[jss::Account] == pseudoStr); + BEAST_EXPECT( + accountData[sfLoanBrokerID] == + to_string(keylet.key)); + } + auto const& pseudoInfo = + accountInfo[jss::result][jss::pseudo_account]; + if (BEAST_EXPECT(pseudoInfo.isObject())) + { + BEAST_EXPECT(pseudoInfo[jss::type] == "LoanBroker"); + } + } + } + + auto verifyCoverAmount = + [&env, &vault, &pseudoAccount, &broker, &keylet, this](auto n) { + using namespace jtx; + + if (BEAST_EXPECT(broker = env.le(keylet))) + { + auto const amount = vault.asset(n); + BEAST_EXPECT( + broker->at(sfCoverAvailable) == amount.number()); + env.require(balance(pseudoAccount, amount)); + } + }; + + // Test Cover funding before allowing alterations + env(coverDeposit(alice, uint256(0), vault.asset(10)), + ter(temINVALID)); + env(coverDeposit(evan, keylet.key, vault.asset(10)), + ter(tecNO_PERMISSION)); + env(coverDeposit(evan, keylet.key, vault.asset(0)), + ter(temBAD_AMOUNT)); + env(coverDeposit(evan, keylet.key, vault.asset(-10)), + ter(temBAD_AMOUNT)); + env(coverDeposit(alice, vault.vaultID, vault.asset(10)), + ter(tecNO_ENTRY)); + + verifyCoverAmount(0); + + // Test cover clawback failure cases BEFORE depositing any cover + // Need one of brokerID or amount + env(coverClawback(alice), ter(temINVALID)); + env(coverClawback(alice), + loanBrokerID(uint256(0)), + ter(temINVALID)); + env(coverClawback(alice), amount(XRP(1000)), ter(temBAD_AMOUNT)); + env(coverClawback(alice), + amount(vault.asset(-10)), + ter(temBAD_AMOUNT)); + // Clawbacks with an MPT need to specify the broker ID + env(coverClawback(alice), amount(badMptAsset(1)), ter(temINVALID)); + env(coverClawback(evan), + loanBrokerID(vault.vaultID), + ter(tecNO_ENTRY)); + // Only the issuer can clawback + env(coverClawback(alice), + loanBrokerID(keylet.key), + ter(tecNO_PERMISSION)); + if (vault.asset.raw().native()) + { + // Can not clawback XRP under any circumstances + env(coverClawback(issuer), + loanBrokerID(keylet.key), + ter(tecNO_PERMISSION)); + } + else + { + if (vault.asset.raw().holds()) + { + // Clawbacks without a loanBrokerID need to specify an IOU + // with the broker's pseudo-account as the issuer + env(coverClawback(alice), + amount(ghostIouAsset(1)), + ter(tecNO_ENTRY)); + env(coverClawback(alice), + amount(badIouAsset(1)), + ter(tecOBJECT_NOT_FOUND)); + // Pseudo-account is not for a broker + env(coverClawback(alice), + amount(vaultPseudoIouAsset(1)), + ter(tecOBJECT_NOT_FOUND)); + // If we specify a pseudo-account as the IOU amount, it + // needs to match the loan broker + env(coverClawback(issuer), + loanBrokerID(keylet.key), + amount(badBrokerPseudoIouAsset(10)), + ter(tecWRONG_ASSET)); + PrettyAsset const brokerWrongCurrencyAsset = + pseudoAccount["WAT"]; + env(coverClawback(issuer), + loanBrokerID(keylet.key), + amount(brokerWrongCurrencyAsset(10)), + ter(tecWRONG_ASSET)); + } + else + { + // Clawbacks with an MPT need to specify the broker ID, even + // if the asset is valid + BEAST_EXPECT(vault.asset.raw().holds()); + env(coverClawback(alice), + amount(vault.asset(10)), + ter(temINVALID)); + } + // Since no cover has been deposited, there's nothing to claw + // back + env(coverClawback(issuer), + loanBrokerID(keylet.key), + amount(vault.asset(10)), + ter(tecINSUFFICIENT_FUNDS)); + } + env.close(); + + // Fund the cover deposit + env(coverDeposit(alice, keylet.key, vault.asset(10))); + env.close(); + verifyCoverAmount(10); + + // Test withdrawal failure cases + env(coverWithdraw(alice, uint256(0), vault.asset(10)), + ter(temINVALID)); + env(coverWithdraw(evan, keylet.key, vault.asset(10)), + ter(tecNO_PERMISSION)); + env(coverWithdraw(evan, keylet.key, vault.asset(0)), + ter(temBAD_AMOUNT)); + env(coverWithdraw(evan, keylet.key, vault.asset(-10)), + ter(temBAD_AMOUNT)); + env(coverWithdraw(alice, vault.vaultID, vault.asset(10)), + ter(tecNO_ENTRY)); + env(coverWithdraw(alice, keylet.key, vault.asset(900)), + ter(tecINSUFFICIENT_FUNDS)); + + // Skip this test for XRP, because that can always be sent + if (!vault.asset.raw().native()) + { + TER const expected = vault.asset.raw().holds() + ? tecNO_AUTH + : tecNO_LINE; + env(coverWithdraw(alice, keylet.key, vault.asset(1)), + destination(bystander), + ter(expected)); + } + + // Can not withdraw to the zero address + env(coverWithdraw(alice, keylet.key, vault.asset(1)), + destination(AccountID{}), + ter(temMALFORMED)); + + // Withdraw some of the cover amount + env(coverWithdraw(alice, keylet.key, vault.asset(7))); + env.close(); + verifyCoverAmount(3); + + // Add some more cover + env(coverDeposit(alice, keylet.key, vault.asset(5))); + env.close(); + verifyCoverAmount(8); + + // Withdraw some more. Send it to Evan. Very generous, considering + // how much trouble he's been. + env(coverWithdraw(alice, keylet.key, vault.asset(1)), + destination(evan)); + env.close(); + verifyCoverAmount(7); + + // Withdraw some more. Send it to Evan. Very generous, considering + // how much trouble he's been. + env(coverWithdraw(alice, keylet.key, vault.asset(1)), + destination(evan), + dtag(3)); + env.close(); + verifyCoverAmount(6); + + if (!vault.asset.raw().native()) + { + // Issuer claws back some of the cover + env(coverClawback(issuer), + loanBrokerID(keylet.key), + amount(vault.asset(2))); + env.close(); + verifyCoverAmount(4); + + // Deposit some back + env(coverDeposit(alice, keylet.key, vault.asset(5))); + env.close(); + verifyCoverAmount(9); + + // Issuer claws it all back in various different ways + for (auto const& tx : { + // defer autofills until submission time + env.json( + coverClawback(issuer), + loanBrokerID(keylet.key), + fee(none), + seq(none), + sig(none)), + env.json( + coverClawback(issuer), + loanBrokerID(keylet.key), + amount(vault.asset(0)), + fee(none), + seq(none), + sig(none)), + env.json( + coverClawback(issuer), + loanBrokerID(keylet.key), + amount(vault.asset(6)), + fee(none), + seq(none), + sig(none)), + // amount will be truncated to what's available + env.json( + coverClawback(issuer), + loanBrokerID(keylet.key), + amount(vault.asset(100)), + fee(none), + seq(none), + sig(none)), + }) + { + // Issuer claws it all back + env(tx); + env.close(); + verifyCoverAmount(0); + + // Deposit some back + env(coverDeposit(alice, keylet.key, vault.asset(6))); + env.close(); + verifyCoverAmount(6); + } + } + + // no-op + env(set(alice, vault.vaultID), loanBrokerID(keylet.key)); + env.close(); + + // Make modifications to the broker + if (changeBroker) + changeBroker(broker); + + env.close(); + + // Check the results of modifications + if (BEAST_EXPECT(broker = env.le(keylet)) && checkChangedBroker) + checkChangedBroker(broker); + + // Verify that fields get removed when set to default values + // Debt maximum: explicit 0 + // Data: explicit empty + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + debtMaximum(Number(0)), + data("")); + env.close(); + + // Check the updated fields + if (BEAST_EXPECT(broker = env.le(keylet))) + { + BEAST_EXPECT(!broker->isFieldPresent(sfDebtMaximum)); + BEAST_EXPECT(!broker->isFieldPresent(sfData)); + } + + ///////////////////////////////////// + // try to delete the wrong broker object + env(del(alice, vault.vaultID), ter(tecNO_ENTRY)); + // evan tries to delete the broker + env(del(evan, keylet.key), ter(tecNO_PERMISSION)); + + // Get the "bad" broker out of the way + env(del(alice, badKeylet.key)); + env.close(); + + // Note alice's balance of the asset and the broker account's cover + // funds + auto const aliceBalance = env.balance(alice, vault.asset); + auto const coverFunds = env.balance(pseudoAccount, vault.asset); + BEAST_EXPECT(coverFunds.number() == broker->at(sfCoverAvailable)); + BEAST_EXPECT(coverFunds != beast::zero); + verifyCoverAmount(6); + + // delete the broker + // log << "Broker before delete: " << to_string(broker->getJson()) + // << std::endl; + // if (auto const pseudo = env.le(pseudoKeylet); + // BEAST_EXPECT(pseudo)) + //{ + // log << "Pseudo-account before delete: " + // << to_string(pseudo->getJson()) << std::endl + // << std::endl; + //} + + env(del(alice, keylet.key)); + env.close(); + { + broker = env.le(keylet); + BEAST_EXPECT(!broker); + auto pseudo = env.le(pseudoKeylet); + BEAST_EXPECT(!pseudo); + } + auto const expectedBalance = aliceBalance + coverFunds - + (aliceBalance.value().native() + ? STAmount(env.current()->fees().base.value()) + : vault.asset(0)); + env.require(balance(alice, expectedBalance)); + env.require(balance(pseudoAccount, vault.asset(none))); + } + } + + void + testLifecycle() + { + testcase("Lifecycle"); + using namespace jtx; + + // Create 3 loan brokers: one for XRP, one for an IOU, and one for an + // MPT. That'll require three corresponding SAVs. + Env env(*this, all); + + Account issuer{"issuer"}; + // For simplicity, alice will be the sole actor for the vault & brokers. + Account alice{"alice"}; + // Evan will attempt to be naughty + Account evan{"evan"}; + // Bystander doesn't have anything to do with the SAV or Broker, or any + // of the relevant tokens + Account bystander{"bystander"}; + Vault vault{env}; + + // Fund the accounts and trust lines with the same amount so that tests + // can use the same values regardless of the asset. + env.fund(XRP(100'000), issuer, noripple(alice, evan, bystander)); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + // Create assets + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + PrettyAsset const iouAsset = issuer["IOU"]; + env(trust(alice, iouAsset(1'000'000))); + env(trust(evan, iouAsset(1'000'000))); + env.close(); + env(pay(issuer, evan, iouAsset(100'000))); + env(pay(issuer, alice, iouAsset(100'000))); + env.close(); + + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + env.close(); + PrettyAsset const mptAsset = mptt["MPT"]; + mptt.authorize({.account = alice}); + mptt.authorize({.account = evan}); + env.close(); + env(pay(issuer, alice, mptAsset(100'000))); + env(pay(issuer, evan, mptAsset(100'000))); + env.close(); + + std::array const assets{xrpAsset, iouAsset, mptAsset}; + + // Create vaults + std::vector vaults; + for (auto const& asset : assets) + { + auto [tx, keylet] = vault.create({.owner = alice, .asset = asset}); + env(tx); + env.close(); + if (auto const le = env.le(keylet); BEAST_EXPECT(env.le(keylet))) + { + vaults.emplace_back(asset, keylet.key, le->at(sfAccount)); + } + + env(vault.deposit( + {.depositor = alice, .id = keylet.key, .amount = asset(50)})); + env.close(); + } + VaultInfo const badVault = [&]() -> VaultInfo { + auto [tx, keylet] = + vault.create({.owner = alice, .asset = iouAsset}); + env(tx); + env.close(); + if (auto const le = env.le(keylet); BEAST_EXPECT(env.le(keylet))) + { + return {iouAsset, keylet.key, le->at(sfAccount)}; + } + // This should never happen + return {iouAsset, keylet.key, evan.id()}; + }(); + + auto const aliceOriginalCount = env.ownerCount(alice); + + // Create and update Loan Brokers + for (auto const& vault : vaults) + { + { + // Get the AccountInfo RPC result for the vault pseudo-account + std::string const pseudoStr = + to_string(vault.pseudoAccount.id()); + auto const accountInfo = env.rpc("account_info", pseudoStr); + if (BEAST_EXPECT(accountInfo.isObject())) + { + auto const& accountData = + accountInfo[jss::result][jss::account_data]; + if (BEAST_EXPECT(accountData.isObject())) + { + BEAST_EXPECT(accountData[jss::Account] == pseudoStr); + BEAST_EXPECT( + accountData[sfVaultID] == to_string(vault.vaultID)); + } + auto const& pseudoInfo = + accountInfo[jss::result][jss::pseudo_account]; + if (BEAST_EXPECT(pseudoInfo.isObject())) + { + BEAST_EXPECT(pseudoInfo[jss::type] == "Vault"); + } + } + } + + using namespace loanBroker; + using namespace ripple::Lending; + + TenthBips32 const tenthBipsZero{0}; + + auto badKeylet = keylet::vault(alice.id(), env.seq(alice)); + // Try some failure cases + // not the vault owner + env(set(evan, vault.vaultID), ter(tecNO_PERMISSION)); + // not a vault + env(set(alice, badKeylet.key), ter(tecNO_ENTRY)); + // flags are checked first + env(set(evan, vault.vaultID, ~tfUniversal), ter(temINVALID_FLAG)); + // field length validation + // sfData: good length, bad account + env(set(evan, vault.vaultID), + data(std::string(maxDataPayloadLength, 'X')), + ter(tecNO_PERMISSION)); + // sfData: too long + env(set(evan, vault.vaultID), + data(std::string(maxDataPayloadLength + 1, 'Y')), + ter(temINVALID)); + // sfManagementFeeRate: good value, bad account + env(set(evan, vault.vaultID), + managementFeeRate(maxManagementFeeRate), + ter(tecNO_PERMISSION)); + // sfManagementFeeRate: too big + env(set(evan, vault.vaultID), + managementFeeRate(maxManagementFeeRate + TenthBips16(10)), + ter(temINVALID)); + // sfCoverRateMinimum and sfCoverRateLiquidation are linked + // Cover: good value, bad account + env(set(evan, vault.vaultID), + coverRateMinimum(maxCoverRate), + coverRateLiquidation(maxCoverRate), + ter(tecNO_PERMISSION)); + // CoverMinimum: too big + env(set(evan, vault.vaultID), + coverRateMinimum(maxCoverRate + 1), + coverRateLiquidation(maxCoverRate + 1), + ter(temINVALID)); + // CoverLiquidation: too big + env(set(evan, vault.vaultID), + coverRateMinimum(maxCoverRate / 2), + coverRateLiquidation(maxCoverRate + 1), + ter(temINVALID)); + // Cover: zero min, non-zero liquidation - implicit and + // explicit zero values. + env(set(evan, vault.vaultID), + coverRateLiquidation(maxCoverRate), + ter(temINVALID)); + env(set(evan, vault.vaultID), + coverRateMinimum(tenthBipsZero), + coverRateLiquidation(maxCoverRate), + ter(temINVALID)); + // Cover: non-zero min, zero liquidation - implicit and + // explicit zero values. + env(set(evan, vault.vaultID), + coverRateMinimum(maxCoverRate), + ter(temINVALID)); + env(set(evan, vault.vaultID), + coverRateMinimum(maxCoverRate), + coverRateLiquidation(tenthBipsZero), + ter(temINVALID)); + // sfDebtMaximum: good value, bad account + env(set(evan, vault.vaultID), + debtMaximum(Number(0)), + ter(tecNO_PERMISSION)); + // sfDebtMaximum: overflow + env(set(evan, vault.vaultID), + debtMaximum(Number(1, 100)), + ter(temINVALID)); + // sfDebtMaximum: negative + env(set(evan, vault.vaultID), + debtMaximum(Number(-1)), + ter(temINVALID)); + + std::string testData; + lifecycle( + "default fields", + env, + issuer, + alice, + evan, + bystander, + vault, + badVault, + // No modifications + {}, + [&](SLE::const_ref broker) { + // Extra checks + BEAST_EXPECT(!broker->isFieldPresent(sfManagementFeeRate)); + BEAST_EXPECT(!broker->isFieldPresent(sfCoverRateMinimum)); + BEAST_EXPECT( + !broker->isFieldPresent(sfCoverRateLiquidation)); + BEAST_EXPECT(!broker->isFieldPresent(sfData)); + BEAST_EXPECT(!broker->isFieldPresent(sfDebtMaximum)); + BEAST_EXPECT(broker->at(sfDebtMaximum) == 0); + BEAST_EXPECT(broker->at(sfCoverRateMinimum) == 0); + BEAST_EXPECT(broker->at(sfCoverRateLiquidation) == 0); + + BEAST_EXPECT( + env.ownerCount(alice) == aliceOriginalCount + 4); + }, + [&](SLE::const_ref broker) { + // Modifications + + // Update the fields + auto const nextKeylet = + keylet::loanbroker(alice.id(), env.seq(alice)); + + // fields that can't be changed + // LoanBrokerID + env(set(alice, vault.vaultID), + loanBrokerID(nextKeylet.key), + ter(tecNO_ENTRY)); + // VaultID + env(set(alice, nextKeylet.key), + loanBrokerID(broker->key()), + ter(tecNO_PERMISSION)); + // Owner + env(set(evan, vault.vaultID), + loanBrokerID(broker->key()), + ter(tecNO_PERMISSION)); + // ManagementFeeRate + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + managementFeeRate(maxManagementFeeRate), + ter(temINVALID)); + // CoverRateMinimum + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + coverRateMinimum(maxManagementFeeRate), + ter(temINVALID)); + // CoverRateLiquidation + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + coverRateLiquidation(maxManagementFeeRate), + ter(temINVALID)); + + // fields that can be changed + testData = "Test Data 1234"; + // Bad data: too long + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + data(std::string(maxDataPayloadLength + 1, 'W')), + ter(temINVALID)); + + // Bad debt maximum + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + debtMaximum(Number(-175, -1)), + ter(temINVALID)); + // Data & Debt maximum + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + data(testData), + debtMaximum(Number(175, -1))); + }, + [&](SLE::const_ref broker) { + // Check the updated fields + BEAST_EXPECT(checkVL(broker->at(sfData), testData)); + BEAST_EXPECT(broker->at(sfDebtMaximum) == Number(175, -1)); + }); + + lifecycle( + "non-default fields", + env, + issuer, + alice, + evan, + bystander, + vault, + badVault, + [&](jtx::JTx const& jv) { + testData = "spam spam spam spam"; + // Finally, create another Loan Broker with none of the + // values at default + return env.jt( + jv, + data(testData), + managementFeeRate(TenthBips16(123)), + debtMaximum(Number(9)), + coverRateMinimum(TenthBips32(100)), + coverRateLiquidation(TenthBips32(200))); + }, + [&](SLE::const_ref broker) { + // Extra checks + BEAST_EXPECT(broker->at(sfManagementFeeRate) == 123); + BEAST_EXPECT(broker->at(sfCoverRateMinimum) == 100); + BEAST_EXPECT(broker->at(sfCoverRateLiquidation) == 200); + BEAST_EXPECT(broker->at(sfDebtMaximum) == Number(9)); + BEAST_EXPECT(checkVL(broker->at(sfData), testData)); + }, + [&](SLE::const_ref broker) { + // Reset Data & Debt maximum to default values + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + data(""), + debtMaximum(Number(0))); + }, + [&](SLE::const_ref broker) { + // Check the updated fields + BEAST_EXPECT(!broker->isFieldPresent(sfData)); + BEAST_EXPECT(!broker->isFieldPresent(sfDebtMaximum)); + }); + } + + BEAST_EXPECT(env.ownerCount(alice) == aliceOriginalCount); + } + + enum LoanBrokerTest { + CoverClawback, + CoverDeposit, + CoverWithdraw, + Delete, + Set + }; + + void + testLoanBroker( + std::function getAsset, + LoanBrokerTest brokerTest) + { + 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 = [&]() { + if (getAsset) + return getAsset(env, issuer, alice); + env(trust(alice, issuer["IOU"](1'000'000)), THISLINE); + env.close(); + return PrettyAsset(issuer["IOU"]); + }(); + + 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(); + + auto broker = env.le(brokerKeylet); + if (!BEAST_EXPECT(broker)) + return; + + auto testZeroBrokerID = [&](auto&& getTxJv) { + auto jv = getTxJv(); + // empty broker ID + jv[sfLoanBrokerID] = ""; + env(jv, ter(temINVALID), THISLINE); + // zero broker ID + jv[sfLoanBrokerID] = to_string(uint256{}); + // needs a flag to distinguish the parsed STTx from the prior + // test + env(jv, txflags(tfFullyCanonicalSig), ter(temINVALID), THISLINE); + }; + auto testZeroVaultID = [&](auto&& getTxJv) { + auto jv = getTxJv(); + // empty broker ID + jv[sfVaultID] = ""; + env(jv, ter(temINVALID), THISLINE); + // zero broker ID + jv[sfVaultID] = to_string(uint256{}); + // needs a flag to distinguish the parsed STTx from the prior + // test + env(jv, txflags(tfFullyCanonicalSig), ter(temINVALID), THISLINE); + }; + + if (brokerTest == CoverDeposit) + { + // preflight: temINVALID (empty/zero broker id) + testZeroBrokerID([&]() { + return coverDeposit(alice, brokerKeylet.key, asset(10)); + }); + + // preclaim: tecWRONG_ASSET + env(coverDeposit(alice, brokerKeylet.key, issuer["BAD"](10)), + ter(tecWRONG_ASSET), + THISLINE); + + // preclaim: tecINSUFFICIENT_FUNDS + env(pay(alice, issuer, asset(100'000 - 50)), THISLINE); + env.close(); + env(coverDeposit(alice, brokerKeylet.key, vaultInfo.asset(10)), + ter(tecINSUFFICIENT_FUNDS)); + + // preclaim: tecFROZEN + env(fset(issuer, asfGlobalFreeze), THISLINE); + env.close(); + env(coverDeposit(alice, brokerKeylet.key, vaultInfo.asset(10)), + ter(tecFROZEN), + THISLINE); + } + else + // Fund the cover deposit + env(coverDeposit(alice, brokerKeylet.key, vaultInfo.asset(10)), + THISLINE); + env.close(); + + if (brokerTest == CoverWithdraw) + { + // preflight: temINVALID (empty/zero broker id) + testZeroBrokerID([&]() { + return coverWithdraw(alice, brokerKeylet.key, asset(10)); + }); + + // preclaim: tecWRONG_ASSSET + env(coverWithdraw(alice, brokerKeylet.key, issuer["BAD"](10)), + ter(tecWRONG_ASSET), + THISLINE); + + // preclaim: tecNO_DST + Account const bogus{"bogus"}; + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(bogus), + ter(tecNO_DST), + THISLINE); + + // preclaim: tecDST_TAG_NEEDED + Account const dest{"dest"}; + env.fund(XRP(1'000), dest); + env(fset(dest, asfRequireDest), THISLINE); + env.close(); + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(dest), + ter(tecDST_TAG_NEEDED), + THISLINE); + + // preclaim: tecNO_PERMISSION + env(fclear(dest, asfRequireDest), THISLINE); + env(fset(dest, asfDepositAuth), THISLINE); + env.close(); + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(dest), + ter(tecNO_PERMISSION), + THISLINE); + + // preclaim: tecFROZEN + env(trust(dest, asset(1'000)), THISLINE); + env(fclear(dest, asfDepositAuth), THISLINE); + env(fset(issuer, asfGlobalFreeze), THISLINE); + env.close(); + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(dest), + ter(tecFROZEN), + THISLINE); + + // preclaim:: tecFROZEN (deep frozen) + env(fclear(issuer, asfGlobalFreeze), THISLINE); + env(trust( + issuer, asset(1'000), dest, tfSetFreeze | tfSetDeepFreeze), + THISLINE); + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(dest), + ter(tecFROZEN), + THISLINE); + } + + if (brokerTest == CoverClawback) + { + // preflight: temINVALID (empty/zero broker id) + testZeroBrokerID([&]() { + return env.json( + coverClawback(alice), + loanBrokerID(brokerKeylet.key), + amount(vaultInfo.asset(2))); + }); + + if (asset.holds()) + { + // preclaim: AllowTrustLineClaback is not set + env(coverClawback(issuer), + loanBrokerID(brokerKeylet.key), + amount(vaultInfo.asset(2)), + ter(tecNO_PERMISSION), + THISLINE); + + // preclaim: NoFreeze is set + env(fset(issuer, asfAllowTrustLineClawback | asfNoFreeze), + THISLINE); + env.close(); + env(coverClawback(issuer), + loanBrokerID(brokerKeylet.key), + amount(vaultInfo.asset(2)), + ter(tecNO_PERMISSION), + THISLINE); + } + else + { + // preclaim: MPTCanClawback is not set or MPTCanLock is not set + env(coverClawback(issuer), + loanBrokerID(brokerKeylet.key), + amount(vaultInfo.asset(2)), + ter(tecNO_PERMISSION), + THISLINE); + } + env.close(); + } + + if (brokerTest == Delete) + { + 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); + + // preflight: temINVALID (empty/zero broker id) + testZeroBrokerID([&]() { return del(alice, brokerKeylet.key); }); + + // preclaim: tecHAS_OBLIGATIONS + env(del(alice, brokerKeylet.key), + ter(tecHAS_OBLIGATIONS), + THISLINE); + + // Repay and delete the loan + auto const loanKeylet = keylet::loan(brokerKeylet.key, 1); + env(loan::pay(borrower, loanKeylet.key, asset(50).value()), + THISLINE); + env(loan::del(alice, loanKeylet.key), THISLINE); + + env(trust(issuer, asset(0), alice, tfSetFreeze | tfSetDeepFreeze), + THISLINE); + // preclaim: tecFROZEN (deep frozen) + env(del(alice, brokerKeylet.key), ter(tecFROZEN), THISLINE); + env(trust( + issuer, asset(0), alice, tfClearFreeze | tfClearDeepFreeze), + THISLINE); + + // successful delete the loan broker object + env(del(alice, brokerKeylet.key), ter(tesSUCCESS), THISLINE); + } + else + env(del(alice, brokerKeylet.key), THISLINE); + + if (brokerTest == Set) + { + // preflight: temINVALID (empty/zero broker id) + testZeroBrokerID([&]() { + return env.json( + set(alice, vaultInfo.vaultID), + loanBrokerID(brokerKeylet.key)); + }); + // preflight: temINVALID (empty/zero vault id) + testZeroVaultID([&]() { + return env.json( + set(alice, vaultInfo.vaultID), + loanBrokerID(brokerKeylet.key)); + }); + + if (asset.holds()) + { + env(fclear(issuer, asfDefaultRipple), THISLINE); + env.close(); + // preclaim: DefaultRipple is not set + env(set(alice, vaultInfo.vaultID), ter(terNO_RIPPLE), THISLINE); + + env(fset(issuer, asfDefaultRipple), THISLINE); + env.close(); + } + + auto const amt = env.balance(alice) - + env.current()->fees().accountReserve(env.ownerCount(alice)); + env(pay(alice, issuer, amt), THISLINE); + + // preclaim:: tecINSUFFICIENT_RESERVE + env(set(alice, vaultInfo.vaultID), + ter(tecINSUFFICIENT_RESERVE), + THISLINE); + } + } + + void + testInvalidLoanBrokerCoverClawback() + { + testcase("Invalid LoanBrokerCoverClawback"); + using namespace jtx; + using namespace loanBroker; + + // preflight + { + Account const alice{"alice"}; + Account const issuer{"issuer"}; + auto const USD = alice["USD"]; + Env env(*this); + env.fund(XRP(100'000), alice); + env.close(); + + auto jtx = env.jt(coverClawback(alice), amount(USD(100))); + + // holder == account + env(jtx, ter(temINVALID), THISLINE); + + // holder == beast::zero + STAmount bad(Issue{USD.currency, beast::zero}, 100); + jtx.jv[sfAmount] = bad.getJson(); + jtx.stx = env.ust(jtx); + Serializer s; + jtx.stx->add(s); + auto const jrr = env.rpc("submit", strHex(s.slice()))[jss::result]; + // fails in doSubmit() on STTx construction + BEAST_EXPECT(jrr[jss::error] == "invalidTransaction"); + BEAST_EXPECT(jrr[jss::error_exception] == "invalid native account"); + } + + // preclaim + + // Issue: + // AllowTrustLineClawback is not set or NoFreeze is set + testLoanBroker({}, CoverClawback); + + // MPTIssue: + // MPTCanClawback is not set + testLoanBroker( + [&](Env& env, Account const& issuer, Account const& alice) -> MPT { + MPTTester mpt( + {.env = env, .issuer = issuer, .holders = {alice}}); + return mpt; + }, + CoverClawback); + } + + void + testInvalidLoanBrokerCoverDeposit() + { + testcase("Invalid LoanBrokerCoverDeposit"); + using namespace jtx; + + // preclaim: + // tecWRONG_ASSET, tecINSUFFICIENT_FUNDS, frozen asset + testLoanBroker({}, CoverDeposit); + } + + void + testInvalidLoanBrokerCoverWithdraw() + { + testcase("Invalid LoanBrokerCoverWithdraw"); + using namespace jtx; + + /* + preflight: illegal net + isLegalNet() check is probably redundant. STAmount parsing + should throw an exception on deserialize + + preclaim: tecWRONG_ASSET, tecNO_DST, tecDST_TAG_NEEDED, + tecNO_PERMISSION, checkFrozen failure, checkDeepFrozenFailure, + second+third tecINSUFFICIENT_FUNDS (can this happen)? + doApply: tecPATH_DRY (can it happen, funds already checked?) + */ + testLoanBroker({}, CoverWithdraw); + } + + void + testInvalidLoanBrokerDelete() + { + using namespace jtx; + testcase("Invalid LoanBrokerDelete"); + /* + preclaim: tecHAS_OBLIGATIONS + doApply: + accountSend failure, removeEmptyHolding failure, + all tecHAS_OBLIGATIONS (can any of these happen?) + */ + testLoanBroker({}, Delete); + } + + void + testInvalidLoanBrokerSet() + { + using namespace jtx; + testcase("Invalid LoanBrokerSet"); + + /*preclaim: canAddHolding failure (can it happen with MPT? + can't create Vault if CanTransfer is not enabled.) + doApply: + first+second dirLink failure, createPseudoAccount failure, + addEmptyHolding failure + can any of these happen? + */ + testLoanBroker({}, Set); + } + + void + testLoanBrokerCoverDepositNullVault() + { + // This test is lifted directly from + // https://bugs.immunefi.com/dashboard/submission/57808 + using namespace jtx; + Env env(*this); + + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + env.close(); + + // Create a Vault owned by alice with an XRP asset + PrettyAsset const asset{xrpIssue(), 1}; + Vault vault{env}; + auto const [createTx, vaultKeylet] = + vault.create({.owner = alice, .asset = asset}); + env(createTx); + env.close(); + + // Predict LoanBroker key using alice's current sequence BEFORE submit + auto const brokerKeylet = + keylet::loanbroker(alice.id(), env.seq(alice)); + + // Create LoanBroker pointing to the vault + env(loanBroker::set(alice, vaultKeylet.key)); + env.close(); + + // Build the CoverDeposit STTx directly + STTx tx{ttLOAN_BROKER_COVER_DEPOSIT, [](STObject&) {}}; + tx.setAccountID(sfAccount, alice.id()); + tx.setFieldH256(sfLoanBrokerID, brokerKeylet.key); + tx.setFieldAmount(sfAmount, asset(1)); + + // Create a writable view cloned from the current ledger and remove the + // vault SLE + OpenView ov{*env.current()}; + test::StreamSink sink{beast::severities::kWarning}; + beast::Journal jlog{sink}; + ApplyContext ac{ + env.app(), + ov, + tx, + tesSUCCESS, + env.current()->fees().base, + tapNONE, + jlog}; + + if (auto sleBroker = + ac.view().peek(keylet::loanbroker(brokerKeylet.key))) + { + auto const vaultID = (*sleBroker)[sfVaultID]; + if (auto sleVault = ac.view().peek(keylet::vault(vaultID))) + { + ac.view().erase(sleVault); + } + } + + // Invoke preclaim against the mutated (ApplyView) view; triggers + // nullptr deref + PreclaimContext pctx{ + env.app(), ac.view(), tesSUCCESS, tx, tapNONE, jlog}; + (void)LoanBrokerCoverDeposit::preclaim(pctx); + } + + void + testRequireAuth() + { + testcase("Require Auth - Implicit Pseudo-account authorization"); + 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(); + + auto asset = MPTTester({ + .env = env, + .issuer = issuer, + .holders = {alice}, + .flags = MPTDEXFlags | tfMPTRequireAuth | tfMPTCanClawback | + tfMPTCanLock, + .authHolder = true, + }); + + env(pay(issuer, alice, asset(100'000))); + env.close(); + + // Alice is not authorized, can still create the vault + asset.authorize( + {.account = issuer, .holder = alice, .flags = tfMPTUnauthorize}); + auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = asset}); + env(tx); + 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; + + // Can't unauthorize Vault pseudo-account + asset.authorize( + {.account = issuer, + .holder = vaultInfo.pseudoAccount, + .flags = tfMPTUnauthorize, + .err = tecNO_PERMISSION}); + + auto forUnauthAuth = [&](auto&& doTx) { + for (auto const flag : {tfMPTUnauthorize, 0u}) + { + asset.authorize( + {.account = issuer, .holder = alice, .flags = flag}); + env.close(); + doTx(flag == 0); + env.close(); + } + }; + + // Can't deposit into Vault if the vault owner is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? ter(tecNO_AUTH) : ter(tesSUCCESS); + env(vault.deposit( + {.depositor = alice, + .id = vaultKeylet.key, + .amount = asset(51)}), + err); + }); + + // Can't withdraw from Vault if the vault owner is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? ter(tecNO_AUTH) : ter(tesSUCCESS); + env(vault.withdraw( + {.depositor = alice, + .id = vaultKeylet.key, + .amount = asset(1)}), + err); + }); + + auto const brokerKeylet = + keylet::loanbroker(alice.id(), env.seq(alice)); + // Can create LoanBroker if the vault owner is not authorized + forUnauthAuth([&](auto) { env(set(alice, vaultInfo.vaultID)); }); + + auto const broker = env.le(brokerKeylet); + if (!BEAST_EXPECT(broker)) + return; + Account brokerPseudo("pseudo", broker->at(sfAccount)); + + // Can't unauthorize LoanBroker pseudo-account + asset.authorize( + {.account = issuer, + .holder = brokerPseudo, + .flags = tfMPTUnauthorize, + .err = tecNO_PERMISSION}); + + // Can't cover deposit into Vault if the vault owner is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? ter(tecNO_AUTH) : ter(tesSUCCESS); + env(coverDeposit(alice, brokerKeylet.key, vaultInfo.asset(10)), + err); + }); + + // Can't cover withdraw from Vault if the vault owner is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? ter(tecNO_AUTH) : ter(tesSUCCESS); + env(coverWithdraw(alice, brokerKeylet.key, vaultInfo.asset(5)), + err); + }); + + // Issuer can always cover clawback. The holder authorization is n/a. + forUnauthAuth([&](bool) { + env(coverClawback(issuer), + loanBrokerID(brokerKeylet.key), + amount(vaultInfo.asset(1))); + }); + } + +public: + void + run() override + { + testLoanBrokerCoverDepositNullVault(); + + testDisabled(); + testLifecycle(); + testInvalidLoanBrokerCoverClawback(); + testInvalidLoanBrokerCoverDeposit(); + testInvalidLoanBrokerCoverWithdraw(); + testInvalidLoanBrokerDelete(); + testInvalidLoanBrokerSet(); + testRequireAuth(); + + // TODO: Write clawback failure tests with an issuer / MPT that doesn't + // have the right flags set. + } +}; + +BEAST_DEFINE_TESTSUITE(LoanBroker, tx, ripple); + +} // namespace test +} // namespace ripple diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp new file mode 100644 index 00000000000..2f5105a27a4 --- /dev/null +++ b/src/test/app/Loan_test.cpp @@ -0,0 +1,7217 @@ +#include +// +#include +#include + +#include +#include +#include +#include + +#include +#include + +namespace ripple { +namespace test { + +class Loan_test : public beast::unit_test::suite +{ +protected: + // Ensure that all the features needed for Lending Protocol are included, + // even if they are set to unsupported. + FeatureBitset const all{ + jtx::testable_amendments() | featureMPTokensV1 | + featureSingleAssetVault | featureLendingProtocol}; + + std::string const iouCurrency{"IOU"}; + + void + testDisabled() + { + testcase("Disabled"); + // Lending Protocol depends on Single Asset Vault (SAV). Test + // combinations of the two amendments. + // Single Asset Vault depends on MPTokensV1, but don't test every combo + // of that. + using namespace jtx; + auto failAll = [this](FeatureBitset features) { + Env env(*this, features); + + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, bob); + + auto const keylet = keylet::loanbroker(alice, env.seq(alice)); + + using namespace std::chrono_literals; + using namespace loan; + + // counter party signature is optional on LoanSet. Confirm that by + // sending transaction without one. + auto setTx = + env.jt(set(alice, keylet.key, Number(10000)), ter(temDISABLED)); + env(setTx); + + // All loan transactions are disabled. + // 1. LoanSet + setTx = env.jt( + setTx, sig(sfCounterpartySignature, bob), ter(temDISABLED)); + env(setTx); + // Actual sequence will be based off the loan broker, but we + // obviously don't have one of those if the amendment is disabled + auto const loanKeylet = keylet::loan(keylet.key, env.seq(alice)); + // Other Loan transactions are disabled, too. + // 2. LoanDelete + env(del(alice, loanKeylet.key), ter(temDISABLED)); + // 3. LoanManage + env(manage(alice, loanKeylet.key, tfLoanImpair), ter(temDISABLED)); + // 4. LoanPay + env(pay(alice, loanKeylet.key, XRP(500)), ter(temDISABLED)); + }; + failAll(all - featureMPTokensV1); + failAll(all - featureSingleAssetVault - featureLendingProtocol); + failAll(all - featureSingleAssetVault); + failAll(all - featureLendingProtocol); + } + + struct BrokerParameters + { + Number vaultDeposit = 1'000'000; + Number debtMax = 25'000; + TenthBips32 coverRateMin = percentageToTenthBips(10); + int coverDeposit = 1000; + TenthBips16 managementFeeRate{100}; + TenthBips32 coverRateLiquidation = percentageToTenthBips(25); + std::string data{}; + std::uint32_t flags = 0; + + Number + maxCoveredLoanValue(Number const& currentDebt) const + { + NumberRoundModeGuard mg(Number::downward); + auto debtLimit = + coverDeposit * tenthBipsPerUnity.value() / coverRateMin.value(); + + return debtLimit - currentDebt; + } + + static BrokerParameters const& + defaults() + { + static BrokerParameters const result{}; + return result; + } + + // TODO: create an operator() which returns a transaction similar to + // LoanParameters + }; + + struct BrokerInfo + { + jtx::PrettyAsset asset; + uint256 brokerID; + uint256 vaultID; + BrokerParameters params; + BrokerInfo( + jtx::PrettyAsset const& asset_, + Keylet const& brokerKeylet_, + Keylet const& vaultKeylet_, + BrokerParameters const& p) + : asset(asset_) + , brokerID(brokerKeylet_.key) + , vaultID(vaultKeylet_.key) + , params(p) + { + } + + Keylet + brokerKeylet() const + { + return keylet::loanbroker(brokerID); + } + Keylet + vaultKeylet() const + { + return keylet::vault(vaultID); + } + + int + vaultScale(jtx::Env const& env) const + { + using namespace jtx; + + auto const vaultSle = env.le(keylet::vault(vaultID)); + return getVaultScale(vaultSle); + } + }; + + struct LoanParameters + { + // The account submitting the transaction. May be borrower or broker. + jtx::Account account; + // The counterparty. Should be the other of borrower or broker. + jtx::Account counter; + // Whether the counterparty is specified in the `counterparty` field, or + // only signs. + bool counterpartyExplicit = true; + Number principalRequest; + std::optional setFee{}; + std::optional originationFee{}; + std::optional serviceFee{}; + std::optional lateFee{}; + std::optional closeFee{}; + std::optional overFee{}; + std::optional interest{}; + std::optional lateInterest{}; + std::optional closeInterest{}; + std::optional overpaymentInterest{}; + std::optional payTotal{}; + std::optional payInterval{}; + std::optional gracePd{}; + std::optional flags{}; + + template + jtx::JTx + operator()(jtx::Env& env, BrokerInfo const& broker, FN const&... fN) + const + { + using namespace jtx; + using namespace jtx::loan; + + JTx jt{loan::set( + account, + broker.brokerID, + broker.asset(principalRequest).number(), + flags.value_or(0))}; + + sig(sfCounterpartySignature, counter)(env, jt); + + fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt); + + if (counterpartyExplicit) + counterparty(counter)(env, jt); + if (originationFee) + loanOriginationFee(broker.asset(*originationFee).number())( + env, jt); + if (serviceFee) + loanServiceFee(broker.asset(*serviceFee).number())(env, jt); + if (lateFee) + latePaymentFee(broker.asset(*lateFee).number())(env, jt); + if (closeFee) + closePaymentFee(broker.asset(*closeFee).number())(env, jt); + if (overFee) + overpaymentFee (*overFee)(env, jt); + if (interest) + interestRate (*interest)(env, jt); + if (lateInterest) + lateInterestRate (*lateInterest)(env, jt); + if (closeInterest) + closeInterestRate (*closeInterest)(env, jt); + if (overpaymentInterest) + overpaymentInterestRate (*overpaymentInterest)(env, jt); + if (payTotal) + paymentTotal (*payTotal)(env, jt); + if (payInterval) + paymentInterval (*payInterval)(env, jt); + if (gracePd) + gracePeriod (*gracePd)(env, jt); + + return env.jt(jt, fN...); + } + }; + + struct PaymentParameters + { + Number overpaymentFactor = Number{1}; + std::optional overpaymentExtra = std::nullopt; + std::uint32_t flags = 0; + bool showStepBalances = false; + bool validateBalances = true; + + static PaymentParameters const& + defaults() + { + static PaymentParameters const result{}; + return result; + } + }; + + struct LoanState + { + std::uint32_t previousPaymentDate = 0; + NetClock::time_point startDate = {}; + std::uint32_t nextPaymentDate = 0; + std::uint32_t paymentRemaining = 0; + std::int32_t const loanScale = 0; + Number totalValue = 0; + Number principalOutstanding = 0; + Number managementFeeOutstanding = 0; + Number periodicPayment = 0; + std::uint32_t flags = 0; + std::uint32_t const paymentInterval = 0; + TenthBips32 const interestRate{}; + }; + + /** Helper class to compare the expected state of a loan and loan broker + * against the data in the ledger. + */ + struct VerifyLoanStatus + { + public: + jtx::Env const& env; + BrokerInfo const& broker; + jtx::Account const& pseudoAccount; + Keylet const& loanKeylet; + + VerifyLoanStatus( + jtx::Env const& env_, + BrokerInfo const& broker_, + jtx::Account const& pseudo_, + Keylet const& keylet_) + : env(env_) + , broker(broker_) + , pseudoAccount(pseudo_) + , loanKeylet(keylet_) + { + } + + /** Checks the expected broker state against the ledger + */ + void + checkBroker( + Number const& principalOutstanding, + Number const& interestOwed, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining, + std::uint32_t ownerCount) const + { + using namespace jtx; + if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + env.test.BEAST_EXPECT(brokerSle)) + { + TenthBips16 const managementFeeRate{ + brokerSle->at(sfManagementFeeRate)}; + auto const brokerDebt = brokerSle->at(sfDebtTotal); + auto const expectedDebt = principalOutstanding + interestOwed; + env.test.BEAST_EXPECT(brokerDebt == expectedDebt); + env.test.BEAST_EXPECT( + env.balance(pseudoAccount, broker.asset).number() == + brokerSle->at(sfCoverAvailable)); + env.test.BEAST_EXPECT( + brokerSle->at(sfOwnerCount) == ownerCount); + + if (auto vaultSle = + env.le(keylet::vault(brokerSle->at(sfVaultID))); + env.test.BEAST_EXPECT(vaultSle)) + { + Account const vaultPseudo{ + "vaultPseudoAccount", vaultSle->at(sfAccount)}; + env.test.BEAST_EXPECT( + vaultSle->at(sfAssetsAvailable) == + env.balance(vaultPseudo, broker.asset).number()); + if (ownerCount == 0) + { + // Allow some slop for rounding IOUs + + // TODO: This needs to be an exact match once all the + // other rounding issues are worked out. + auto const total = vaultSle->at(sfAssetsTotal); + auto const available = vaultSle->at(sfAssetsAvailable); + env.test.BEAST_EXPECT( + total == available || + (!broker.asset.integral() && available != 0 && + ((total - available) / available < + Number(1, -6)))); + env.test.BEAST_EXPECT( + vaultSle->at(sfLossUnrealized) == 0); + } + } + } + } + + void + checkPayment( + std::int32_t loanScale, + jtx::Account const& account, + jtx::PrettyAmount const& balanceBefore, + STAmount const& expectedPayment, + jtx::PrettyAmount const& adjustment) const + { + auto const borrowerScale = + std::max(loanScale, balanceBefore.number().exponent()); + + STAmount const balanceChangeAmount{ + broker.asset, + roundToAsset( + broker.asset, expectedPayment + adjustment, borrowerScale)}; + { + auto const difference = roundToScale( + env.balance(account, broker.asset) - + (balanceBefore - balanceChangeAmount), + borrowerScale); + env.test.BEAST_EXPECT( + roundToScale(difference, loanScale) >= beast::zero); + } + } + + /** Checks both the loan and broker expect states against the ledger */ + void + operator()( + std::uint32_t previousPaymentDate, + std::uint32_t nextPaymentDate, + std::uint32_t paymentRemaining, + Number const& loanScale, + Number const& totalValue, + Number const& principalOutstanding, + Number const& managementFeeOutstanding, + Number const& periodicPayment, + std::uint32_t flags) const + { + using namespace jtx; + if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) + { + env.test.BEAST_EXPECT( + loan->at(sfPreviousPaymentDate) == previousPaymentDate); + env.test.BEAST_EXPECT( + loan->at(sfPaymentRemaining) == paymentRemaining); + env.test.BEAST_EXPECT( + loan->at(sfNextPaymentDueDate) == nextPaymentDate); + env.test.BEAST_EXPECT(loan->at(sfLoanScale) == loanScale); + env.test.BEAST_EXPECT( + loan->at(sfTotalValueOutstanding) == totalValue); + env.test.BEAST_EXPECT( + loan->at(sfPrincipalOutstanding) == principalOutstanding); + env.test.BEAST_EXPECT( + loan->at(sfManagementFeeOutstanding) == + managementFeeOutstanding); + env.test.BEAST_EXPECT( + loan->at(sfPeriodicPayment) == periodicPayment); + env.test.BEAST_EXPECT(loan->at(sfFlags) == flags); + + auto const ls = constructRoundedLoanState(loan); + + auto const interestRate = TenthBips32{loan->at(sfInterestRate)}; + auto const paymentInterval = loan->at(sfPaymentInterval); + checkBroker( + principalOutstanding, + ls.interestDue, + interestRate, + paymentInterval, + paymentRemaining, + 1); + + if (auto brokerSle = + env.le(keylet::loanbroker(broker.brokerID)); + env.test.BEAST_EXPECT(brokerSle)) + { + if (auto vaultSle = + env.le(keylet::vault(brokerSle->at(sfVaultID))); + env.test.BEAST_EXPECT(vaultSle)) + { + if ((flags & lsfLoanImpaired) && + !(flags & lsfLoanDefault)) + { + env.test.BEAST_EXPECT( + vaultSle->at(sfLossUnrealized) == + totalValue - managementFeeOutstanding); + } + else + { + env.test.BEAST_EXPECT( + vaultSle->at(sfLossUnrealized) == 0); + } + } + } + } + } + + /** Checks both the loan and broker expect states against the ledger */ + void + operator()(LoanState const& state) const + { + operator()( + state.previousPaymentDate, + state.nextPaymentDate, + state.paymentRemaining, + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + state.flags); + }; + }; + + BrokerInfo + createVaultAndBroker( + jtx::Env& env, + jtx::PrettyAsset const& asset, + jtx::Account const& lender, + BrokerParameters const& params = BrokerParameters::defaults()) + { + using namespace jtx; + + Vault vault{env}; + + auto const deposit = asset(params.vaultDeposit); + auto const debtMaximumValue = asset(params.debtMax).value(); + auto const coverDepositValue = asset(params.coverDeposit).value(); + + auto const coverRateMinValue = params.coverRateMin; + + auto [tx, vaultKeylet] = + vault.create({.owner = lender, .asset = asset}); + env(tx); + env.close(); + BEAST_EXPECT(env.le(vaultKeylet)); + + env(vault.deposit( + {.depositor = lender, .id = vaultKeylet.key, .amount = deposit})); + env.close(); + if (auto const vault = env.le(keylet::vault(vaultKeylet.key)); + BEAST_EXPECT(vault)) + { + BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value()); + } + + auto const keylet = keylet::loanbroker(lender.id(), env.seq(lender)); + + using namespace loanBroker; + env(set(lender, vaultKeylet.key, params.flags), + data(params.data), + managementFeeRate(params.managementFeeRate), + debtMaximum(debtMaximumValue), + coverRateMinimum(coverRateMinValue), + coverRateLiquidation(TenthBips32(params.coverRateLiquidation))); + + if (coverDepositValue != beast::zero) + env(coverDeposit(lender, keylet.key, coverDepositValue)); + + env.close(); + + return {asset, keylet, vaultKeylet, params}; + } + + /// Get the state without checking anything + LoanState + getCurrentState( + jtx::Env const& env, + BrokerInfo const& broker, + Keylet const& loanKeylet) + { + using d = NetClock::duration; + using tp = NetClock::time_point; + + // Lookup the current loan state + if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + return LoanState{ + .previousPaymentDate = loan->at(sfPreviousPaymentDate), + .startDate = tp{d{loan->at(sfStartDate)}}, + .nextPaymentDate = loan->at(sfNextPaymentDueDate), + .paymentRemaining = loan->at(sfPaymentRemaining), + .loanScale = loan->at(sfLoanScale), + .totalValue = loan->at(sfTotalValueOutstanding), + .principalOutstanding = loan->at(sfPrincipalOutstanding), + .managementFeeOutstanding = + loan->at(sfManagementFeeOutstanding), + .periodicPayment = loan->at(sfPeriodicPayment), + .flags = loan->at(sfFlags), + .paymentInterval = loan->at(sfPaymentInterval), + .interestRate = TenthBips32{loan->at(sfInterestRate)}, + }; + } + return LoanState{}; + } + + /// Get the state and check the values against the parameters used in + /// `lifecycle` + LoanState + getCurrentState( + jtx::Env const& env, + BrokerInfo const& broker, + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) + { + using namespace std::chrono_literals; + using d = NetClock::duration; + using tp = NetClock::time_point; + + auto const state = getCurrentState(env, broker, loanKeylet); + BEAST_EXPECT(state.previousPaymentDate == 0); + BEAST_EXPECT(tp{d{state.nextPaymentDate}} == state.startDate + 600s); + BEAST_EXPECT(state.paymentRemaining == 12); + BEAST_EXPECT(state.principalOutstanding == broker.asset(1000).value()); + BEAST_EXPECT( + state.loanScale >= + (broker.asset.integral() + ? 0 + : std::max( + broker.vaultScale(env), + state.principalOutstanding.exponent()))); + BEAST_EXPECT(state.paymentInterval == 600); + BEAST_EXPECT( + state.totalValue == + roundToAsset( + broker.asset, + state.periodicPayment * state.paymentRemaining, + state.loanScale)); + BEAST_EXPECT( + state.managementFeeOutstanding == + computeManagementFee( + broker.asset, + state.totalValue - state.principalOutstanding, + broker.params.managementFeeRate, + state.loanScale)); + + verifyLoanStatus(state); + + return state; + } + + bool + canImpairLoan( + jtx::Env const& env, + BrokerInfo const& broker, + LoanState const& state) + { + if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + if (auto const vaultSle = + env.le(keylet::vault(brokerSle->at(sfVaultID))); + BEAST_EXPECT(vaultSle)) + { + // log << vaultSle->getJson() << std::endl; + auto const assetsUnavailable = vaultSle->at(sfAssetsTotal) - + vaultSle->at(sfAssetsAvailable); + auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + + state.totalValue - state.managementFeeOutstanding; + + if (unrealizedLoss > assetsUnavailable) + { + return false; + } + } + } + return true; + } + + enum class AssetType { XRP = 0, IOU = 1, MPT = 2 }; + + // Specify the accounts as params to allow other accounts to be used + jtx::PrettyAsset + createAsset( + jtx::Env& env, + AssetType assetType, + BrokerParameters const& brokerParams, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + switch (assetType) + { + case AssetType::XRP: + // TODO: remove the factor, and set up loans in drops + return PrettyAsset{xrpIssue(), 1'000'000}; + + case AssetType::IOU: { + PrettyAsset const asset{issuer[iouCurrency]}; + + auto const limit = asset( + 100 * + (brokerParams.vaultDeposit + brokerParams.coverDeposit)); + if (lender != issuer) + env(trust(lender, limit)); + if (borrower != issuer) + env(trust(borrower, limit)); + + return asset; + } + + case AssetType::MPT: { + // Enough to cover initial fees + if (!env.le(keylet::account(issuer))) + env.fund( + env.current()->fees().accountReserve(10) * 10, issuer); + if (!env.le(keylet::account(lender))) + env.fund( + env.current()->fees().accountReserve(10) * 10, + noripple(lender)); + if (!env.le(keylet::account(borrower))) + env.fund( + env.current()->fees().accountReserve(10) * 10, + noripple(borrower)); + + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = + tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + // Scale the MPT asset so interest is interesting + PrettyAsset const asset{mptt.issuanceID(), 10'000}; + // Need to do the authorization here because mptt isn't + // accessible outside + if (lender != issuer) + mptt.authorize({.account = lender}); + if (borrower != issuer) + mptt.authorize({.account = borrower}); + + env.close(); + + return asset; + } + + default: + throw std::runtime_error("Unknown asset type"); + } + } + + void + describeLoan( + jtx::Env& env, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams, + AssetType assetType, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + auto const asset = + createAsset(env, assetType, brokerParams, issuer, lender, borrower); + auto const principal = asset(loanParams.principalRequest).number(); + auto const interest = loanParams.interest.value_or(TenthBips32{}); + auto const interval = + loanParams.payInterval.value_or(LoanSet::defaultPaymentInterval); + auto const total = + loanParams.payTotal.value_or(LoanSet::defaultPaymentTotal); + auto const feeRate = brokerParams.managementFeeRate; + auto const props = computeLoanProperties( + asset, + principal, + interest, + interval, + total, + feeRate, + asset(brokerParams.vaultDeposit).number().exponent()); + log << "Loan properties:\n" + << "\tPrincipal: " << principal << std::endl + << "\tInterest rate: " << interest << std::endl + << "\tPayment interval: " << interval << std::endl + << "\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 + << std::endl + << "\tLoan Scale: " << props.loanScale << std::endl + << "\tFirst payment principal: " << props.firstPaymentPrincipal + << std::endl; + + // checkGuards returns a TER, so success is 0 + BEAST_EXPECT(!checkLoanGuards( + asset, + asset(loanParams.principalRequest).number(), + loanParams.interest.value_or(TenthBips32{}) != beast::zero, + loanParams.payTotal.value_or(LoanSet::defaultPaymentTotal), + props, + env.journal)); + } + + std::optional> + createLoan( + jtx::Env& env, + AssetType assetType, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + // Enough to cover initial fees + env.fund(env.current()->fees().accountReserve(10) * 10, issuer); + if (lender != issuer) + env.fund( + env.current()->fees().accountReserve(10) * 10, + noripple(lender)); + if (borrower != issuer && borrower != lender) + env.fund( + env.current()->fees().accountReserve(10) * 10, + noripple(borrower)); + + describeLoan( + env, brokerParams, loanParams, assetType, issuer, lender, borrower); + + // Make the asset + auto const asset = + createAsset(env, assetType, brokerParams, issuer, lender, borrower); + + env.close(); + if (asset.native() || lender != issuer) + env(pay( + (asset.native() ? env.master : issuer), + lender, + asset(brokerParams.vaultDeposit + brokerParams.coverDeposit))); + // Fund the borrower later once we know the total loan + // size + + BrokerInfo const broker = + createVaultAndBroker(env, asset, lender, brokerParams); + + auto const pseudoAcctOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + auto const brokerPseudo = brokerSle->at(sfAccount); + return Account("Broker pseudo-account", brokerPseudo); + }(); + if (!pseudoAcctOpt) + return std::nullopt; + Account const& pseudoAcct = *pseudoAcctOpt; + + auto const loanKeyletOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the + // _LOAN_BROKER_ object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return keylet::loan(broker.brokerID, loanSequence); + }(); + if (!loanKeyletOpt) + return std::nullopt; + Keylet const& loanKeylet = *loanKeyletOpt; + + env(loanParams(env, broker)); + + env.close(); + + return std::make_tuple(broker, loanKeylet, pseudoAcct); + } + + void + topUpBorrower( + jtx::Env& env, + BrokerInfo const& broker, + jtx::Account const& issuer, + jtx::Account const& borrower, + LoanState const& state, + std::optional const& servFee) + { + using namespace jtx; + + STAmount const serviceFee = broker.asset(servFee.value_or(0)); + + // Ensure the borrower has enough funds to make the payments + // (including tx fees, if necessary) + auto const borrowerBalance = env.balance(borrower, broker.asset); + + auto const baseFee = env.current()->fees().base; + + // Add extra for transaction fees and reserves, if appropriate, or a + // tiny amount for the extra paid in each transaction + auto const totalNeeded = state.totalValue + + (serviceFee * state.paymentRemaining) + + (broker.asset.native() ? Number( + baseFee * state.paymentRemaining + + env.current()->fees().accountReserve( + env.ownerCount(borrower))) + : broker.asset(15).number()); + + auto const shortage = totalNeeded - borrowerBalance.number(); + + if (shortage > beast::zero && + (broker.asset.native() || issuer != borrower)) + env( + pay((broker.asset.native() ? env.master : issuer), + borrower, + STAmount{broker.asset, shortage})); + } + + void + makeLoanPayments( + jtx::Env& env, + BrokerInfo const& broker, + LoanParameters const& loanParams, + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower, + PaymentParameters const& paymentParams = PaymentParameters::defaults()) + { + // Make all the individual payments + using namespace jtx; + using namespace jtx::loan; + 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); + + auto const baseFee = env.current()->fees().base; + + env.close(); + auto state = getCurrentState(env, broker, loanKeylet); + + verifyLoanStatus(state); + + STAmount const serviceFee = + broker.asset(loanParams.serviceFee.value_or(0)); + + topUpBorrower( + env, broker, issuer, borrower, state, loanParams.serviceFee); + + // Periodic payment amount will consist of + // 1. principal outstanding (1000) + // 2. interest interest rate (at 12%) + // 3. payment interval (600s) + // 4. loan service fee (2) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + auto const periodicRate = + loanPeriodicRate(state.interestRate, state.paymentInterval); + STAmount const roundedPeriodicPayment{ + broker.asset, + roundPeriodicPayment( + broker.asset, state.periodicPayment, state.loanScale)}; + + if (!showStepBalances) + log << currencyLabel << " Payment components: " + << "Payments remaining, " + << "rawInterest, rawPrincipal, " + "rawMFee, " + << "trackedValueDelta, trackedPrincipalDelta, " + "trackedInterestDelta, trackedMgmtFeeDelta, special" + << std::endl; + + // Include the service fee + STAmount const totalDue = roundToScale( + roundedPeriodicPayment + serviceFee, + state.loanScale, + Number::upward); + + auto currentRoundedState = constructLoanState( + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding); + { + auto const raw = computeRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + if (showStepBalances) + { + log << currencyLabel << " Starting loan balances: " + << "\n\tTotal value: " + << currentRoundedState.valueOutstanding << "\n\tPrincipal: " + << currentRoundedState.principalOutstanding + << "\n\tInterest: " << currentRoundedState.interestDue + << "\n\tMgmt fee: " << currentRoundedState.managementFeeDue + << "\n\tPayments remaining " << state.paymentRemaining + << std::endl; + } + else + { + log << currencyLabel + << " Loan starting state: " << state.paymentRemaining + << ", " << raw.interestDue << ", " + << raw.principalOutstanding << ", " << raw.managementFeeDue + << ", " << currentRoundedState.valueOutstanding << ", " + << currentRoundedState.principalOutstanding << ", " + << currentRoundedState.interestDue << ", " + << currentRoundedState.managementFeeDue << std::endl; + } + } + + // Try to pay a little extra to show that it's _not_ + // taken + auto const extraAmount = paymentParams.overpaymentExtra + ? broker.asset(*paymentParams.overpaymentExtra).value() + : std::min( + broker.asset(10).value(), + STAmount{broker.asset, totalDue / 20}); + + STAmount const transactionAmount = + STAmount{broker.asset, totalDue * paymentParams.overpaymentFactor} + + extraAmount; + + auto const borrowerInitialBalance = + env.balance(borrower, broker.asset).number(); + auto const initialState = state; + detail::PaymentComponents totalPaid{ + .trackedValueDelta = 0, + .trackedPrincipalDelta = 0, + .trackedManagementFeeDelta = 0}; + Number totalInterestPaid = 0; + Number totalFeesPaid = 0; + std::size_t totalPaymentsMade = 0; + + ripple::LoanState currentTrueState = computeRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + auto validateBorrowerBalance = [&]() { + if (borrower == issuer || !paymentParams.validateBalances) + return; + auto const totalSpent = + (totalPaid.trackedValueDelta + totalFeesPaid + + (broker.asset.native() ? Number(baseFee) * totalPaymentsMade + : numZero)); + BEAST_EXPECT( + env.balance(borrower, broker.asset).number() == + borrowerInitialBalance - totalSpent); + }; + + auto const defaultRound = broker.asset.integral() ? 3 : 0; + auto truncate = [defaultRound]( + Number const& n, + std::optional places = std::nullopt) { + auto const p = places.value_or(defaultRound); + if (p == 0) + return n; + auto const factor = Number{1, p}; + return (n * factor).truncate() / factor; + }; + while (state.paymentRemaining > 0) + { + validateBorrowerBalance(); + // Compute the expected principal amount + auto const paymentComponents = detail::computePaymentComponents( + broker.asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + BEAST_EXPECT( + paymentComponents.trackedValueDelta <= roundedPeriodicPayment || + (paymentComponents.specialCase == + detail::PaymentSpecialCase::final && + paymentComponents.trackedValueDelta >= + roundedPeriodicPayment)); + BEAST_EXPECT( + paymentComponents.trackedValueDelta == + paymentComponents.trackedPrincipalDelta + + paymentComponents.trackedInterestPart() + + paymentComponents.trackedManagementFeeDelta); + + ripple::LoanState const nextTrueState = computeRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining - 1, + broker.params.managementFeeRate); + detail::LoanStateDeltas const deltas = + currentTrueState - nextTrueState; + BEAST_EXPECT( + deltas.total() == + deltas.principal + deltas.interest + deltas.managementFee); + BEAST_EXPECT( + paymentComponents.specialCase == + detail::PaymentSpecialCase::final || + deltas.total() == state.periodicPayment || + (state.loanScale - + (deltas.total() - state.periodicPayment).exponent()) > 14); + + if (!showStepBalances) + log << currencyLabel + << " Payment components: " << state.paymentRemaining << ", " + + << deltas.interest << ", " << deltas.principal << ", " + << deltas.managementFee << ", " + << paymentComponents.trackedValueDelta << ", " + << paymentComponents.trackedPrincipalDelta << ", " + << paymentComponents.trackedInterestPart() << ", " + << paymentComponents.trackedManagementFeeDelta << ", " + << (paymentComponents.specialCase == + detail::PaymentSpecialCase::final + ? "final" + : paymentComponents.specialCase == + detail::PaymentSpecialCase::extra + ? "extra" + : "none") + << std::endl; + + auto const totalDueAmount = STAmount{ + broker.asset, paymentComponents.trackedValueDelta + serviceFee}; + + if (paymentParams.validateBalances) + { + // Due to the rounding algorithms to keep the interest and + // principal in sync with "true" values, the computed amount + // may be a little less than the rounded fixed payment + // amount. For integral types, the difference should be < 3 + // (1 unit for each of the interest and management fee). For + // IOUs, the difference should be dust. + Number const diff = totalDue - totalDueAmount; + BEAST_EXPECT( + paymentComponents.specialCase == + detail::PaymentSpecialCase::final || + diff == beast::zero || + (diff > beast::zero && + ((broker.asset.integral() && + (static_cast(diff) < 3)) || + (state.loanScale - diff.exponent() > 13)))); + + BEAST_EXPECT( + paymentComponents.trackedPrincipalDelta >= beast::zero && + paymentComponents.trackedPrincipalDelta <= + state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase != + detail::PaymentSpecialCase::final || + paymentComponents.trackedPrincipalDelta == + state.principalOutstanding); + } + + auto const borrowerBalanceBeforePayment = + env.balance(borrower, broker.asset); + + // Make the payment + env( + pay(borrower, + loanKeylet.key, + transactionAmount, + paymentParams.flags)); + + env.close(d{state.paymentInterval / 2}); + + if (paymentParams.validateBalances) + { + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + + // Check the result + verifyLoanStatus.checkPayment( + state.loanScale, + borrower, + borrowerBalanceBeforePayment, + totalDueAmount, + adjustment); + } + + if (showStepBalances) + { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + // No reason for this not to exist + return; + auto const current = constructRoundedLoanState(loanSle); + auto const errors = nextTrueState - current; + log << currencyLabel << " Loan balances: " + << "\n\tAmount taken: " + << paymentComponents.trackedValueDelta + << "\n\tTotal value: " << current.valueOutstanding + << " (true: " << truncate(nextTrueState.valueOutstanding) + << ", error: " << truncate(errors.total()) + << ")\n\tPrincipal: " << current.principalOutstanding + << " (true: " + << truncate(nextTrueState.principalOutstanding) + << ", error: " << truncate(errors.principal) + << ")\n\tInterest: " << current.interestDue + << " (true: " << truncate(nextTrueState.interestDue) + << ", error: " << truncate(errors.interest) + << ")\n\tMgmt fee: " << current.managementFeeDue + << " (true: " << truncate(nextTrueState.managementFeeDue) + << ", error: " << truncate(errors.managementFee) + << ")\n\tPayments remaining " + << loanSle->at(sfPaymentRemaining) << std::endl; + + currentRoundedState = current; + } + + --state.paymentRemaining; + state.previousPaymentDate = state.nextPaymentDate; + if (paymentComponents.specialCase == + detail::PaymentSpecialCase::final) + { + state.paymentRemaining = 0; + state.nextPaymentDate = 0; + } + else + { + state.nextPaymentDate += state.paymentInterval; + } + state.principalOutstanding -= + paymentComponents.trackedPrincipalDelta; + state.managementFeeOutstanding -= + paymentComponents.trackedManagementFeeDelta; + state.totalValue -= paymentComponents.trackedValueDelta; + + if (paymentParams.validateBalances) + verifyLoanStatus(state); + + totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; + totalPaid.trackedPrincipalDelta += + paymentComponents.trackedPrincipalDelta; + totalPaid.trackedManagementFeeDelta += + paymentComponents.trackedManagementFeeDelta; + totalInterestPaid += paymentComponents.trackedInterestPart(); + totalFeesPaid += serviceFee; + ++totalPaymentsMade; + + currentTrueState = nextTrueState; + } + validateBorrowerBalance(); + + // Loan is paid off + BEAST_EXPECT(state.paymentRemaining == 0); + BEAST_EXPECT(state.principalOutstanding == 0); + + auto const initialInterestDue = initialState.totalValue - + (initialState.principalOutstanding + + initialState.managementFeeOutstanding); + if (paymentParams.validateBalances) + { + // Make sure all the payments add up + BEAST_EXPECT( + totalPaid.trackedValueDelta == initialState.totalValue); + BEAST_EXPECT( + totalPaid.trackedPrincipalDelta == + initialState.principalOutstanding); + BEAST_EXPECT( + totalPaid.trackedManagementFeeDelta == + initialState.managementFeeOutstanding); + // This is almost a tautology given the previous checks, but + // check it anyway for completeness. + BEAST_EXPECT(totalInterestPaid == initialInterestDue); + BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); + } + + if (showStepBalances) + { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + // No reason for this not to exist + return; + log << currencyLabel << " Total amounts paid: " + << "\n\tTotal value: " << totalPaid.trackedValueDelta + << " (initial: " << truncate(initialState.totalValue) + << ", error: " + << truncate( + initialState.totalValue - totalPaid.trackedValueDelta) + << ")\n\tPrincipal: " << totalPaid.trackedPrincipalDelta + << " (initial: " << truncate(initialState.principalOutstanding) + << ", error: " + << truncate( + initialState.principalOutstanding - + totalPaid.trackedPrincipalDelta) + << ")\n\tInterest: " << totalInterestPaid + << " (initial: " << truncate(initialInterestDue) << ", error: " + << truncate(initialInterestDue - totalInterestPaid) + << ")\n\tMgmt fee: " << totalPaid.trackedManagementFeeDelta + << " (initial: " + << truncate(initialState.managementFeeOutstanding) + << ", error: " + << truncate( + initialState.managementFeeOutstanding - + totalPaid.trackedManagementFeeDelta) + << ")\n\tTotal payments made: " << totalPaymentsMade + << std::endl; + } + } + + void + runLoan( + AssetType assetType, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams) + { + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + Env env(*this, all); + + auto loanResult = createLoan( + env, assetType, brokerParams, loanParams, issuer, lender, borrower); + if (!BEAST_EXPECT(loanResult)) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower); + } + + /** Runs through the complete lifecycle of a loan + * + * 1. Create a loan. + * 2. Test a bunch of transaction failure conditions. + * 3. Use the `toEndOfLife` callback to take the loan to 0. How that is done + * depends on the callback. e.g. Default, Early payoff, make all the + * normal payments, etc. + * 4. Delete the loan. The loan will alternate between being deleted by the + * lender and the borrower. + */ + void + lifecycle( + std::string const& caseLabel, + char const* label, + jtx::Env& env, + Number const& loanAmount, + int interestExponent, + jtx::Account const& lender, + jtx::Account const& borrower, + jtx::Account const& evan, + BrokerInfo const& broker, + jtx::Account const& pseudoAcct, + std::uint32_t flags, + // The end of life callback is expected to take the loan to 0 payments + // remaining, one way or another + std::function toEndOfLife) + { + auto const [keylet, loanSequence] = [&]() { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + // will be invalid + return std::make_pair( + keylet::loan(broker.brokerID), std::uint32_t(0)); + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the _LOAN_BROKER_ + // object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return std::make_pair( + keylet::loan(broker.brokerID, loanSequence), loanSequence); + }(); + + VerifyLoanStatus const verifyLoanStatus( + env, broker, pseudoAcct, keylet); + + // No loans yet + verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 1, 0, 0); + + if (!BEAST_EXPECT(loanSequence != 0)) + return; + + testcase << caseLabel << " " << label; + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + auto applyExponent = [interestExponent, + this](TenthBips32 value) mutable { + BEAST_EXPECT(value > TenthBips32(0)); + while (interestExponent > 0) + { + auto const oldValue = value; + value *= 10; + --interestExponent; + BEAST_EXPECT(value / 10 == oldValue); + } + while (interestExponent < 0) + { + auto const oldValue = value; + value /= 10; + ++interestExponent; + BEAST_EXPECT(value * 10 == oldValue); + } + return value; + }; + + auto const borrowerOwnerCount = env.ownerCount(borrower); + + auto const loanSetFee = env.current()->fees().base * 2; + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .counterpartyExplicit = false, + .principalRequest = loanAmount, + .setFee = loanSetFee, + .originationFee = 1, + .serviceFee = 2, + .lateFee = 3, + .closeFee = 4, + .overFee = applyExponent(percentageToTenthBips(5) / 10), + .interest = applyExponent(percentageToTenthBips(12)), + // 2.4% + .lateInterest = applyExponent(percentageToTenthBips(24) / 10), + .closeInterest = applyExponent(percentageToTenthBips(36) / 10), + .overpaymentInterest = + applyExponent(percentageToTenthBips(48) / 10), + .payTotal = 12, + .payInterval = 600, + .gracePd = 60, + .flags = flags, + }; + Number const principalRequestAmount = + broker.asset(loanParams.principalRequest).value(); + auto const originationFeeAmount = + broker.asset(*loanParams.originationFee).value(); + auto const serviceFeeAmount = + broker.asset(*loanParams.serviceFee).value(); + auto const lateFeeAmount = broker.asset(*loanParams.lateFee).value(); + auto const closeFeeAmount = broker.asset(*loanParams.closeFee).value(); + + auto const borrowerStartbalance = env.balance(borrower, broker.asset); + + auto createJtx = loanParams(env, broker); + // Successfully create a Loan + env(createJtx); + + env.close(); + + auto const startDate = + env.current()->info().parentCloseTime.time_since_epoch().count(); + + if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 1); + } + + { + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = 2 * env.current()->fees().base; + } + + BEAST_EXPECT( + env.balance(borrower, broker.asset).value() == + borrowerStartbalance.value() + principalRequestAmount - + originationFeeAmount - adjustment.value()); + } + + auto const loanFlags = createJtx.stx->isFlag(tfLoanOverpayment) + ? lsfLoanOverpayment + : LedgerSpecificFlags(0); + + if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) + { + // log << "loan after create: " << to_string(loan->getJson()) + // << std::endl; + BEAST_EXPECT( + loan->isFlag(lsfLoanOverpayment) == + createJtx.stx->isFlag(tfLoanOverpayment)); + BEAST_EXPECT(loan->at(sfLoanSequence) == loanSequence); + BEAST_EXPECT(loan->at(sfBorrower) == borrower.id()); + BEAST_EXPECT(loan->at(sfLoanBrokerID) == broker.brokerID); + BEAST_EXPECT( + loan->at(sfLoanOriginationFee) == originationFeeAmount); + BEAST_EXPECT(loan->at(sfLoanServiceFee) == serviceFeeAmount); + BEAST_EXPECT(loan->at(sfLatePaymentFee) == lateFeeAmount); + BEAST_EXPECT(loan->at(sfClosePaymentFee) == closeFeeAmount); + BEAST_EXPECT(loan->at(sfOverpaymentFee) == *loanParams.overFee); + BEAST_EXPECT(loan->at(sfInterestRate) == *loanParams.interest); + BEAST_EXPECT( + loan->at(sfLateInterestRate) == *loanParams.lateInterest); + BEAST_EXPECT( + loan->at(sfCloseInterestRate) == *loanParams.closeInterest); + BEAST_EXPECT( + loan->at(sfOverpaymentInterestRate) == + *loanParams.overpaymentInterest); + BEAST_EXPECT(loan->at(sfStartDate) == startDate); + BEAST_EXPECT( + loan->at(sfPaymentInterval) == *loanParams.payInterval); + BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd); + BEAST_EXPECT(loan->at(sfPreviousPaymentDate) == 0); + BEAST_EXPECT( + loan->at(sfNextPaymentDueDate) == + startDate + *loanParams.payInterval); + BEAST_EXPECT(loan->at(sfPaymentRemaining) == *loanParams.payTotal); + BEAST_EXPECT( + loan->at(sfLoanScale) >= + (broker.asset.integral() + ? 0 + : std::max( + broker.vaultScale(env), + principalRequestAmount.exponent()))); + BEAST_EXPECT( + loan->at(sfPrincipalOutstanding) == principalRequestAmount); + } + + auto state = getCurrentState(env, broker, keylet, verifyLoanStatus); + + auto const loanProperties = computeLoanProperties( + broker.asset.raw(), + state.principalOutstanding, + state.interestRate, + state.paymentInterval, + state.paymentRemaining, + broker.params.managementFeeRate, + state.loanScale); + + verifyLoanStatus( + 0, + startDate + *loanParams.payInterval, + *loanParams.payTotal, + state.loanScale, + loanProperties.totalValueOutstanding, + principalRequestAmount, + loanProperties.managementFeeOwedToBroker, + loanProperties.periodicPayment, + loanFlags | 0); + + // Manage the loan + // no-op + env(manage(lender, keylet.key, 0)); + { + // no flags + auto jt = manage(lender, keylet.key, 0); + jt.removeMember(sfFlags.getName()); + env(jt); + } + // Only the lender can manage + env(manage(evan, keylet.key, 0), ter(tecNO_PERMISSION)); + // unknown flags + env(manage(lender, keylet.key, tfLoanManageMask), ter(temINVALID_FLAG)); + // combinations of flags are not allowed + env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair), + ter(temINVALID_FLAG)); + env(manage(lender, keylet.key, tfLoanImpair | tfLoanDefault), + ter(temINVALID_FLAG)); + env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanDefault), + ter(temINVALID_FLAG)); + env(manage( + lender, + keylet.key, + tfLoanUnimpair | tfLoanImpair | tfLoanDefault), + ter(temINVALID_FLAG)); + // invalid loan ID + env(manage(lender, broker.brokerID, tfLoanImpair), ter(tecNO_ENTRY)); + // Loan is unimpaired, can't unimpair it again + env(manage(lender, keylet.key, tfLoanUnimpair), ter(tecNO_PERMISSION)); + // Loan is unimpaired, it can go into default, but only after it's past + // due + env(manage(lender, keylet.key, tfLoanDefault), ter(tecTOO_SOON)); + + // Check the vault + bool const canImpair = canImpairLoan(env, broker, state); + // Impair the loan, if possible + env(manage(lender, keylet.key, tfLoanImpair), + canImpair ? ter(tesSUCCESS) : ter(tecLIMIT_EXCEEDED)); + // Unimpair the loan + env(manage(lender, keylet.key, tfLoanUnimpair), + canImpair ? ter(tesSUCCESS) : ter(tecNO_PERMISSION)); + + auto const nextDueDate = startDate + *loanParams.payInterval; + + env.close(); + + verifyLoanStatus( + 0, + nextDueDate, + *loanParams.payTotal, + loanProperties.loanScale, + loanProperties.totalValueOutstanding, + principalRequestAmount, + loanProperties.managementFeeOwedToBroker, + loanProperties.periodicPayment, + loanFlags | 0); + + // Can't delete the loan yet. It has payments remaining. + env(del(lender, keylet.key), ter(tecHAS_OBLIGATIONS)); + + if (BEAST_EXPECT(toEndOfLife)) + toEndOfLife(keylet, verifyLoanStatus); + env.close(); + + // Verify the loan is at EOL + if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0); + BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == 0); + } + auto const borrowerStartingBalance = + env.balance(borrower, broker.asset); + + // Try to delete the loan broker with an active loan + env(loanBroker::del(lender, broker.brokerID), ter(tecHAS_OBLIGATIONS)); + // Ensure the above tx doesn't get ordered after the LoanDelete and + // delete our broker! + env.close(); + + // Test failure cases + env(del(lender, keylet.key, tfLoanOverpayment), ter(temINVALID_FLAG)); + env(del(evan, keylet.key), ter(tecNO_PERMISSION)); + env(del(lender, broker.brokerID), ter(tecNO_ENTRY)); + + // Delete the loan + // Either the borrower or the lender can delete the loan. Alternate + // between who does it across tests. + static unsigned deleteCounter = 0; + auto const deleter = ++deleteCounter % 2 ? lender : borrower; + env(del(deleter, keylet.key)); + env.close(); + + PrettyAmount adjustment = broker.asset(0); + if (deleter == borrower) + { + // Need to account for fees if the loan is in XRP + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + } + + // No loans left + verifyLoanStatus.checkBroker(0, 0, *loanParams.interest, 1, 0, 0); + + BEAST_EXPECT( + env.balance(borrower, broker.asset).value() == + borrowerStartingBalance.value() - adjustment); + BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount); + + if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + } + } + + std::string + getCurrencyLabel(Asset const& asset) + { + return ( + asset.native() ? "XRP" + : asset.holds() ? "IOU" + : asset.holds() ? "MPT" + : "Unknown"); + } + + /** Wrapper to run a series of lifecycle tests for a given asset and loan + * amount + * + * Will be used in the future to vary the loan parameters. For now, it is + * only called once. + * + * Tests a bunch of LoanSet failure conditions before lifecycle. + */ + template + void + testCaseWrapper( + jtx::Env& env, + jtx::MPTTester& mptt, + std::array const& assets, + BrokerInfo const& broker, + Number const& loanAmount, + int interestExponent) + { + using namespace jtx; + using namespace Lending; + + auto const& asset = broker.asset.raw(); + auto const currencyLabel = getCurrencyLabel(asset); + auto const caseLabel = [&]() { + std::stringstream ss; + ss << "Lifecycle: " << loanAmount << " " << currencyLabel + << " Scale interest to: " << interestExponent << " "; + return ss.str(); + }(); + testcase << caseLabel; + + using namespace loan; + using namespace std::chrono_literals; + using d = NetClock::duration; + using tp = NetClock::time_point; + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + // Borrower only wants to borrow + Account const borrower{"borrower"}; + // Evan will attempt to be naughty + Account const evan{"evan"}; + // Do not fund alice + Account const alice{"alice"}; + + Number const principalRequest = broker.asset(loanAmount).value(); + Number const maxCoveredLoanValue = broker.params.maxCoveredLoanValue(0); + BEAST_EXPECT(maxCoveredLoanValue == 1000 * 100 / 10); + Number const maxCoveredLoanRequest = + broker.asset(maxCoveredLoanValue).value(); + Number const totalVaultRequest = + broker.asset(broker.params.vaultDeposit).value(); + Number const debtMaximumRequest = + broker.asset(broker.params.debtMax).value(); + + auto const loanSetFee = fee(env.current()->fees().base * 2); + + auto const pseudoAcct = [&]() { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return lender; + auto const brokerPseudo = brokerSle->at(sfAccount); + return Account("Broker pseudo-account", brokerPseudo); + }(); + + auto const baseFee = env.current()->fees().base; + + auto badKeylet = keylet::vault(lender.id(), env.seq(lender)); + // Try some failure cases + // flags are checked first + env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(temINVALID_FLAG)); + + // field length validation + // sfData: good length, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + data(std::string(maxDataPayloadLength, 'X')), + loanSetFee, + ter(tefBAD_AUTH)); + // sfData: too long + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + data(std::string(maxDataPayloadLength + 1, 'Y')), + loanSetFee, + ter(temINVALID)); + + // field range validation + // sfOverpaymentFee: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + overpaymentFee(maxOverpaymentFee), + loanSetFee, + ter(tefBAD_AUTH)); + // sfOverpaymentFee: too big + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + overpaymentFee(maxOverpaymentFee + 1), + loanSetFee, + ter(temINVALID)); + + // sfInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + interestRate(maxInterestRate), + loanSetFee, + ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + interestRate(TenthBips32(0)), + loanSetFee, + ter(tefBAD_AUTH)); + // sfInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + interestRate(maxInterestRate + 1), + loanSetFee, + ter(temINVALID)); + // sfInterestRate: too small + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + interestRate(TenthBips32(-1)), + loanSetFee, + ter(temINVALID)); + + // sfLateInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + lateInterestRate(maxLateInterestRate), + loanSetFee, + ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + lateInterestRate(TenthBips32(0)), + loanSetFee, + ter(tefBAD_AUTH)); + // sfLateInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + lateInterestRate(maxLateInterestRate + 1), + loanSetFee, + ter(temINVALID)); + // sfLateInterestRate: too small + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + lateInterestRate(TenthBips32(-1)), + loanSetFee, + ter(temINVALID)); + + // sfCloseInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + closeInterestRate(maxCloseInterestRate), + loanSetFee, + ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + closeInterestRate(TenthBips32(0)), + loanSetFee, + ter(tefBAD_AUTH)); + // sfCloseInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + closeInterestRate(maxCloseInterestRate + 1), + loanSetFee, + ter(temINVALID)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + closeInterestRate(TenthBips32(-1)), + loanSetFee, + ter(temINVALID)); + + // sfOverpaymentInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + overpaymentInterestRate(maxOverpaymentInterestRate), + loanSetFee, + ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + overpaymentInterestRate(TenthBips32(0)), + loanSetFee, + ter(tefBAD_AUTH)); + // sfOverpaymentInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + overpaymentInterestRate(maxOverpaymentInterestRate + 1), + loanSetFee, + ter(temINVALID)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + overpaymentInterestRate(TenthBips32(-1)), + loanSetFee, + ter(temINVALID)); + + // sfPaymentTotal: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + paymentTotal(LoanSet::minPaymentTotal), + loanSetFee, + ter(tefBAD_AUTH)); + // sfPaymentTotal: too small (there is no max) + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + paymentTotal(LoanSet::minPaymentTotal - 1), + loanSetFee, + ter(temINVALID)); + + // sfPaymentInterval: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + paymentInterval(LoanSet::minPaymentInterval), + loanSetFee, + ter(tefBAD_AUTH)); + // sfPaymentInterval: too small (there is no max) + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + paymentInterval(LoanSet::minPaymentInterval - 1), + loanSetFee, + ter(temINVALID)); + + // sfGracePeriod: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + paymentInterval(LoanSet::minPaymentInterval * 2), + gracePeriod(LoanSet::minPaymentInterval * 2), + loanSetFee, + ter(tefBAD_AUTH)); + // sfGracePeriod: larger than paymentInterval + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + paymentInterval(LoanSet::minPaymentInterval * 2), + gracePeriod(LoanSet::minPaymentInterval * 3), + loanSetFee, + ter(temINVALID)); + + // insufficient fee - single sign + env(set(borrower, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + ter(telINSUF_FEE_P)); + // insufficient fee - multisign + env(signers(lender, 2, {{evan, 1}, {borrower, 1}})); + env(signers(borrower, 2, {{evan, 1}, {lender, 1}})); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + msig(evan, lender), + msig(sfCounterpartySignature, evan, borrower), + fee(env.current()->fees().base * 5 - 1), + ter(telINSUF_FEE_P)); + // Bad multisign signatures for borrower (Account) + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + msig(alice, issuer), + msig(sfCounterpartySignature, evan, borrower), + fee(env.current()->fees().base * 5), + ter(tefBAD_SIGNATURE), + THISLINE); + // Bad multisign signatures for issuer (Counterparty) + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + msig(evan, lender), + msig(sfCounterpartySignature, alice, issuer), + fee(env.current()->fees().base * 5), + ter(tefBAD_SIGNATURE), + THISLINE); + env(signers(lender, none)); + env(signers(borrower, none)); + // multisign sufficient fee, but no signers set up + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + msig(evan, lender), + msig(sfCounterpartySignature, evan, borrower), + fee(env.current()->fees().base * 5), + ter(tefNOT_MULTI_SIGNING)); + // not the broker owner, no counterparty, not signed by broker + // owner + env(set(borrower, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, evan), + loanSetFee, + ter(tefBAD_AUTH)); + // not the broker owner, counterparty is borrower + env(set(evan, broker.brokerID, principalRequest), + counterparty(borrower), + sig(sfCounterpartySignature, borrower), + loanSetFee, + ter(tecNO_PERMISSION)); + // not a LoanBroker object, no counterparty + env(set(lender, badKeylet.key, principalRequest), + sig(sfCounterpartySignature, evan), + loanSetFee, + ter(temBAD_SIGNER)); + // not a LoanBroker object, counterparty is valid + env(set(lender, badKeylet.key, principalRequest), + counterparty(borrower), + sig(sfCounterpartySignature, borrower), + loanSetFee, + ter(tecNO_ENTRY)); + // borrower doesn't exist + env(set(lender, broker.brokerID, principalRequest), + counterparty(alice), + sig(sfCounterpartySignature, alice), + loanSetFee, + ter(terNO_ACCOUNT)); + + // Request more funds than the vault has available + env(set(evan, broker.brokerID, totalVaultRequest + 1), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(tecINSUFFICIENT_FUNDS)); + + // Request more funds than the broker's first-loss capital can + // cover. + env(set(evan, broker.brokerID, maxCoveredLoanRequest + 1), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(tecINSUFFICIENT_FUNDS)); + + // Frozen trust line / locked MPT issuance + // XRP can not be frozen, but run through the loop anyway to test + // the tecLIMIT_EXCEEDED case + { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return; + + auto const vaultPseudo = [&]() { + auto const vaultSle = + env.le(keylet::vault(brokerSle->at(sfVaultID))); + if (!BEAST_EXPECT(vaultSle)) + // This will be wrong, but the test has failed anyway. + return lender; + auto const vaultPseudo = + Account("Vault pseudo-account", vaultSle->at(sfAccount)); + return vaultPseudo; + }(); + + auto const [freeze, deepfreeze, unfreeze, expectedResult] = + [&]() -> std::tuple< + std::function, + std::function, + std::function, + TER> { + // Freeze / lock the asset + std::function empty; + if (broker.asset.native()) + { + // XRP can't be frozen + return std::make_tuple(empty, empty, empty, tesSUCCESS); + } + else if (broker.asset.holds()) + { + auto freeze = [&](Account const& holder) { + env(trust(issuer, holder[iouCurrency](0), tfSetFreeze)); + }; + auto deepfreeze = [&](Account const& holder) { + env(trust( + issuer, + holder[iouCurrency](0), + tfSetFreeze | tfSetDeepFreeze)); + }; + auto unfreeze = [&](Account const& holder) { + env(trust( + issuer, + holder[iouCurrency](0), + tfClearFreeze | tfClearDeepFreeze)); + }; + return std::make_tuple( + freeze, deepfreeze, unfreeze, tecFROZEN); + } + else + { + auto freeze = [&](Account const& holder) { + mptt.set( + {.account = issuer, + .holder = holder, + .flags = tfMPTLock}); + }; + auto unfreeze = [&](Account const& holder) { + mptt.set( + {.account = issuer, + .holder = holder, + .flags = tfMPTUnlock}); + }; + return std::make_tuple(freeze, empty, unfreeze, tecLOCKED); + } + }(); + + // Try freezing the accounts that can't be frozen + if (freeze) + { + for (auto const& account : {vaultPseudo, evan}) + { + // Freeze the account + freeze(account); + + // Try to create a loan with a frozen line + env(set(evan, broker.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(expectedResult)); + + // Unfreeze the account + BEAST_EXPECT(unfreeze); + unfreeze(account); + + // Ensure the line is unfrozen with a request that is fine + // except too it requests more principal than the broker can + // carry + env(set(evan, broker.brokerID, debtMaximumRequest + 1), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(tecLIMIT_EXCEEDED)); + } + } + + // Deep freeze the borrower, which prevents them from receiving + // funds + if (deepfreeze) + { + // Make sure evan has a trust line that so the issuer can + // freeze it. (Don't need to do this for the borrower, + // because LoanSet will create a line to the borrower + // automatically.) + env(trust(evan, issuer[iouCurrency](100'000))); + + for (auto const& account : + {// these accounts can't be frozen, which deep freeze + // implies + vaultPseudo, + evan, + // these accounts can't be deep frozen + lender}) + { + // Freeze evan + deepfreeze(account); + + // Try to create a loan with a deep frozen line + env(set(evan, broker.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(expectedResult)); + + // Unfreeze evan + BEAST_EXPECT(unfreeze); + unfreeze(account); + + // Ensure the line is unfrozen with a request that is fine + // except too it requests more principal than the broker can + // carry + env(set(evan, broker.brokerID, debtMaximumRequest + 1), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(tecLIMIT_EXCEEDED)); + } + } + } + + // Finally! Create a loan + std::string testData; + + auto coverAvailable = + [&env, this](uint256 const& brokerID, Number const& expected) { + if (auto const brokerSle = env.le(keylet::loanbroker(brokerID)); + BEAST_EXPECT(brokerSle)) + { + auto const available = brokerSle->at(sfCoverAvailable); + BEAST_EXPECT(available == expected); + return available; + } + return Number{}; + }; + auto getDefaultInfo = [&env, this]( + LoanState const& state, + BrokerInfo const& broker) { + if (auto const brokerSle = + env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT( + state.loanScale >= + (broker.asset.integral() + ? 0 + : std::max( + broker.vaultScale(env), + state.principalOutstanding.exponent()))); + NumberRoundModeGuard mg(Number::upward); + auto const defaultAmount = roundToAsset( + broker.asset, + std::min( + tenthBipsOfValue( + tenthBipsOfValue( + brokerSle->at(sfDebtTotal), + broker.params.coverRateMin), + broker.params.coverRateLiquidation), + state.totalValue - state.managementFeeOutstanding), + state.loanScale); + return std::make_pair(defaultAmount, brokerSle->at(sfOwner)); + } + return std::make_pair(Number{}, AccountID{}); + }; + auto replenishCover = [&env, &coverAvailable]( + BrokerInfo const& broker, + AccountID const& brokerAcct, + Number const& startingCoverAvailable, + Number const& amountToBeCovered) { + coverAvailable( + broker.brokerID, startingCoverAvailable - amountToBeCovered); + env(loanBroker::coverDeposit( + brokerAcct, + broker.brokerID, + STAmount{broker.asset, amountToBeCovered})); + coverAvailable(broker.brokerID, startingCoverAvailable); + env.close(); + }; + + auto defaultImmediately = [&](std::uint32_t baseFlag, + bool impair = true) { + return [&, impair, baseFlag]( + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + // Default the loan + + // Initialize values with the current state + auto state = + getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + BEAST_EXPECT(state.flags == baseFlag); + + auto const& broker = verifyLoanStatus.broker; + auto const startingCoverAvailable = coverAvailable( + broker.brokerID, + broker.asset(broker.params.coverDeposit).number()); + + if (impair) + { + // Check the vault + bool const canImpair = canImpairLoan(env, broker, state); + // Impair the loan, if possible + env(manage(lender, loanKeylet.key, tfLoanImpair), + canImpair ? ter(tesSUCCESS) : ter(tecLIMIT_EXCEEDED)); + + if (canImpair) + { + state.flags |= tfLoanImpair; + state.nextPaymentDate = + env.now().time_since_epoch().count(); + + // Once the loan is impaired, it can't be impaired again + env(manage(lender, loanKeylet.key, tfLoanImpair), + ter(tecNO_PERMISSION)); + } + verifyLoanStatus(state); + } + + auto const nextDueDate = tp{d{state.nextPaymentDate}}; + + // Can't default the loan yet. The grace period hasn't + // expired + env(manage(lender, loanKeylet.key, tfLoanDefault), + ter(tecTOO_SOON)); + + // Let some time pass so that the loan can be + // defaulted + env.close(nextDueDate + 60s); + + auto const [amountToBeCovered, brokerAcct] = + getDefaultInfo(state, broker); + + // Default the loan + env(manage(lender, loanKeylet.key, tfLoanDefault)); + env.close(); + + // The LoanBroker just lost some of it's first-loss capital. + // Replenish it. + replenishCover( + broker, + brokerAcct, + startingCoverAvailable, + amountToBeCovered); + + state.flags |= tfLoanDefault; + state.paymentRemaining = 0; + state.totalValue = 0; + state.principalOutstanding = 0; + state.managementFeeOutstanding = 0; + state.nextPaymentDate = 0; + verifyLoanStatus(state); + + // Once a loan is defaulted, it can't be managed + env(manage(lender, loanKeylet.key, tfLoanUnimpair), + ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanImpair), + ter(tecNO_PERMISSION)); + // Can't make a payment on it either + env(pay(borrower, loanKeylet.key, broker.asset(300)), + ter(tecKILLED)); + }; + }; + + auto singlePayment = [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus, + LoanState& state, + STAmount const& payoffAmount, + std::uint32_t numPayments, + std::uint32_t baseFlag, + std::uint32_t txFlags) { + // toEndOfLife + // + verifyLoanStatus(state); + + // Send some bogus pay transactions + env(pay(borrower, + keylet::loan(uint256(0)).key, + broker.asset(10), + txFlags), + ter(temINVALID)); + // broker.asset(80) is less than a single payment, but all these + // checks fail before that matters + env(pay(borrower, loanKeylet.key, broker.asset(-80), txFlags), + ter(temBAD_AMOUNT)); + env(pay(borrower, broker.brokerID, broker.asset(80), txFlags), + ter(tecNO_ENTRY)); + env(pay(evan, loanKeylet.key, broker.asset(80), txFlags), + ter(tecNO_PERMISSION)); + + // TODO: Write a general "isFlag" function? See STObject::isFlag. + // Maybe add a static overloaded member? + if (!(state.flags & lsfLoanOverpayment)) + { + // If the loan does not allow overpayments, send a payment that + // tries to make an overpayment. Do not include `txFlags`, so we + // don't end up duplicating the next test transaction. + env(pay(borrower, + loanKeylet.key, + STAmount{ + broker.asset, + state.periodicPayment * Number{15, -1}}, + tfLoanOverpayment), + fee(XRPAmount{ + baseFee * + (Number{15, -1} / loanPaymentsPerFeeIncrement + 1)}), + ter(temINVALID_FLAG)); + } + // Try to send a payment marked as multiple mutually exclusive + // payment types. Do not include `txFlags`, so we don't duplicate + // the prior test transaction. + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanFullPayment), + ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanOverpayment), + ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanOverpayment | tfLoanFullPayment), + ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanOverpayment | tfLoanFullPayment), + ter(temINVALID_FLAG)); + + { + auto const otherAsset = broker.asset.raw() == assets[0].raw() + ? assets[1] + : assets[0]; + env(pay(borrower, loanKeylet.key, otherAsset(100), txFlags), + ter(tecWRONG_ASSET)); + } + + // Amount doesn't cover a single payment + env(pay(borrower, + loanKeylet.key, + STAmount{broker.asset, 1}, + txFlags), + ter(tecINSUFFICIENT_PAYMENT)); + + // Get the balance after these failed transactions take + // fees + auto const borrowerBalanceBeforePayment = + env.balance(borrower, broker.asset); + + BEAST_EXPECT(payoffAmount > state.principalOutstanding); + // Try to pay a little extra to show that it's _not_ + // taken + auto const transactionAmount = payoffAmount + broker.asset(10); + + // Send a transaction that tries to pay more than the borrowers's + // balance + XRPAmount const badFee{ + baseFee * + (borrowerBalanceBeforePayment.number() * 2 / + state.periodicPayment / loanPaymentsPerFeeIncrement + + 1)}; + env(pay(borrower, + loanKeylet.key, + STAmount{ + broker.asset, + borrowerBalanceBeforePayment.number() * 2}, + txFlags), + fee(badFee), + ter(tecINSUFFICIENT_FUNDS)); + + XRPAmount const goodFee{ + baseFee * (numPayments / loanPaymentsPerFeeIncrement + 1)}; + env(pay(borrower, loanKeylet.key, transactionAmount, txFlags), + fee(goodFee)); + + env.close(); + + // log << env.meta()->getJson() << std::endl; + + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = badFee + goodFee; + } + + state.paymentRemaining = 0; + state.principalOutstanding = 0; + state.totalValue = 0; + state.managementFeeOutstanding = 0; + state.previousPaymentDate = state.nextPaymentDate + + state.paymentInterval * (numPayments - 1); + state.nextPaymentDate = 0; + verifyLoanStatus(state); + + verifyLoanStatus.checkPayment( + state.loanScale, + borrower, + borrowerBalanceBeforePayment, + payoffAmount, + adjustment); + + // Can't impair or default a paid off loan + env(manage(lender, loanKeylet.key, tfLoanImpair), + ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), + ter(tecNO_PERMISSION)); + }; + + auto fullPayment = [&](std::uint32_t baseFlag) { + return [&, baseFlag]( + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + auto state = + getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + env.close(state.startDate + 20s); + auto const loanAge = (env.now() - state.startDate).count(); + BEAST_EXPECT(loanAge == 30); + + // Full payoff amount will consist of + // 1. principal outstanding (1000) + // 2. accrued interest (at 12%) + // 3. prepayment penalty (closeInterest at 3.6%) + // 4. close payment fee (4) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + Number const interval = state.paymentInterval; + auto const periodicRate = + interval * Number(12, -2) / secondsInYear; + BEAST_EXPECT( + periodicRate == + Number(2283105022831050, -21, Number::unchecked{})); + STAmount const principalOutstanding{ + broker.asset, state.principalOutstanding}; + STAmount const accruedInterest{ + broker.asset, + state.principalOutstanding * periodicRate * loanAge / + interval}; + BEAST_EXPECT( + accruedInterest == + broker.asset(Number(1141552511415525, -19))); + STAmount const prepaymentPenalty{ + broker.asset, state.principalOutstanding * Number(36, -3)}; + BEAST_EXPECT(prepaymentPenalty == broker.asset(36)); + STAmount const closePaymentFee = broker.asset(4); + auto const payoffAmount = roundToScale( + principalOutstanding + accruedInterest + prepaymentPenalty + + closePaymentFee, + state.loanScale); + BEAST_EXPECT( + payoffAmount == + roundToAsset( + broker.asset, + broker.asset(Number(1040000114155251, -12)).number(), + state.loanScale)); + + // The terms of this loan actually make the early payoff + // more expensive than just making payments + BEAST_EXPECT( + payoffAmount > state.paymentRemaining * + (state.periodicPayment + broker.asset(2).value())); + + singlePayment( + loanKeylet, + verifyLoanStatus, + state, + payoffAmount, + 1, + baseFlag, + tfLoanFullPayment); + }; + }; + + auto combineAllPayments = [&](std::uint32_t baseFlag) { + return [&, baseFlag]( + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + + auto state = + getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + env.close(); + + // 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); + + singlePayment( + loanKeylet, + verifyLoanStatus, + state, + payoffAmount, + state.paymentRemaining, + baseFlag, + 0); + }; + }; + + // There are a lot of fields that can be set on a loan, but most + // of them only affect the "math" when a payment is made. The + // only one that really affects behavior is the + // `tfLoanOverpayment` flag. + lifecycle( + caseLabel, + "Loan overpayment allowed - Impair and Default", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + defaultImmediately(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Impair and Default", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + defaultImmediately(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Default without Impair", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + defaultImmediately(lsfLoanOverpayment, false)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Default without Impair", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + defaultImmediately(0, false)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Pay off immediately", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + fullPayment(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Pay off immediately", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + fullPayment(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Combine all payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + combineAllPayments(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Combine all payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + combineAllPayments(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Make payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + // Draw and make multiple payments + auto state = + getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + BEAST_EXPECT(state.flags == 0); + env.close(); + + verifyLoanStatus(state); + + env.close(state.startDate + 20s); + auto const loanAge = (env.now() - state.startDate).count(); + BEAST_EXPECT(loanAge == 30); + + // Periodic payment amount will consist of + // 1. principal outstanding (1000) + // 2. interest interest rate (at 12%) + // 3. payment interval (600s) + // 4. loan service fee (2) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + Number const interval = state.paymentInterval; + auto const periodicRate = + interval * Number(12, -2) / secondsInYear; + BEAST_EXPECT( + periodicRate == + Number(2283105022831050, -21, Number::unchecked{})); + STAmount const roundedPeriodicPayment{ + broker.asset, + roundPeriodicPayment( + broker.asset, state.periodicPayment, state.loanScale)}; + + testcase + << currencyLabel << " Payment components: " + << "Payments remaining, rawInterest, rawPrincipal, " + "rawMFee, trackedValueDelta, trackedPrincipalDelta, " + "trackedInterestDelta, trackedMgmtFeeDelta, special"; + + auto const serviceFee = broker.asset(2); + + BEAST_EXPECT( + roundedPeriodicPayment == + roundToScale( + broker.asset( + Number(8333457001162141, -14), Number::upward), + state.loanScale, + Number::upward)); + // 83334570.01162141 + // Include the service fee + STAmount const totalDue = roundToScale( + roundedPeriodicPayment + serviceFee, + state.loanScale, + Number::upward); + // Only check the first payment since the rounding + // may drift as payments are made + BEAST_EXPECT( + totalDue == + roundToScale( + broker.asset( + Number(8533457001162141, -14), Number::upward), + state.loanScale, + Number::upward)); + + { + auto const raw = computeRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + auto const rounded = constructLoanState( + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding); + testcase + << currencyLabel + << " Loan starting state: " << state.paymentRemaining + << ", " << raw.interestDue << ", " + << raw.principalOutstanding << ", " + << raw.managementFeeDue << ", " + << rounded.valueOutstanding << ", " + << rounded.principalOutstanding << ", " + << rounded.interestDue << ", " + << rounded.managementFeeDue; + } + + // Try to pay a little extra to show that it's _not_ + // taken + STAmount const transactionAmount = + STAmount{broker.asset, totalDue} + broker.asset(10); + // Only check the first payment since the rounding + // may drift as payments are made + BEAST_EXPECT( + transactionAmount == + roundToScale( + broker.asset( + Number(9533457001162141, -14), Number::upward), + state.loanScale, + Number::upward)); + + auto const initialState = state; + detail::PaymentComponents totalPaid{ + .trackedValueDelta = 0, + .trackedPrincipalDelta = 0, + .trackedManagementFeeDelta = 0}; + Number totalInterestPaid = 0; + std::size_t totalPaymentsMade = 0; + + ripple::LoanState currentTrueState = computeRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + while (state.paymentRemaining > 0) + { + // Compute the expected principal amount + auto const paymentComponents = + detail::computePaymentComponents( + broker.asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + BEAST_EXPECT( + paymentComponents.trackedValueDelta <= + roundedPeriodicPayment); + + ripple::LoanState const nextTrueState = computeRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining - 1, + broker.params.managementFeeRate); + detail::LoanStateDeltas const deltas = + currentTrueState - nextTrueState; + + testcase + << currencyLabel + << " Payment components: " << state.paymentRemaining + << ", " << deltas.interest << ", " << deltas.principal + << ", " << deltas.managementFee << ", " + << paymentComponents.trackedValueDelta << ", " + << paymentComponents.trackedPrincipalDelta << ", " + << paymentComponents.trackedInterestPart() << ", " + << paymentComponents.trackedManagementFeeDelta << ", " + << (paymentComponents.specialCase == + detail::PaymentSpecialCase::final + ? "final" + : paymentComponents.specialCase == + detail::PaymentSpecialCase::extra + ? "extra" + : "none"); + + auto const totalDueAmount = STAmount{ + broker.asset, + paymentComponents.trackedValueDelta + + serviceFee.number()}; + + // Due to the rounding algorithms to keep the interest and + // principal in sync with "true" values, the computed amount + // may be a little less than the rounded fixed payment + // amount. For integral types, the difference should be < 3 + // (1 unit for each of the interest and management fee). For + // IOUs, the difference should be after the 8th digit. + Number const diff = totalDue - totalDueAmount; + BEAST_EXPECT( + paymentComponents.specialCase == + detail::PaymentSpecialCase::final || + diff == beast::zero || + (diff > beast::zero && + ((broker.asset.integral() && + (static_cast(diff) < 3)) || + (state.loanScale - diff.exponent() > 13)))); + + BEAST_EXPECT( + paymentComponents.trackedValueDelta == + paymentComponents.trackedPrincipalDelta + + paymentComponents.trackedInterestPart() + + paymentComponents.trackedManagementFeeDelta); + BEAST_EXPECT( + paymentComponents.trackedValueDelta <= + roundedPeriodicPayment); + + BEAST_EXPECT( + state.paymentRemaining < 12 || + roundToAsset( + broker.asset, + deltas.principal, + state.loanScale, + Number::upward) == + roundToScale( + broker.asset( + Number(8333228695260180, -14), + Number::upward), + state.loanScale, + Number::upward)); + BEAST_EXPECT( + paymentComponents.trackedPrincipalDelta >= + beast::zero && + paymentComponents.trackedPrincipalDelta <= + state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase != + detail::PaymentSpecialCase::final || + paymentComponents.trackedPrincipalDelta == + state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase == + detail::PaymentSpecialCase::final || + (state.periodicPayment.exponent() - + (deltas.principal + deltas.interest + + deltas.managementFee - state.periodicPayment) + .exponent()) > 14); + + auto const borrowerBalanceBeforePayment = + env.balance(borrower, broker.asset); + + if (canImpairLoan(env, broker, state)) + // Making a payment will unimpair the loan + env(manage(lender, loanKeylet.key, tfLoanImpair)); + + env.close(); + + // Make the payment + env(pay(borrower, loanKeylet.key, transactionAmount)); + + env.close(); + + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + + // Check the result + verifyLoanStatus.checkPayment( + state.loanScale, + borrower, + borrowerBalanceBeforePayment, + totalDueAmount, + adjustment); + + --state.paymentRemaining; + state.previousPaymentDate = state.nextPaymentDate; + if (paymentComponents.specialCase == + detail::PaymentSpecialCase::final) + { + state.paymentRemaining = 0; + state.nextPaymentDate = 0; + } + else + { + state.nextPaymentDate += state.paymentInterval; + } + state.principalOutstanding -= + paymentComponents.trackedPrincipalDelta; + state.managementFeeOutstanding -= + paymentComponents.trackedManagementFeeDelta; + state.totalValue -= paymentComponents.trackedValueDelta; + + verifyLoanStatus(state); + + totalPaid.trackedValueDelta += + paymentComponents.trackedValueDelta; + totalPaid.trackedPrincipalDelta += + paymentComponents.trackedPrincipalDelta; + totalPaid.trackedManagementFeeDelta += + paymentComponents.trackedManagementFeeDelta; + totalInterestPaid += + paymentComponents.trackedInterestPart(); + ++totalPaymentsMade; + + currentTrueState = nextTrueState; + } + + // Loan is paid off + BEAST_EXPECT(state.paymentRemaining == 0); + BEAST_EXPECT(state.principalOutstanding == 0); + + // Make sure all the payments add up + BEAST_EXPECT( + totalPaid.trackedValueDelta == initialState.totalValue); + BEAST_EXPECT( + totalPaid.trackedPrincipalDelta == + initialState.principalOutstanding); + BEAST_EXPECT( + totalPaid.trackedManagementFeeDelta == + initialState.managementFeeOutstanding); + // This is almost a tautology given the previous checks, but + // check it anyway for completeness. + BEAST_EXPECT( + totalInterestPaid == + initialState.totalValue - + (initialState.principalOutstanding + + initialState.managementFeeOutstanding)); + BEAST_EXPECT( + totalPaymentsMade == initialState.paymentRemaining); + + // Can't impair or default a paid off loan + env(manage(lender, loanKeylet.key, tfLoanImpair), + ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), + ter(tecNO_PERMISSION)); + }); + +#if LOANTODO + // TODO + + /* + LoanPay fails with tecINVARIANT_FAILED error when loan_broker(also + borrower) tries to do the payment. Here's the sceanrio: Create a XRP + loan with loan broker as borrower, loan origination fee and loan service + fee. Loan broker makes the first payment with periodic payment and loan + service fee. + */ + + auto time = [&](std::string label, std::function timed) { + if (!BEAST_EXPECT(timed)) + return; + + using clock_type = std::chrono::steady_clock; + using duration_type = std::chrono::milliseconds; + + auto const start = clock_type::now(); + timed(); + auto const duration = std::chrono::duration_cast( + clock_type::now() - start); + + log << label << " took " << duration.count() << "ms" << std::endl; + + return duration; + }; + + lifecycle( + caseLabel, + "timing", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { + // Estimate optimal values for loanPaymentsPerFeeIncrement and + // loanMaximumPaymentsPerTransaction. + using namespace loan; + + auto const state = + getCurrentState(env, broker, verifyLoanStatus.keylet); + auto const serviceFee = broker.asset(2).value(); + + STAmount const totalDue{ + broker.asset, + roundPeriodicPayment( + broker.asset, + state.periodicPayment + serviceFee, + state.loanScale)}; + + // Make a single payment + time("single payment", [&]() { + env(pay(borrower, loanKeylet.key, totalDue)); + }); + env.close(); + + // Make all but the final payment + auto const numPayments = (state.paymentRemaining - 2); + STAmount const bigPayment{broker.asset, totalDue * numPayments}; + XRPAmount const bigFee{ + baseFee * (numPayments / loanPaymentsPerFeeIncrement + 1)}; + time("ten payments", [&]() { + env(pay(borrower, loanKeylet.key, bigPayment), fee(bigFee)); + }); + env.close(); + + time("final payment", [&]() { + // Make the final payment + env( + pay(borrower, + loanKeylet.key, + totalDue + STAmount{broker.asset, 1})); + }); + env.close(); + }); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Explicit overpayment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Late payment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Late payment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Late payment and overpayment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + +#endif + } + + void + testLoanSet() + { + using namespace jtx; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + struct CaseArgs + { + bool requireAuth = false; + bool authorizeBorrower = false; + int initialXRP = 1'000'000; + }; + + auto const testCase = + [&, this]( + std::function + mptTest, + std::function iouTest, + CaseArgs args = {}) { + Env env(*this, all); + env.fund(XRP(args.initialXRP), issuer, lender, borrower); + env.close(); + if (args.requireAuth) + { + env(fset(issuer, asfRequireAuth)); + env.close(); + } + + // We need two different asset types, MPT and IOU. Prepare MPT + // first + MPTTester mptt{env, issuer, mptInitNoFund}; + + auto const none = LedgerSpecificFlags(0); + mptt.create( + {.flags = tfMPTCanTransfer | tfMPTCanLock | + (args.requireAuth ? tfMPTRequireAuth : none)}); + env.close(); + PrettyAsset mptAsset = mptt.issuanceID(); + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + env.close(); + if (args.requireAuth) + { + mptt.authorize({.account = issuer, .holder = lender}); + if (args.authorizeBorrower) + mptt.authorize({.account = issuer, .holder = borrower}); + env.close(); + } + + env(pay(issuer, lender, mptAsset(10'000'000))); + env.close(); + + // Prepare IOU + PrettyAsset const iouAsset = issuer[iouCurrency]; + env(trust(lender, iouAsset(10'000'000))); + env(trust(borrower, iouAsset(10'000'000))); + env.close(); + if (args.requireAuth) + { + env(trust(issuer, iouAsset(0), lender, tfSetfAuth)); + env(pay(issuer, lender, iouAsset(10'000'000))); + if (args.authorizeBorrower) + { + env(trust(issuer, iouAsset(0), borrower, tfSetfAuth)); + env(pay(issuer, borrower, iouAsset(10'000))); + } + } + else + { + env(pay(issuer, lender, iouAsset(10'000'000))); + env(pay(issuer, borrower, iouAsset(10'000))); + } + env.close(); + + // Create vaults and loan brokers + std::array const assets{mptAsset, iouAsset}; + std::vector brokers; + for (auto const& asset : assets) + { + brokers.emplace_back( + createVaultAndBroker(env, asset, lender)); + } + + if (mptTest) + (mptTest)(env, brokers[0], mptt); + if (iouTest) + (iouTest)(env, brokers[1]); + }; + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT issuer is borrower, issuer submits"); + env(set(issuer, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + + testcase("MPT issuer is borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + counterparty(issuer), + sig(sfCounterpartySignature, issuer), + fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU issuer is borrower, issuer submits"); + env(set(issuer, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + + testcase("IOU issuer is borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + counterparty(issuer), + sig(sfCounterpartySignature, issuer), + fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT unauthorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + ter{tecNO_AUTH}); + + testcase("MPT unauthorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + counterparty(borrower), + sig(sfCounterpartySignature, borrower), + fee(env.current()->fees().base * 5), + ter{tecNO_AUTH}); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU unauthorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + ter{tecNO_AUTH}); + + testcase("IOU unauthorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + counterparty(borrower), + sig(sfCounterpartySignature, borrower), + fee(env.current()->fees().base * 5), + ter{tecNO_AUTH}); + }, + CaseArgs{.requireAuth = true}); + + auto const [acctReserve, incReserve] = [this]() -> std::pair { + Env env{*this, testable_amendments()}; + return { + env.current()->fees().accountReserve(0).drops() / + DROPS_PER_XRP.drops(), + env.current()->fees().increment.drops() / + DROPS_PER_XRP.drops()}; + }(); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "MPT authorized borrower, borrower submits, borrower has " + "no reserve"); + mptt.authorize( + {.account = borrower, .flags = tfMPTUnauthorize}); + env.close(); + + auto const mptoken = + keylet::mptoken(mptt.issuanceID(), borrower); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 == nullptr); + + // Burn some XRP + env(noop(borrower), fee(XRP(acctReserve * 2 + incReserve * 2))); + env.close(); + + // Cannot create loan, not enough reserve to create MPToken + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + ter{tecINSUFFICIENT_RESERVE}); + env.close(); + + // Can create loan now, will implicitly create MPToken + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + env.close(); + + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 != nullptr); + }, + {}, + CaseArgs{.initialXRP = acctReserve * 2 + incReserve * 8 + 1}); + + testCase( + {}, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "IOU authorized borrower, borrower submits, borrower has " + "no reserve"); + // Remove trust line from borrower to issuer + env.trust(broker.asset(0), borrower); + env.close(); + + env(pay(borrower, issuer, broker.asset(10'000))); + env.close(); + auto const trustline = + keylet::line(borrower, broker.asset.raw().get()); + auto const sleLine1 = env.le(trustline); + BEAST_EXPECT(sleLine1 == nullptr); + + // Burn some XRP + env(noop(borrower), fee(XRP(acctReserve * 2 + incReserve * 2))); + env.close(); + + // Cannot create loan, not enough reserve to create trust line + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + ter{tecNO_LINE_INSUF_RESERVE}); + env.close(); + + // Can create loan now, will implicitly create trust line + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + env.close(); + + auto const sleLine2 = env.le(trustline); + BEAST_EXPECT(sleLine2 != nullptr); + }, + CaseArgs{.initialXRP = acctReserve * 2 + incReserve * 8 + 1}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "MPT authorized borrower, borrower submits, lender has " + "no reserve"); + auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 != nullptr); + + env(pay( + lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); + env.close(); + + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); + + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 == nullptr); + + // Burn some XRP + env(noop(lender), fee(XRP(incReserve))); + env.close(); + + // Cannot create loan, not enough reserve to create MPToken + env(set(borrower, broker.brokerID, principalRequest), + loanOriginationFee(broker.asset(1).value()), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + ter{tecINSUFFICIENT_RESERVE}); + env.close(); + + // Can create loan now, will implicitly create MPToken + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + loanOriginationFee(broker.asset(1).value()), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + env.close(); + + auto const sleMPT3 = env.le(mptoken); + BEAST_EXPECT(sleMPT3 != nullptr); + }, + {}, + CaseArgs{.initialXRP = acctReserve * 2 + incReserve * 8 + 1}); + + testCase( + {}, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "IOU authorized borrower, borrower submits, lender has no " + "reserve"); + // Remove trust line from lender to issuer + env.trust(broker.asset(0), lender); + env.close(); + + auto const trustline = + keylet::line(lender, broker.asset.raw().get()); + auto const sleLine1 = env.le(trustline); + BEAST_EXPECT(sleLine1 != nullptr); + + env( + pay(lender, + issuer, + broker.asset(abs(sleLine1->at(sfBalance).value())))); + env.close(); + auto const sleLine2 = env.le(trustline); + BEAST_EXPECT(sleLine2 == nullptr); + + // Burn some XRP + env(noop(lender), fee(XRP(incReserve))); + env.close(); + + // Cannot create loan, not enough reserve to create trust line + env(set(borrower, broker.brokerID, principalRequest), + loanOriginationFee(broker.asset(1).value()), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + ter{tecNO_LINE_INSUF_RESERVE}); + env.close(); + + // Can create loan now, will implicitly create trust line + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + loanOriginationFee(broker.asset(1).value()), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + env.close(); + + auto const sleLine3 = env.le(trustline); + BEAST_EXPECT(sleLine3 != nullptr); + }, + CaseArgs{.initialXRP = acctReserve * 2 + incReserve * 8 + 1}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT authorized borrower, unauthorized lender"); + auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 != nullptr); + + env(pay( + lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); + env.close(); + + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); + + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 == nullptr); + + // Cannot create loan, lender not authorized to receive fee + env(set(borrower, broker.brokerID, principalRequest), + loanOriginationFee(broker.asset(1).value()), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + ter{tecNO_AUTH}); + env.close(); + + // Can create loan without origination fee + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + env.close(); + + // No MPToken for lender - no authorization and no payment + auto const sleMPT3 = env.le(mptoken); + BEAST_EXPECT(sleMPT3 == nullptr); + }, + {}, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT authorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU authorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + sig(sfCounterpartySignature, lender), + 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(); + + testcase("MPT authorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + counterparty(borrower), + sig(sfCounterpartySignature, borrower), + fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU authorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + counterparty(borrower), + sig(sfCounterpartySignature, borrower), + fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + jtx::Account const alice{"alice"}; + jtx::Account const bella{"bella"}; + auto const msigSetup = [&](Env& env, Account const& account) { + Json::Value tx1 = signers(account, 2, {{alice, 1}, {bella, 1}}); + env(tx1); + env.close(); + }; + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + msigSetup(env, lender); + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "MPT authorized borrower, borrower submits, lender " + "multisign"); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + msig(sfCounterpartySignature, alice, bella), + fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + msigSetup(env, lender); + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "IOU authorized borrower, borrower submits, lender " + "multisign"); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + msig(sfCounterpartySignature, alice, bella), + fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + msigSetup(env, borrower); + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "MPT authorized borrower, lender submits, borrower " + "multisign"); + env(set(lender, broker.brokerID, principalRequest), + counterparty(borrower), + msig(sfCounterpartySignature, alice, bella), + fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + msigSetup(env, borrower); + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "IOU authorized borrower, lender submits, borrower " + "multisign"); + env(set(lender, broker.brokerID, principalRequest), + counterparty(borrower), + msig(sfCounterpartySignature, alice, bella), + fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + } + + void + testLifecycle() + { + testcase("Lifecycle"); + using namespace jtx; + + // Create 3 loan brokers: one for XRP, one for an IOU, and one for + // an MPT. That'll require three corresponding SAVs. + Env env(*this, all); + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + // Borrower only wants to borrow + Account const borrower{"borrower"}; + // Evan will attempt to be naughty + Account const evan{"evan"}; + // Do not fund alice + Account const alice{"alice"}; + + // Fund the accounts and trust lines with the same amount so that + // tests can use the same values regardless of the asset. + env.fund(XRP(100'000'000), issuer, noripple(lender, borrower, evan)); + env.close(); + + // Create assets + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + PrettyAsset const iouAsset = issuer[iouCurrency]; + env(trust(lender, iouAsset(10'000'000))); + env(trust(borrower, iouAsset(10'000'000))); + env(trust(evan, iouAsset(10'000'000))); + env(pay(issuer, evan, iouAsset(1'000'000))); + env(pay(issuer, lender, iouAsset(10'000'000))); + // Fund the borrower with enough to cover interest and fees + env(pay(issuer, borrower, iouAsset(10'000))); + env.close(); + + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + // Scale the MPT asset a little bit so we can get some interest + PrettyAsset const mptAsset{mptt.issuanceID(), 100}; + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + mptt.authorize({.account = evan}); + env(pay(issuer, lender, mptAsset(10'000'000))); + env(pay(issuer, evan, mptAsset(1'000'000))); + // Fund the borrower with enough to cover interest and fees + env(pay(issuer, borrower, mptAsset(10'000))); + env.close(); + + std::array const assets{xrpAsset, mptAsset, iouAsset}; + + // Create vaults and loan brokers + std::vector brokers; + for (auto const& asset : assets) + { + brokers.emplace_back(createVaultAndBroker( + env, + asset, + lender, + BrokerParameters{.data = "spam spam spam spam"})); + } + + // Create and update Loans + for (auto const& broker : brokers) + { + for (int amountExponent = 3; amountExponent >= 3; --amountExponent) + { + Number const loanAmount{1, amountExponent}; + for (int interestExponent = 0; interestExponent >= 0; + --interestExponent) + { + testCaseWrapper( + env, + mptt, + assets, + broker, + loanAmount, + interestExponent); + } + } + + if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0); + + auto const coverAvailable = brokerSle->at(sfCoverAvailable); + env(loanBroker::coverWithdraw( + lender, + broker.brokerID, + STAmount(broker.asset, coverAvailable))); + env.close(); + + brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0); + } + // Verify we can delete the loan broker + env(loanBroker::del(lender, broker.brokerID)); + env.close(); + } + } + + void + testSelfLoan() + { + testcase << "Self Loan"; + + using namespace jtx; + using namespace std::chrono_literals; + // Create 3 loan brokers: one for XRP, one for an IOU, and one for + // an MPT. That'll require three corresponding SAVs. + Env env(*this, all); + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + + // Fund the accounts and trust lines with the same amount so that + // tests can use the same values regardless of the asset. + env.fund(XRP(100'000'000), issuer, noripple(lender)); + env.close(); + + // Use an XRP asset for simplicity + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + // Create vaults and loan brokers + BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + // The LoanSet json can be created without a counterparty signature, + // but it will not pass preflight + auto createJson = env.json( + set(lender, + broker.brokerID, + broker.asset(principalRequest).value()), + fee(loanSetFee)); + env(createJson, ter(temBAD_SIGNER)); + + // Adding an empty counterparty signature object also fails, but + // at the RPC level. + createJson = env.json( + createJson, json(sfCounterpartySignature, Json::objectValue)); + env(createJson, ter(telENV_RPC_FAILED)); + + if (auto const jt = env.jt(createJson); BEAST_EXPECT(jt.stx)) + { + Serializer s; + jt.stx->add(s); + auto const jr = env.rpc("submit", strHex(s.slice())); + + 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."); + } + + // Copy the transaction signature into the counterparty signature. + Json::Value counterpartyJson{Json::objectValue}; + counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; + counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; + if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) + counterpartyJson[sfSigners] = createJson[sfSigners]; + + // The duplicated signature works + createJson = env.json( + createJson, json(sfCounterpartySignature, counterpartyJson)); + env(createJson); + + env.close(); + + auto const startDate = env.current()->info().parentCloseTime; + + // Loan is successfully created + { + auto const res = env.rpc("account_objects", lender.human()); + auto const objects = res[jss::result][jss::account_objects]; + + std::map types; + BEAST_EXPECT(objects.size() == 4); + for (auto const& object : objects) + { + ++types[object[sfLedgerEntryType].asString()]; + } + BEAST_EXPECT(types.size() == 4); + for (std::string const type : + {"MPToken", "Vault", "LoanBroker", "Loan"}) + { + BEAST_EXPECT(types[type] == 1); + } + } + auto const loanID = [&]() { + Json::Value params(Json::objectValue); + params[jss::account] = lender.human(); + params[jss::type] = "Loan"; + auto const res = + env.rpc("json", "account_objects", to_string(params)); + auto const objects = res[jss::result][jss::account_objects]; + + BEAST_EXPECT(objects.size() == 1); + + auto const loan = objects[0u]; + BEAST_EXPECT(loan[sfBorrower] == lender.human()); + // soeDEFAULT fields are not returned if they're in the default + // state + BEAST_EXPECT(!loan.isMember(sfCloseInterestRate)); + BEAST_EXPECT(!loan.isMember(sfClosePaymentFee)); + BEAST_EXPECT(loan[sfFlags] == 0); + BEAST_EXPECT(loan[sfGracePeriod] == 60); + BEAST_EXPECT(!loan.isMember(sfInterestRate)); + BEAST_EXPECT(!loan.isMember(sfLateInterestRate)); + BEAST_EXPECT(!loan.isMember(sfLatePaymentFee)); + BEAST_EXPECT(loan[sfLoanBrokerID] == to_string(broker.brokerID)); + BEAST_EXPECT(!loan.isMember(sfLoanOriginationFee)); + BEAST_EXPECT(loan[sfLoanSequence] == 1); + BEAST_EXPECT(!loan.isMember(sfLoanServiceFee)); + BEAST_EXPECT( + loan[sfNextPaymentDueDate] == loan[sfStartDate].asUInt() + 60); + BEAST_EXPECT(!loan.isMember(sfOverpaymentFee)); + BEAST_EXPECT(!loan.isMember(sfOverpaymentInterestRate)); + BEAST_EXPECT(loan[sfPaymentInterval] == 60); + BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000"); + BEAST_EXPECT(loan[sfPaymentRemaining] == 1); + BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDate)); + BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000"); + BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000"); + BEAST_EXPECT(!loan.isMember(sfLoanScale)); + BEAST_EXPECT( + loan[sfStartDate].asUInt() == + startDate.time_since_epoch().count()); + + return loan["index"].asString(); + }(); + auto const loanKeylet{keylet::loan(uint256{std::string_view(loanID)})}; + + env.close(startDate); + + // Make a payment + env(pay(lender, loanKeylet.key, broker.asset(1000))); + } + + void + testBatchBypassCounterparty() + { + // From FIND-001 + testcase << "Batch Bypass Counterparty"; + + bool const lendingBatchEnabled = !std::any_of( + Batch::disabledTxTypes.begin(), + Batch::disabledTxTypes.end(), + [](auto const& disabled) { return disabled == ttLOAN_BROKER_SET; }); + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, all); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + BrokerParameters brokerParams; + env.fund(XRP(brokerParams.vaultDeposit * 100), lender, borrower); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + BrokerInfo broker{ + createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto forgedLoanSet = + set(borrower, broker.brokerID, principalRequest, 0); + + Json::Value randomData{Json::objectValue}; + randomData[jss::SigningPubKey] = Json::StaticString{"2600"}; + Json::Value sigObject{Json::objectValue}; + sigObject[jss::SigningPubKey] = strHex(lender.pk().slice()); + Serializer ss; + ss.add32(HashPrefix::txSign); + parse(randomData).addWithoutSigningFields(ss); + auto const sig = ripple::sign(borrower.pk(), borrower.sk(), ss.slice()); + sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()}); + + forgedLoanSet[Json::StaticString{"CounterpartySignature"}] = sigObject; + + // ? Fails because the lender hasn't signed the tx + env(env.json(forgedLoanSet, fee(loanSetFee)), ter(telENV_RPC_FAILED)); + + auto const seq = env.seq(borrower); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + // ! Should fail because the lender hasn't signed the tx + env(batch::outer(borrower, seq, batchFee, tfAllOrNothing), + batch::inner(forgedLoanSet, seq + 1), + batch::inner(pay(borrower, lender, XRP(1)), seq + 2), + ter(lendingBatchEnabled ? temBAD_SIGNATURE + : temINVALID_INNER_BATCH)); + env.close(); + + // ? Check that the loan was NOT created + { + Json::Value params(Json::objectValue); + params[jss::account] = borrower.human(); + params[jss::type] = "Loan"; + auto const res = + env.rpc("json", "account_objects", to_string(params)); + auto const objects = res[jss::result][jss::account_objects]; + BEAST_EXPECT(objects.size() == 0); + } + } + + void + testWrongMaxDebtBehavior() + { + // From FIND-003 + testcase << "Wrong Max Debt Behavior"; + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + + BrokerParameters brokerParams{.debtMax = 0}; + env.fund( + XRP(brokerParams.vaultDeposit * 100), issuer, noripple(lender)); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + BrokerInfo broker{ + createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfDebtMaximum) == 0); + } + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(lender, broker.brokerID, principalRequest), fee(loanSetFee)); + + Json::Value counterpartyJson{Json::objectValue}; + counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; + counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; + if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) + counterpartyJson[sfSigners] = createJson[sfSigners]; + + createJson = env.json( + createJson, json(sfCounterpartySignature, counterpartyJson)); + env(createJson); + + env.close(); + } + + void + testLoanPayComputePeriodicPaymentValidRateInvariant() + { + // From FIND-012 + testcase << "LoanPay ripple::detail::computePeriodicPayment : " + "valid rate"; + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + BrokerParameters brokerParams; + env.fund( + XRP(brokerParams.vaultDeposit * 100), issuer, lender, borrower); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerInfo broker{ + createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{640562, -5}; + + Number const serviceFee{2462611968}; + std::uint32_t const numPayments{4294967295 / 800}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + fee(loanSetFee), + loanServiceFee(serviceFee), + paymentTotal(numPayments), + json(sfCounterpartySignature, Json::objectValue)); + + createJson["CloseInterestRate"] = 55374; + createJson["ClosePaymentFee"] = "3825205248"; + createJson["GracePeriod"] = 0; + createJson["LatePaymentFee"] = "237"; + createJson["LoanOriginationFee"] = "0"; + createJson["OverpaymentFee"] = 35167; + createJson["OverpaymentInterestRate"] = 1360; + createJson["PaymentInterval"] = 727; + + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + 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.close(); + + BEAST_EXPECT(!env.le(keylet)); + + Number const actualPrincipal{6}; + + createJson[sfPrincipalRequested] = actualPrincipal; + createJson.removeMember(sfSequence.jsonName); + 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.close(); + } + + void + testRPC() + { + // This will expand as more test cases are added. Some functionality + // is tested in other test functions. + testcase("RPC"); + + using namespace jtx; + + Env env(*this, all); + + auto lowerFee = [&]() { + // Run the local fee back down. + while (env.app().getFeeTrack().lowerLocalFee()) + ; + }; + + auto const baseFee = env.current()->fees().base; + + Account const alice{"alice"}; + std::string const borrowerPass = "borrower"; + std::string const borrowerSeed = "ssBRAsLpH4778sLNYC4ik1JBJsBVf"; + Account borrower{borrowerPass, KeyType::ed25519}; + auto const lenderPass = "lender"; + std::string const lenderSeed = "shPTCZGwTEhJrYT8NbcNkeaa8pzPM"; + Account lender{lenderPass, KeyType::ed25519}; + + env.fund(XRP(1'000'000), alice, lender, borrower); + env.close(); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env.close(); + + { + testcase("RPC AccountSet"); + Json::Value txJson{Json::objectValue}; + txJson[sfTransactionType] = "AccountSet"; + txJson[sfAccount] = borrower.human(); + + auto const signParams = [&]() { + Json::Value signParams{Json::objectValue}; + signParams[jss::passphrase] = borrowerPass; + signParams[jss::key_type] = "ed25519"; + signParams[jss::tx_json] = txJson; + return signParams; + }(); + auto const jSign = env.rpc("json", "sign", to_string(signParams)); + BEAST_EXPECT( + jSign.isMember(jss::result) && + jSign[jss::result].isMember(jss::tx_json)); + auto txSignResult = jSign[jss::result][jss::tx_json]; + auto txSignBlob = jSign[jss::result][jss::tx_blob].asString(); + txSignResult.removeMember(jss::hash); + + auto const jtx = env.jt(txJson, sig(borrower)); + BEAST_EXPECT(txSignResult == jtx.jv); + + lowerFee(); + auto const jSubmit = env.rpc("submit", txSignBlob); + BEAST_EXPECT( + jSubmit.isMember(jss::result) && + jSubmit[jss::result].isMember(jss::engine_result) && + jSubmit[jss::result][jss::engine_result].asString() == + "tesSUCCESS"); + + lowerFee(); + env(jtx.jv, sig(none), seq(none), fee(none), ter(tefPAST_SEQ)); + } + + { + testcase("RPC LoanSet - illegal signature_target"); + + Json::Value txJson{Json::objectValue}; + txJson[sfTransactionType] = "AccountSet"; + txJson[sfAccount] = borrower.human(); + + auto const borrowerSignParams = [&]() { + Json::Value params{Json::objectValue}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "Destination"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignBorrower = + env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECT( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::error) && + jSignBorrower[jss::result][jss::error] == "invalidParams" && + jSignBorrower[jss::result].isMember(jss::error_message) && + jSignBorrower[jss::result][jss::error_message] == + "Destination"); + } + { + testcase("RPC LoanSet - sign and submit borrower initiated"); + // 1. Borrower creates the transaction + Json::Value txJson{Json::objectValue}; + txJson[sfTransactionType] = "LoanSet"; + txJson[sfAccount] = borrower.human(); + txJson[sfCounterparty] = lender.human(); + txJson[sfLoanBrokerID] = + "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" + "F83F" + "5C"; + txJson[sfPrincipalRequested] = "100000000"; + txJson[sfPaymentTotal] = 10000; + txJson[sfPaymentInterval] = 3600; + txJson[sfGracePeriod] = 300; + txJson[sfFlags] = 65536; // tfLoanOverpayment + txJson[sfFee] = to_string(24 * baseFee / 10); + + // 2. Borrower signs the transaction + auto const borrowerSignParams = [&]() { + Json::Value params{Json::objectValue}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignBorrower = + env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECTS( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::tx_json), + to_string(jSignBorrower)); + auto const txBorrowerSignResult = + jSignBorrower[jss::result][jss::tx_json]; + auto const txBorrowerSignBlob = + jSignBorrower[jss::result][jss::tx_blob].asString(); + + // 2a. Borrower attempts to submit the transaction. It doesn't + // work + { + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + // Transaction fails because the CounterpartySignature is + // missing + BEAST_EXPECT( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == + "temBAD_SIGNER"); + } + + // 3. Borrower sends the signed transaction to the lender + // 4. Lender signs the transaction + auto const lenderSignParams = [&]() { + Json::Value params{Json::objectValue}; + params[jss::passphrase] = lenderPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "CounterpartySignature"; + params[jss::tx_json] = txBorrowerSignResult; + return params; + }(); + auto const jSignLender = + env.rpc("json", "sign", to_string(lenderSignParams)); + BEAST_EXPECT( + jSignLender.isMember(jss::result) && + jSignLender[jss::result].isMember(jss::tx_json)); + auto const txLenderSignResult = + jSignLender[jss::result][jss::tx_json]; + auto const txLenderSignBlob = + jSignLender[jss::result][jss::tx_blob].asString(); + + // 5. Lender submits the signed transaction blob + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; + // To get far enough to return tecNO_ENTRY means that the + // signatures all validated. Of course the transaction won't + // succeed because no Vault or Broker were created. + BEAST_EXPECTS( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == + "tecNO_ENTRY", + to_string(jSubmitBlobResult)); + + BEAST_EXPECT( + !jSubmitBlob.isMember(jss::error) && + !jSubmitBlobResult.isMember(jss::error)); + + // 4-alt. Lender submits the transaction json originally + // received from the Borrower. It gets signed, but is now a + // duplicate, so fails. Borrower could done this instead of + // steps 4 and 5. + lowerFee(); + auto const jSubmitJson = + env.rpc("json", "submit", to_string(lenderSignParams)); + BEAST_EXPECT(jSubmitJson.isMember(jss::result)); + auto const jSubmitJsonResult = jSubmitJson[jss::result]; + BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); + auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; + // Since the previous tx claimed a fee, this duplicate is not + // going anywhere + BEAST_EXPECTS( + jSubmitJsonResult.isMember(jss::engine_result) && + jSubmitJsonResult[jss::engine_result].asString() == + "tefPAST_SEQ", + to_string(jSubmitJsonResult)); + + BEAST_EXPECT( + !jSubmitJson.isMember(jss::error) && + !jSubmitJsonResult.isMember(jss::error)); + + BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); + } + + { + testcase("RPC LoanSet - sign and submit lender initiated"); + // 1. Lender creates the transaction + Json::Value txJson{Json::objectValue}; + txJson[sfTransactionType] = "LoanSet"; + txJson[sfAccount] = lender.human(); + txJson[sfCounterparty] = borrower.human(); + txJson[sfLoanBrokerID] = + "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" + "F83F" + "5C"; + txJson[sfPrincipalRequested] = "100000000"; + txJson[sfPaymentTotal] = 10000; + txJson[sfPaymentInterval] = 3600; + txJson[sfGracePeriod] = 300; + txJson[sfFlags] = 65536; // tfLoanOverpayment + txJson[sfFee] = to_string(24 * baseFee / 10); + + // 2. Lender signs the transaction + auto const lenderSignParams = [&]() { + Json::Value params{Json::objectValue}; + params[jss::passphrase] = lenderPass; + params[jss::key_type] = "ed25519"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignLender = + env.rpc("json", "sign", to_string(lenderSignParams)); + BEAST_EXPECT( + jSignLender.isMember(jss::result) && + jSignLender[jss::result].isMember(jss::tx_json)); + auto const txLenderSignResult = + jSignLender[jss::result][jss::tx_json]; + auto const txLenderSignBlob = + jSignLender[jss::result][jss::tx_blob].asString(); + + // 2a. Lender attempts to submit the transaction. It doesn't + // work + { + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + // Transaction fails because the CounterpartySignature is + // missing + BEAST_EXPECT( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == + "temBAD_SIGNER"); + } + + // 3. Lender sends the signed transaction to the Borrower + // 4. Borrower signs the transaction + auto const borrowerSignParams = [&]() { + Json::Value params{Json::objectValue}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "CounterpartySignature"; + params[jss::tx_json] = txLenderSignResult; + return params; + }(); + auto const jSignBorrower = + env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECT( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::tx_json)); + auto const txBorrowerSignResult = + jSignBorrower[jss::result][jss::tx_json]; + auto const txBorrowerSignBlob = + jSignBorrower[jss::result][jss::tx_blob].asString(); + + // 5. Borrower submits the signed transaction blob + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; + // To get far enough to return tecNO_ENTRY means that the + // signatures all validated. Of course the transaction won't + // succeed because no Vault or Broker were created. + BEAST_EXPECTS( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == + "tecNO_ENTRY", + to_string(jSubmitBlobResult)); + + BEAST_EXPECT( + !jSubmitBlob.isMember(jss::error) && + !jSubmitBlobResult.isMember(jss::error)); + + // 4-alt. Borrower submits the transaction json originally + // received from the Lender. It gets signed, but is now a + // duplicate, so fails. Lender could done this instead of steps + // 4 and 5. + lowerFee(); + auto const jSubmitJson = + env.rpc("json", "submit", to_string(borrowerSignParams)); + BEAST_EXPECT(jSubmitJson.isMember(jss::result)); + auto const jSubmitJsonResult = jSubmitJson[jss::result]; + BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); + auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; + // Since the previous tx claimed a fee, this duplicate is not + // going anywhere + BEAST_EXPECTS( + jSubmitJsonResult.isMember(jss::engine_result) && + jSubmitJsonResult[jss::engine_result].asString() == + "tefPAST_SEQ", + to_string(jSubmitJsonResult)); + + BEAST_EXPECT( + !jSubmitJson.isMember(jss::error) && + !jSubmitJsonResult.isMember(jss::error)); + + BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); + } + } + + void + testServiceFeeOnBrokerDeepFreeze() + { + testcase << "Service Fee On Broker Deep Freeze"; + using namespace jtx; + using namespace loan; + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + auto const IOU = issuer["IOU"]; + + for (bool const deepFreeze : {true, false}) + { + Env env(*this); + + auto getCoverBalance = [&](BrokerInfo const& brokerInfo, + auto const& accountField) { + if (auto const le = + env.le(keylet::loanbroker(brokerInfo.brokerID)); + BEAST_EXPECT(le)) + { + auto const account = le->at(accountField); + if (auto const sleLine = env.le(keylet::line(account, IOU)); + BEAST_EXPECT(sleLine)) + { + STAmount balance = sleLine->at(sfBalance); + if (account > issuer.id()) + balance.negate(); + return balance; + } + } + return STAmount{IOU}; + }; + + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + env(trust(broker, IOU(20'000'000))); + env(pay(issuer, broker, IOU(10'000'000))); + env.close(); + + auto const brokerInfo = createVaultAndBroker(env, IOU, broker); + + BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == IOU(1'000)); + + 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(); + + env(trust(borrower, IOU(20'000'000))); + // The borrower increases their limit and acquires some IOU so + // they can pay interest + env(pay(issuer, borrower, IOU(500))); + env.close(); + + if (auto const le = env.le(keylet::loan(keylet.key)); + BEAST_EXPECT(le)) + { + if (deepFreeze) + { + env(trust( + issuer, + broker["IOU"](0), + tfSetFreeze | tfSetDeepFreeze)); + env.close(); + } + + env(pay(borrower, keylet.key, IOU(10'100)), fee(XRP(100))); + env.close(); + + if (deepFreeze) + { + // The fee goes to the broker pseudo-account + BEAST_EXPECT( + getCoverBalance(brokerInfo, sfAccount) == IOU(1'100)); + BEAST_EXPECT( + getCoverBalance(brokerInfo, sfOwner) == IOU(8'999'000)); + } + else + { + // The fee goes to the broker account + BEAST_EXPECT( + getCoverBalance(brokerInfo, sfOwner) == IOU(8'999'100)); + BEAST_EXPECT( + getCoverBalance(brokerInfo, sfAccount) == IOU(1'000)); + } + } + }; + } + + void + testBasicMath() + { + // Test the functions defined in LendingHelpers.h + testcase("Basic Math"); + + pass(); + } + + void + testIssuerLoan() + { + testcase << "Issuer Loan"; + + using namespace jtx; + using namespace loan; + Account const issuer("issuer"); + Account const borrower = issuer; + Account const lender("lender"); + Env env(*this); + + env.fund(XRP(1'000), issuer, lender); + + std::int64_t constexpr issuerBalance = 10'000'000; + MPTTester asset( + {.env = env, + .issuer = issuer, + .holders = {lender}, + .pay = issuerBalance}); + + BrokerParameters const brokerParams{ + .debtMax = 200, + }; + auto const broker = + createVaultAndBroker(env, asset, lender, brokerParams); + auto const loanSetFee = fee(env.current()->fees().base * 2); + // Create Loan + env(set(borrower, broker.brokerID, 200), + sig(sfCounterpartySignature, lender), + loanSetFee); + env.close(); + // Issuer should not create MPToken + BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer))); + // Issuer "borrowed" 200, OutstandingAmount decreased by 200 + BEAST_EXPECT(env.balance(issuer, asset) == asset(-issuerBalance + 200)); + // Pay Loan + auto const loanKeylet = keylet::loan(broker.brokerID, 1); + env(pay(borrower, loanKeylet.key, asset(200))); + env.close(); + // Issuer "re-payed" 200, OutstandingAmount increased by 200 + BEAST_EXPECT(env.balance(issuer, asset) == asset(-issuerBalance)); + } + + void + testInvalidLoanDelete() + { + testcase("Invalid LoanDelete"); + using namespace jtx; + using namespace loan; + + // preflight: temINVALID, LoanID == zero + { + Account const alice{"alice"}; + Env env(*this); + env.fund(XRP(1'000), alice); + env.close(); + env(del(alice, beast::zero), ter(temINVALID)); + } + } + + void + testInvalidLoanManage() + { + testcase("Invalid LoanManage"); + using namespace jtx; + using namespace loan; + + // preflight: temINVALID, LoanID == zero + { + Account const alice{"alice"}; + Env env(*this); + env.fund(XRP(1'000), alice); + env.close(); + env(manage(alice, beast::zero, tfLoanDefault), ter(temINVALID)); + } + } + + void + testInvalidLoanPay() + { + testcase("Invalid LoanPay"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + auto const IOU = issuer["IOU"]; + + // preclaim + Env env(*this); + env.fund(XRP(1'000), lender, issuer, borrower); + env(trust(lender, IOU(10'000'000)), THISLINE); + env(pay(issuer, lender, IOU(5'000'000)), THISLINE); + BrokerInfo brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); + + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + THISLINE); + + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); + + env(fset(issuer, asfGlobalFreeze), THISLINE); + env.close(); + + // preclaim: tecFROZEN + env(pay(borrower, loanKeylet.key, debtMaximumRequest), + ter(tecFROZEN), + THISLINE); + env.close(); + + env(fclear(issuer, asfGlobalFreeze), THISLINE); + env.close(); + + auto const pseudoBroker = [&]() -> std::optional { + if (auto brokerSle = + env.le(keylet::loanbroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + return Account{"pseudo", brokerSle->at(sfAccount)}; + } + else + { + return std::nullopt; + } + }(); + if (!pseudoBroker) + return; + + // Lender and pseudoaccount must both be frozen + env(trust( + issuer, + lender["IOU"](1'000), + lender, + tfSetFreeze | tfSetDeepFreeze), + THISLINE); + env(trust( + issuer, + (*pseudoBroker)["IOU"](1'000), + *pseudoBroker, + tfSetFreeze | tfSetDeepFreeze), + THISLINE); + env.close(); + + // preclaim: tecFROZEN due to deep frozen + env(pay(borrower, loanKeylet.key, debtMaximumRequest), + ter(tecFROZEN), + THISLINE); + env.close(); + + // Only one needs to be unfrozen + env(trust( + issuer, + lender["IOU"](1'000), + tfClearFreeze | tfClearDeepFreeze), + THISLINE); + env.close(); + + // The payment is late by this point + env(pay(borrower, loanKeylet.key, debtMaximumRequest), + ter(tecEXPIRED), + THISLINE); + env.close(); + env(pay(borrower, + loanKeylet.key, + debtMaximumRequest, + tfLoanLatePayment), + THISLINE); + env.close(); + + // preclaim: tecKILLED + // note that tecKILLED in loanMakePayment() + // doesn't happen because of the preclaim check. + env(pay(borrower, loanKeylet.key, debtMaximumRequest), + ter(tecKILLED), + THISLINE); + } + + void + testInvalidLoanSet() + { + testcase("Invalid LoanSet"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + auto const IOU = issuer["IOU"]; + + auto testWrapper = [&](auto&& test) { + Env env(*this); + env.fund(XRP(1'000), lender, issuer, borrower); + env(trust(lender, IOU(10'000'000))); + env(pay(issuer, lender, IOU(5'000'000))); + BrokerInfo brokerInfo{ + createVaultAndBroker(env, issuer["IOU"], lender)}; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const debtMaximumRequest = brokerInfo.asset(1'000).value(); + test(env, brokerInfo, loanSetFee, debtMaximumRequest); + }; + + // preflight: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::fee const& loanSetFee, + Number const& debtMaximumRequest) { + // first temBAD_SIGNER: TODO + + // empty/zero broker ID + { + auto jv = set(borrower, uint256{}, debtMaximumRequest); + + auto testZeroBrokerID = [&](std::string const& id, + std::uint32_t flags = 0) { + // empty broker ID + jv[sfLoanBrokerID] = id; + env(jv, + sig(sfCounterpartySignature, lender), + loanSetFee, + txflags(flags), + ter(temINVALID)); + }; + // empty broker ID + testZeroBrokerID(std::string("")); + // zero broker ID + // needs a flag to distinguish the parsed STTx from the prior + // test + testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig); + } + + // preflightCheckSigningKey() failure: + // can it happen? the signature is checked before transactor + // executes + + JTx tx = env.jt( + set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee); + STTx local = *(tx.stx); + auto counterpartySig = + local.getFieldObject(sfCounterpartySignature); + auto badPubKey = counterpartySig.getFieldVL(sfSigningPubKey); + badPubKey[20] ^= 0xAA; + counterpartySig.setFieldVL(sfSigningPubKey, badPubKey); + local.setFieldObject(sfCounterpartySignature, counterpartySig); + Json::Value jvResult; + jvResult[jss::tx_blob] = strHex(local.getSerializer().slice()); + auto res = env.rpc("json", "submit", to_string(jvResult))["result"]; + BEAST_EXPECT( + res[jss::error] == "invalidTransaction" && + res[jss::error_exception] == + "fails local checks: Counterparty: Invalid signature."); + }); + + // preclaim: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::fee const& loanSetFee, + Number const& debtMaximumRequest) { + // canAddHoldingFailure (IOU only, if MPT doesn't have + // MPTCanTransfer set, then can't create Vault/LoanBroker, + // and LoanSet will fail with different error + env(fclear(issuer, asfDefaultRipple)); + env.close(); + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(terNO_RIPPLE)); + }); + + // doApply: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::fee const& loanSetFee, + Number const& debtMaximumRequest) { + auto const amt = env.balance(borrower) - + env.current()->fees().accountReserve(env.ownerCount(borrower)); + env(pay(borrower, issuer, amt)); + + // tecINSUFFICIENT_RESERVE + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(tecINSUFFICIENT_RESERVE)); + + // addEmptyHolding failure + env(pay(issuer, borrower, amt)); + env(fset(issuer, asfGlobalFreeze)); + env.close(); + + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(tecFROZEN)); + }); + } + + void + testAccountSendMptMinAmountInvariant() + { + // (From FIND-006) + testcase << "LoanSet trigger ripple::accountSendMPT : minimum amount " + "and MPT"; + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset const mptAsset = mptt.issuanceID(); + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + env(pay(issuer, lender, mptAsset(2'000'000))); + env(pay(issuer, borrower, mptAsset(1'000))); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, mptAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + fee(loanSetFee), + json(sfCounterpartySignature, Json::objectValue)); + + createJson["CloseInterestRate"] = 76671; + createJson["ClosePaymentFee"] = "2061925410"; + createJson["GracePeriod"] = 434; + createJson["InterestRate"] = 50302; + createJson["LateInterestRate"] = 30322; + createJson["LatePaymentFee"] = "294427911"; + createJson["LoanOriginationFee"] = "3250635102"; + createJson["LoanServiceFee"] = "9557386"; + createJson["OverpaymentFee"] = 51249; + createJson["OverpaymentInterestRate"] = 14304; + createJson["PaymentInterval"] = 434; + createJson["PaymentTotal"] = "2891743748"; + createJson["PrincipalRequested"] = "8516.98"; + + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + + createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); + env(createJson, ter(temINVALID)); + env.close(); + } + + void + testLoanPayDebtDecreaseInvariant() + { + // From FIND-007 + testcase << "LoanPay ripple::LoanPay::doApply : debtDecrease " + "rounding good"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const iouAsset = issuer[iouCurrency]; + auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); + env(trustLenderTx); + auto trustBorrowerTx = + env.json(trust(borrower, iouAsset(1'000'000'000))); + env(trustBorrowerTx); + auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); + env(payLenderTx); + auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); + env(payIssuerTx); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const baseFee = env.current()->fees().base; + auto const loanSetFee = fee(baseFee * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + fee(loanSetFee), + json(sfCounterpartySignature, Json::objectValue)); + + createJson["ClosePaymentFee"] = "0"; + createJson["GracePeriod"] = 60; + createJson["InterestRate"] = 24346; + createJson["LateInterestRate"] = 65535; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); + env(createJson, ter(tesSUCCESS)); + env.close(); + + auto const pseudoAcct = [&]() { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return lender; + auto const brokerPseudo = brokerSle->at(sfAccount); + return Account("Broker pseudo-account", brokerPseudo); + }(); + + VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, keylet); + auto const originalState = getCurrentState(env, broker, keylet); + verifyLoanStatus(originalState); + + Number const payment{3'269'349'176'470'588, -12}; + XRPAmount const payFee{ + baseFee * + ((payment / originalState.periodicPayment) / + loanPaymentsPerFeeIncrement + + 1)}; + auto loanPayTx = env.json( + pay(borrower, keylet.key, STAmount{broker.asset, payment}), + fee(payFee)); + BEAST_EXPECT(to_string(payment) == "3269.349176470588"); + env(loanPayTx, ter(tesSUCCESS)); + env.close(); + + auto const newState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(isRounded( + broker.asset, + newState.managementFeeOutstanding, + originalState.loanScale)); + BEAST_EXPECT( + newState.managementFeeOutstanding < + originalState.managementFeeOutstanding); + BEAST_EXPECT(isRounded( + broker.asset, newState.totalValue, originalState.loanScale)); + BEAST_EXPECT(isRounded( + broker.asset, + newState.principalOutstanding, + originalState.loanScale)); + } + + void + testLoanPayComputePeriodicPaymentValidTotalInterestInvariant() + { + // From FIND-010 + testcase << "ripple::loanComputePaymentParts : valid total interest"; + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const iouAsset = issuer[iouCurrency]; + auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); + env(trustLenderTx); + auto trustBorrowerTx = + env.json(trust(borrower, iouAsset(1'000'000'000))); + env(trustBorrowerTx); + auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); + env(payLenderTx); + auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); + env(payIssuerTx); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + 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), + fee(loanSetFee), + json(sfCounterpartySignature, Json::objectValue)); + + createJson["CloseInterestRate"] = 47299; + createJson["ClosePaymentFee"] = "3985819770"; + createJson["GracePeriod"] = 0; + createJson["InterestRate"] = 92; + createJson["LatePaymentFee"] = "3866894865"; + createJson["LoanOriginationFee"] = "0"; + createJson["LoanServiceFee"] = "2348810240"; + createJson["OverpaymentFee"] = 58545; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 1; + createJson["PrincipalRequested"] = "0.000763058"; + + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); + env(createJson, ter(tecPRECISION_LOSS)); + env.close(startDate); + + auto loanPayTx = env.json( + pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + loanPayTx["Amount"]["value"] = "0.000281284125490196"; + env(loanPayTx, ter(tecNO_ENTRY)); + env.close(); + } + + void + testDosLoanPay() + { + // From FIND-005 + testcase << "DoS LoanPay"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const iouAsset = issuer[iouCurrency]; + env(trust(lender, iouAsset(100'000'000))); + env(trust(borrower, iouAsset(100'000'000))); + env(pay(issuer, lender, iouAsset(10'000'000))); + env(pay(issuer, borrower, iouAsset(1'000))); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + auto const baseFee = env.current()->fees().base; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + fee(loanSetFee), + json(sfCounterpartySignature, Json::objectValue)); + + createJson["ClosePaymentFee"] = "0"; + createJson["GracePeriod"] = 60; + createJson["InterestRate"] = 20930; + createJson["LateInterestRate"] = 77049; + createJson["LatePaymentFee"] = "0"; + createJson["LoanServiceFee"] = "0"; + createJson["OverpaymentFee"] = 7; + createJson["OverpaymentInterestRate"] = 66653; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 3239184; + createJson["PrincipalRequested"] = "3959.37"; + + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); + env(createJson, ter(tesSUCCESS)); + env.close(); + + auto const stateBefore = getCurrentState(env, broker, keylet); + BEAST_EXPECT(stateBefore.paymentRemaining == 3239184); + BEAST_EXPECT( + stateBefore.paymentRemaining > loanMaximumPaymentsPerTransaction); + + auto loanPayTx = env.json( + pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{395937, -2}; + loanPayTx["Amount"]["value"] = to_string(amount); + XRPAmount const payFee{ + baseFee * + std::int64_t( + amount / stateBefore.periodicPayment / + loanPaymentsPerFeeIncrement + + 1)}; + env(loanPayTx, ter(tesSUCCESS), fee(payFee)); + env.close(); + + auto const stateAfter = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + stateAfter.paymentRemaining == + stateBefore.paymentRemaining - loanMaximumPaymentsPerTransaction); + } + + void + testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant() + { + // From FIND-009 + testcase << "ripple::loanComputePaymentParts : totalPrincipalPaid " + "rounded"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const iouAsset = issuer[iouCurrency]; + auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); + env(trustLenderTx); + auto trustBorrowerTx = + env.json(trust(borrower, iouAsset(1'000'000'000))); + env(trustBorrowerTx); + auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); + env(payLenderTx); + auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); + env(payIssuerTx); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + fee(loanSetFee), + json(sfCounterpartySignature, Json::objectValue)); + + createJson["ClosePaymentFee"] = "0"; + createJson["GracePeriod"] = 0; + createJson["InterestRate"] = 24346; + createJson["LateInterestRate"] = 65535; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); + env(createJson, ter(tesSUCCESS)); + env.close(); + + auto const baseFee = env.current()->fees().base; + + auto const stateBefore = getCurrentState(env, broker, keylet); + + { + auto loanPayTx = env.json( + pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{3074'745'058'823'529, -12}; + BEAST_EXPECT(to_string(amount) == "3074.745058823529"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / + loanPaymentsPerFeeIncrement + + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, fee(payFee), ter(tesSUCCESS)); + env.close(); + } + + { + auto loanPayTx = env.json( + pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{6732'118'170'944'051, -12}; + BEAST_EXPECT(to_string(amount) == "6732.118170944051"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / + loanPaymentsPerFeeIncrement + + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, fee(payFee), ter(tesSUCCESS)); + env.close(); + } + + auto const stateAfter = getCurrentState(env, broker, keylet); + // Total interest outstanding is non-negative + BEAST_EXPECT(stateAfter.totalValue >= stateAfter.principalOutstanding); + // Principal paid is non-negative + BEAST_EXPECT( + stateBefore.principalOutstanding >= + stateAfter.principalOutstanding); + // Total value change is non-negative + BEAST_EXPECT(stateBefore.totalValue >= stateAfter.totalValue); + // Value delta is larger or same as principal delta (meaning + // non-negative interest paid) + BEAST_EXPECT( + (stateBefore.totalValue - stateAfter.totalValue) >= + (stateBefore.principalOutstanding - + stateAfter.principalOutstanding)); + } + + void + testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant() + { + // From FIND-008 + testcase << "ripple::loanComputePaymentParts : loanValueChange rounded"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const iouAsset = issuer[iouCurrency]; + auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); + env(trustLenderTx); + auto trustBorrowerTx = + env.json(trust(borrower, iouAsset(1'000'000'000))); + env(trustBorrowerTx); + auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); + env(payLenderTx); + auto payIssuerTx = pay(issuer, borrower, iouAsset(10'000'000)); + env(payIssuerTx); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; + { + auto const coverDepositValue = + broker.asset(broker.params.coverDeposit * 10).value(); + env(loanBroker::coverDeposit( + lender, broker.brokerID, coverDepositValue)); + env.close(); + } + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + fee(loanSetFee), + json(sfCounterpartySignature, Json::objectValue)); + + createJson["ClosePaymentFee"] = "0"; + createJson["GracePeriod"] = 0; + createJson["InterestRate"] = 12833; + createJson["LateInterestRate"] = 77048; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 752; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); + env(createJson, ter(tesSUCCESS)); + env.close(); + + auto const baseFee = env.current()->fees().base; + + auto const stateBefore = getCurrentState(env, broker, keylet); + BEAST_EXPECT(stateBefore.paymentRemaining == 5678); + BEAST_EXPECT( + stateBefore.paymentRemaining > loanMaximumPaymentsPerTransaction); + + auto loanPayTx = env.json( + pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{9924'81, -2}; + BEAST_EXPECT(to_string(amount) == "9924.81"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / + loanPaymentsPerFeeIncrement + + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, fee(payFee), ter(tesSUCCESS)); + env.close(); + + auto const stateAfter = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + stateAfter.paymentRemaining == + stateBefore.paymentRemaining - loanMaximumPaymentsPerTransaction); + } + + void + testLoanNextPaymentDueDateOverflow() + { + // For FIND-013 + testcase << "Prevent nextPaymentDueDate overflow"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const iouAsset = issuer[iouCurrency]; + auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); + env(trustLenderTx); + auto trustBorrowerTx = + env.json(trust(borrower, iouAsset(1'000'000'000))); + env(trustBorrowerTx); + auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); + env(payLenderTx); + auto payIssuerTx = pay(issuer, borrower, iouAsset(10'000'000)); + env(payIssuerTx); + env.close(); + + BrokerParameters const brokerParams{ + .debtMax = Number{0}, .coverRateMin = TenthBips32{1}}; + BrokerInfo broker{ + createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + + using timeType = decltype(sfNextPaymentDueDate)::type::value_type; + static_assert(std::is_same_v); + timeType constexpr maxTime = std::numeric_limits::max(); + static_assert(maxTime == 4'294'967'295); + + auto const baseJson = [&]() { + auto createJson = env.json( + set(borrower, broker.brokerID, Number{55524'81, -2}), + fee(loanSetFee), + closePaymentFee(0), + gracePeriod(0), + interestRate(TenthBips32(12833)), + lateInterestRate(TenthBips32(77048)), + latePaymentFee(0), + loanOriginationFee(218), + json(sfCounterpartySignature, Json::objectValue)); + + createJson.removeMember(sfSequence.getJsonName()); + + return createJson; + }(); + + auto const baseFee = env.current()->fees().base; + + auto parentCloseTime = [&]() { + return env.current()->parentCloseTime().time_since_epoch().count(); + }; + auto maxLoanTime = [&]() { + auto const startDate = parentCloseTime(); + + BEAST_EXPECT(startDate >= 50); + + return maxTime - startDate; + }; + + { + // straight-up overflow: interval + auto const interval = maxLoanTime() + 1; + auto const total = 1; + auto createJson = env.json( + baseJson, paymentInterval(interval), paymentTotal(total)); + + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tecKILLED)); + env.close(); + } + { + // straight-up overflow: total + // min interval is 60 + auto const interval = 60; + auto const total = maxLoanTime() + 1; + auto createJson = env.json( + baseJson, paymentInterval(interval), paymentTotal(total)); + + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tecKILLED)); + env.close(); + } + { + // straight-up overflow: grace period + // min interval is 60 + auto const interval = maxLoanTime() + 1; + auto const total = 1; + auto const grace = interval; + auto createJson = env.json( + baseJson, + paymentInterval(interval), + paymentTotal(total), + gracePeriod(grace)); + + // The grace period can't be larger than the interval. + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tecKILLED)); + env.close(); + } + { + // Overflow with multiplication of a few large intervals + auto const interval = 1'000'000'000; + auto const total = 10; + auto createJson = env.json( + baseJson, paymentInterval(interval), paymentTotal(total)); + + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tecKILLED)); + env.close(); + } + { + // Overflow with multiplication of many small payments + // min interval is 60 + auto const interval = 60; + auto const total = 1'000'000'000; + auto createJson = env.json( + baseJson, paymentInterval(interval), paymentTotal(total)); + + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tecKILLED)); + env.close(); + } + { + // Overflow with an absurdly large grace period + // min interval is 60 + auto const total = 60; + auto const interval = (maxLoanTime() - total) / total; + auto const grace = interval; + auto createJson = env.json( + baseJson, + paymentInterval(interval), + paymentTotal(total), + gracePeriod(grace)); + + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tecKILLED)); + env.close(); + } + { + // Start date when the ledger is closed will be larger + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + auto const grace = 100; + auto const interval = maxLoanTime() - grace; + auto const total = 1; + auto createJson = env.json( + baseJson, + paymentInterval(interval), + paymentTotal(total), + gracePeriod(grace)); + + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tesSUCCESS)); + env.close(); + + // The transaction is killed in the closed ledger + auto const meta = env.meta(); + if (BEAST_EXPECT(meta)) + { + BEAST_EXPECT(meta->at(sfTransactionResult) == tecKILLED); + } + + // If the transaction had succeeded, the loan would exist + auto const loanSle = env.le(keylet); + // but it doesn't + BEAST_EXPECT(!loanSle); + } + { + // Start date when the ledger is closed will be larger + auto const brokerStateBefore = + env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + auto const closeStartDate = (parentCloseTime() / 10 + 1) * 10; + auto const grace = 5'000; + auto const interval = maxTime - closeStartDate - grace; + auto const total = 1; + auto createJson = env.json( + baseJson, + paymentInterval(interval), + paymentTotal(total), + gracePeriod(grace)); + + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tesSUCCESS)); + env.close(); + + // The transaction succeeds in the closed ledger + auto const meta = env.meta(); + if (BEAST_EXPECT(meta)) + { + BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); + } + + // This loan exists + auto const afterState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(afterState.nextPaymentDate == maxTime - grace); + BEAST_EXPECT(afterState.previousPaymentDate == 0); + BEAST_EXPECT(afterState.paymentRemaining == 1); + } + + { + // Ensure the borrower has funds to pay back the loan + env(pay(issuer, borrower, iouAsset(Number{1'055'524'81, -2}))); + + // Start date when the ledger is closed will be larger + auto const closeStartDate = (parentCloseTime() / 10 + 1) * 10; + auto const grace = 5'000; + auto const maxLoanTime = maxTime - closeStartDate - grace; + auto const total = [&]() { + if (maxLoanTime % 5 == 0) + return 5; + if (maxLoanTime % 3 == 0) + return 3; + if (maxLoanTime % 2 == 0) + return 2; + return 0; + }(); + if (!BEAST_EXPECT(total != 0)) + return; + + auto const brokerState = + env.le(keylet::loanbroker(broker.brokerID)); + // Intentionally shadow the outer values + auto const loanSequence = brokerState->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + auto const interval = maxLoanTime / total; + auto createJson = env.json( + baseJson, + paymentInterval(interval), + paymentTotal(total), + gracePeriod(grace)); + + env(createJson, + sig(sfCounterpartySignature, lender), + ter(tesSUCCESS)); + env.close(); + + // This loan exists + auto const beforeState = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + beforeState.nextPaymentDate == closeStartDate + interval); + BEAST_EXPECT(beforeState.previousPaymentDate == 0); + BEAST_EXPECT(beforeState.paymentRemaining == total); + 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(); + + // The loan is on the last payment + auto const afterState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(afterState.nextPaymentDate == maxTime - grace); + BEAST_EXPECT( + afterState.previousPaymentDate == maxTime - grace - interval); + BEAST_EXPECT(afterState.paymentRemaining == 1); + } + } + + void + testRequireAuth() + { + testcase("Require Auth - Implicit Pseudo-account authorization"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Env env(*this); + + env.fund(XRP(100'000), issuer, lender, borrower); + env.close(); + + auto asset = MPTTester({ + .env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = MPTDEXFlags | tfMPTRequireAuth | tfMPTCanClawback | + tfMPTCanLock, + .authHolder = true, + }); + + env(pay(issuer, lender, asset(5'000'000))); + BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)}; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); + + auto forUnauthAuth = [&](auto&& doTx) { + for (auto const flag : {tfMPTUnauthorize, 0u}) + { + asset.authorize( + {.account = issuer, .holder = borrower, .flags = flag}); + env.close(); + doTx(flag == 0); + env.close(); + } + }; + + // Can't create a loan if the borrower is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? ter(tecNO_AUTH) : ter(tesSUCCESS); + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + err); + }); + + std::uint32_t constexpr loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); + + // Can't loan pay if the borrower is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? ter(tecNO_AUTH) : ter(tesSUCCESS); + env(pay(borrower, loanKeylet.key, debtMaximumRequest), err); + }); + } + + void + testCoverDepositWithdrawNonTransferableMPT() + { + testcase( + "CoverDeposit and CoverWithdraw reject MPT without CanTransfer"); + using namespace jtx; + using namespace loanBroker; + + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const alice{"alice"}; + + env.fund(XRP(100'000), issuer, alice); + env.close(); + + MPTTester mpt{env, issuer, mptInitNoFund}; + + mpt.create( + {.flags = tfMPTCanTransfer, + .mutableFlags = tmfMPTCanMutateCanTransfer}); + + env.close(); + + PrettyAsset const asset = mpt["MPT"]; + mpt.authorize({.account = alice}); + env.close(); + + // Issuer can fund the holder even if CanTransfer is not set. + env(pay(issuer, alice, asset(100))); + env.close(); + + Vault vault{env}; + auto const [createTx, vaultKeylet] = + vault.create({.owner = alice, .asset = asset}); + env(createTx); + env.close(); + + auto const brokerKeylet = + keylet::loanbroker(alice.id(), env.seq(alice)); + env(set(alice, vaultKeylet.key)); + env.close(); + + auto const brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return; + + Account const pseudoAccount{ + "Loan Broker pseudo-account", brokerSle->at(sfAccount)}; + + // Remove CanTransfer after the broker is set up. + mpt.set({.mutableFlags = tmfMPTClearCanTransfer}); + env.close(); + + // Standard Payment path should forbid third-party transfers. + env(pay(alice, pseudoAccount, asset(1)), ter(tecNO_AUTH)); + env.close(); + + // Cover cannot be transferred to broker account + auto const depositAmount = asset(1); + env(coverDeposit(alice, brokerKeylet.key, depositAmount), + ter{tecNO_AUTH}); + env.close(); + + if (auto const refreshed = env.le(brokerKeylet); + BEAST_EXPECT(refreshed)) + { + BEAST_EXPECT(refreshed->at(sfCoverAvailable) == 0); + env.require(balance(pseudoAccount, asset(0))); + } + + // Set CanTransfer again and transfer some deposit + mpt.set({.mutableFlags = tmfMPTSetCanTransfer}); + env.close(); + + env(coverDeposit(alice, brokerKeylet.key, depositAmount)); + env.close(); + + if (auto const refreshed = env.le(brokerKeylet); + BEAST_EXPECT(refreshed)) + { + BEAST_EXPECT(refreshed->at(sfCoverAvailable) == 1); + env.require(balance(pseudoAccount, depositAmount)); + } + + // Remove CanTransfer after the deposit + mpt.set({.mutableFlags = tmfMPTClearCanTransfer}); + env.close(); + + // Cover cannot be transferred from broker account + env(coverWithdraw(alice, brokerKeylet.key, depositAmount), + ter{tecNO_AUTH}); + env.close(); + + // Set CanTransfer again and withdraw + mpt.set({.mutableFlags = tmfMPTSetCanTransfer}); + env.close(); + + env(coverWithdraw(alice, brokerKeylet.key, depositAmount)); + env.close(); + + if (auto const refreshed = env.le(brokerKeylet); + BEAST_EXPECT(refreshed)) + { + BEAST_EXPECT(refreshed->at(sfCoverAvailable) == 0); + env.require(balance(pseudoAccount, asset(0))); + } + } + +#if LOANTODO + void + testLoanPayLateFullPaymentBypassesPenalties() + { + testcase("LoanPay full payment skips late penalties"); + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const asset = issuer[iouCurrency]; + env(trust(lender, asset(100'000'000))); + env(trust(borrower, asset(100'000'000))); + env(pay(issuer, lender, asset(50'000'000))); + env(pay(issuer, borrower, asset(5'000'000))); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, asset, lender)}; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + + auto const brokerPreLoan = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerPreLoan)) + return; + + auto const loanSequence = brokerPreLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + Number const principal = asset(1'000).value(); + Number const serviceFee = asset(2).value(); + Number const lateFee = asset(5).value(); + Number const closeFee = asset(4).value(); + + env(set(borrower, broker.brokerID, principal), + sig(sfCounterpartySignature, lender), + loanServiceFee(serviceFee), + latePaymentFee(lateFee), + closePaymentFee(closeFee), + interestRate(percentageToTenthBips(12)), + lateInterestRate(percentageToTenthBips(24) / 10), + closeInterestRate(percentageToTenthBips(5)), + paymentTotal(12), + paymentInterval(600), + gracePeriod(0), + fee(loanSetFee)); + env.close(); + + auto state1 = getCurrentState(env, broker, loanKeylet); + if (!BEAST_EXPECT(state1.paymentRemaining > 1)) + return; + + using d = NetClock::duration; + using tp = NetClock::time_point; + auto const overdueClose = + tp{d{state1.nextPaymentDate + state1.paymentInterval}}; + env.close(overdueClose); + + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(brokerSle && loanSle)) + return; + + auto state = getCurrentState(env, broker, loanKeylet); + + TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; + TenthBips32 const interestRateValue{loanSle->at(sfInterestRate)}; + TenthBips32 const lateInterestRateValue{ + loanSle->at(sfLateInterestRate)}; + TenthBips32 const closeInterestRateValue{ + loanSle->at(sfCloseInterestRate)}; + + Number const closePaymentFeeRounded = roundToAsset( + broker.asset, loanSle->at(sfClosePaymentFee), state.loanScale); + Number const latePaymentFeeRounded = roundToAsset( + broker.asset, loanSle->at(sfLatePaymentFee), state.loanScale); + + auto const roundedLoanState = constructLoanState( + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding); + Number const totalInterestOutstanding = roundedLoanState.interestDue; + + auto const periodicRate = + loanPeriodicRate(interestRateValue, state.paymentInterval); + auto const rawLoanState = computeRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining, + managementFeeRate); + + auto const parentCloseTime = env.current()->parentCloseTime(); + auto const startDateSeconds = static_cast( + state.startDate.time_since_epoch().count()); + + Number const fullPaymentInterest = computeFullPaymentInterest( + rawLoanState.principalOutstanding, + periodicRate, + parentCloseTime, + state.paymentInterval, + state.previousPaymentDate, + startDateSeconds, + closeInterestRateValue); + + Number const roundedFullInterestAmount = + roundToAsset(broker.asset, fullPaymentInterest, state.loanScale); + Number const roundedFullManagementFee = computeManagementFee( + broker.asset, + roundedFullInterestAmount, + managementFeeRate, + state.loanScale); + Number const roundedFullInterest = + roundedFullInterestAmount - roundedFullManagementFee; + + Number const trackedValueDelta = state.principalOutstanding + + totalInterestOutstanding + state.managementFeeOutstanding; + Number const untrackedManagementFee = closePaymentFeeRounded + + roundedFullManagementFee - state.managementFeeOutstanding; + Number const untrackedInterest = + roundedFullInterest - totalInterestOutstanding; + + Number const baseFullDue = + trackedValueDelta + untrackedInterest + untrackedManagementFee; + BEAST_EXPECT( + baseFullDue == + roundToAsset(broker.asset, baseFullDue, state.loanScale)); + + auto const overdueSeconds = + parentCloseTime.time_since_epoch().count() - state.nextPaymentDate; + if (!BEAST_EXPECT(overdueSeconds > 0)) + return; + + Number const overdueRate = + loanPeriodicRate(lateInterestRateValue, overdueSeconds); + Number const lateInterestRaw = state.principalOutstanding * overdueRate; + Number const lateInterestRounded = + roundToAsset(broker.asset, lateInterestRaw, state.loanScale); + Number const lateManagementFeeRounded = computeManagementFee( + broker.asset, + lateInterestRounded, + managementFeeRate, + state.loanScale); + Number const penaltyDue = lateInterestRounded + + lateManagementFeeRounded + latePaymentFeeRounded; + BEAST_EXPECT(penaltyDue > Number{}); + + auto const balanceBefore = env.balance(borrower, broker.asset).number(); + + STAmount const paymentAmount{broker.asset.raw(), baseFullDue}; + env(pay(borrower, loanKeylet.key, paymentAmount, tfLoanFullPayment)); + env.close(); + + if (auto const meta = env.meta(); BEAST_EXPECT(meta)) + BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); + + auto const balanceAfter = env.balance(borrower, broker.asset).number(); + Number const actualPaid = balanceBefore - balanceAfter; + BEAST_EXPECT(actualPaid == baseFullDue); + + Number const expectedWithPenalty = baseFullDue + penaltyDue; + BEAST_EXPECT(expectedWithPenalty > actualPaid); + BEAST_EXPECT(expectedWithPenalty - actualPaid == penaltyDue); + } + + void + testLoanCoverMinimumRoundingExploit() + { + auto testLoanCoverMinimumRoundingExploit = + [&, this](Number const& principalRequest) { + testcase << "LoanBrokerCoverClawback drains cover via rounding" + << " principalRequested=" + << to_string(principalRequest); + + using namespace jtx; + using namespace loan; + using namespace loanBroker; + + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000'000), issuer, lender, borrower); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const asset = issuer[iouCurrency]; + env(trust(lender, asset(2'000'0000))); + env(trust(borrower, asset(2'000'0000))); + env.close(); + + env(pay(issuer, lender, asset(2'000'0000))); + env.close(); + + BrokerParameters brokerParams{ + .debtMax = 0, .coverRateMin = TenthBips32{10'000}}; + BrokerInfo broker{ + createVaultAndBroker(env, asset, lender, brokerParams)}; + + auto const loanSetFee = fee(env.current()->fees().base * 2); + auto createTx = env.jt( + set(borrower, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + paymentInterval(600), + paymentTotal(1), + gracePeriod(60)); + env(createTx); + env.close(); + + auto const brokerBefore = + env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerBefore); + if (!brokerBefore) + return; + + Number const debtOutstanding = brokerBefore->at(sfDebtTotal); + Number const coverAvailableBefore = + brokerBefore->at(sfCoverAvailable); + + BEAST_EXPECT(debtOutstanding > Number{}); + BEAST_EXPECT(coverAvailableBefore > Number{}); + + log << "debt=" << to_string(debtOutstanding) + << " cover_available=" << to_string(coverAvailableBefore); + + env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID)); + env.close(); + + auto const brokerAfter = + env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerAfter); + if (!brokerAfter) + return; + + Number const debtAfter = brokerAfter->at(sfDebtTotal); + // the debt has not changed + BEAST_EXPECT(debtAfter == debtOutstanding); + + Number const coverAvailableAfter = + brokerAfter->at(sfCoverAvailable); + + // since the cover rate min != 0, the cover available should not + // be zero + BEAST_EXPECT(coverAvailableAfter != Number{}); + }; + + // Call the lambda with different principal values + testLoanCoverMinimumRoundingExploit(Number{1, -30}); // 1e-30 units + testLoanCoverMinimumRoundingExploit(Number{1, -20}); // 1e-20 units + testLoanCoverMinimumRoundingExploit(Number{1, -10}); // 1e-10 units + testLoanCoverMinimumRoundingExploit(Number{1, 1}); // 1e-10 units + } +#endif + + void + testPoC_UnsignedUnderflowOnFullPayAfterEarlyPeriodic() + { + // --- PoC Summary ---------------------------------------------------- + // Scenario: Borrower makes one periodic payment early (before next due) + // so doPayment sets sfPreviousPaymentDate 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 + // uses + // delta = now - max(prevPaymentDate, startDate) + // with an unsigned clock representation (uint32). If prevPaymentDate is + // in the future, the subtraction underflows to a very large positive + // number. This inflates roundedFullInterest and total full-close due, + // and LoanPay applies the inflated valueChange to the vault + // (sfAssetsTotal), increasing NAV. + // -------------------------------------------------------------------- + testcase( + "PoC: Unsigned-underflow full-pay accrual after early periodic"); + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + Env env(*this, all); + + Account const lender{"poc_lender4"}; + Account const borrower{"poc_borrower4"}; + env.fund(XRP(3'000'000), lender, borrower); + env.close(); + + PrettyAsset const asset{xrpIssue(), 1'000'000}; + BrokerParameters brokerParams{}; + auto const broker = + createVaultAndBroker(env, asset, lender, brokerParams); + + // Create a 3-payment loan so full-payment path is enabled after 1 + // periodic payment. + auto const loanSetFee = fee(env.current()->fees().base * 2); + Number const principalRequest = asset(1000).value(); + auto const originationFee = asset(0).value(); + auto const serviceFee = asset(1).value(); + auto const serviceFeePA = asset(1); + auto const lateFee = asset(0).value(); + auto const closeFee = asset(0).value(); + auto const interest = percentageToTenthBips(12); + auto const lateInterest = percentageToTenthBips(12) / 10; + auto const closeInterest = percentageToTenthBips(12) / 10; + auto const overpaymentInterest = percentageToTenthBips(12) / 10; + auto const total = 3u; + auto const interval = 600u; + auto const grace = 60u; + + auto createJtx = env.jt( + set(borrower, broker.brokerID, principalRequest, 0), + sig(sfCounterpartySignature, lender), + loanOriginationFee(originationFee), + loanServiceFee(serviceFee), + latePaymentFee(lateFee), + closePaymentFee(closeFee), + overpaymentFee(percentageToTenthBips(5) / 10), + interestRate(interest), + lateInterestRate(lateInterest), + closeInterestRate(closeInterest), + overpaymentInterestRate(overpaymentInterest), + paymentTotal(total), + paymentInterval(interval), + gracePeriod(grace), + fee(loanSetFee)); + + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle); + auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(createJtx); + env.close(); + + // Compute a regular periodic due and pay it early (before next due). + auto state = getCurrentState(env, broker, loanKeylet); + Number const periodicRate = + loanPeriodicRate(state.interestRate, state.paymentInterval); + auto const components = detail::computePaymentComponents( + asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + brokerParams.managementFeeRate); + STAmount const regularDue{ + asset, components.trackedValueDelta + serviceFeePA.number()}; + // now < nextDue immediately after creation, so this is an early pay. + env(pay(borrower, loanKeylet.key, regularDue)); + env.close(); + + // Immediately attempt a full payoff. Compute the exact full-payment + // due to ensure the tx applies. + auto after = getCurrentState(env, broker, loanKeylet); + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + auto const brokerSle2 = env.le(keylet::loanbroker(broker.brokerID)); + BEAST_EXPECT(brokerSle2); + + auto const closePaymentFee = + loanSle ? loanSle->at(sfClosePaymentFee) : Number{}; + auto const closeInterestRate = loanSle + ? TenthBips32{loanSle->at(sfCloseInterestRate)} + : TenthBips32{}; + auto const managementFeeRate = brokerSle2 + ? TenthBips16{brokerSle2->at(sfManagementFeeRate)} + : TenthBips16{}; + + Number const periodicRate2 = + loanPeriodicRate(after.interestRate, after.paymentInterval); + // Accrued + prepayment-penalty interest based on current periodic + // schedule + auto const fullPaymentInterest = computeFullPaymentInterest( + after.periodicPayment, + 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); + Number const roundedFullMgmtFee = computeManagementFee( + asset.raw(), roundedInterest, managementFeeRate, after.loanScale); + Number const roundedFullInterest = roundedInterest - roundedFullMgmtFee; + + // Show both signed and unsigned deltas to highlight the underflow. + auto const nowSecs = static_cast( + env.current()->parentCloseTime().time_since_epoch().count()); + auto const startSecs = static_cast( + after.startDate.time_since_epoch().count()); + auto const lastPaymentDate = + std::max(after.previousPaymentDate, startSecs); + auto const signedDelta = static_cast(nowSecs) - + static_cast(lastPaymentDate); + auto const unsignedDelta = + static_cast(nowSecs - lastPaymentDate); + log << "PoC window: prev=" << after.previousPaymentDate + << " start=" << startSecs << " now=" << nowSecs + << " signedDelta=" << signedDelta + << " unsignedDelta=" << unsignedDelta << std::endl; + + // Reference (clamped) computation: emulate a non-negative accrual + // 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, + periodicRate2, + after.paymentRemaining, + env.current()->parentCloseTime(), + after.paymentInterval, + prevClamped, + startSecs, + closeInterestRate); + auto const roundedInterestClamped = roundToAsset( + asset.raw(), fullPaymentInterestClamped, after.loanScale); + Number const roundedFullMgmtFeeClamped = computeManagementFee( + asset.raw(), + roundedInterestClamped, + managementFeeRate, + after.loanScale); + Number const roundedFullInterestClamped = + roundedInterestClamped - roundedFullMgmtFeeClamped; + STAmount const fullDueClamped{ + asset, + after.principalOutstanding + roundedFullInterestClamped + + roundedFullMgmtFeeClamped + closePaymentFee}; + + // Collect vault NAV before closing payment + auto const vaultId2 = + brokerSle2 ? brokerSle2->at(sfVaultID) : uint256{}; + auto const vaultKey2 = keylet::vault(vaultId2); + auto const vaultBefore = env.le(vaultKey2); + BEAST_EXPECT(vaultBefore); + Number const assetsTotalBefore = + vaultBefore ? vaultBefore->at(sfAssetsTotal) : Number{}; + + STAmount const fullDue{ + asset, + after.principalOutstanding + roundedFullInterest + + roundedFullMgmtFee + closePaymentFee}; + + log << "PoC payoff: principalOutstanding=" << after.principalOutstanding + << " roundedFullInterest=" << roundedFullInterest + << " roundedFullMgmtFee=" << roundedFullMgmtFee + << " closeFee=" << closePaymentFee + << " fullDue=" << to_string(fullDue.getJson()) << std::endl; + log << "PoC reference (clamped): roundedFullInterestClamped=" + << roundedFullInterestClamped + << " roundedFullMgmtFeeClamped=" << roundedFullMgmtFeeClamped + << " fullDueClamped=" << to_string(fullDueClamped.getJson()) + << std::endl; + + env(pay(borrower, loanKeylet.key, fullDue), txflags(tfLoanFullPayment)); + env.close(); + + // Sanity: underflow present (unsigned delta very large relative to + // interval) + BEAST_EXPECT(unsignedDelta > after.paymentInterval); + + // Compare vault NAV before/after the full close + auto const vaultAfter = env.le(vaultKey2); + BEAST_EXPECT(vaultAfter); + if (vaultAfter) + { + auto const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); + log << "PoC NAV: assetsTotalBefore=" << assetsTotalBefore + << " assetsTotalAfter=" << assetsTotalAfter + << " delta=" << (assetsTotalAfter - assetsTotalBefore) + << std::endl; + + // Value-based proof: underflowed window yields a payoff larger than + // the clamped (non-underflow) reference. + BEAST_EXPECT(fullDue == fullDueClamped); + if (fullDue > fullDueClamped) + log << "PoC delta: overcharge (fullDue > clamped)" << std::endl; + } + + // Loan should be paid off + auto const finalLoan = env.le(loanKeylet); + BEAST_EXPECT(finalLoan); + if (finalLoan) + { + BEAST_EXPECT(finalLoan->at(sfPaymentRemaining) == 0); + BEAST_EXPECT(finalLoan->at(sfPrincipalOutstanding) == 0); + } + } + + void + testDustManipulation() + { + testcase("Dust manipulation"); + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, all); + + // Setup: Create accounts + Account issuer{"issuer"}; + Account lender{"lender"}; + Account borrower{"borrower"}; + Account victim{"victim"}; + + env.fund(XRP(1'000'000'00), issuer, lender, borrower, victim); + env.close(); + + // Step 1: Create vault with IOU asset + auto asset = issuer["USD"]; + env(trust(lender, asset(100000))); + env(trust(borrower, asset(100000))); + env(trust(victim, asset(100000))); + env(pay(issuer, lender, asset(50000))); + env(pay(issuer, borrower, asset(50000))); + env(pay(issuer, victim, asset(50000))); + env.close(); + + BrokerParameters brokerParams{ + .vaultDeposit = 10000, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{1000}, + .coverRateLiquidation = TenthBips32{2500}}; + + auto broker = createVaultAndBroker(env, asset, lender, brokerParams); + + auto const loanKeyletOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the + // _LOAN_BROKER_ object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return keylet::loan(broker.brokerID, loanSequence); + }(); + if (!loanKeyletOpt) + return; + + auto const& vaultKeylet = broker.vaultKeylet(); + + { + auto const vaultSle = env.le(vaultKeylet); + Number assetsTotal = vaultSle->at(sfAssetsTotal); + Number assetsAvail = vaultSle->at(sfAssetsAvailable); + + log << "Before loan creation:" << std::endl; + log << " AssetsTotal: " << assetsTotal << std::endl; + log << " AssetsAvailable: " << assetsAvail << std::endl; + log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; + + // before the loan the assets total and available should be equal + BEAST_EXPECT(assetsAvail == assetsTotal); + BEAST_EXPECT( + assetsAvail == + broker.asset(brokerParams.vaultDeposit).number()); + } + + Keylet const& loanKeylet = *loanKeyletOpt; + + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{100}, + .interest = TenthBips32{1922}, + .payTotal = 5816, + .payInterval = 86400 * 6, + .gracePd = 86400 * 5, + }; + + env(loanParams(env, broker)); + env.close(); + + // Wait for loan to be late enough to default + env.close(std::chrono::seconds(86400 * 40)); // 40 days + + { + auto const vaultSle = env.le(vaultKeylet); + Number assetsTotal = vaultSle->at(sfAssetsTotal); + Number assetsAvail = vaultSle->at(sfAssetsAvailable); + + log << "After loan creation:" << std::endl; + log << " AssetsTotal: " << assetsTotal << std::endl; + log << " AssetsAvailable: " << assetsAvail << std::endl; + log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; + + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + return; + auto const state = constructRoundedLoanState(loanSle); + + log << "Loan state:" << std::endl; + log << " ValueOutstanding: " << state.valueOutstanding + << std::endl; + log << " PrincipalOutstanding: " << state.principalOutstanding + << std::endl; + log << " InterestOutstanding: " << state.interestOutstanding() + << std::endl; + log << " InterestDue: " << state.interestDue << std::endl; + log << " FeeDue: " << state.managementFeeDue << std::endl; + + // after loan creation the assets total and available should + // reflect the value of the loan + BEAST_EXPECT(assetsAvail < assetsTotal); + BEAST_EXPECT( + assetsAvail == + broker + .asset( + brokerParams.vaultDeposit - loanParams.principalRequest) + .number()); + BEAST_EXPECT( + assetsTotal == + broker.asset(brokerParams.vaultDeposit + state.interestDue) + .number()); + } + + // Step 7: Trigger default (dust adjustment will occur) + env(jtx::loan::manage(lender, loanKeylet.key, tfLoanDefault)); + env.close(); + + // Step 8: Verify phantom assets created + { + auto const vaultSle2 = env.le(vaultKeylet); + Number assetsTotal2 = vaultSle2->at(sfAssetsTotal); + Number assetsAvail2 = vaultSle2->at(sfAssetsAvailable); + + log << "After default:" << std::endl; + log << " AssetsTotal: " << assetsTotal2 << std::endl; + log << " AssetsAvailable: " << assetsAvail2 << std::endl; + log << " Difference: " << (assetsTotal2 - assetsAvail2) + << std::endl; + + // after a default the assets total and available should be equal + BEAST_EXPECT(assetsAvail2 == assetsTotal2); + } + } + + void + testRIPD3831() + { + using namespace jtx; + + testcase("RIPD-3831"); + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 100000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + // .managementFeeRate = TenthBips16{5919}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{200'000, -6}, + .lateFee = Number{200, -6}, + .interest = TenthBips32{50'000}, + .payTotal = 10, + .payInterval = 150, + .gracePd = 0}; + + auto const assetType = AssetType::XRP; + + Env env(*this, all); + + auto loanResult = createLoan( + env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (!BEAST_EXPECT(loanResult)) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + + using tp = NetClock::time_point; + using d = NetClock::duration; + + 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}}); + } + + topUpBorrower( + env, broker, issuer, borrower, state, loanParams.serviceFee); + + using namespace jtx::loan; + + auto jv = + pay(borrower, loanKeylet.key, drops(XRPAmount(state.totalValue))); + + { + 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(); + + // Make sure the system keeps responding + env(noop(borrower)); + env.close(); + env(noop(issuer)); + env.close(); + env(noop(lender)); + env.close(); + } + + void + testRIPD3459() + { + testcase("RIPD-3459 - LoanBroker incorrect debt total"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 200'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{500}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{100'000, -4}, + .interest = TenthBips32{100'000}, + .payTotal = 10, + .gracePd = 0}; + + auto const assetType = AssetType::MPT; + + Env env(*this, all); + + auto loanResult = createLoan( + env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (!BEAST_EXPECT(loanResult)) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + if (auto const brokerSle = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerSle)) + { + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + BEAST_EXPECT( + brokerSle->at(sfDebtTotal) == + loanSle->at(sfTotalValueOutstanding)); + } + } + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); + + if (auto const brokerSle = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerSle)) + { + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + BEAST_EXPECT( + brokerSle->at(sfDebtTotal) == + loanSle->at(sfTotalValueOutstanding)); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::zero); + } + } + } + + void + testRIPD3901() + { + testcase("Crash with tfLoanOverpayment"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Account const depositor{"depositor"}; + auto const txfee = fee(XRP(100)); + + Env env(*this); + Vault vault(env); + + env.fund(XRP(10'000), lender, issuer, borrower, depositor); + env.close(); + + auto [tx, vaultKeyLet] = + vault.create({.owner = lender, .asset = xrpIssue()}); + env(tx, txfee); + env.close(); + + env(vault.deposit( + {.depositor = depositor, + .id = vaultKeyLet.key, + .amount = XRP(1'000)}), + txfee); + env.close(); + + auto const brokerKeyLet = + keylet::loanbroker(lender.id(), env.seq(lender)); + + env(loanBroker::set(lender, vaultKeyLet.key), txfee); + env.close(); + + // BrokerInfo brokerInfo{xrpIssue(), keylet, vaultKeyLet, {}}; + + STAmount const debtMaximumRequest = XRPAmount(200'000); + + env(set(borrower, brokerKeyLet.key, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + interestRate(TenthBips32(50'000)), + paymentTotal(2), + paymentInterval(150), + txflags(tfLoanOverpayment), + txfee); + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerKeyLet.key, loanSequence); + + if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) + { + env(loan::pay(borrower, loanKeylet.key, XRPAmount(150'001)), + txflags(tfLoanOverpayment), + txfee); + env.close(); + } + } + + void + testRoundingAllowsUndercoverage() + { + testcase("Minimum cover rounding allows undercoverage (XRP)"); + + using namespace jtx; + using namespace loanBroker; + + Env env(*this, all); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(200'000), lender, borrower); + env.close(); + + // Vault with XRP asset + Vault vault{env}; + auto [vaultCreate, vaultKeylet] = + vault.create({.owner = lender, .asset = xrpIssue()}); + env(vaultCreate); + env.close(); + BEAST_EXPECT(env.le(vaultKeylet)); + + // Seed the vault with XRP so it can fund the loan principal + PrettyAsset const xrpAsset{xrpIssue(), 1}; + + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{10'000}, + .coverDeposit = 82, + }; + + auto const brokerInfo = + createVaultAndBroker(env, xrpAsset, lender, brokerParams); + // Create a loan with principal 804 XRP and 0% interest (so + // DebtTotal increases by exactly 804) + env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()), + loan::interestRate(TenthBips32(0)), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 2)); + BEAST_EXPECT(env.ter() == tesSUCCESS); + env.close(); + + // Verify DebtTotal is exactly 804 + if (auto const brokerSle = + env.le(keylet::loanbroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + log << *brokerSle << std::endl; + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); + } + + // Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable. + // The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails. + env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()), + ter(tecINSUFFICIENT_FUNDS)); + BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS); + env.close(); + + // Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable. + // because that leaves sufficient cover, this succeeds + env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value())); + BEAST_EXPECT(env.ter() == tesSUCCESS); + env.close(); + + // Validate CoverAvailable == 80 XRP and DebtTotal remains 804 + if (auto const brokerSle = + env.le(keylet::loanbroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + log << *brokerSle << std::endl; + BEAST_EXPECT( + brokerSle->at(sfCoverAvailable) == xrpAsset(81).value()); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); + + // Also demonstrate that the true minimum (804 * 10%) exceeds 80 + auto const theoreticalMin = + tenthBipsOfValue(Number(804), TenthBips32(10'000)); + log << "Theoretical min cover: " << theoreticalMin << std::endl; + BEAST_EXPECT(Number(804, -1) == theoreticalMin); + } + } + + void + testRIPD3902() + { + testcase("RIPD-3902 - 1 IOU loan payments"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 10, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{1, 0}, + .interest = TenthBips32{100'000}, + .payTotal = 5, + .payInterval = 150, + .gracePd = 60}; + + auto const assetType = AssetType::IOU; + + Env env(*this, all); + + auto loanResult = createLoan( + env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (!BEAST_EXPECT(loanResult)) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); + } + + void + testBorrowerIsBroker() + { + testcase("Test Borrower is Broker"); + using namespace jtx; + using namespace loan; + Account const broker{"broker"}; + Account const issuer{"issuer"}; + Account const borrower_{"borrower"}; + Account const depositor{"depositor"}; + + auto testLoanAsset = [&](auto&& getMaxDebt, auto const& borrower) { + Env env(*this); + Vault vault(env); + + if (borrower == broker) + env.fund(XRP(10'000), broker, issuer, depositor); + else + env.fund(XRP(10'000), broker, borrower, issuer, depositor); + env.close(); + + auto const xrpFee = XRP(100); + auto const txFee = fee(xrpFee); + + STAmount const debtMaximumRequest = getMaxDebt(env); + + auto const& asset = debtMaximumRequest.asset(); + auto const initialVault = asset(debtMaximumRequest * 100); + + auto [tx, vaultKeylet] = + vault.create({.owner = broker, .asset = asset}); + env(tx, txFee); + env.close(); + + env(vault.deposit( + {.depositor = depositor, + .id = vaultKeylet.key, + .amount = initialVault}), + txFee); + env.close(); + + auto const brokerKeylet = + keylet::loanbroker(broker.id(), env.seq(broker)); + + env(loanBroker::set(broker, vaultKeylet.key), txFee); + env.close(); + + auto const serviceFee = 101; + + env(set(broker, brokerKeylet.key, debtMaximumRequest), + counterparty(borrower), + sig(sfCounterpartySignature, borrower), + loanServiceFee(serviceFee), + paymentTotal(10), + txFee); + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = + keylet::loan(brokerKeylet.key, loanSequence); + + auto const brokerBalanceBefore = env.balance(broker, asset); + + if (auto const loanSle = env.le(loanKeylet); + env.test.BEAST_EXPECT(loanSle)) + { + auto const payment = loanSle->at(sfPeriodicPayment); + auto const totalPayment = payment + serviceFee; + env(loan::pay(borrower, loanKeylet.key, asset(totalPayment)), + txFee); + env.close(); + if (auto const vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle)) + { + auto const expected = [&]() { + // The service fee is transferred to the broker if + // a borrower is not the broker + if (borrower != broker) + return brokerBalanceBefore.number() + serviceFee; + // Since a borrower is the broker, the payment is + // transferred to the Vault from the broker but not + // the service fee. + // If the asset is XRP then the broker pays the txfee. + if (asset.native()) + return brokerBalanceBefore.number() - payment - + xrpFee.number(); + return brokerBalanceBefore.number() - payment; + }(); + BEAST_EXPECT( + env.balance(broker, asset).value() == + asset(expected).value()); + } + } + }; + // Test when a borrower is the broker and is not to verify correct + // service fee transfer in both cases. + for (auto const& borrowerAcct : {broker, borrower_}) + { + testLoanAsset( + [&](Env&) -> STAmount { return STAmount{XRPAmount{200'000}}; }, + borrowerAcct); + testLoanAsset( + [&](Env& env) -> STAmount { + auto const IOU = issuer["USD"]; + env(trust(broker, IOU(1'000'000'000))); + env(trust(depositor, IOU(1'000'000'000))); + env(pay(issuer, broker, IOU(100'000'000))); + env(pay(issuer, depositor, IOU(100'000'000))); + env.close(); + return IOU(200'000); + }, + borrowerAcct); + testLoanAsset( + [&](Env& env) -> STAmount { + MPTTester mpt( + {.env = env, + .issuer = issuer, + .holders = {broker, depositor}, + .pay = 100'000'000}); + return mpt(200'000); + }, + borrowerAcct); + } + } + + void + testIssuerIsBorrower() + { + testcase("RIPD-4096 - Issuer as borrower"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = issuer, + .principalRequest = Number{10000}}; + + auto const assetType = AssetType::IOU; + + Env env(*this, all); + + auto loanResult = createLoan( + env, assetType, brokerParams, loanParams, issuer, lender, issuer); + + if (!BEAST_EXPECT(loanResult)) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + issuer, + PaymentParameters{.showStepBalances = true}); + } + + void + testLimitExceeded() + { + testcase("RIPD-4125 - overpayment"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{200000, -6}, + .interest = TenthBips32{50000}, + .payTotal = 3, + .payInterval = 200, + .gracePd = 60, + .flags = tfLoanOverpayment, + }; + + auto const assetType = AssetType::XRP; + + Env env( + *this, + makeConfig(), + all, + nullptr, + beast::severities::Severity::kWarning); + + auto loanResult = createLoan( + env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (!BEAST_EXPECT(loanResult)) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + auto const state = getCurrentState(env, broker, loanKeylet); + + env(loan::pay( + borrower, + loanKeylet.key, + STAmount{broker.asset, state.periodicPayment * 3 / 2 + 1}, + tfLoanOverpayment)); + env.close(); + + PaymentParameters paymentParams{ + //.overpaymentFactor = Number{15, -1}, + //.overpaymentExtra = Number{1, -6}, + //.flags = tfLoanOverpayment, + .showStepBalances = true, + //.validateBalances = false, + }; + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + paymentParams); + } + +public: + void + run() override + { +#if LOANTODO + testLoanPayLateFullPaymentBypassesPenalties(); + testLoanCoverMinimumRoundingExploit(); +#endif + testCoverDepositWithdrawNonTransferableMPT(); + testPoC_UnsignedUnderflowOnFullPayAfterEarlyPeriodic(); + + testDisabled(); + testSelfLoan(); + testIssuerLoan(); + testLoanSet(); + testLifecycle(); + testServiceFeeOnBrokerDeepFreeze(); + + testRPC(); + testBasicMath(); + + testInvalidLoanDelete(); + testInvalidLoanManage(); + testInvalidLoanPay(); + testInvalidLoanSet(); + + testBatchBypassCounterparty(); + testLoanPayComputePeriodicPaymentValidRateInvariant(); + testAccountSendMptMinAmountInvariant(); + testLoanPayDebtDecreaseInvariant(); + testWrongMaxDebtBehavior(); + testLoanPayComputePeriodicPaymentValidTotalInterestInvariant(); + testDosLoanPay(); + testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant(); + testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant(); + testLoanNextPaymentDueDateOverflow(); + + testRequireAuth(); + testDustManipulation(); + + testRIPD3831(); + testRIPD3459(); + testRIPD3901(); + testRIPD3902(); + testRoundingAllowsUndercoverage(); + testBorrowerIsBroker(); + testIssuerIsBorrower(); + testLimitExceeded(); + } +}; + +class LoanBatch_test : public Loan_test +{ +protected: + beast::xor_shift_engine engine_; + + std::uniform_int_distribution<> assetDist{0, 2}; + std::uniform_int_distribution principalDist{ + 100'000, + 1'000'000'000}; + std::uniform_int_distribution interestRateDist{0, 10000}; + std::uniform_int_distribution<> paymentTotalDist{12, 10000}; + std::uniform_int_distribution<> paymentIntervalDist{60, 3600 * 24 * 30}; + std::uniform_int_distribution managementFeeRateDist{ + 0, + 10'000}; + std::uniform_int_distribution<> serviceFeeDist{0, 20}; + /* + # Generate parameters that are more likely to be valid + principal = Decimal(str(rand.randint(100000, + 100'000'000))).quantize(ROUND_TARGET) + + interest_rate = Decimal(rand.randint(1, 10000)) / + Decimal(100000) + + payment_total = rand.randint(12, 10000) + + payment_interval = Decimal(str(rand.randint(60, 2629746))) + + interest_fee = Decimal(rand.randint(0, 100000)) / + Decimal(100000) +*/ + + void + testRandomLoan() + { + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + // Determine all the random parameters at once + AssetType assetType = static_cast(assetDist(engine_)); + auto const principalRequest = principalDist(engine_); + TenthBips16 managementFeeRate{managementFeeRateDist(engine_)}; + auto const serviceFee = serviceFeeDist(engine_); + TenthBips32 interest{interestRateDist(engine_)}; + auto const payTotal = paymentTotalDist(engine_); + auto const payInterval = paymentIntervalDist(engine_); + + BrokerParameters brokerParams{ + .vaultDeposit = principalRequest * 10, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = managementFeeRate}; + LoanParameters loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = principalRequest, + .serviceFee = serviceFee, + .interest = interest, + .payTotal = payTotal, + .payInterval = payInterval, + }; + + runLoan(assetType, brokerParams, loanParams); + } + +public: + void + run() override + { + auto const argument = arg(); + auto const numIterations = [s = arg()]() -> int { + int defaultNum = 5; + if (s.empty()) + return defaultNum; + try + { + std::size_t pos; + auto const r = stoi(s, &pos); + if (pos != s.size()) + return defaultNum; + return r; + } + catch (...) + { + return defaultNum; + } + }(); + + using namespace jtx; + + auto const updateInterval = std::min(numIterations / 5, 100); + + for (int i = 0; i < numIterations; ++i) + { + if (i % updateInterval == 0) + testcase << "Random Loan Test iteration " << (i + 1) << "/" + << numIterations; + testRandomLoan(); + } + } +}; + +class LoanArbitrary_test : public LoanBatch_test +{ + void + run() override + { + using namespace jtx; + + BrokerParameters const brokerParams{ + .vaultDeposit = 10000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + // .managementFeeRate = TenthBips16{5919}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = Account("lender"), + .counter = Account("borrower"), + .principalRequest = Number{10000, 0}, + // .interest = TenthBips32{0}, + // .payTotal = 5816, + .payInterval = 150}; + + runLoan(AssetType::XRP, brokerParams, loanParams); + } +}; + +BEAST_DEFINE_TESTSUITE(Loan, tx, ripple); +BEAST_DEFINE_TESTSUITE_MANUAL(LoanBatch, tx, ripple); +BEAST_DEFINE_TESTSUITE_MANUAL(LoanArbitrary, tx, ripple); + +} // namespace test +} // namespace ripple diff --git a/src/test/app/MPToken_test.cpp b/src/test/app/MPToken_test.cpp index e9740e67dee..4b8eb871a2f 100644 --- a/src/test/app/MPToken_test.cpp +++ b/src/test/app/MPToken_test.cpp @@ -1171,7 +1171,7 @@ class MPToken_test : public beast::unit_test::suite env(credentials::accept(bob, credIssuer1, credType)); env.close(); - MPTTester mptAlice(env, alice, {}); + MPTTester mptAlice(env, alice); env.close(); mptAlice.create({ @@ -1213,7 +1213,7 @@ class MPToken_test : public beast::unit_test::suite env(credentials::accept(bob, credIssuer1, credType)); env.close(); - MPTTester mptAlice(env, alice, {}); + MPTTester mptAlice(env, alice); env.close(); mptAlice.create({ @@ -1293,7 +1293,7 @@ class MPToken_test : public beast::unit_test::suite env(credentials::accept(carol, credIssuer2, credType)); env.close(); - MPTTester mptAlice(env, alice, {}); + MPTTester mptAlice(env, alice); env.close(); mptAlice.create({ diff --git a/src/test/app/NFToken_test.cpp b/src/test/app/NFToken_test.cpp index 1c4314643cb..a10c379d9c9 100644 --- a/src/test/app/NFToken_test.cpp +++ b/src/test/app/NFToken_test.cpp @@ -7273,7 +7273,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::suite mintAndCreateSellOffer(env, alice, XRP(0)); // Bob can accept the offer because the new NFT is stored in - // an existing NFTokenPage so no new reserve is requried + // an existing NFTokenPage so no new reserve is required env(token::acceptSellOffer(bob, sellOfferIndex)); env.close(); } diff --git a/src/test/app/NetworkOPs_test.cpp b/src/test/app/NetworkOPs_test.cpp index edea55105b5..3965778221a 100644 --- a/src/test/app/NetworkOPs_test.cpp +++ b/src/test/app/NetworkOPs_test.cpp @@ -1,22 +1,3 @@ -//------------------------------------------------------------------------------ -/* - This file is part of rippled: https://github.com/ripple/rippled - Copyright (c) 2020 Dev Null Productions - - Permission to use, copy, modify, and/or distribute this software for any - purpose with or without fee is hereby granted, provided that the above - copyright notice and this permission notice appear in all copies. - - THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES - WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF - MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR - ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES - WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN - ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF - OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -*/ -//============================================================================== - #include #include #include @@ -38,7 +19,7 @@ class NetworkOPs_test : public beast::unit_test::suite void testAllBadHeldTransactions() { - // All trasactions are already marked as SF_BAD, and we should be able + // All transactions are already marked as SF_BAD, and we should be able // to handle the case properly without an assertion failure testcase("No valid transactions in batch"); diff --git a/src/test/app/PayChan_test.cpp b/src/test/app/PayChan_test.cpp index 595a12aed91..b16e0d8d750 100644 --- a/src/test/app/PayChan_test.cpp +++ b/src/test/app/PayChan_test.cpp @@ -2150,7 +2150,7 @@ struct PayChan_test : public beast::unit_test::suite // Owner closes, will close after settleDelay env(claim(alice, chan), txflags(tfClose)); env.close(); - // settle delay hasn't ellapsed. Channels should exist. + // settle delay hasn't elapsed. Channels should exist. BEAST_EXPECT(channelExists(*env.current(), chan)); auto const closeTime = env.current()->info().parentCloseTime; auto const minExpiration = closeTime + settleDelay; diff --git a/src/test/app/PermissionedDEX_test.cpp b/src/test/app/PermissionedDEX_test.cpp index 80c75a2daf8..0b1c3bef251 100644 --- a/src/test/app/PermissionedDEX_test.cpp +++ b/src/test/app/PermissionedDEX_test.cpp @@ -985,8 +985,8 @@ class PermissionedDEX_test : public beast::unit_test::suite { testcase("Remove unfunded offer"); - // checking that an unfunded offer will be implictly removed by a - // successfuly payment tx + // checking that an unfunded offer will be implicitly removed by a + // successful payment tx Env env(*this, features); auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = PermissionedDEX(env); diff --git a/src/test/app/ValidatorList_test.cpp b/src/test/app/ValidatorList_test.cpp index 2b004c3b521..ae69a4ed0dc 100644 --- a/src/test/app/ValidatorList_test.cpp +++ b/src/test/app/ValidatorList_test.cpp @@ -1759,7 +1759,7 @@ class ValidatorList_test : public beast::unit_test::suite // locals[0]: from 0 to maxKeys - 4 // locals[1]: from 1 to maxKeys - 2 // locals[2]: from 2 to maxKeys - // interesection of at least 2: same as locals[1] + // intersection of at least 2: same as locals[1] // intersection when 1 is dropped: from 2 to maxKeys - 4 constexpr static int publishers = 3; std::array< diff --git a/src/test/app/ValidatorSite_test.cpp b/src/test/app/ValidatorSite_test.cpp index 840a6cdb431..47ac7077490 100644 --- a/src/test/app/ValidatorSite_test.cpp +++ b/src/test/app/ValidatorSite_test.cpp @@ -591,7 +591,7 @@ class ValidatorSite_test : public beast::unit_test::suite false, true, 1, - std::chrono::seconds{Json::Value::maxInt + 1}}}); + std::chrono::seconds{Json::Value::minInt}}}); // force an out-of-range validUntil value on the future list // The first list is accepted. The second fails. The parser // returns the "best" result, so this looks like a success. @@ -627,7 +627,7 @@ class ValidatorSite_test : public beast::unit_test::suite false, true, 1, - std::chrono::seconds{Json::Value::maxInt + 1}, + std::chrono::seconds{Json::Value::minInt}, std::chrono::seconds{Json::Value::maxInt - 6000}}}); // verify refresh intervals are properly clamped testFetchList( diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 3488ccf458f..99e1ad2063f 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -21,6 +21,8 @@ #include #include #include +#include +#include #include #include @@ -88,14 +90,14 @@ class Vault_test : public beast::unit_test::suite this]() -> std::tuple { auto const vault = env.le(keylet); BEAST_EXPECT(vault != nullptr); - if (asset.raw().holds() && !asset.raw().native()) + if (!asset.integral()) BEAST_EXPECT(vault->at(sfScale) == 6); else BEAST_EXPECT(vault->at(sfScale) == 0); auto const shares = env.le(keylet::mptIssuance(vault->at(sfShareMPTID))); BEAST_EXPECT(shares != nullptr); - if (asset.raw().holds() && !asset.raw().native()) + if (!asset.integral()) BEAST_EXPECT(shares->at(sfAssetScale) == 6); else BEAST_EXPECT(shares->at(sfAssetScale) == 0); @@ -521,7 +523,7 @@ class Vault_test : public beast::unit_test::suite } } - if (!asset.raw().native() && asset.raw().holds()) + if (!asset.integral()) { testcase(prefix + " temporary authorization for 3rd party"); env(trust(erin, asset(1000))); @@ -689,12 +691,13 @@ class Vault_test : public beast::unit_test::suite test(env, issuer, owner, asset, vault); }; - testCase( - [&](Env& env, - Account const& issuer, - Account const& owner, - Asset const& asset, - Vault& vault) { + auto testDisabled = [&](TER resultAfterCreate = temDISABLED) { + return [&, resultAfterCreate]( + Env& env, + Account const& issuer, + Account const& owner, + Asset const& asset, + Vault& vault) { testcase("disabled single asset vault"); auto [tx, keylet] = @@ -703,7 +706,7 @@ class Vault_test : public beast::unit_test::suite { auto tx = vault.set({.owner = owner, .id = keylet.key}); - env(tx, ter{temDISABLED}); + env(tx, data("test"), ter{resultAfterCreate}); } { @@ -711,7 +714,7 @@ class Vault_test : public beast::unit_test::suite {.depositor = owner, .id = keylet.key, .amount = asset(10)}); - env(tx, ter{temDISABLED}); + env(tx, ter{resultAfterCreate}); } { @@ -719,7 +722,7 @@ class Vault_test : public beast::unit_test::suite {.depositor = owner, .id = keylet.key, .amount = asset(10)}); - env(tx, ter{temDISABLED}); + env(tx, ter{resultAfterCreate}); } { @@ -728,15 +731,49 @@ class Vault_test : public beast::unit_test::suite .id = keylet.key, .holder = owner, .amount = asset(10)}); - env(tx, ter{temDISABLED}); + env(tx, ter{resultAfterCreate}); } { auto tx = vault.del({.owner = owner, .id = keylet.key}); + env(tx, ter{resultAfterCreate}); + } + }; + }; + + testCase( + testDisabled(), + {.features = testable_amendments() - featureSingleAssetVault}); + + testCase( + testDisabled(tecNO_ENTRY), + {.features = testable_amendments() - featureMPTokensV1}); + + testCase( + [&](Env& env, + Account const& issuer, + Account const& owner, + Asset const& asset, + Vault& vault) { + testcase("disabled permissioned domains"); + + auto [tx, keylet] = + vault.create({.owner = owner, .asset = asset}); + env(tx); + + tx[sfFlags] = tx[sfFlags].asUInt() | tfVaultPrivate; + tx[sfDomainID] = to_string(base_uint<256>(42ul)); + env(tx, ter{temDISABLED}); + + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + env(tx, data("Test")); + + tx[sfDomainID] = to_string(base_uint<256>(13ul)); env(tx, ter{temDISABLED}); } }, - {.features = testable_amendments() - featureSingleAssetVault}); + {.features = testable_amendments() - featurePermissionedDomains}); testCase([&](Env& env, Account const& issuer, @@ -1348,7 +1385,7 @@ class Vault_test : public beast::unit_test::suite Vault& vault) { auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); testcase("insufficient fee"); - env(tx, fee(env.current()->fees().base), ter(telINSUF_FEE_P)); + env(tx, fee(env.current()->fees().base - 1), ter(telINSUF_FEE_P)); }); testCase([this]( @@ -1749,7 +1786,8 @@ class Vault_test : public beast::unit_test::suite mptt.create( {.flags = tfMPTCanTransfer | tfMPTCanLock | (args.enableClawback ? tfMPTCanClawback : none) | - (args.requireAuth ? tfMPTRequireAuth : none)}); + (args.requireAuth ? tfMPTRequireAuth : none), + .mutableFlags = tmfMPTCanMutateCanTransfer}); PrettyAsset asset = mptt.issuanceID(); mptt.authorize({.account = owner}); mptt.authorize({.account = depositor}); @@ -2093,6 +2131,10 @@ class Vault_test : public beast::unit_test::suite auto const sleMPT = env.le(mptoken); BEAST_EXPECT(sleMPT == nullptr); + // Use one reserve so the next transaction fails + env(ticket::create(owner, 1)); + env.close(); + // No reserve to create MPToken for asset in VaultWithdraw tx = vault.withdraw( {.depositor = owner, @@ -2110,7 +2152,7 @@ class Vault_test : public beast::unit_test::suite } }, {.requireAuth = false, - .initialXRP = acctReserve + incReserve * 4 - 1}); + .initialXRP = acctReserve + incReserve * 4 + 1}); testCase([this]( Env& env, @@ -2463,6 +2505,53 @@ class Vault_test : public beast::unit_test::suite env(tx2, ter{tecWRONG_ASSET}); env.close(); } + + testCase([this]( + Env& env, + Account const&, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT non-transferable"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + tx = vault.deposit( + {.depositor = depositor, + .id = keylet.key, + .amount = asset(100)}); + env(tx); + env.close(); + + // Remove CanTransfer + mptt.set({.mutableFlags = tmfMPTClearCanTransfer}); + env.close(); + + env(tx, ter{tecNO_AUTH}); + env.close(); + + tx = vault.withdraw( + {.depositor = depositor, + .id = keylet.key, + .amount = asset(100)}); + + env(tx, ter{tecNO_AUTH}); + env.close(); + + // Restore CanTransfer + mptt.set({.mutableFlags = tmfMPTSetCanTransfer}); + env.close(); + + env(tx); + env.close(); + + // Delete vault with zero balance + env(vault.del({.owner = owner, .id = keylet.key})); + }); } void @@ -2475,6 +2564,7 @@ class Vault_test : public beast::unit_test::suite int initialXRP = 1000; Number initialIOU = 200; double transferRate = 1.0; + bool charlieRipple = true; }; auto testCase = @@ -2500,8 +2590,21 @@ class Vault_test : public beast::unit_test::suite PrettyAsset const asset = issuer["IOU"]; env.trust(asset(1000), owner); - env.trust(asset(1000), charlie); env(pay(issuer, owner, asset(args.initialIOU))); + env.close(); + if (!args.charlieRipple) + { + env(fset(issuer, 0, asfDefaultRipple)); + env.close(); + env.trust(asset(1000), charlie); + env.close(); + env(pay(issuer, charlie, asset(args.initialIOU))); + env.close(); + env(fset(issuer, asfDefaultRipple)); + } + else + env.trust(asset(1000), charlie); + env.close(); env(rate(issuer, args.transferRate)); env.close(); @@ -2879,6 +2982,94 @@ class Vault_test : public beast::unit_test::suite env(tx1); }); + testCase( + [&, this]( + Env& env, + Account const& owner, + Account const& issuer, + Account const& charlie, + auto vaultAccount, + Vault& vault, + PrettyAsset const& asset, + std::function issuanceId) { + testcase("IOU non-transferable"); + + auto [tx, keylet] = + vault.create({.owner = owner, .asset = asset}); + tx[sfScale] = 0; + env(tx); + env.close(); + + // Turn on noripple on the pseudo account's trust line. + // Charlie's is already set. + env(trust(issuer, vaultAccount(keylet)["IOU"], tfSetNoRipple), + THISLINE); + + { + // Charlie cannot deposit + auto tx = vault.deposit( + {.depositor = charlie, + .id = keylet.key, + .amount = asset(100)}); + env(tx, ter{terNO_RIPPLE}, THISLINE); + env.close(); + } + + { + PrettyAsset shares = issuanceId(keylet); + auto tx1 = vault.deposit( + {.depositor = owner, + .id = keylet.key, + .amount = asset(100)}); + env(tx1, THISLINE); + env.close(); + + // Charlie cannot receive funds + auto tx2 = vault.withdraw( + {.depositor = owner, + .id = keylet.key, + .amount = shares(100)}); + tx2[sfDestination] = charlie.human(); + env(tx2, ter{terNO_RIPPLE}, THISLINE); + env.close(); + + { + // Create MPToken for shares held by Charlie + Json::Value tx{Json::objectValue}; + tx[sfAccount] = charlie.human(); + tx[sfMPTokenIssuanceID] = + to_string(shares.raw().get().getMptID()); + tx[sfTransactionType] = jss::MPTokenAuthorize; + env(tx); + env.close(); + } + env(pay(owner, charlie, shares(100)), THISLINE); + env.close(); + + // Charlie cannot withdraw + auto tx3 = vault.withdraw( + {.depositor = charlie, + .id = keylet.key, + .amount = shares(100)}); + env(tx3, ter{terNO_RIPPLE}); + env.close(); + + env(pay(charlie, owner, shares(100)), THISLINE); + env.close(); + } + + tx = vault.withdraw( + {.depositor = owner, + .id = keylet.key, + .amount = asset(100)}); + env(tx, THISLINE); + env.close(); + + // Delete vault with zero balance + env(vault.del({.owner = owner, .id = keylet.key}), THISLINE); + }, + {.charlieRipple = false}); + testCase( [&, this]( Env& env, @@ -2999,6 +3190,9 @@ class Vault_test : public beast::unit_test::suite env.le(keylet::line(owner, asset.raw().get())); BEAST_EXPECT(trustline == nullptr); + env(ticket::create(owner, 1)); + env.close(); + // Fail because not enough reserve to create trust line tx = vault.withdraw( {.depositor = owner, @@ -3014,7 +3208,7 @@ class Vault_test : public beast::unit_test::suite env(tx); env.close(); }, - CaseArgs{.initialXRP = acctReserve + incReserve * 4 - 1}); + CaseArgs{.initialXRP = acctReserve + incReserve * 4 + 1}); testCase( [&, this]( @@ -3035,8 +3229,7 @@ class Vault_test : public beast::unit_test::suite env(pay(owner, charlie, asset(100))); env.close(); - // Use up some reserve on tickets - env(ticket::create(charlie, 2)); + env(ticket::create(charlie, 3)); env.close(); // Fail because not enough reserve to create MPToken for shares @@ -3054,7 +3247,7 @@ class Vault_test : public beast::unit_test::suite env(tx); env.close(); }, - CaseArgs{.initialXRP = acctReserve + incReserve * 4 - 1}); + CaseArgs{.initialXRP = acctReserve + incReserve * 4 + 1}); testCase([&, this]( Env& env, @@ -4538,7 +4731,7 @@ class Vault_test : public beast::unit_test::suite BEAST_EXPECT(checkString(vault, sfAssetsAvailable, "50")); BEAST_EXPECT(checkString(vault, sfAssetsMaximum, "1000")); BEAST_EXPECT(checkString(vault, sfAssetsTotal, "50")); - BEAST_EXPECT(checkString(vault, sfLossUnrealized, "0")); + BEAST_EXPECT(!vault.isMember(sfLossUnrealized.getJsonName())); auto const strShareID = strHex(sle->at(sfShareMPTID)); BEAST_EXPECT(checkString(vault, sfShareMPTID, strShareID)); diff --git a/src/test/app/tx/apply_test.cpp b/src/test/app/tx/apply_test.cpp index a754866c7ff..0348f01c8e4 100644 --- a/src/test/app/tx/apply_test.cpp +++ b/src/test/app/tx/apply_test.cpp @@ -66,7 +66,7 @@ class Apply_test : public beast::unit_test::suite .first; if (valid != Validity::Valid) - fail("Non-Fully canoncial signature was not permitted"); + fail("Non-Fully canonical signature was not permitted"); } { @@ -80,7 +80,7 @@ class Apply_test : public beast::unit_test::suite fully_canonical.app().config()) .first; if (valid == Validity::Valid) - fail("Non-Fully canoncial signature was permitted"); + fail("Non-Fully canonical signature was permitted"); } pass(); diff --git a/src/test/basics/Number_test.cpp b/src/test/basics/Number_test.cpp index f24c0b35e1f..acebf86cc56 100644 --- a/src/test/basics/Number_test.cpp +++ b/src/test/basics/Number_test.cpp @@ -744,6 +744,115 @@ class Number_test : public beast::unit_test::suite BEAST_EXPECT(Number(-100, -30000).truncate() == Number(0, 0)); } + void + testRounding() + { + // Test that rounding works as expected. + testcase("Rounding"); + + using NumberRoundings = std::map; + + std::map const expected{ + // Positive numbers + {Number{13, -1}, + {{Number::to_nearest, 1}, + {Number::towards_zero, 1}, + {Number::downward, 1}, + {Number::upward, 2}}}, + {Number{23, -1}, + {{Number::to_nearest, 2}, + {Number::towards_zero, 2}, + {Number::downward, 2}, + {Number::upward, 3}}}, + {Number{15, -1}, + {{Number::to_nearest, 2}, + {Number::towards_zero, 1}, + {Number::downward, 1}, + {Number::upward, 2}}}, + {Number{25, -1}, + {{Number::to_nearest, 2}, + {Number::towards_zero, 2}, + {Number::downward, 2}, + {Number::upward, 3}}}, + {Number{152, -2}, + {{Number::to_nearest, 2}, + {Number::towards_zero, 1}, + {Number::downward, 1}, + {Number::upward, 2}}}, + {Number{252, -2}, + {{Number::to_nearest, 3}, + {Number::towards_zero, 2}, + {Number::downward, 2}, + {Number::upward, 3}}}, + {Number{17, -1}, + {{Number::to_nearest, 2}, + {Number::towards_zero, 1}, + {Number::downward, 1}, + {Number::upward, 2}}}, + {Number{27, -1}, + {{Number::to_nearest, 3}, + {Number::towards_zero, 2}, + {Number::downward, 2}, + {Number::upward, 3}}}, + + // Negative numbers + {Number{-13, -1}, + {{Number::to_nearest, -1}, + {Number::towards_zero, -1}, + {Number::downward, -2}, + {Number::upward, -1}}}, + {Number{-23, -1}, + {{Number::to_nearest, -2}, + {Number::towards_zero, -2}, + {Number::downward, -3}, + {Number::upward, -2}}}, + {Number{-15, -1}, + {{Number::to_nearest, -2}, + {Number::towards_zero, -1}, + {Number::downward, -2}, + {Number::upward, -1}}}, + {Number{-25, -1}, + {{Number::to_nearest, -2}, + {Number::towards_zero, -2}, + {Number::downward, -3}, + {Number::upward, -2}}}, + {Number{-152, -2}, + {{Number::to_nearest, -2}, + {Number::towards_zero, -1}, + {Number::downward, -2}, + {Number::upward, -1}}}, + {Number{-252, -2}, + {{Number::to_nearest, -3}, + {Number::towards_zero, -2}, + {Number::downward, -3}, + {Number::upward, -2}}}, + {Number{-17, -1}, + {{Number::to_nearest, -2}, + {Number::towards_zero, -1}, + {Number::downward, -2}, + {Number::upward, -1}}}, + {Number{-27, -1}, + {{Number::to_nearest, -3}, + {Number::towards_zero, -2}, + {Number::downward, -3}, + {Number::upward, -2}}}, + }; + + for (auto const& [num, roundings] : expected) + { + for (auto const& [mode, val] : roundings) + { + NumberRoundModeGuard g{mode}; + auto const res = static_cast(num); + BEAST_EXPECTS( + res == val, + to_string(num) + " with mode " + std::to_string(mode) + + " expected " + std::to_string(val) + " got " + + std::to_string(res)); + } + } + } + void run() override { @@ -765,6 +874,7 @@ class Number_test : public beast::unit_test::suite test_inc_dec(); test_toSTAmount(); test_truncate(); + testRounding(); } }; diff --git a/src/test/core/Config_test.cpp b/src/test/core/Config_test.cpp index a1a6a079cc0..029fc7e95bc 100644 --- a/src/test/core/Config_test.cpp +++ b/src/test/core/Config_test.cpp @@ -1202,7 +1202,7 @@ r.ripple.com:51235 BEAST_EXPECT(cfg.IPS_FIXED[6] == "12.34.12.123 12345"); BEAST_EXPECT(cfg.IPS_FIXED[7] == "12.34.12.123 12345"); - // all ipv6 should be ignored by colon replacer, howsoever formated + // all ipv6 should be ignored by colon replacer, howsoever formatted BEAST_EXPECT(cfg.IPS_FIXED[8] == "::"); BEAST_EXPECT(cfg.IPS_FIXED[9] == "2001:db8::"); BEAST_EXPECT(cfg.IPS_FIXED[10] == "::1"); diff --git a/src/test/core/SociDB_test.cpp b/src/test/core/SociDB_test.cpp index 9a3666f072e..7d0ffd161e4 100644 --- a/src/test/core/SociDB_test.cpp +++ b/src/test/core/SociDB_test.cpp @@ -98,7 +98,7 @@ class SociDB_test final : public TestSuite void testSQLiteFileNames() { - // confirm that files are given the correct exensions + // confirm that files are given the correct extensions testcase("sqliteFileNames"); BasicConfig c; setupSQLiteConfig(c, getDatabasePath()); diff --git a/src/test/csf/Peer.h b/src/test/csf/Peer.h index 1cb2d03cc6d..6808a4d15eb 100644 --- a/src/test/csf/Peer.h +++ b/src/test/csf/Peer.h @@ -423,7 +423,7 @@ struct Peer { minDuration = std::min(minDuration, link.data.delay); - // Send a messsage to neighbors to find the ledger + // Send a message to neighbors to find the ledger net.send( this, link.target, [to = link.target, from = this, ledgerID]() { if (auto it = to->ledgers.find(ledgerID); diff --git a/src/test/csf/collectors.h b/src/test/csf/collectors.h index 0494178ae9d..b7fe2cfb34b 100644 --- a/src/test/csf/collectors.h +++ b/src/test/csf/collectors.h @@ -47,7 +47,7 @@ namespace csf { /** Group of collectors. Presents a group of collectors as a single collector which process an event - by calling each collector sequentially. This is analagous to CollectorRefs + by calling each collector sequentially. This is analogous to CollectorRefs in CollectorRef.h, but does *not* erase the type information of the combined collectors. */ diff --git a/src/test/csf/events.h b/src/test/csf/events.h index 2209a10f60c..17e3561d3cf 100644 --- a/src/test/csf/events.h +++ b/src/test/csf/events.h @@ -32,7 +32,7 @@ namespace test { namespace csf { // Events are emitted by peers at a variety of points during the simulation. -// Each event is emitted by a particlar peer at a particular time. Collectors +// Each event is emitted by a particular peer at a particular time. Collectors // process these events, perhaps calculating statistics or storing events to // a log for post-processing. // diff --git a/src/test/csf/ledgers.h b/src/test/csf/ledgers.h index 45e255ffd5b..63fdc02730b 100644 --- a/src/test/csf/ledgers.h +++ b/src/test/csf/ledgers.h @@ -54,7 +54,7 @@ namespace csf { Ledgers are immutable value types. All ledgers with the same sequence number, transactions, close time, etc. will have the same ledger ID. The - LedgerOracle class below manges ID assignments for a simulation and is the + LedgerOracle class below manages ID assignments for a simulation and is the only way to close and create a new ledger. Since the parent ledger ID is part of type, this also means ledgers with distinct histories will have distinct ids, even if they have the same set of transactions, sequence diff --git a/src/test/jtx.h b/src/test/jtx.h index 3d3a4f41f83..b53d1ec152d 100644 --- a/src/test/jtx.h +++ b/src/test/jtx.h @@ -71,6 +71,7 @@ #include #include #include +#include #include #include #include diff --git a/src/test/jtx/Env.h b/src/test/jtx/Env.h index 68d8d3e53f3..69f5f968c28 100644 --- a/src/test/jtx/Env.h +++ b/src/test/jtx/Env.h @@ -251,7 +251,9 @@ class Env * * @param suite_ the current unit_test::suite */ - Env(beast::unit_test::suite& suite_) : Env(suite_, envconfig()) + Env(beast::unit_test::suite& suite_, + beast::severities::Severity thresh = beast::severities::kError) + : Env(suite_, envconfig(), nullptr, thresh) { } diff --git a/src/test/jtx/JTx.h b/src/test/jtx/JTx.h index 198839dd28b..054587be719 100644 --- a/src/test/jtx/JTx.h +++ b/src/test/jtx/JTx.h @@ -54,7 +54,14 @@ struct JTx bool fill_sig = true; bool fill_netid = true; std::shared_ptr stx; - std::function signer; + // Functions that sign the transaction from the Account + std::vector> mainSigners; + // Functions that sign something else after the mainSigners, such as + // sfCounterpartySignature + std::vector> postSigners; + // Metadata about the unit test itself + // The line where the JTx was constructed + std::optional testLine = std::nullopt; JTx() = default; JTx(JTx const&) = default; diff --git a/src/test/jtx/TestHelpers.h b/src/test/jtx/TestHelpers.h index a7bdbb9b9f2..d87c955e305 100644 --- a/src/test/jtx/TestHelpers.h +++ b/src/test/jtx/TestHelpers.h @@ -177,6 +177,29 @@ struct accountIDField : public JTxField } }; +struct stAmountField : public JTxField +{ + using SF = SF_AMOUNT; + using SV = STAmount; + using OV = Json::Value; + using base = JTxField; + +protected: + using base::value_; + +public: + explicit stAmountField(SF const& sfield, SV const& value) + : JTxField(sfield, value) + { + } + + OV + value() const override + { + return value_.getJson(JsonOptions::none); + } +}; + struct blobField : public JTxField { using SF = SF_VL; @@ -292,6 +315,8 @@ using simpleField = JTxFieldWrapper>; */ auto const data = JTxFieldWrapper(sfData); +auto const amount = JTxFieldWrapper(sfAmount); + // TODO We only need this long "requires" clause as polyfill, for C++20 // implementations which are missing header. Replace with // `std::ranges::range`, and accordingly use std::ranges::begin/end @@ -618,7 +643,7 @@ create( } // namespace check -static constexpr FeeLevel64 baseFeeLevel{256}; +static constexpr FeeLevel64 baseFeeLevel{TxQ::baseLevel}; static constexpr FeeLevel64 minEscalationFeeLevel = baseFeeLevel * 500; template @@ -714,6 +739,110 @@ checkMetrics( line); } +/* LoanBroker */ +/******************************************************************************/ + +namespace loanBroker { + +Json::Value +set(AccountID const& account, uint256 const& vaultId, std::uint32_t flags = 0); + +// Use "del" because "delete" is a reserved word in C++. +Json::Value +del(AccountID const& account, uint256 const& brokerID, std::uint32_t flags = 0); + +Json::Value +coverDeposit( + AccountID const& account, + uint256 const& brokerID, + STAmount const& amount, + std::uint32_t flags = 0); + +Json::Value +coverWithdraw( + AccountID const& account, + uint256 const& brokerID, + STAmount const& amount, + std::uint32_t flags = 0); + +// Must specify at least one of loanBrokerID or amount. +Json::Value +coverClawback(AccountID const& account, std::uint32_t flags = 0); + +auto const loanBrokerID = JTxFieldWrapper(sfLoanBrokerID); + +auto const managementFeeRate = + valueUnitWrapper(sfManagementFeeRate); + +auto const debtMaximum = simpleField(sfDebtMaximum); + +auto const coverRateMinimum = + valueUnitWrapper(sfCoverRateMinimum); + +auto const coverRateLiquidation = + valueUnitWrapper(sfCoverRateLiquidation); + +auto const destination = JTxFieldWrapper(sfDestination); + +} // namespace loanBroker + +/* Loan */ +/******************************************************************************/ +namespace loan { + +Json::Value +set(AccountID const& account, + uint256 const& loanBrokerID, + Number principalRequested, + std::uint32_t flags = 0); + +auto const counterparty = JTxFieldWrapper(sfCounterparty); + +// For `CounterPartySignature`, use `sig(sfCounterpartySignature, ...)` + +auto const loanOriginationFee = simpleField(sfLoanOriginationFee); + +auto const loanServiceFee = simpleField(sfLoanServiceFee); + +auto const latePaymentFee = simpleField(sfLatePaymentFee); + +auto const closePaymentFee = simpleField(sfClosePaymentFee); + +auto const overpaymentFee = + valueUnitWrapper(sfOverpaymentFee); + +auto const interestRate = + valueUnitWrapper(sfInterestRate); + +auto const lateInterestRate = + valueUnitWrapper(sfLateInterestRate); + +auto const closeInterestRate = + valueUnitWrapper(sfCloseInterestRate); + +auto const overpaymentInterestRate = + valueUnitWrapper(sfOverpaymentInterestRate); + +auto const paymentTotal = simpleField(sfPaymentTotal); + +auto const paymentInterval = simpleField(sfPaymentInterval); + +auto const gracePeriod = simpleField(sfGracePeriod); + +Json::Value +manage(AccountID const& account, uint256 const& loanID, std::uint32_t flags); + +Json::Value +del(AccountID const& account, uint256 const& loanID, std::uint32_t flags = 0); + +Json::Value +pay(AccountID const& account, + uint256 const& loanID, + STAmount const& amount, + std::uint32_t flags = 0); + +} // namespace loan + } // namespace jtx } // namespace test } // namespace ripple diff --git a/src/test/jtx/amount.h b/src/test/jtx/amount.h index a793f3a2875..81a452559de 100644 --- a/src/test/jtx/amount.h +++ b/src/test/jtx/amount.h @@ -213,14 +213,16 @@ struct PrettyAsset template PrettyAmount - operator()(T v) const + operator()(T v, Number::rounding_mode rounding = Number::getround()) const { - return operator()(Number(v)); + return operator()(Number(v), rounding); } PrettyAmount - operator()(Number v) const + operator()(Number v, Number::rounding_mode rounding = Number::getround()) + const { + NumberRoundModeGuard mg(rounding); STAmount amount{asset_, v * scale_}; return {amount, ""}; } @@ -230,6 +232,25 @@ struct PrettyAsset { return {asset_}; } + + bool + integral() const + { + return asset_.integral(); + } + + bool + native() const + { + return asset_.native(); + } + + template + bool + holds() const + { + return asset_.holds(); + } }; //------------------------------------------------------------------------------ @@ -260,7 +281,7 @@ struct XRP_t } /** Returns an amount of XRP as PrettyAmount, - which is trivially convertable to STAmount + which is trivially convertible to STAmount @param v The number of XRP (not drops) */ @@ -274,6 +295,21 @@ struct XRP_t return {TOut{v} * dropsPerXRP}; } + /** Returns an amount of XRP as PrettyAmount, + which is trivially convertable to STAmount + + @param v The Number of XRP (not drops). May be fractional. + */ + PrettyAmount + operator()(Number v) const + { + auto const c = dropsPerXRP.drops(); + auto const d = std::int64_t(v * c); + if (Number(d) / c != v) + Throw("unrepresentable"); + return {d}; + } + PrettyAmount operator()(double v) const { diff --git a/src/test/jtx/batch.h b/src/test/jtx/batch.h index ab235c293fa..2baa52fd066 100644 --- a/src/test/jtx/batch.h +++ b/src/test/jtx/batch.h @@ -68,8 +68,7 @@ class inner inner( Json::Value const& txn, std::uint32_t const& sequence, - std::optional const& ticket = std::nullopt, - std::optional const& fee = std::nullopt) + std::optional const& ticket = std::nullopt) : txn_(txn), seq_(sequence), ticket_(ticket) { txn_[jss::SigningPubKey] = ""; diff --git a/src/test/jtx/envconfig.h b/src/test/jtx/envconfig.h index 432ef28ff6b..16e515ec450 100644 --- a/src/test/jtx/envconfig.h +++ b/src/test/jtx/envconfig.h @@ -25,7 +25,7 @@ namespace ripple { namespace test { -// frequently used macros defined here for convinience. +// frequently used macros defined here for convenience. #define PORT_WS "port_ws" #define PORT_RPC "port_rpc" #define PORT_PEER "port_peer" diff --git a/src/test/jtx/impl/Env.cpp b/src/test/jtx/impl/Env.cpp index f17f6cb39d5..cb88313461d 100644 --- a/src/test/jtx/impl/Env.cpp +++ b/src/test/jtx/impl/Env.cpp @@ -34,6 +34,7 @@ #include #include +#include #include #include #include @@ -445,14 +446,16 @@ Env::postconditions( ParsedResult const& parsed, Json::Value const& jr) { - bool bad = !test.expect(parsed.ter, "apply: No ter result!"); + auto const line = jt.testLine ? " (" + to_string(*jt.testLine) + ")" : ""; + bool bad = !test.expect(parsed.ter, "apply: No ter result!" + line); bad = (jt.ter && parsed.ter && !test.expect( *parsed.ter == *jt.ter, "apply: Got " + transToken(*parsed.ter) + " (" + transHuman(*parsed.ter) + "); Expected " + - transToken(*jt.ter) + " (" + transHuman(*jt.ter) + ")")); + transToken(*jt.ter) + " (" + transHuman(*jt.ter) + ")" + + line)); using namespace std::string_literals; bad = (jt.rpcCode && !test.expect( @@ -464,21 +467,21 @@ Env::postconditions( : "NO RESULT") + " (" + parsed.rpcMessage + "); Expected " + RPC::get_error_info(jt.rpcCode->first).token.c_str() + " (" + - jt.rpcCode->second + ")")) || + jt.rpcCode->second + ")" + line)) || bad; // If we have an rpcCode (just checked), then the rpcException check is // optional - the 'error' field may not be defined, but if it is, it must // match rpcError. - bad = - (jt.rpcException && - !test.expect( - (jt.rpcCode && parsed.rpcError.empty()) || - (parsed.rpcError == jt.rpcException->first && - (!jt.rpcException->second || - parsed.rpcException == *jt.rpcException->second)), - "apply: Got RPC result "s + parsed.rpcError + " (" + - parsed.rpcException + "); Expected " + jt.rpcException->first + - " (" + jt.rpcException->second.value_or("n/a") + ")")) || + bad = (jt.rpcException && + !test.expect( + (jt.rpcCode && parsed.rpcError.empty()) || + (parsed.rpcError == jt.rpcException->first && + (!jt.rpcException->second || + parsed.rpcException == *jt.rpcException->second)), + "apply: Got RPC result "s + parsed.rpcError + " (" + + parsed.rpcException + "); Expected " + + jt.rpcException->first + " (" + + jt.rpcException->second.value_or("n/a") + ")" + line)) || bad; if (bad) { @@ -531,8 +534,22 @@ void Env::autofill_sig(JTx& jt) { auto& jv = jt.jv; - if (jt.signer) - return jt.signer(*this, jt); + + scope_success success([&]() { + // Call all the post-signers after the main signers or autofill are done + for (auto const& signer : jt.postSigners) + signer(*this, jt); + }); + + // Call all the main signers + if (!jt.mainSigners.empty()) + { + for (auto const& signer : jt.mainSigners) + signer(*this, jt); + return; + } + + // If the sig is still needed, get it here. if (!jt.fill_sig) return; auto const account = jv.isMember(sfDelegate.jsonName) diff --git a/src/test/jtx/impl/TestHelpers.cpp b/src/test/jtx/impl/TestHelpers.cpp index 71f44c691e3..618ecba71c1 100644 --- a/src/test/jtx/impl/TestHelpers.cpp +++ b/src/test/jtx/impl/TestHelpers.cpp @@ -375,6 +375,134 @@ allpe(AccountID const& a, Issue const& iss) iss.account); }; +/* LoanBroker */ +/******************************************************************************/ + +namespace loanBroker { + +Json::Value +set(AccountID const& account, uint256 const& vaultId, uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanBrokerSet; + jv[sfAccount] = to_string(account); + jv[sfVaultID] = to_string(vaultId); + jv[sfFlags] = flags; + return jv; +} + +Json::Value +del(AccountID const& account, uint256 const& brokerID, uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanBrokerDelete; + jv[sfAccount] = to_string(account); + jv[sfLoanBrokerID] = to_string(brokerID); + jv[sfFlags] = flags; + return jv; +} + +Json::Value +coverDeposit( + AccountID const& account, + uint256 const& brokerID, + STAmount const& amount, + uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanBrokerCoverDeposit; + jv[sfAccount] = to_string(account); + jv[sfLoanBrokerID] = to_string(brokerID); + jv[sfAmount] = amount.getJson(JsonOptions::none); + jv[sfFlags] = flags; + return jv; +} + +Json::Value +coverWithdraw( + AccountID const& account, + uint256 const& brokerID, + STAmount const& amount, + uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanBrokerCoverWithdraw; + jv[sfAccount] = to_string(account); + jv[sfLoanBrokerID] = to_string(brokerID); + jv[sfAmount] = amount.getJson(JsonOptions::none); + jv[sfFlags] = flags; + return jv; +} + +Json::Value +coverClawback(AccountID const& account, std::uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanBrokerCoverClawback; + jv[sfAccount] = to_string(account); + jv[sfFlags] = flags; + return jv; +} + +} // namespace loanBroker + +/* Loan */ +/******************************************************************************/ +namespace loan { + +Json::Value +set(AccountID const& account, + uint256 const& loanBrokerID, + Number principalRequested, + std::uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanSet; + jv[sfAccount] = to_string(account); + jv[sfLoanBrokerID] = to_string(loanBrokerID); + jv[sfPrincipalRequested] = to_string(principalRequested); + jv[sfFlags] = flags; + return jv; +} + +Json::Value +manage(AccountID const& account, uint256 const& loanID, std::uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanManage; + jv[sfAccount] = to_string(account); + jv[sfLoanID] = to_string(loanID); + jv[sfFlags] = flags; + return jv; +} + +Json::Value +del(AccountID const& account, uint256 const& loanID, std::uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanDelete; + jv[sfAccount] = to_string(account); + jv[sfLoanID] = to_string(loanID); + jv[sfFlags] = flags; + return jv; +} + +Json::Value +pay(AccountID const& account, + uint256 const& loanID, + STAmount const& amount, + std::uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanPay; + jv[sfAccount] = to_string(account); + jv[sfLoanID] = to_string(loanID); + jv[sfAmount] = amount.getJson(); + jv[sfFlags] = flags; + return jv; +} + +} // namespace loan } // namespace jtx } // namespace test } // namespace ripple diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index f2f51492e32..a7c714db161 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -81,17 +81,67 @@ MPTTester::MPTTester(Env& env, Account const& issuer, MPTInit const& arg) env_.require(owners(it.second, 0)); } } + if (arg.create) + create(*arg.create); } -void -MPTTester::create(MPTCreate const& arg) +MPTTester::MPTTester( + Env& env, + Account const& issuer, + MPTID const& id, + std::vector const& holders, + bool close) + : env_(env) + , issuer_(issuer) + , holders_(makeHolders(holders)) + , id_(id) + , close_(close) { - if (id_) - Throw("MPT can't be reused"); - id_ = makeMptID(env_.seq(issuer_), issuer_); +} + +static MPTCreate +makeMPTCreate(MPTInitDef const& arg) +{ + if (arg.pay) + return { + .maxAmt = arg.maxAmt, + .transferFee = arg.transferFee, + .pay = {{arg.holders, *arg.pay}}, + .flags = arg.flags, + .authHolder = arg.authHolder}; + return { + .maxAmt = arg.maxAmt, + .transferFee = arg.transferFee, + .authorize = arg.holders, + .flags = arg.flags, + .authHolder = arg.authHolder}; +} + +MPTTester::MPTTester(MPTInitDef const& arg) + : MPTTester{ + arg.env, + arg.issuer, + MPTInit{ + .fund = arg.fund, + .close = arg.close, + .create = makeMPTCreate(arg)}} +{ +} + +MPTTester::operator MPT() const +{ + if (!id_) + Throw("MPT has not been created"); + return MPT("", *id_); +} + +Json::Value +MPTTester::createjv(MPTCreate const& arg) +{ + if (!arg.issuer) + Throw("MPTTester::createjv: issuer is not set"); Json::Value jv; - jv[sfAccount] = issuer_.human(); - jv[sfTransactionType] = jss::MPTokenIssuanceCreate; + jv[sfAccount] = arg.issuer->human(); if (arg.assetScale) jv[sfAssetScale] = *arg.assetScale; if (arg.transferFee) @@ -104,6 +154,25 @@ MPTTester::create(MPTCreate const& arg) jv[sfDomainID] = to_string(*arg.domainID); if (arg.mutableFlags) jv[sfMutableFlags] = *arg.mutableFlags; + jv[sfTransactionType] = jss::MPTokenIssuanceCreate; + + return jv; +} + +void +MPTTester::create(MPTCreate const& arg) +{ + if (id_) + Throw("MPT can't be reused"); + id_ = makeMptID(env_.seq(issuer_), issuer_); + Json::Value jv = createjv( + {.issuer = issuer_, + .maxAmt = arg.maxAmt, + .assetScale = arg.assetScale, + .transferFee = arg.transferFee, + .metadata = arg.metadata, + .mutableFlags = arg.mutableFlags, + .domainID = arg.domainID}); if (submit(arg, jv) != tesSUCCESS) { // Verify issuance doesn't exist @@ -114,26 +183,62 @@ MPTTester::create(MPTCreate const& arg) id_.reset(); } else + { env_.require(mptflags(*this, arg.flags.value_or(0))); + auto authAndPay = [&](auto const& accts, auto const&& getAcct) { + for (auto const& it : accts) + { + authorize({.account = getAcct(it)}); + if ((arg.flags.value_or(0) & tfMPTRequireAuth) && + arg.authHolder) + authorize({.account = issuer_, .holder = getAcct(it)}); + if (arg.pay && arg.pay->first.empty()) + pay(issuer_, getAcct(it), arg.pay->second); + } + if (arg.pay) + { + for (auto const& p : arg.pay->first) + pay(issuer_, p, arg.pay->second); + } + }; + if (arg.authorize) + { + if (arg.authorize->empty()) + authAndPay(holders_, [](auto const& it) { return it.second; }); + else + authAndPay(*arg.authorize, [](auto const& it) { return it; }); + } + else if (arg.pay) + { + if (arg.pay->first.empty()) + authAndPay(holders_, [](auto const& it) { return it.second; }); + else + authAndPay(arg.pay->first, [](auto const& it) { return it; }); + } + } } -void -MPTTester::destroy(MPTDestroy const& arg) +Json::Value +MPTTester::destroyjv(MPTDestroy const& arg) { Json::Value jv; - if (arg.issuer) - jv[sfAccount] = arg.issuer->human(); - else - jv[sfAccount] = issuer_.human(); - if (arg.id) - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + if (!arg.issuer || !arg.id) + Throw("MPTTester::destroyjv: issuer/id is not set"); + jv[sfAccount] = arg.issuer->human(); + jv[sfMPTokenIssuanceID] = to_string(*arg.id); jv[sfTransactionType] = jss::MPTokenIssuanceDestroy; + + return jv; +} + +void +MPTTester::destroy(MPTDestroy const& arg) +{ + if (!arg.id && !id_) + Throw("MPT has not been created"); + Json::Value jv = destroyjv( + {.issuer = arg.issuer ? arg.issuer : issuer_, + .id = arg.id ? arg.id : id_}); submit(arg, jv); } @@ -146,25 +251,32 @@ MPTTester::holder(std::string const& holder_) const return it->second; } -void -MPTTester::authorize(MPTAuthorize const& arg) +Json::Value +MPTTester::authorizejv(MPTAuthorize const& arg) { Json::Value jv; - if (arg.account) - jv[sfAccount] = arg.account->human(); - else - jv[sfAccount] = issuer_.human(); - jv[sfTransactionType] = jss::MPTokenAuthorize; - if (arg.id) - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + if (!arg.account || !arg.id) + Throw( + "MPTTester::authorizejv: issuer/id is not set"); + jv[sfAccount] = arg.account->human(); + jv[sfMPTokenIssuanceID] = to_string(*arg.id); if (arg.holder) jv[sfHolder] = arg.holder->human(); + jv[sfTransactionType] = jss::MPTokenAuthorize; + + return jv; +} + +void +MPTTester::authorize(MPTAuthorize const& arg) +{ + if (!arg.id && !id_) + Throw("MPT has not been created"); + Json::Value jv = authorizejv({ + .account = arg.account ? arg.account : issuer_, + .holder = arg.holder, + .id = arg.id ? arg.id : id_, + }); if (auto const result = submit(arg, jv); result == tesSUCCESS) { // Issuer authorizes @@ -220,24 +332,34 @@ MPTTester::authorize(MPTAuthorize const& arg) } void -MPTTester::set(MPTSet const& arg) +MPTTester::authorizeHolders(Holders const& holders) { - Json::Value jv; - if (arg.account) - jv[sfAccount] = arg.account->human(); - else - jv[sfAccount] = issuer_.human(); - jv[sfTransactionType] = jss::MPTokenIssuanceSet; - if (arg.id) - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - else + for (auto const& holder : holders) { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); + authorize({.account = holder}); } +} + +Json::Value +MPTTester::setjv(MPTSet const& arg) +{ + Json::Value jv; + if (!arg.account || !arg.id) + Throw("MPTTester::setjv: issuer/id is not set"); + jv[sfAccount] = arg.account->human(); + jv[sfMPTokenIssuanceID] = to_string(*arg.id); if (arg.holder) - jv[sfHolder] = arg.holder->human(); + { + std::visit( + [&jv](T const& holder) { + if constexpr (std::is_same_v) + jv[sfHolder] = holder.human(); + else if constexpr (std::is_same_v) + jv[sfHolder] = toBase58(holder); + }, + *arg.holder); + } + if (arg.delegate) jv[sfDelegate] = arg.delegate->human(); if (arg.domainID) @@ -248,7 +370,27 @@ MPTTester::set(MPTSet const& arg) jv[sfTransferFee] = *arg.transferFee; if (arg.metadata) jv[sfMPTokenMetadata] = strHex(*arg.metadata); - if (submit(arg, jv) == tesSUCCESS && (arg.flags || arg.mutableFlags)) + jv[sfTransactionType] = jss::MPTokenIssuanceSet; + + return jv; +} + +void +MPTTester::set(MPTSet const& arg) +{ + if (!arg.id && !id_) + Throw("MPT has not been created"); + Json::Value jv = setjv( + {.account = arg.account ? arg.account : issuer_, + .holder = arg.holder, + .id = arg.id ? arg.id : id_, + .mutableFlags = arg.mutableFlags, + .transferFee = arg.transferFee, + .metadata = arg.metadata, + .delegate = arg.delegate, + .domainID = arg.domainID}); + if (submit(arg, jv) == tesSUCCESS && + (arg.flags.value_or(0) || arg.mutableFlags)) { auto require = [&](std::optional const& holder, bool unchanged) { @@ -300,8 +442,9 @@ MPTTester::set(MPTSet const& arg) }; if (arg.account) require(std::nullopt, arg.holder.has_value()); - if (arg.holder) - require(*arg.holder, false); + if (auto const account = + (arg.holder ? std::get_if(&(*arg.holder)) : nullptr)) + require(*account, false); } } @@ -474,6 +617,13 @@ MPTTester::mpt(std::int64_t amount) const return ripple::test::jtx::MPT(issuer_.name(), *id_)(amount); } +MPTTester::operator Asset() const +{ + if (!id_) + Throw("MPT has not been created"); + return Asset(*id_); +} + std::int64_t MPTTester::getBalance(Account const& account) const { @@ -507,11 +657,17 @@ MPTTester::getFlags(std::optional const& holder) const } MPT -MPTTester::operator[](std::string const& name) +MPTTester::operator[](std::string const& name) const { return MPT(name, issuanceID()); } +PrettyAmount +MPTTester::operator()(std::uint64_t amount) const +{ + return MPT("", issuanceID())(amount); +} + } // namespace jtx } // namespace test } // namespace ripple diff --git a/src/test/jtx/impl/multisign.cpp b/src/test/jtx/impl/multisign.cpp index 6ed6df68049..fc6163a2f33 100644 --- a/src/test/jtx/impl/multisign.cpp +++ b/src/test/jtx/impl/multisign.cpp @@ -69,8 +69,15 @@ void msig::operator()(Env& env, JTx& jt) const { auto const mySigners = signers; - jt.signer = [mySigners, &env](Env&, JTx& jtx) { - jtx[sfSigningPubKey.getJsonName()] = ""; + auto callback = [subField = subField, mySigners, &env](Env&, JTx& jtx) { + // Where to put the signature. Supports sfCounterPartySignature. + auto& sigObject = subField ? jtx[*subField] : jtx.jv; + + // The signing pub key is only required at the top level. + if (!subField) + sigObject[sfSigningPubKey] = ""; + else if (sigObject.isNull()) + sigObject = Json::Value(Json::objectValue); std::optional st; try { @@ -81,7 +88,7 @@ msig::operator()(Env& env, JTx& jt) const env.test.log << pretty(jtx.jv) << std::endl; Rethrow(); } - auto& js = jtx[sfSigners.getJsonName()]; + auto& js = sigObject[sfSigners]; for (std::size_t i = 0; i < mySigners.size(); ++i) { auto const& e = mySigners[i]; @@ -96,6 +103,10 @@ msig::operator()(Env& env, JTx& jt) const strHex(Slice{sig.data(), sig.size()}); } }; + if (!subField) + jt.mainSigners.emplace_back(callback); + else + jt.postSigners.emplace_back(callback); } } // namespace jtx diff --git a/src/test/jtx/impl/sig.cpp b/src/test/jtx/impl/sig.cpp index fa1977fe080..6ea1c153cb7 100644 --- a/src/test/jtx/impl/sig.cpp +++ b/src/test/jtx/impl/sig.cpp @@ -29,12 +29,22 @@ sig::operator()(Env&, JTx& jt) const { if (!manual_) return; - jt.fill_sig = false; + if (!subField_) + jt.fill_sig = false; if (account_) { // VFALCO Inefficient pre-C++14 auto const account = *account_; - jt.signer = [account](Env&, JTx& jtx) { jtx::sign(jtx.jv, account); }; + auto callback = [subField = subField_, account](Env&, JTx& jtx) { + // Where to put the signature. Supports sfCounterPartySignature. + auto& sigObject = subField ? jtx[*subField] : jtx.jv; + + jtx::sign(jtx.jv, account, sigObject); + }; + if (!subField_) + jt.mainSigners.emplace_back(callback); + else + jt.postSigners.emplace_back(callback); } } diff --git a/src/test/jtx/impl/testline.cpp b/src/test/jtx/impl/testline.cpp new file mode 100644 index 00000000000..722dc33bff3 --- /dev/null +++ b/src/test/jtx/impl/testline.cpp @@ -0,0 +1,15 @@ +#include + +namespace ripple { +namespace test { +namespace jtx { + +void +testline::operator()(Env&, JTx& jt) const +{ + jt.testLine = line_; +} + +} // namespace jtx +} // namespace test +} // namespace ripple diff --git a/src/test/jtx/impl/utility.cpp b/src/test/jtx/impl/utility.cpp index 27b45a32cb8..fbdbaee4ef7 100644 --- a/src/test/jtx/impl/utility.cpp +++ b/src/test/jtx/impl/utility.cpp @@ -44,14 +44,20 @@ parse(Json::Value const& jv) } void -sign(Json::Value& jv, Account const& account) +sign(Json::Value& jv, Account const& account, Json::Value& sigObject) { - jv[jss::SigningPubKey] = strHex(account.pk().slice()); + sigObject[jss::SigningPubKey] = strHex(account.pk().slice()); Serializer ss; ss.add32(HashPrefix::txSign); parse(jv).addWithoutSigningFields(ss); auto const sig = ripple::sign(account.pk(), account.sk(), ss.slice()); - jv[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()}); + sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()}); +} + +void +sign(Json::Value& jv, Account const& account) +{ + sign(jv, account, jv); } void diff --git a/src/test/jtx/impl/vault.cpp b/src/test/jtx/impl/vault.cpp index 663c42c6eeb..13c85781466 100644 --- a/src/test/jtx/impl/vault.cpp +++ b/src/test/jtx/impl/vault.cpp @@ -38,7 +38,6 @@ Vault::create(CreateArgs const& args) jv[jss::TransactionType] = jss::VaultCreate; jv[jss::Account] = args.owner.human(); jv[jss::Asset] = to_json(args.asset); - jv[jss::Fee] = STAmount(env.current()->fees().increment).getJson(); if (args.flags) jv[jss::Flags] = *args.flags; return {jv, keylet}; diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index 2eacac68ec5..e24fc9d3277 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -33,6 +33,8 @@ namespace jtx { class MPTTester; +auto const MPTDEXFlags = tfMPTCanTrade | tfMPTCanTransfer; + // Check flags settings on MPT create class mptflags { @@ -86,31 +88,59 @@ class requireAny operator()(Env& env) const; }; -struct MPTInit -{ - std::vector holders = {}; - PrettyAmount const xrp = XRP(10'000); - PrettyAmount const xrpHolders = XRP(10'000); - bool fund = true; - bool close = true; -}; -static MPTInit const mptInitNoFund{.fund = false}; +using Holders = std::vector; struct MPTCreate { + static inline std::vector AllHolders = {}; + std::optional issuer = std::nullopt; std::optional maxAmt = std::nullopt; std::optional assetScale = std::nullopt; std::optional transferFee = std::nullopt; std::optional metadata = std::nullopt; std::optional ownerCount = std::nullopt; std::optional holderCount = std::nullopt; - bool fund = true; + // authorize if seated. + // if empty vector then authorize all holders + std::optional> authorize = std::nullopt; + // pay if seated. if authorize is not seated then authorize. + // if empty vector then pay to either authorize or all holders. + std::optional, std::uint64_t>> pay = + std::nullopt; std::optional flags = {0}; std::optional mutableFlags = std::nullopt; + bool authHolder = false; std::optional domainID = std::nullopt; std::optional err = std::nullopt; }; +struct MPTInit +{ + Holders holders = {}; + PrettyAmount const xrp = XRP(10'000); + PrettyAmount const xrpHolders = XRP(10'000); + bool fund = true; + bool close = true; + // create MPTIssuanceID if seated and follow rules for MPTCreate args + std::optional create = std::nullopt; +}; +static MPTInit const mptInitNoFund{.fund = false}; + +struct MPTInitDef +{ + Env& env; + Account issuer; + Holders holders = {}; + std::uint16_t transferFee = 0; + std::optional pay = std::nullopt; + std::uint32_t flags = MPTDEXFlags; + bool authHolder = false; + bool fund = false; + bool close = true; + std::optional maxAmt = std::nullopt; + std::optional err = std::nullopt; +}; + struct MPTDestroy { std::optional issuer = std::nullopt; @@ -135,7 +165,7 @@ struct MPTAuthorize struct MPTSet { std::optional account = std::nullopt; - std::optional holder = std::nullopt; + std::optional> holder = std::nullopt; std::optional id = std::nullopt; std::optional ownerCount = std::nullopt; std::optional holderCount = std::nullopt; @@ -151,26 +181,49 @@ struct MPTSet class MPTTester { Env& env_; - Account const& issuer_; + Account const issuer_; std::unordered_map const holders_; std::optional id_; bool close_; public: MPTTester(Env& env, Account const& issuer, MPTInit const& constr = {}); + MPTTester(MPTInitDef const& constr); + MPTTester( + Env& env, + Account const& issuer, + MPTID const& id, + std::vector const& holders = {}, + bool close = true); + operator MPT() const; void create(MPTCreate const& arg = MPTCreate{}); + static Json::Value + createjv(MPTCreate const& arg = MPTCreate{}); + void destroy(MPTDestroy const& arg = MPTDestroy{}); + static Json::Value + destroyjv(MPTDestroy const& arg = MPTDestroy{}); + void authorize(MPTAuthorize const& arg = MPTAuthorize{}); + static Json::Value + authorizejv(MPTAuthorize const& arg = MPTAuthorize{}); + + void + authorizeHolders(Holders const& holders); + void set(MPTSet const& set = {}); + static Json::Value + setjv(MPTSet const& set = {}); + [[nodiscard]] bool checkDomainID(std::optional expected) const; @@ -235,10 +288,15 @@ class MPTTester getBalance(Account const& account) const; MPT - operator[](std::string const& name); + operator[](std::string const& name) const; + + PrettyAmount + operator()(std::uint64_t amount) const; + + operator Asset() const; private: - using SLEP = std::shared_ptr; + using SLEP = SLE::const_pointer; bool forObject( std::function const& cb, diff --git a/src/test/jtx/multisign.h b/src/test/jtx/multisign.h index 1fed895c6d1..7a035a9ff0f 100644 --- a/src/test/jtx/multisign.h +++ b/src/test/jtx/multisign.h @@ -67,18 +67,63 @@ class msig { public: std::vector signers; - - msig(std::vector signers_) : signers(std::move(signers_)) + /** Alternative transaction object field in which to place the signer list. + * + * subField is only supported if an account_ is provided as well. + */ + SField const* const subField = nullptr; + /// Used solely as a convenience placeholder for ctors that do _not_ specify + /// a subfield. + static constexpr SField* const topLevel = nullptr; + + msig(SField const* subField_, std::vector signers_) + : signers(std::move(signers_)), subField(subField_) { sortSigners(signers); } + msig(SField const& subField_, std::vector signers_) + : msig{&subField_, signers_} + { + } + + msig(std::vector signers_) : msig(topLevel, signers_) + { + } + template requires std::convertible_to + explicit msig(SField const* subField_, AccountType&& a0, Accounts&&... aN) + : msig{ + subField_, + std::vector{ + std::forward(a0), + std::forward(aN)...}} + { + } + + template + requires std::convertible_to + explicit msig(SField const& subField_, AccountType&& a0, Accounts&&... aN) + : msig{ + &subField_, + std::vector{ + std::forward(a0), + std::forward(aN)...}} + { + } + + template + requires( + std::convertible_to && + !std::is_same_v) explicit msig(AccountType&& a0, Accounts&&... aN) - : signers{std::forward(a0), std::forward(aN)...} + : msig{ + topLevel, + std::vector{ + std::forward(a0), + std::forward(aN)...}} { - sortSigners(signers); } void diff --git a/src/test/jtx/sig.h b/src/test/jtx/sig.h index aa65a4f6979..b96a306a37e 100644 --- a/src/test/jtx/sig.h +++ b/src/test/jtx/sig.h @@ -35,7 +35,20 @@ class sig { private: bool manual_ = true; + /** Alternative transaction object field in which to place the signature. + * + * subField is only supported if an account_ is provided as well. + */ + SField const* const subField_ = nullptr; + /** Account that will generate the signature. + * + * If not provided, no signature will be added by this helper. See also + * Env::autofill_sig. + */ std::optional account_; + /// Used solely as a convenience placeholder for ctors that do _not_ specify + /// a subfield. + static constexpr SField* const topLevel = nullptr; public: explicit sig(autofill_t) : manual_(false) @@ -46,7 +59,17 @@ class sig { } - explicit sig(Account const& account) : account_(account) + explicit sig(SField const* subField, Account const& account) + : subField_(subField), account_(account) + { + } + + explicit sig(SField const& subField, Account const& account) + : sig(&subField, account) + { + } + + explicit sig(Account const& account) : sig(topLevel, account) { } diff --git a/src/test/jtx/testline.h b/src/test/jtx/testline.h new file mode 100644 index 00000000000..65dd3b7d929 --- /dev/null +++ b/src/test/jtx/testline.h @@ -0,0 +1,34 @@ +#ifndef XRPL_TEST_JTX_TESTLINE_H_INCLUDED +#define XRPL_TEST_JTX_TESTLINE_H_INCLUDED + +#include + +namespace ripple { +namespace test { +namespace jtx { + +/** Store the line number of the current test in a JTx. + + Intended to help debug failing transaction submission tests. +*/ +class testline +{ +private: + int line_; + +public: + explicit testline(int line) : line_(line) + { + } + + void + operator()(Env&, JTx& jt) const; +}; + +#define THISLINE testline(__LINE__) + +} // namespace jtx +} // namespace test +} // namespace ripple + +#endif diff --git a/src/test/jtx/utility.h b/src/test/jtx/utility.h index 2c21fbd3ff9..9e89c3bb93c 100644 --- a/src/test/jtx/utility.h +++ b/src/test/jtx/utility.h @@ -51,6 +51,12 @@ struct parse_error : std::logic_error STObject parse(Json::Value const& jv); +/** Sign automatically into a specific Json field of the jv object. + @note This only works on accounts with multi-signing off. +*/ +void +sign(Json::Value& jv, Account const& account, Json::Value& sigObject); + /** Sign automatically. @note This only works on accounts with multi-signing off. */ diff --git a/src/test/nodestore/TestBase.h b/src/test/nodestore/TestBase.h index 634f2bf0051..484b7f6fcb8 100644 --- a/src/test/nodestore/TestBase.h +++ b/src/test/nodestore/TestBase.h @@ -101,7 +101,7 @@ class TestBase : public beast::unit_test::suite case 3: return hotUNKNOWN; } - // will never happen, but make static analysys tool happy. + // will never happen, but make static analysis tool happy. return hotUNKNOWN; }(); diff --git a/src/test/nodestore/import_test.cpp b/src/test/nodestore/import_test.cpp index ea5f23548af..21373dc7101 100644 --- a/src/test/nodestore/import_test.cpp +++ b/src/test/nodestore/import_test.cpp @@ -255,7 +255,7 @@ parse_args(std::string const& s) // '=' static boost::regex const re1( "^" // start of line - "(?:\\s*)" // whitespace (optonal) + "(?:\\s*)" // whitespace (optional) "([a-zA-Z][_a-zA-Z0-9]*)" // "(?:\\s*)" // whitespace (optional) "(?:=)" // '=' diff --git a/src/test/overlay/cluster_test.cpp b/src/test/overlay/cluster_test.cpp index 76bbc9d4e32..bf730640347 100644 --- a/src/test/overlay/cluster_test.cpp +++ b/src/test/overlay/cluster_test.cpp @@ -246,7 +246,7 @@ class cluster_test : public ripple::TestSuite BEAST_EXPECT(!c->load(s4)); // Check if we properly terminate when we encounter - // a malformed or unparseable entry: + // a malformed or unparsable entry: auto const node1 = randomNode(); auto const node2 = randomNode(); diff --git a/src/test/overlay/tx_reduce_relay_test.cpp b/src/test/overlay/tx_reduce_relay_test.cpp index aaf650e2133..270032757b4 100644 --- a/src/test/overlay/tx_reduce_relay_test.cpp +++ b/src/test/overlay/tx_reduce_relay_test.cpp @@ -264,7 +264,7 @@ class tx_reduce_relay_test : public beast::unit_test::suite // (20+0.25*(60-20)-5=25), queue the rest, skip counts towards relayed // (60-25-5=30) testRelay("skip", true, 60, 0, 20, 25, 25, 30, skip); - // relay to minPeers + disabled + 25% of (nPeers - minPeers - disalbed) + // relay to minPeers + disabled + 25% of (nPeers - minPeers - disabled) // (20+10+0.25*(70-20-10)=40), queue the rest (30) testRelay("disabled", true, 70, 10, 20, 25, 40, 30); // relay to minPeers + disabled-not-in-skip + 25% of (nPeers - minPeers diff --git a/src/test/protocol/STAmount_test.cpp b/src/test/protocol/STAmount_test.cpp index 5d3fdfb28aa..ca70232022f 100644 --- a/src/test/protocol/STAmount_test.cpp +++ b/src/test/protocol/STAmount_test.cpp @@ -22,6 +22,7 @@ #include #include #include +#include namespace ripple { @@ -604,6 +605,216 @@ class STAmount_test : public beast::unit_test::suite #endif } + void + testParseJson() + { + static_assert(!std::is_convertible_v); + + { + STAmount const stnum{sfNumber}; + BEAST_EXPECT(stnum.getSType() == STI_AMOUNT); + BEAST_EXPECT(stnum.getText() == "0"); + BEAST_EXPECT(stnum.isDefault() == true); + BEAST_EXPECT(stnum.value() == Number{0}); + } + + { + BEAST_EXPECT( + amountFromJson(sfNumber, Json::Value(42)) == XRPAmount(42)); + BEAST_EXPECT( + amountFromJson(sfNumber, Json::Value(-42)) == XRPAmount(-42)); + + BEAST_EXPECT( + amountFromJson(sfNumber, Json::UInt(42)) == XRPAmount(42)); + + BEAST_EXPECT(amountFromJson(sfNumber, "-123") == XRPAmount(-123)); + + BEAST_EXPECT(amountFromJson(sfNumber, "123") == XRPAmount(123)); + BEAST_EXPECT(amountFromJson(sfNumber, "-123") == XRPAmount(-123)); + + BEAST_EXPECT(amountFromJson(sfNumber, "3.14e2") == XRPAmount(314)); + BEAST_EXPECT( + amountFromJson(sfNumber, "-3.14e2") == XRPAmount(-314)); + + BEAST_EXPECT(amountFromJson(sfNumber, "0") == XRPAmount(0)); + BEAST_EXPECT(amountFromJson(sfNumber, "-0") == XRPAmount(0)); + + constexpr auto imin = std::numeric_limits::min(); + BEAST_EXPECT(amountFromJson(sfNumber, imin) == XRPAmount(imin)); + BEAST_EXPECT( + amountFromJson(sfNumber, std::to_string(imin)) == + XRPAmount(imin)); + + constexpr auto imax = std::numeric_limits::max(); + BEAST_EXPECT(amountFromJson(sfNumber, imax) == XRPAmount(imax)); + BEAST_EXPECT( + amountFromJson(sfNumber, std::to_string(imax)) == + XRPAmount(imax)); + + constexpr auto umax = std::numeric_limits::max(); + BEAST_EXPECT(amountFromJson(sfNumber, umax) == XRPAmount(umax)); + BEAST_EXPECT( + amountFromJson(sfNumber, std::to_string(umax)) == + XRPAmount(umax)); + + // XRP does not handle fractional part + try + { + auto _ = amountFromJson(sfNumber, "0.0"); + BEAST_EXPECT(false); + } + catch (std::runtime_error const& e) + { + std::string const expected = + "XRP and MPT must be specified as integral amount."; + BEAST_EXPECT(e.what() == expected); + } + + // XRP does not handle fractional part + try + { + auto _ = amountFromJson(sfNumber, "1000e-2"); + BEAST_EXPECT(false); + } + catch (std::runtime_error const& e) + { + std::string const expected = + "XRP and MPT must be specified as integral amount."; + BEAST_EXPECT(e.what() == expected); + } + + // Obvious non-numbers tested here + try + { + auto _ = amountFromJson(sfNumber, ""); + BEAST_EXPECT(false); + } + catch (std::runtime_error const& e) + { + std::string const expected = "'' is not a number"; + BEAST_EXPECT(e.what() == expected); + } + + try + { + auto _ = amountFromJson(sfNumber, "e"); + BEAST_EXPECT(false); + } + catch (std::runtime_error const& e) + { + std::string const expected = "'e' is not a number"; + BEAST_EXPECT(e.what() == expected); + } + + try + { + auto _ = amountFromJson(sfNumber, "1e"); + BEAST_EXPECT(false); + } + catch (std::runtime_error const& e) + { + std::string const expected = "'1e' is not a number"; + BEAST_EXPECT(e.what() == expected); + } + + try + { + auto _ = amountFromJson(sfNumber, "e2"); + BEAST_EXPECT(false); + } + catch (std::runtime_error const& e) + { + std::string const expected = "'e2' is not a number"; + BEAST_EXPECT(e.what() == expected); + } + + try + { + auto _ = amountFromJson(sfNumber, Json::Value()); + BEAST_EXPECT(false); + } + catch (std::runtime_error const& e) + { + std::string const expected = + "XRP may not be specified with a null Json value"; + BEAST_EXPECT(e.what() == expected); + } + + try + { + auto _ = amountFromJson( + sfNumber, + "123456789012345678901234567890123456789012345678901234" + "5678" + "901234567890123456789012345678901234567890123456789012" + "3456" + "78901234567890123456789012345678901234567890"); + BEAST_EXPECT(false); + } + catch (std::bad_cast const& e) + { + BEAST_EXPECT(true); + } + + // We do not handle leading zeros + try + { + auto _ = amountFromJson(sfNumber, "001"); + BEAST_EXPECT(false); + } + catch (std::runtime_error const& e) + { + std::string const expected = "'001' is not a number"; + BEAST_EXPECT(e.what() == expected); + } + + try + { + auto _ = amountFromJson(sfNumber, "000.0"); + BEAST_EXPECT(false); + } + catch (std::runtime_error const& e) + { + std::string const expected = "'000.0' is not a number"; + BEAST_EXPECT(e.what() == expected); + } + + // We do not handle dangling dot + try + { + auto _ = amountFromJson(sfNumber, ".1"); + BEAST_EXPECT(false); + } + catch (std::runtime_error const& e) + { + std::string const expected = "'.1' is not a number"; + BEAST_EXPECT(e.what() == expected); + } + + try + { + auto _ = amountFromJson(sfNumber, "1."); + BEAST_EXPECT(false); + } + catch (std::runtime_error const& e) + { + std::string const expected = "'1.' is not a number"; + BEAST_EXPECT(e.what() == expected); + } + + try + { + auto _ = amountFromJson(sfNumber, "1.e3"); + BEAST_EXPECT(false); + } + catch (std::runtime_error const& e) + { + std::string const expected = "'1.e3' is not a number"; + BEAST_EXPECT(e.what() == expected); + } + } + } + void testConvertXRP() { @@ -1041,6 +1252,7 @@ class STAmount_test : public beast::unit_test::suite testArithmetic(); testUnderflow(); testRounding(); + testParseJson(); testConvertXRP(); testConvertIOU(); testCanAddXRP(); diff --git a/src/test/protocol/STNumber_test.cpp b/src/test/protocol/STNumber_test.cpp index 6f2c57ecb07..70ffa0efbcd 100644 --- a/src/test/protocol/STNumber_test.cpp +++ b/src/test/protocol/STNumber_test.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -145,6 +146,30 @@ struct STNumber_test : public beast::unit_test::suite BEAST_EXPECT( numberFromJson(sfNumber, "-0.000e6") == STNumber(sfNumber, 0)); + constexpr auto imin = std::numeric_limits::min(); + BEAST_EXPECT( + numberFromJson(sfNumber, imin) == + STNumber(sfNumber, Number(imin, 0))); + BEAST_EXPECT( + numberFromJson(sfNumber, std::to_string(imin)) == + STNumber(sfNumber, Number(imin, 0))); + + constexpr auto imax = std::numeric_limits::max(); + BEAST_EXPECT( + numberFromJson(sfNumber, imax) == + STNumber(sfNumber, Number(imax, 0))); + BEAST_EXPECT( + numberFromJson(sfNumber, std::to_string(imax)) == + STNumber(sfNumber, Number(imax, 0))); + + constexpr auto umax = std::numeric_limits::max(); + BEAST_EXPECT( + numberFromJson(sfNumber, umax) == + STNumber(sfNumber, Number(umax, 0))); + BEAST_EXPECT( + numberFromJson(sfNumber, std::to_string(umax)) == + STNumber(sfNumber, Number(umax, 0))); + // Obvious non-numbers tested here try { diff --git a/src/test/protocol/STParsedJSON_test.cpp b/src/test/protocol/STParsedJSON_test.cpp index 1e1e1fb9f46..12408c272ef 100644 --- a/src/test/protocol/STParsedJSON_test.cpp +++ b/src/test/protocol/STParsedJSON_test.cpp @@ -743,63 +743,63 @@ class STParsedJSON_test : public beast::unit_test::suite { Json::Value j; int const minInt32 = -2147483648; - j[sfDummyInt32] = minInt32; + j[sfLoanScale] = minInt32; STParsedJSONObject obj("Test", j); BEAST_EXPECT(obj.object.has_value()); - if (BEAST_EXPECT(obj.object->isFieldPresent(sfDummyInt32))) - BEAST_EXPECT(obj.object->getFieldI32(sfDummyInt32) == minInt32); + if (BEAST_EXPECT(obj.object->isFieldPresent(sfLoanScale))) + BEAST_EXPECT(obj.object->getFieldI32(sfLoanScale) == minInt32); } // max value { Json::Value j; int const maxInt32 = 2147483647; - j[sfDummyInt32] = maxInt32; + j[sfLoanScale] = maxInt32; STParsedJSONObject obj("Test", j); BEAST_EXPECT(obj.object.has_value()); - if (BEAST_EXPECT(obj.object->isFieldPresent(sfDummyInt32))) - BEAST_EXPECT(obj.object->getFieldI32(sfDummyInt32) == maxInt32); + if (BEAST_EXPECT(obj.object->isFieldPresent(sfLoanScale))) + BEAST_EXPECT(obj.object->getFieldI32(sfLoanScale) == maxInt32); } // max uint value { Json::Value j; unsigned int const maxUInt32 = 2147483647u; - j[sfDummyInt32] = maxUInt32; + j[sfLoanScale] = maxUInt32; STParsedJSONObject obj("Test", j); BEAST_EXPECT(obj.object.has_value()); - if (BEAST_EXPECT(obj.object->isFieldPresent(sfDummyInt32))) + if (BEAST_EXPECT(obj.object->isFieldPresent(sfLoanScale))) BEAST_EXPECT( - obj.object->getFieldI32(sfDummyInt32) == + obj.object->getFieldI32(sfLoanScale) == static_cast(maxUInt32)); } // Test with string value { Json::Value j; - j[sfDummyInt32] = "2147483647"; + j[sfLoanScale] = "2147483647"; STParsedJSONObject obj("Test", j); BEAST_EXPECT(obj.object.has_value()); - if (BEAST_EXPECT(obj.object->isFieldPresent(sfDummyInt32))) + if (BEAST_EXPECT(obj.object->isFieldPresent(sfLoanScale))) BEAST_EXPECT( - obj.object->getFieldI32(sfDummyInt32) == 2147483647u); + obj.object->getFieldI32(sfLoanScale) == 2147483647u); } // Test with string negative value { Json::Value j; int value = -2147483648; - j[sfDummyInt32] = std::to_string(value); + j[sfLoanScale] = std::to_string(value); STParsedJSONObject obj("Test", j); BEAST_EXPECT(obj.object.has_value()); - if (BEAST_EXPECT(obj.object->isFieldPresent(sfDummyInt32))) - BEAST_EXPECT(obj.object->getFieldI32(sfDummyInt32) == value); + if (BEAST_EXPECT(obj.object->isFieldPresent(sfLoanScale))) + BEAST_EXPECT(obj.object->getFieldI32(sfLoanScale) == value); } // Test out of range value for int32 (negative) { Json::Value j; - j[sfDummyInt32] = "-2147483649"; + j[sfLoanScale] = "-2147483649"; STParsedJSONObject obj("Test", j); BEAST_EXPECT(!obj.object.has_value()); } @@ -807,7 +807,7 @@ class STParsedJSON_test : public beast::unit_test::suite // Test out of range value for int32 (positive) { Json::Value j; - j[sfDummyInt32] = 2147483648u; + j[sfLoanScale] = 2147483648u; STParsedJSONObject obj("Test", j); BEAST_EXPECT(!obj.object.has_value()); } @@ -815,7 +815,7 @@ class STParsedJSON_test : public beast::unit_test::suite // Test string value out of range { Json::Value j; - j[sfDummyInt32] = "2147483648"; + j[sfLoanScale] = "2147483648"; STParsedJSONObject obj("Test", j); BEAST_EXPECT(!obj.object.has_value()); } @@ -823,7 +823,7 @@ class STParsedJSON_test : public beast::unit_test::suite // Test bad_type (arrayValue) { Json::Value j; - j[sfDummyInt32] = Json::Value(Json::arrayValue); + j[sfLoanScale] = Json::Value(Json::arrayValue); STParsedJSONObject obj("Test", j); BEAST_EXPECT(!obj.object.has_value()); } @@ -831,7 +831,7 @@ class STParsedJSON_test : public beast::unit_test::suite // Test bad_type (objectValue) { Json::Value j; - j[sfDummyInt32] = Json::Value(Json::objectValue); + j[sfLoanScale] = Json::Value(Json::objectValue); STParsedJSONObject obj("Test", j); BEAST_EXPECT(!obj.object.has_value()); } diff --git a/src/test/rpc/NoRippleCheck_test.cpp b/src/test/rpc/NoRippleCheck_test.cpp index f379e273dd0..0e2cbd3f558 100644 --- a/src/test/rpc/NoRippleCheck_test.cpp +++ b/src/test/rpc/NoRippleCheck_test.cpp @@ -271,7 +271,7 @@ class NoRippleCheckLimits_test : public beast::unit_test::suite auto checkBalance = [&env]() { // this is endpoint drop prevention. Non admin ports will drop // requests if they are coming too fast, so we manipulate the - // resource manager here to reset the enpoint balance (for + // resource manager here to reset the endpoint balance (for // localhost) if we get too close to the drop limit. It would // be better if we could add this functionality to Env somehow // or otherwise disable endpoint charging for certain test diff --git a/src/test/rpc/RPCCall_test.cpp b/src/test/rpc/RPCCall_test.cpp index d22896388d7..26a6a536ef8 100644 --- a/src/test/rpc/RPCCall_test.cpp +++ b/src/test/rpc/RPCCall_test.cpp @@ -4643,10 +4643,34 @@ static RPCCallTestData const rpcCallTestArray[] = { } ] })"}, - {"sign: too many arguments.", + {"sign: offline flag with signature_target.", __LINE__, {"sign", "my_secret", R"({"json_argument":true})", "offline", "extra"}, RPCCallTestData::no_exception, + R"({ + "method" : "sign", + "params" : [ + { + "api_version" : %API_VER%, + "offline" : true, + "secret" : "my_secret", + "signature_target" : "extra", + "tx_json" : + { + "json_argument" : true + } + } + ] + })"}, + {"sign: too many arguments.", + __LINE__, + {"sign", + "my_secret", + R"({"json_argument":true})", + "offline", + "CounterpartySignature", + "extra"}, + RPCCallTestData::no_exception, R"({ "method" : "sign", "params" : [ @@ -4675,20 +4699,24 @@ static RPCCallTestData const rpcCallTestArray[] = { } ] })"}, - {"sign: invalid final argument.", + {"sign: misspelled offline flag interpreted as signature_target.", __LINE__, {"sign", "my_secret", R"({"json_argument":true})", "offlin"}, RPCCallTestData::no_exception, R"({ - "method" : "sign", - "params" : [ - { - "error" : "invalidParams", - "error_code" : 31, - "error_message" : "Invalid parameters." - } - ] - })"}, + "method" : "sign", + "params" : [ + { + "api_version" : %API_VER%, + "secret" : "my_secret", + "signature_target" : "offlin", + "tx_json" : + { + "json_argument" : true + } + } + ] + })"}, // sign_for // -------------------------------------------------------------------- @@ -4880,10 +4908,34 @@ static RPCCallTestData const rpcCallTestArray[] = { } ] })"}, - {"submit: too many arguments.", + {"submit: offline flag with signature_target.", __LINE__, {"submit", "my_secret", R"({"json_argument":true})", "offline", "extra"}, RPCCallTestData::no_exception, + R"({ + "method" : "submit", + "params" : [ + { + "api_version" : %API_VER%, + "offline" : true, + "secret" : "my_secret", + "signature_target" : "extra", + "tx_json" : + { + "json_argument" : true + } + } + ] + })"}, + {"submit: too many arguments.", + __LINE__, + {"submit", + "my_secret", + R"({"json_argument":true})", + "offline", + "CounterpartySignature", + "extra"}, + RPCCallTestData::no_exception, R"({ "method" : "submit", "params" : [ @@ -4912,19 +4964,23 @@ static RPCCallTestData const rpcCallTestArray[] = { } ] })"}, - {"submit: last argument not \"offline\".", + {"submit: misspelled offline flag interpreted as signature_target.", __LINE__, {"submit", "my_secret", R"({"json_argument":true})", "offlne"}, RPCCallTestData::no_exception, R"({ - "method" : "submit", - "params" : [ - { - "error" : "invalidParams", - "error_code" : 31, - "error_message" : "Invalid parameters." - } - ] + "method" : "submit", + "params" : [ + { + "api_version" : %API_VER%, + "secret" : "my_secret", + "signature_target" : "offlne", + "tx_json" : + { + "json_argument" : true + } + } + ] })"}, // submit_multisigned diff --git a/src/tests/libxrpl/json/Value.cpp b/src/tests/libxrpl/json/Value.cpp index 3da8e14ba4b..c57bbec209a 100644 --- a/src/tests/libxrpl/json/Value.cpp +++ b/src/tests/libxrpl/json/Value.cpp @@ -26,6 +26,7 @@ #include #include +#include #include #include #include @@ -34,6 +35,14 @@ namespace ripple { TEST_SUITE_BEGIN("json_value"); +TEST_CASE("limits") +{ + using namespace Json; + static_assert(Value::minInt == Int(~(UInt(-1) / 2))); + static_assert(Value::maxInt == Int(UInt(-1) / 2)); + static_assert(Value::maxUInt == UInt(-1)); +} + TEST_CASE("construct and compare Json::StaticString") { static constexpr char sample[]{"Contents of a Json::StaticString"}; @@ -601,8 +610,6 @@ TEST_CASE("bad json") TEST_CASE("edge cases") { - std::string json; - std::uint32_t max_uint = std::numeric_limits::max(); std::int32_t max_int = std::numeric_limits::max(); std::int32_t min_int = std::numeric_limits::min(); @@ -611,71 +618,145 @@ TEST_CASE("edge cases") std::int32_t a_large_int = max_int - 1978; std::int32_t a_small_int = min_int + 1978; - json = "{\"max_uint\":" + std::to_string(max_uint); - json += ",\"max_int\":" + std::to_string(max_int); - json += ",\"min_int\":" + std::to_string(min_int); - json += ",\"a_uint\":" + std::to_string(a_uint); - json += ",\"a_large_int\":" + std::to_string(a_large_int); - json += ",\"a_small_int\":" + std::to_string(a_small_int); - json += "}"; + { + std::string json = "{\"max_uint\":" + std::to_string(max_uint); + json += ",\"max_int\":" + std::to_string(max_int); + json += ",\"min_int\":" + std::to_string(min_int); + json += ",\"a_uint\":" + std::to_string(a_uint); + json += ",\"a_large_int\":" + std::to_string(a_large_int); + json += ",\"a_small_int\":" + std::to_string(a_small_int); + json += "}"; + + Json::Value j1; + Json::Reader r1; + + CHECK(r1.parse(json, j1)); + CHECK(j1["max_uint"].asUInt() == max_uint); + CHECK(j1["max_uint"].asAbsUInt() == max_uint); + CHECK(j1["max_int"].asInt() == max_int); + CHECK(j1["max_int"].asAbsUInt() == max_int); + CHECK(j1["min_int"].asInt() == min_int); + CHECK( + j1["min_int"].asAbsUInt() == + static_cast(min_int) * -1); + CHECK(j1["a_uint"].asUInt() == a_uint); + CHECK(j1["a_uint"].asAbsUInt() == a_uint); + CHECK(j1["a_uint"] > a_large_int); + CHECK(j1["a_uint"] > a_small_int); + CHECK(j1["a_large_int"].asInt() == a_large_int); + CHECK(j1["a_large_int"].asAbsUInt() == a_large_int); + CHECK(j1["a_large_int"].asUInt() == a_large_int); + CHECK(j1["a_large_int"] < a_uint); + CHECK(j1["a_small_int"].asInt() == a_small_int); + CHECK( + j1["a_small_int"].asAbsUInt() == + static_cast(a_small_int) * -1); + CHECK(j1["a_small_int"] < a_uint); + } + + std::uint64_t overflow = std::uint64_t(max_uint) + 1; + { + std::string json = "{\"overflow\":"; + json += std::to_string(overflow); + json += "}"; + + Json::Value j2; + Json::Reader r2; + + CHECK(!r2.parse(json, j2)); + } + + std::int64_t underflow = std::int64_t(min_int) - 1; + { + std::string json = "{\"underflow\":"; + json += std::to_string(underflow); + json += "}"; + + Json::Value j3; + Json::Reader r3; + + CHECK(!r3.parse(json, j3)); + } + + { + Json::Value intString{std::to_string(overflow)}; + CHECK_THROWS_AS(intString.asUInt(), beast::BadLexicalCast); + CHECK_THROWS_AS(intString.asAbsUInt(), Json::error); - Json::Value j1; - Json::Reader r1; + intString = "4294967295"; + CHECK(intString.asUInt() == 4294967295u); + CHECK(intString.asAbsUInt() == 4294967295u); - CHECK(r1.parse(json, j1)); - CHECK(j1["max_uint"].asUInt() == max_uint); - CHECK(j1["max_int"].asInt() == max_int); - CHECK(j1["min_int"].asInt() == min_int); - CHECK(j1["a_uint"].asUInt() == a_uint); - CHECK(j1["a_uint"] > a_large_int); - CHECK(j1["a_uint"] > a_small_int); - CHECK(j1["a_large_int"].asInt() == a_large_int); - CHECK(j1["a_large_int"].asUInt() == a_large_int); - CHECK(j1["a_large_int"] < a_uint); - CHECK(j1["a_small_int"].asInt() == a_small_int); - CHECK(j1["a_small_int"] < a_uint); + intString = "0"; + CHECK(intString.asUInt() == 0); + CHECK(intString.asAbsUInt() == 0); - json = "{\"overflow\":"; - json += std::to_string(std::uint64_t(max_uint) + 1); - json += "}"; + intString = "-1"; + CHECK_THROWS_AS(intString.asUInt(), beast::BadLexicalCast); + CHECK(intString.asAbsUInt() == 1); - Json::Value j2; - Json::Reader r2; + intString = "-4294967295"; + CHECK(intString.asAbsUInt() == 4294967295); - CHECK(!r2.parse(json, j2)); + intString = "-4294967296"; + CHECK_THROWS_AS(intString.asAbsUInt(), Json::error); - json = "{\"underflow\":"; - json += std::to_string(std::int64_t(min_int) - 1); - json += "}"; + intString = "2147483648"; + CHECK_THROWS_AS(intString.asInt(), beast::BadLexicalCast); + CHECK(intString.asAbsUInt() == 2147483648); - Json::Value j3; - Json::Reader r3; + intString = "2147483647"; + CHECK(intString.asInt() == 2147483647); + CHECK(intString.asAbsUInt() == 2147483647); - CHECK(!r3.parse(json, j3)); + intString = "-2147483648"; + CHECK(intString.asInt() == -2147483648LL); // MSVC wants the LL + CHECK(intString.asAbsUInt() == 2147483648LL); - Json::Value intString{"4294967296"}; - CHECK_THROWS_AS(intString.asUInt(), beast::BadLexicalCast); + intString = "-2147483649"; + CHECK_THROWS_AS(intString.asInt(), beast::BadLexicalCast); + CHECK(intString.asAbsUInt() == 2147483649); + } + + { + Json::Value intReal{4294967297.0}; + CHECK_THROWS_AS(intReal.asUInt(), Json::error); + CHECK_THROWS_AS(intReal.asAbsUInt(), Json::error); - intString = "4294967295"; - CHECK(intString.asUInt() == 4294967295u); + intReal = 4294967295.0; + CHECK(intReal.asUInt() == 4294967295u); + CHECK(intReal.asAbsUInt() == 4294967295u); - intString = "0"; - CHECK(intString.asUInt() == 0); + intReal = 0.0; + CHECK(intReal.asUInt() == 0); + CHECK(intReal.asAbsUInt() == 0); - intString = "-1"; - CHECK_THROWS_AS(intString.asUInt(), beast::BadLexicalCast); + intReal = -1.0; + CHECK_THROWS_AS(intReal.asUInt(), Json::error); + CHECK(intReal.asAbsUInt() == 1); - intString = "2147483648"; - CHECK_THROWS_AS(intString.asInt(), beast::BadLexicalCast); + intReal = -4294967295.0; + CHECK(intReal.asAbsUInt() == 4294967295); - intString = "2147483647"; - CHECK(intString.asInt() == 2147483647); + intReal = -4294967296.0; + CHECK_THROWS_AS(intReal.asAbsUInt(), Json::error); - intString = "-2147483648"; - CHECK(intString.asInt() == -2147483648LL); // MSVC wants the LL + intReal = 2147483648.0; + CHECK_THROWS_AS(intReal.asInt(), Json::error); + CHECK(intReal.asAbsUInt() == 2147483648); - intString = "-2147483649"; - CHECK_THROWS_AS(intString.asInt(), beast::BadLexicalCast); + intReal = 2147483647.0; + CHECK(intReal.asInt() == 2147483647); + CHECK(intReal.asAbsUInt() == 2147483647); + + intReal = -2147483648.0; + CHECK(intReal.asInt() == -2147483648LL); // MSVC wants the LL + CHECK(intReal.asAbsUInt() == 2147483648LL); + + intReal = -2147483649.0; + CHECK_THROWS_AS(intReal.asInt(), Json::error); + CHECK(intReal.asAbsUInt() == 2147483649); + } } TEST_CASE("copy") @@ -812,6 +893,7 @@ TEST_CASE("conversions") CHECK(val.asString() == ""); CHECK(val.asInt() == 0); CHECK(val.asUInt() == 0); + CHECK(val.asAbsUInt() == 0); CHECK(val.asDouble() == 0.0); CHECK(val.asBool() == false); @@ -832,6 +914,7 @@ TEST_CASE("conversions") CHECK(val.asString() == "-1234"); CHECK(val.asInt() == -1234); CHECK_THROWS_AS(val.asUInt(), Json::error); + CHECK(val.asAbsUInt() == 1234u); CHECK(val.asDouble() == -1234.0); CHECK(val.asBool() == true); @@ -852,6 +935,7 @@ TEST_CASE("conversions") CHECK(val.asString() == "1234"); CHECK(val.asInt() == 1234); CHECK(val.asUInt() == 1234u); + CHECK(val.asAbsUInt() == 1234u); CHECK(val.asDouble() == 1234.0); CHECK(val.asBool() == true); @@ -872,6 +956,7 @@ TEST_CASE("conversions") CHECK(std::regex_match(val.asString(), std::regex("^2\\.0*$"))); CHECK(val.asInt() == 2); CHECK(val.asUInt() == 2u); + CHECK(val.asAbsUInt() == 2u); CHECK(val.asDouble() == 2.0); CHECK(val.asBool() == true); @@ -892,6 +977,7 @@ TEST_CASE("conversions") CHECK(val.asString() == "54321"); CHECK(val.asInt() == 54321); CHECK(val.asUInt() == 54321u); + CHECK(val.asAbsUInt() == 54321); CHECK_THROWS_AS(val.asDouble(), Json::error); CHECK(val.asBool() == true); @@ -912,6 +998,7 @@ TEST_CASE("conversions") CHECK(val.asString() == ""); CHECK_THROWS_AS(val.asInt(), std::exception); CHECK_THROWS_AS(val.asUInt(), std::exception); + CHECK_THROWS_AS(val.asAbsUInt(), std::exception); CHECK_THROWS_AS(val.asDouble(), std::exception); CHECK(val.asBool() == false); @@ -932,6 +1019,7 @@ TEST_CASE("conversions") CHECK(val.asString() == "false"); CHECK(val.asInt() == 0); CHECK(val.asUInt() == 0); + CHECK(val.asAbsUInt() == 0); CHECK(val.asDouble() == 0.0); CHECK(val.asBool() == false); @@ -952,6 +1040,7 @@ TEST_CASE("conversions") CHECK(val.asString() == "true"); CHECK(val.asInt() == 1); CHECK(val.asUInt() == 1); + CHECK(val.asAbsUInt() == 1); CHECK(val.asDouble() == 1.0); CHECK(val.asBool() == true); @@ -972,6 +1061,7 @@ TEST_CASE("conversions") CHECK_THROWS_AS(val.asString(), Json::error); CHECK_THROWS_AS(val.asInt(), Json::error); CHECK_THROWS_AS(val.asUInt(), Json::error); + CHECK_THROWS_AS(val.asAbsUInt(), Json::error); CHECK_THROWS_AS(val.asDouble(), Json::error); CHECK(val.asBool() == false); // empty or not @@ -992,6 +1082,7 @@ TEST_CASE("conversions") CHECK_THROWS_AS(val.asString(), Json::error); CHECK_THROWS_AS(val.asInt(), Json::error); CHECK_THROWS_AS(val.asUInt(), Json::error); + CHECK_THROWS_AS(val.asAbsUInt(), Json::error); CHECK_THROWS_AS(val.asDouble(), Json::error); CHECK(val.asBool() == false); // empty or not diff --git a/src/xrpld/app/ledger/detail/LedgerReplayTask.cpp b/src/xrpld/app/ledger/detail/LedgerReplayTask.cpp index a9d2657f9b6..a8847e59a9e 100644 --- a/src/xrpld/app/ledger/detail/LedgerReplayTask.cpp +++ b/src/xrpld/app/ledger/detail/LedgerReplayTask.cpp @@ -299,7 +299,7 @@ LedgerReplayTask::addDelta(std::shared_ptr const& delta) deltas_.empty() || deltas_.back()->ledgerSeq_ + 1 == delta->ledgerSeq_, "ripple::LedgerReplayTask::addDelta : no deltas or consecutive " - "sequence", ); + "sequence"); deltas_.push_back(delta); } } diff --git a/src/xrpld/app/ledger/detail/OpenLedger.cpp b/src/xrpld/app/ledger/detail/OpenLedger.cpp index 5bba544e311..64a080053fd 100644 --- a/src/xrpld/app/ledger/detail/OpenLedger.cpp +++ b/src/xrpld/app/ledger/detail/OpenLedger.cpp @@ -124,8 +124,11 @@ OpenLedger::accept( auto const txId = tx->getTransactionID(); // skip batch txns + // The flag should only be settable if Batch feature is enabled. If + // Batch is not enabled, the flag is always invalid, so don't relay it + // regardless. // LCOV_EXCL_START - if (tx->isFlag(tfInnerBatchTxn) && rules.enabled(featureBatch)) + if (tx->isFlag(tfInnerBatchTxn)) { XRPL_ASSERT( txpair.second && txpair.second->isFieldPresent(sfParentBatchID), diff --git a/src/xrpld/app/main/Main.cpp b/src/xrpld/app/main/Main.cpp index 3fdf362dd9a..0f49f78d1c4 100644 --- a/src/xrpld/app/main/Main.cpp +++ b/src/xrpld/app/main/Main.cpp @@ -168,7 +168,7 @@ printHelp(po::options_description const& desc) " server_state [counters]\n" " sign [offline]\n" " sign_for " - "[offline]\n" + "[offline] []\n" " stop\n" " simulate [|] []\n" " submit |[ ]\n" diff --git a/src/xrpld/app/misc/LendingHelpers.h b/src/xrpld/app/misc/LendingHelpers.h new file mode 100644 index 00000000000..559af28a471 --- /dev/null +++ b/src/xrpld/app/misc/LendingHelpers.h @@ -0,0 +1,444 @@ +#ifndef XRPL_APP_MISC_LENDINGHELPERS_H_INCLUDED +#define XRPL_APP_MISC_LENDINGHELPERS_H_INCLUDED + +#include +#include + +namespace ripple { + +struct PreflightContext; + +// Lending protocol has dependencies, so capture them here. +bool +checkLendingProtocolDependencies(PreflightContext const& ctx); + +static constexpr std::uint32_t secondsInYear = 365 * 24 * 60 * 60; + +Number +loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval); + +/// Ensure the periodic payment is always rounded consistently +inline Number +roundPeriodicPayment( + Asset const& asset, + Number const& periodicPayment, + std::int32_t scale) +{ + return roundToAsset(asset, periodicPayment, scale, Number::upward); +} + +/* Represents the breakdown of amounts to be paid and changes applied to the + * Loan object while processing a loan payment. + * + * This structure is returned after processing a loan payment transaction and + * captures the amounts that need to be paid. The actual ledger entry changes + * are made in LoanPay based on this structure values. + * + * The sum of principalPaid, interestPaid, and feePaid represents the total + * amount to be deducted from the borrower's account. The valueChange field + * tracks whether the loan's total value increased or decreased beyond normal + * amortization. + * + * This structure is explained in the XLS-66 spec, section 3.2.4.2 (Payment + * Processing). + */ +struct LoanPaymentParts +{ + // The amount of principal paid that reduces the loan balance. + // This amount is subtracted from sfPrincipalOutstanding in the Loan object + // and paid to the Vault + Number principalPaid = numZero; + + // The total amount of interest paid to the Vault. + // This includes: + // - Tracked interest from the amortization schedule + // - Untracked interest (e.g., late payment penalty interest) + // This value is always non-negative. + Number interestPaid = numZero; + + // The change in the loan's total value outstanding. + // - If valueChange < 0: Loan value decreased + // - If valueChange > 0: Loan value increased + // - If valueChange = 0: No value adjustment + // + // For regular on-time payments, this is always 0. Non-zero values occur + // when: + // - Overpayments reduce the loan balance beyond the scheduled amount + // - Late payments add penalty interest to the loan value + // - Early full payment may increase or decrease the loan value based on + // terms + Number valueChange = numZero; + + /* The total amount of fees paid to the Broker. + * This includes: + * - Tracked management fees from the amortization schedule + * - Untracked fees (e.g., late payment fees, service fees, origination + * fees) This value is always non-negative. + */ + Number feePaid = numZero; + + LoanPaymentParts& + operator+=(LoanPaymentParts const& other); + + bool + 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. + * + * Many of the fields can be derived from each other, but they're all provided + * here to reduce code duplication and possible mistakes. + * e.g. + * * interestOutstanding = valueOutstanding - principalOutstanding + * * interestDue = interestOutstanding - managementFeeDue + */ +struct LoanState +{ + // Total value still due to be paid by the borrower. + Number valueOutstanding; + // Principal still due to be paid by the borrower. + Number principalOutstanding; + // Interest still due to be paid to the Vault. + // This is a portion of interestOutstanding + Number interestDue; + // Management fee still due to be paid to the broker. + // This is a portion of interestOutstanding + Number managementFeeDue; + + // Interest still due to be paid by the borrower. + Number + interestOutstanding() const + { + XRPL_ASSERT_PARTS( + interestDue + managementFeeDue == + valueOutstanding - principalOutstanding, + "ripple::LoanState::interestOutstanding", + "other values add up correctly"); + return interestDue + managementFeeDue; + } +}; + +// 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. +template +void +adjustImpreciseNumber( + NumberProxy value, + Number const& adjustment, + Asset const& asset, + int vaultScale) +{ + value = roundToAsset(asset, value + adjustment, vaultScale); + + if (*value < beast::zero) + value = 0; +} + +inline int +getVaultScale(SLE::const_ref vaultSle) +{ + if (!vaultSle) + return Number::minExponent - 1; // LCOV_EXCL_LINE + return vaultSle->at(sfAssetsTotal).exponent(); +} + +TER +checkLoanGuards( + Asset const& vaultAsset, + Number const& principalRequested, + bool expectInterest, + std::uint32_t paymentTotal, + LoanProperties const& properties, + beast::Journal j); + +LoanState +computeRawLoanState( + 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( + Number const& totalValueOutstanding, + Number const& principalOutstanding, + Number const& managementFeeOutstanding); + +// Constructs a valid LoanState object from a Loan object, which always has +// rounded values +LoanState +constructRoundedLoanState(SLE::const_ref loan); + +Number +computeManagementFee( + Asset const& asset, + Number const& interest, + TenthBips32 managementFeeRate, + std::int32_t scale); + +Number +computeFullPaymentInterest( + Number const& rawPrincipalOutstanding, + Number const& periodicRate, + NetClock::time_point parentCloseTime, + std::uint32_t paymentInterval, + std::uint32_t prevPaymentDate, + 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 + +enum class PaymentSpecialCase { none, final, extra }; + +/* Represents a single loan payment component parts. + +* This structure captures the "delta" (change) values that will be applied to +* the tracked fields in the Loan ledger object when a payment is processed. +* +* These are called "deltas" because they represent the amount by which each +* corresponding field in the Loan object will be reduced. +* They are "tracked" as they change tracked loan values. +*/ +struct PaymentComponents +{ + // The change in total value outstanding for this payment. + // This amount will be subtracted from sfTotalValueOutstanding in the Loan + // object. Equal to the sum of trackedPrincipalDelta, + // trackedInterestPart(), and trackedManagementFeeDelta. + Number trackedValueDelta; + + // The change in principal outstanding for this payment. + // This amount will be subtracted from sfPrincipalOutstanding in the Loan + // object, representing the portion of the payment that reduces the + // original loan amount. + Number trackedPrincipalDelta; + + // The change in management fee outstanding for this payment. + // This amount will be subtracted from sfManagementFeeOutstanding in the + // Loan object. This represents only the tracked management fees from the + // amortization schedule and does not include additional untracked fees + // (such as late payment fees) that go directly to the broker. + Number trackedManagementFeeDelta; + + // Indicates if this payment has special handling requirements. + // - none: Regular scheduled payment + // - final: The last payment that closes out the loan + // - extra: An additional payment beyond the regular schedule (overpayment) + PaymentSpecialCase specialCase = PaymentSpecialCase::none; + + // Calculates the tracked interest portion of this payment. + // This is derived from the other components as: + // trackedValueDelta - trackedPrincipalDelta - trackedManagementFeeDelta + // + // @return The amount of tracked interest included in this payment that + // will be paid to the vault. + Number + trackedInterestPart() const; +}; + +/* Extends PaymentComponents with untracked payment amounts. + * + * This structure adds untracked fees and interest to the base + * PaymentComponents, representing amounts that don't affect the Loan object's + * tracked state but are still part of the total payment due from the borrower. + * + * Untracked amounts include: + * - Late payment fees that go directly to the Broker + * - Late payment penalty interest that goes directly to the Vault + * - Service fees + * + * The key distinction is that tracked amounts reduce the Loan object's state + * (sfTotalValueOutstanding, sfPrincipalOutstanding, + * sfManagementFeeOutstanding), while untracked amounts are paid directly to the + * recipient without affecting the loan's amortization schedule. + */ +struct ExtendedPaymentComponents : public PaymentComponents +{ + // Additional management fees that go directly to the Broker. + // This includes fees not part of the standard amortization schedule + // (e.g., late fees, service fees, origination fees). + // This value may be negative, though the final value returned in + // LoanPaymentParts.feePaid will never be negative. + Number untrackedManagementFee; + + // Additional interest that goes directly to the Vault. + // This includes interest not part of the standard amortization schedule + // (e.g., late payment penalty interest). + // This value may be negative, though the final value returned in + // LoanPaymentParts.interestPaid will never be negative. + Number untrackedInterest; + + // The complete amount due from the borrower for this payment. + // Calculated as: trackedValueDelta + untrackedInterest + + // untrackedManagementFee + // + // This value is used to validate that the payment amount provided by the + // borrower is sufficient to cover all components of the payment. + Number totalDue; + + ExtendedPaymentComponents( + PaymentComponents const& p, + Number fee, + Number interest = numZero) + : PaymentComponents(p) + , untrackedManagementFee(fee) + , untrackedInterest(interest) + , totalDue( + trackedValueDelta + untrackedInterest + untrackedManagementFee) + { + } +}; + +/* Represents the differences between two loan states. + * + * This structure is used to capture the change in each component of a loan's + * state, typically when computing the difference between two LoanState objects + * (e.g., before and after a payment). It is a convenient way to capture changes + * in each component. How that difference is used depends on the context. + */ +struct LoanStateDeltas +{ + // The difference in principal outstanding between two loan states. + Number principal; + + // The difference in interest due between two loan states. + Number interest; + + // The difference in management fee outstanding between two loan states. + Number managementFee; + + /* Calculates the total change across all components. + * @return The sum of principal, interest, and management fee deltas. + */ + Number + total() const + { + return principal + interest + managementFee; + } + + // Ensures all delta values are non-negative. + void + nonNegative(); +}; + +PaymentComponents +computePaymentComponents( + Asset const& asset, + std::int32_t scale, + Number const& totalValueOutstanding, + Number const& principalOutstanding, + Number const& managementFeeOutstanding, + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentRemaining, + TenthBips16 managementFeeRate); + +} // namespace detail + +detail::LoanStateDeltas +operator-(LoanState const& lhs, LoanState const& rhs); + +LoanState +operator-(LoanState const& lhs, detail::LoanStateDeltas const& rhs); + +LoanState +operator+(LoanState const& lhs, detail::LoanStateDeltas const& rhs); + +LoanProperties +computeLoanProperties( + Asset const& asset, + Number principalOutstanding, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining, + TenthBips32 managementFeeRate, + std::int32_t minimumScale); + +bool +isRounded(Asset const& asset, Number const& value, std::int32_t scale); + +// Indicates what type of payment is being made. +// regular, late, and full are mutually exclusive. +// overpayment is an "add on" to a regular payment, and follows that path with +// potential extra work at the end. +enum class LoanPaymentType { regular = 0, late, full, overpayment }; + +Expected +loanMakePayment( + Asset const& asset, + ApplyView& view, + SLE::ref loan, + SLE::const_ref brokerSle, + STAmount const& amount, + LoanPaymentType const paymentType, + beast::Journal j); + +} // namespace ripple + +#endif // XRPL_APP_MISC_LENDINGHELPERS_H_INCLUDED diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp index 6609815d3be..3367bcd6a8b 100644 --- a/src/xrpld/app/misc/NetworkOPs.cpp +++ b/src/xrpld/app/misc/NetworkOPs.cpp @@ -1692,10 +1692,11 @@ NetworkOPsImp::apply(std::unique_lock& batchLock) app_.getHashRouter().shouldRelay(e.transaction->getID()); if (auto const sttx = *(e.transaction->getSTransaction()); toSkip && - // Skip relaying if it's an inner batch txn and batch - // feature is enabled - !(sttx.isFlag(tfInnerBatchTxn) && - newOL->rules().enabled(featureBatch))) + // Skip relaying if it's an inner batch txn. The flag should + // 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)) { protocol::TMTransaction tx; Serializer s; @@ -3058,9 +3059,11 @@ NetworkOPsImp::pubProposedTransaction( std::shared_ptr const& transaction, TER result) { - // never publish an inner txn inside a batch txn - if (transaction->isFlag(tfInnerBatchTxn) && - ledger->rules().enabled(featureBatch)) + // never publish an inner txn inside a batch txn. The flag should + // only be set if the Batch feature is enabled. If Batch is not + // enabled, the flag is always invalid, so don't publish it + // regardless. + if (transaction->isFlag(tfInnerBatchTxn)) return; MultiApiJson jvObj = diff --git a/src/xrpld/app/misc/detail/LendingHelpers.cpp b/src/xrpld/app/misc/detail/LendingHelpers.cpp new file mode 100644 index 00000000000..8020b47ba94 --- /dev/null +++ b/src/xrpld/app/misc/detail/LendingHelpers.cpp @@ -0,0 +1,2058 @@ +#include +// DO NOT REMOVE forces header file include to sort first +#include + +namespace ripple { + +bool +checkLendingProtocolDependencies(PreflightContext const& ctx) +{ + return ctx.rules.enabled(featureSingleAssetVault) && + VaultCreate::checkExtraFeatures(ctx); +} + +LoanPaymentParts& +LoanPaymentParts::operator+=(LoanPaymentParts const& other) +{ + XRPL_ASSERT( + + other.principalPaid >= beast::zero, + "ripple::LoanPaymentParts::operator+= : other principal " + "non-negative"); + XRPL_ASSERT( + other.interestPaid >= beast::zero, + "ripple::LoanPaymentParts::operator+= : other interest paid " + "non-negative"); + XRPL_ASSERT( + other.feePaid >= beast::zero, + "ripple::LoanPaymentParts::operator+= : other fee paid " + "non-negative"); + + principalPaid += other.principalPaid; + interestPaid += other.interestPaid; + valueChange += other.valueChange; + feePaid += other.feePaid; + return *this; +} + +bool +LoanPaymentParts::operator==(LoanPaymentParts const& other) const +{ + return principalPaid == other.principalPaid && + interestPaid == other.interestPaid && + valueChange == other.valueChange && feePaid == other.feePaid; +} + +/* Converts annualized interest rate to per-payment-period rate. + * The rate is prorated based on the payment interval in seconds. + * + * Equation (1) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval) +{ + // Need floating point math, since we're dividing by a large number + return tenthBipsOfValue(Number(paymentInterval), interestRate) / + secondsInYear; +} + +/* Checks if a value is already rounded to the specified scale. + * Returns true if rounding down and rounding up produce the same result, + * indicating no further precision exists beyond the scale. + */ +bool +isRounded(Asset const& asset, Number const& value, std::int32_t scale) +{ + return roundToAsset(asset, value, scale, Number::downward) == + roundToAsset(asset, value, scale, Number::upward); +} + +namespace detail { + +void +LoanStateDeltas::nonNegative() +{ + if (principal < beast::zero) + principal = numZero; + if (interest < beast::zero) + interest = numZero; + if (managementFee < beast::zero) + managementFee = numZero; +} + +/* Computes (1 + periodicRate)^paymentsRemaining for amortization calculations. + * + * Equation (5) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +computeRaisedRate(Number const& periodicRate, std::uint32_t paymentsRemaining) +{ + return power(1 + periodicRate, paymentsRemaining); +} + +/* Computes the payment factor used in standard amortization formulas. + * This factor converts principal to periodic payment amount. + * + * Equation (6) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +computePaymentFactor( + Number const& periodicRate, + std::uint32_t paymentsRemaining) +{ + // For zero interest, payment factor is simply 1/paymentsRemaining + if (periodicRate == beast::zero) + return Number{1} / paymentsRemaining; + + Number const raisedRate = + computeRaisedRate(periodicRate, paymentsRemaining); + + return (periodicRate * raisedRate) / (raisedRate - 1); +} + +/* Calculates the periodic payment amount using standard amortization formula. + * For interest-free loans, returns principal divided equally across payments. + * + * Equation (7) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +loanPeriodicPayment( + Number const& principalOutstanding, + Number const& periodicRate, + std::uint32_t paymentsRemaining) +{ + if (principalOutstanding == 0 || paymentsRemaining == 0) + return 0; + + // Interest-free loans: equal principal payments + if (periodicRate == beast::zero) + return principalOutstanding / paymentsRemaining; + + return principalOutstanding * + 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. + * + * Equation (10) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +loanPrincipalFromPeriodicPayment( + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentsRemaining) +{ + if (periodicRate == 0) + return periodicPayment * paymentsRemaining; + + return periodicPayment / + 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. + * + * Equation (33) from XLS-66 spec, Section A-2 Equation Glossary + */ +std::pair +computeInterestAndFeeParts( + Asset const& asset, + Number const& interest, + TenthBips16 managementFeeRate, + std::int32_t loanScale) +{ + auto const fee = + computeManagementFee(asset, interest, managementFeeRate, loanScale); + + return std::make_pair(interest - fee, fee); +} + +/* Calculates penalty interest accrued on overdue payments. + * Returns 0 if payment is not late. + * + * Equation (16) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +loanLatePaymentInterest( + Number const& principalOutstanding, + TenthBips32 lateInterestRate, + NetClock::time_point parentCloseTime, + std::uint32_t nextPaymentDueDate) +{ + auto const now = parentCloseTime.time_since_epoch().count(); + + // If the payment is not late by any amount of time, then there's no late + // interest + if (now <= nextPaymentDueDate) + return 0; + + // Equation (3) from XLS-66 spec, Section A-2 Equation Glossary + auto const secondsOverdue = now - nextPaymentDueDate; + + auto const rate = loanPeriodicRate(lateInterestRate, secondsOverdue); + + return principalOutstanding * rate; +} + +/* Calculates interest accrued since the last payment based on time elapsed. + * Returns 0 if loan is paid ahead of schedule. + * + * Equation (27) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +loanAccruedInterest( + Number const& principalOutstanding, + Number const& periodicRate, + NetClock::time_point parentCloseTime, + std::uint32_t startDate, + std::uint32_t prevPaymentDate, + std::uint32_t paymentInterval) +{ + if (periodicRate == beast::zero) + return numZero; + + auto const lastPaymentDate = std::max(prevPaymentDate, startDate); + auto const now = parentCloseTime.time_since_epoch().count(); + + // If the loan has been paid ahead, then "lastPaymentDate" is in the future, + // and no interest has accrued. + if (now <= lastPaymentDate) + return numZero; + + // Equation (4) from XLS-66 spec, Section A-2 Equation Glossary + auto const secondsSinceLastPayment = now - lastPaymentDate; + + // Division is more likely to introduce rounding errors, which will then get + // amplified by multiplication. Therefore, we first multiply, and only then + // divide. + return principalOutstanding * periodicRate * secondsSinceLastPayment / + paymentInterval; +} + +/* Applies a payment to the loan state and returns the breakdown of amounts + * paid. + * + * This is the core function that updates the Loan ledger object fields based on + * a computed payment. + + * The function is templated to work with both direct Number/uint32_t values + * (for testing/simulation) and ValueProxy types (for actual ledger updates). + */ +template +LoanPaymentParts +doPayment( + ExtendedPaymentComponents const& payment, + NumberProxy& totalValueOutstandingProxy, + NumberProxy& principalOutstandingProxy, + NumberProxy& managementFeeOutstandingProxy, + UInt32Proxy& paymentRemainingProxy, + UInt32Proxy& prevPaymentDateProxy, + UInt32OptionalProxy& nextDueDateProxy, + std::uint32_t paymentInterval) +{ + XRPL_ASSERT_PARTS( + nextDueDateProxy, + "ripple::detail::doPayment", + "Next due date proxy set"); + + if (payment.specialCase == PaymentSpecialCase::final) + { + XRPL_ASSERT_PARTS( + principalOutstandingProxy == payment.trackedPrincipalDelta, + "ripple::detail::doPayment", + "Full principal payment"); + XRPL_ASSERT_PARTS( + totalValueOutstandingProxy == payment.trackedValueDelta, + "ripple::detail::doPayment", + "Full value payment"); + XRPL_ASSERT_PARTS( + managementFeeOutstandingProxy == payment.trackedManagementFeeDelta, + "ripple::detail::doPayment", + "Full management fee payment"); + + // Mark the loan as complete + paymentRemainingProxy = 0; + + // Record when the final payment was made + prevPaymentDateProxy = *nextDueDateProxy; + + // Clear the next due date. Setting it to 0 causes + // it to be removed from the Loan ledger object, saving space. + nextDueDateProxy = 0; + + // Zero out all tracked loan balances to mark the loan as paid off. + // These will be removed from the Loan object since they're default + // values. + principalOutstandingProxy = 0; + totalValueOutstandingProxy = 0; + managementFeeOutstandingProxy = 0; + } + else + { + // For regular payments (not overpayments), advance the payment schedule + if (payment.specialCase != PaymentSpecialCase::extra) + { + paymentRemainingProxy -= 1; + + prevPaymentDateProxy = nextDueDateProxy; + nextDueDateProxy += paymentInterval; + } + XRPL_ASSERT_PARTS( + principalOutstandingProxy > payment.trackedPrincipalDelta, + "ripple::detail::doPayment", + "Partial principal payment"); + XRPL_ASSERT_PARTS( + totalValueOutstandingProxy > payment.trackedValueDelta, + "ripple::detail::doPayment", + "Partial value payment"); + // Management fees are expected to be relatively small, and could get to + // zero before the loan is paid off + XRPL_ASSERT_PARTS( + managementFeeOutstandingProxy >= payment.trackedManagementFeeDelta, + "ripple::detail::doPayment", + "Valid management fee"); + + // Apply the payment deltas to reduce the outstanding balances + principalOutstandingProxy -= payment.trackedPrincipalDelta; + totalValueOutstandingProxy -= payment.trackedValueDelta; + managementFeeOutstandingProxy -= payment.trackedManagementFeeDelta; + } + + // Principal can never exceed total value (principal is part of total value) + XRPL_ASSERT_PARTS( + // Use an explicit cast because the template parameter can be + // ValueProxy or Number + static_cast(principalOutstandingProxy) <= + static_cast(totalValueOutstandingProxy), + "ripple::detail::doPayment", + "principal does not exceed total"); + + XRPL_ASSERT_PARTS( + // Use an explicit cast because the template parameter can be + // ValueProxy or Number + static_cast(managementFeeOutstandingProxy) >= beast::zero, + "ripple::detail::doPayment", + "fee outstanding stays valid"); + + return LoanPaymentParts{ + // Principal paid is straightforward - it's the tracked delta + .principalPaid = payment.trackedPrincipalDelta, + + // Interest paid combines: + // 1. Tracked interest from the amortization schedule + // (derived from the tracked deltas) + // 2. Untracked interest (e.g., late payment penalties) + .interestPaid = + payment.trackedInterestPart() + payment.untrackedInterest, + + // Value change represents how the loan's total value changed beyond + // normal amortization. + .valueChange = payment.untrackedInterest, + + // Fee paid combines: + // 1. Tracked management fees from the amortization schedule + // 2. Untracked fees (e.g., late payment fees, service fees) + .feePaid = + payment.trackedManagementFeeDelta + payment.untrackedManagementFee}; +} + +/* Simulates an overpayment to validate it won't break the loan's amortization. + * + * When a borrower pays more than the scheduled amount, the loan needs to be + * re-amortized with a lower principal. This function performs that calculation + * in a "sandbox" using temporary variables, allowing the caller to validate + * the result before committing changes to the actual ledger. + * + * The function preserves accumulated rounding errors across the re-amortization + * to ensure the loan state remains consistent with its payment history. + */ +Expected +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, + 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( + 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; + + // 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, + Number{0}); + + // Compute new loan properties based on the reduced principal. This + // recalculates the periodic payment, total value, and management fees + // for the remaining payment schedule. + auto newLoanProperties = computeLoanProperties( + asset, + newRawPrincipal, + interestRate, + paymentInterval, + paymentRemaining, + managementFeeRate, + loanScale); + + JLOG(j.debug()) << "new periodic payment: " + << newLoanProperties.periodicPayment + << ", new total value: " + << newLoanProperties.totalValueOutstanding + << ", 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) + + 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. + + principalOutstanding = std::clamp( + roundToAsset( + asset, newRaw.principalOutstanding, loanScale, Number::upward), + numZero, + rounded.principalOutstanding); + totalValueOutstanding = std::clamp( + roundToAsset( + asset, + principalOutstanding + newRaw.interestOutstanding(), + loanScale, + Number::upward), + numZero, + rounded.valueOutstanding); + managementFeeOutstanding = std::clamp( + roundToAsset(asset, newRaw.managementFeeDue, loanScale), + numZero, + rounded.managementFeeDue); + + auto const newRounded = constructLoanState( + totalValueOutstanding, principalOutstanding, managementFeeOutstanding); + + // Update newLoanProperties so that checkLoanGuards can make an accurate + // evaluation. + newLoanProperties.totalValueOutstanding = newRounded.valueOutstanding; + + 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; + + // check that the loan is still valid + if (auto const ter = checkLoanGuards( + asset, + principalOutstanding, + // The loan may have been created with interest, but for + // 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, + paymentRemaining, + newLoanProperties, + j)) + { + JLOG(j.warn()) << "Principal overpayment would cause the loan to be in " + "an invalid state. Ignore the overpayment"; + + return Unexpected(tesSUCCESS); + } + + // 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) + { + // LCOV_EXCL_START + JLOG(j.warn()) << "Overpayment not allowed: Computed loan " + "properties are invalid. Does " + "not compute. TotalValueOutstanding: " + << newLoanProperties.totalValueOutstanding + << ", PeriodicPayment : " + << newLoanProperties.periodicPayment + << ", ManagementFeeOwedToBroker: " + << newLoanProperties.managementFeeOwedToBroker; + return Unexpected(tesSUCCESS); + // LCOV_EXCL_STOP + } + + auto const deltas = rounded - newRounded; + + auto const hypotheticalValueOutstanding = + rounded.valueOutstanding - deltas.principal; + + // 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; + if (valueChange > 0) + { + JLOG(j.warn()) << "Principal overpayment would increase the value of " + "the loan. Ignore the overpayment"; + 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}; +} + +/* Validates and applies an overpayment to the loan state. + * + * This function acts as a wrapper around tryOverpayment(), performing the + * re-amortization calculation in a sandbox (using temporary copies of the + * loan state), then validating the results before committing them to the + * actual ledger via the proxy objects. + * + * The two-step process (try in sandbox, then commit) ensures that if the + * overpayment would leave the loan in an invalid state, we can reject it + * gracefully without corrupting the ledger data. + */ +template +Expected +doOverpayment( + Asset const& asset, + std::int32_t loanScale, + ExtendedPaymentComponents const& overpaymentComponents, + NumberProxy& totalValueOutstandingProxy, + 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; + + JLOG(j.debug()) + << "overpayment components:" + << ", totalValue before: " << *totalValueOutstandingProxy + << ", valueDelta: " << overpaymentComponents.trackedValueDelta + << ", principalDelta: " << overpaymentComponents.trackedPrincipalDelta + << ", managementFeeDelta: " + << overpaymentComponents.trackedManagementFeeDelta + << ", interestPart: " << overpaymentComponents.trackedInterestPart() + << ", untrackedInterest: " << overpaymentComponents.untrackedInterest + << ", totalDue: " << overpaymentComponents.totalDue + << ", payments remaining :" << paymentRemaining; + + // Attempt to re-amortize the loan with the overpayment applied. + // This modifies the temporary copies, leaving the proxies unchanged. + auto const ret = tryOverpayment( + asset, + loanScale, + overpaymentComponents, + totalValueOutstanding, + principalOutstanding, + managementFeeOutstanding, + periodicPayment, + interestRate, + paymentInterval, + periodicRate, + paymentRemaining, + prevPaymentDate, + nextDueDate, + managementFeeRate, + j); + if (!ret) + return Unexpected(ret.error()); + + auto const& loanPaymentParts = *ret; + + // 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) + { + // LCOV_EXCL_START + JLOG(j.warn()) << "Overpayment not allowed: principal " + << "outstanding did not decrease. Before: " + << *principalOutstandingProxy + << ". After: " << principalOutstanding; + return Unexpected(tesSUCCESS); + // LCOV_EXCL_STOP + } + + // The proxies still hold the original (pre-overpayment) values, which + // allows us to compute deltas and verify they match what we expect + // from the overpaymentComponents and loanPaymentParts. + + XRPL_ASSERT_PARTS( + overpaymentComponents.trackedPrincipalDelta == + principalOutstandingProxy - principalOutstanding, + "ripple::detail::doOverpayment", + "principal change agrees"); + + XRPL_ASSERT_PARTS( + overpaymentComponents.trackedManagementFeeDelta == + managementFeeOutstandingProxy - managementFeeOutstanding, + "ripple::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); + + XRPL_ASSERT_PARTS( + loanPaymentParts.valueChange == + totalValueOutstanding - + (totalValueOutstandingProxy - + overpaymentComponents.trackedPrincipalDelta) + + overpaymentComponents.trackedInterestPart(), + "ripple::detail::doOverpayment", + "interest paid agrees"); + + XRPL_ASSERT_PARTS( + overpaymentComponents.trackedPrincipalDelta == + loanPaymentParts.principalPaid, + "ripple::detail::doOverpayment", + "principal payment matches"); + + XRPL_ASSERT_PARTS( + loanPaymentParts.feePaid == + overpaymentComponents.untrackedManagementFee + + overpaymentComponents.trackedManagementFeeDelta, + "ripple::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; + + return loanPaymentParts; +} + +/* Computes the payment components for a late payment. + * + * A late payment is made after the grace period has expired and includes: + * 1. All components of a regular periodic payment + * 2. Late payment penalty interest (accrued since the due date) + * 3. Late payment fee charged by the broker + * + * The late penalty interest increases the loan's total value (the borrower + * owes more than scheduled), while the regular payment components follow + * the normal amortization schedule. + * + * Implements equation (15) from XLS-66 spec, Section A-2 Equation Glossary + */ +Expected +computeLatePayment( + Asset const& asset, + ApplyView const& view, + Number const& principalOutstanding, + std::int32_t nextDueDate, + ExtendedPaymentComponents const& periodic, + TenthBips32 lateInterestRate, + std::int32_t loanScale, + Number const& latePaymentFee, + STAmount const& amount, + TenthBips16 managementFeeRate, + beast::Journal j) +{ + // Check if the due date has passed. If not, reject the payment as + // being too soon + if (!hasExpired(view, nextDueDate)) + return Unexpected(tecTOO_SOON); + + // Calculate the penalty interest based on how long the payment is overdue. + auto const latePaymentInterest = loanLatePaymentInterest( + principalOutstanding, + lateInterestRate, + view.parentCloseTime(), + nextDueDate); + + // Round the late interest and split it between the vault (net interest) + // and the broker (management fee portion). This lambda ensures we + // round before splitting to maintain precision. + auto const [roundedLateInterest, roundedLateManagementFee] = [&]() { + auto const interest = + roundToAsset(asset, latePaymentInterest, loanScale); + return computeInterestAndFeeParts( + asset, interest, managementFeeRate, loanScale); + }(); + + XRPL_ASSERT( + roundedLateInterest >= 0, + "ripple::detail::computeLatePayment : valid late interest"); + XRPL_ASSERT_PARTS( + periodic.specialCase != PaymentSpecialCase::extra, + "ripple::detail::computeLatePayment", + "no extra parts to this payment"); + + // Create the late payment components by copying the regular periodic + // payment and adding the late penalties. We use a lambda to construct + // this to keep the logic clear. This preserves all the other fields without + // having to enumerate them. + + ExtendedPaymentComponents const late = [&]() { + auto inner = periodic; + + 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}; + }(); + + XRPL_ASSERT_PARTS( + isRounded(asset, late.totalDue, loanScale), + "ripple::detail::computeLatePayment", + "total due is rounded"); + + // Check that the borrower provided enough funds to cover the late payment. + // The late payment is more expensive than a regular payment due to the + // penalties. + if (amount < late.totalDue) + { + JLOG(j.warn()) << "Late loan payment amount is insufficient. Due: " + << late.totalDue << ", paid: " << amount; + return Unexpected(tecINSUFFICIENT_PAYMENT); + } + + return late; +} + +/* Computes payment components for paying off a loan early (before final + * payment). + * + * A full payment closes the loan immediately, paying off all outstanding + * balances plus a prepayment penalty and any accrued interest since the last + * payment. This is different from the final scheduled payment, which has no + * prepayment penalty. + * + * The function calculates: + * - Accrued interest since last payment (time-based) + * - Prepayment penalty (percentage of remaining principal) + * - Close payment fee (fixed fee for early closure) + * - All remaining principal and outstanding fees + * + * The loan's value may increase or decrease depending on whether the prepayment + * penalty exceeds the scheduled interest that would have been paid. + * + * Implements equation (26) from XLS-66 spec, Section A-2 Equation Glossary + */ +Expected +computeFullPayment( + Asset const& asset, + ApplyView& view, + Number const& principalOutstanding, + Number const& managementFeeOutstanding, + Number const& periodicPayment, + std::uint32_t paymentRemaining, + std::uint32_t prevPaymentDate, + std::uint32_t const startDate, + std::uint32_t const paymentInterval, + TenthBips32 const closeInterestRate, + std::int32_t loanScale, + Number const& totalInterestOutstanding, + Number const& periodicRate, + Number const& closePaymentFee, + STAmount const& amount, + TenthBips16 managementFeeRate, + beast::Journal j) +{ + // Full payment must be made before the final scheduled payment. + if (paymentRemaining <= 1) + { + // If this is the last payment, it has to be a regular payment + JLOG(j.warn()) << "Last payment cannot be a full payment."; + return Unexpected(tecKILLED); + } + + // 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); + + // Full payment interest includes both accrued interest (time since last + // payment) and prepayment penalty (for closing early). + auto const fullPaymentInterest = computeFullPaymentInterest( + rawPrincipalOutstanding, + periodicRate, + view.parentCloseTime(), + paymentInterval, + prevPaymentDate, + startDate, + closeInterestRate); + + // Split the full payment interest into net interest (to vault) and + // management fee (to broker), applying proper rounding. + auto const [roundedFullInterest, roundedFullManagementFee] = [&]() { + auto const interest = roundToAsset( + asset, fullPaymentInterest, loanScale, Number::downward); + auto const parts = computeInterestAndFeeParts( + asset, interest, managementFeeRate, loanScale); + return std::make_tuple(parts.first, parts.second); + }(); + + ExtendedPaymentComponents const full{ + PaymentComponents{ + // Pay off all tracked outstanding balances: principal, interest, + // and fees. + // This marks the loan as complete (final payment). + .trackedValueDelta = principalOutstanding + + totalInterestOutstanding + managementFeeOutstanding, + .trackedPrincipalDelta = principalOutstanding, + + // All outstanding management fees are paid. This zeroes out the + // tracked fee balance. + .trackedManagementFeeDelta = managementFeeOutstanding, + .specialCase = PaymentSpecialCase::final, + }, + + // Untracked management fee includes: + // 1. Close payment fee (fixed fee for early closure) + // 2. Management fee on the full payment interest + // 3. Minus the outstanding tracked fee (already accounted for above) + // This can be negative because the outstanding fee is subtracted, but + // it gets combined with trackedManagementFeeDelta in the final + // accounting. + closePaymentFee + roundedFullManagementFee - managementFeeOutstanding, + + // Value change represents the difference between what the loan was + // expected to earn (totalInterestOutstanding) and what it actually + // earns (roundedFullInterest with prepayment penalty). + // - Positive: Prepayment penalty exceeds scheduled interest (loan value + // increases) + // - Negative: Prepayment penalty is less than scheduled interest (loan + // value decreases) + roundedFullInterest - totalInterestOutstanding, + }; + + XRPL_ASSERT_PARTS( + isRounded(asset, full.totalDue, loanScale), + "ripple::detail::computeFullPayment", + "total due is rounded"); + + JLOG(j.trace()) << "computeFullPayment result: periodicPayment: " + << periodicPayment << ", periodicRate: " << periodicRate + << ", paymentRemaining: " << paymentRemaining + << ", rawPrincipalOutstanding: " << rawPrincipalOutstanding + << ", fullPaymentInterest: " << fullPaymentInterest + << ", roundedFullInterest: " << roundedFullInterest + << ", roundedFullManagementFee: " + << roundedFullManagementFee + << ", untrackedInterest: " << full.untrackedInterest; + + if (amount < full.totalDue) + // If the payment is less than the full payment amount, it's not + // sufficient to be a full payment. + return Unexpected(tecINSUFFICIENT_PAYMENT); + + return full; +} + +Number +PaymentComponents::trackedInterestPart() const +{ + return trackedValueDelta - + (trackedPrincipalDelta + trackedManagementFeeDelta); +} + +/* Computes the breakdown of a regular periodic payment into principal, + * interest, and management fee components. + * + * This function determines how a single scheduled payment should be split among + * the three tracked loan components. The calculation accounts for accumulated + * rounding errors. + * + * The algorithm: + * 1. Calculate what the loan state SHOULD be after this payment (target) + * 2. Compare current state to target to get deltas + * 3. Adjust deltas to handle rounding artifacts and edge cases + * 4. Ensure deltas don't exceed available balances or payment amount + * + * Special handling for the final payment: all remaining balances are paid off + * regardless of the periodic payment amount. + */ +PaymentComponents +computePaymentComponents( + Asset const& asset, + std::int32_t scale, + Number const& totalValueOutstanding, + Number const& principalOutstanding, + Number const& managementFeeOutstanding, + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentRemaining, + TenthBips16 managementFeeRate) +{ + XRPL_ASSERT_PARTS( + isRounded(asset, totalValueOutstanding, scale) && + isRounded(asset, principalOutstanding, scale) && + isRounded(asset, managementFeeOutstanding, scale), + "ripple::detail::computePaymentComponents", + "Outstanding values are rounded"); + XRPL_ASSERT_PARTS( + paymentRemaining > 0, + "ripple::detail::computePaymentComponents", + "some payments remaining"); + + auto const roundedPeriodicPayment = + roundPeriodicPayment(asset, periodicPayment, scale); + + // Final payment: pay off everything remaining, ignoring the normal + // periodic payment amount. This ensures the loan completes cleanly. + if (paymentRemaining == 1 || + totalValueOutstanding <= roundedPeriodicPayment) + { + // If there's only one payment left, we need to pay off each of the loan + // parts. + return PaymentComponents{ + .trackedValueDelta = totalValueOutstanding, + .trackedPrincipalDelta = principalOutstanding, + .trackedManagementFeeDelta = managementFeeOutstanding, + .specialCase = PaymentSpecialCase::final}; + } + + // 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( + periodicPayment, periodicRate, paymentRemaining - 1, managementFeeRate); + + // Round the target to the loan's scale to match how actual loan values + // are stored. + LoanState const roundedTarget = LoanState{ + .valueOutstanding = + roundToAsset(asset, trueTarget.valueOutstanding, scale), + .principalOutstanding = + roundToAsset(asset, trueTarget.principalOutstanding, scale), + .interestDue = roundToAsset(asset, trueTarget.interestDue, scale), + .managementFeeDue = + roundToAsset(asset, trueTarget.managementFeeDue, scale)}; + + // Get the current actual loan state from the ledger values + LoanState const currentLedgerState = constructLoanState( + totalValueOutstanding, principalOutstanding, managementFeeOutstanding); + + // The difference between current and target states gives us the payment + // components. Any discrepancies from accumulated rounding are captured + // here. + + LoanStateDeltas deltas = currentLedgerState - roundedTarget; + + // Rounding can occasionally produce negative deltas. Zero them out. + deltas.nonNegative(); + + XRPL_ASSERT_PARTS( + deltas.principal <= currentLedgerState.principalOutstanding, + "ripple::detail::computePaymentComponents", + "principal delta not greater than outstanding"); + + // Cap each component to never exceed what's actually outstanding + deltas.principal = + std::min(deltas.principal, currentLedgerState.principalOutstanding); + + XRPL_ASSERT_PARTS( + deltas.interest <= currentLedgerState.interestDue, + "ripple::detail::computePaymentComponents", + "interest due delta not greater than outstanding"); + + // Cap interest to both the outstanding amount AND what's left of the + // periodic payment after principal is paid + deltas.interest = std::min( + {deltas.interest, + std::max(numZero, roundedPeriodicPayment - deltas.principal), + currentLedgerState.interestDue}); + + XRPL_ASSERT_PARTS( + deltas.managementFee <= currentLedgerState.managementFeeDue, + "ripple::detail::computePaymentComponents", + "management fee due delta not greater than outstanding"); + + // Cap management fee to both the outstanding amount AND what's left of the + // periodic payment after principal and interest are paid + deltas.managementFee = std::min( + {deltas.managementFee, + roundedPeriodicPayment - (deltas.principal + deltas.interest), + currentLedgerState.managementFeeDue}); + + // The shortage must never be negative, which indicates that the parts are + // trying to take more than the whole payment. The excess can be positive, + // which indicates that we're not going to take the whole payment amount, + // but if so, it must be small. + auto takeFrom = [](Number& component, Number& excess) { + if (excess > beast::zero) + { + auto part = std::min(component, excess); + component -= part; + excess -= part; + } + XRPL_ASSERT_PARTS( + excess >= beast::zero, + "ripple::detail::computePaymentComponents", + "excess non-negative"); + }; + // Helper to reduce deltas when they collectively exceed a limit. + // Order matters: we prefer to reduce interest first (most flexible), + // then management fee, then principal (least flexible). + auto addressExcess = [&takeFrom](LoanStateDeltas& deltas, Number& excess) { + // This order is based on where errors are the least problematic + takeFrom(deltas.interest, excess); + takeFrom(deltas.managementFee, excess); + takeFrom(deltas.principal, excess); + }; + + // Check if deltas exceed the total outstanding value. This should never + // happen due to earlier caps, but handle it defensively. + Number totalOverpayment = + deltas.total() - currentLedgerState.valueOutstanding; + + if (totalOverpayment > beast::zero) + { + // LCOV_EXCL_START + UNREACHABLE( + "ripple::detail::computePaymentComponents : payment exceeded loan " + "state"); + addressExcess(deltas, totalOverpayment); + // LCOV_EXCL_STOP + } + + // Check if deltas exceed the periodic payment amount. Reduce if needed. + Number shortage = roundedPeriodicPayment - deltas.total(); + + XRPL_ASSERT_PARTS( + isRounded(asset, shortage, scale), + "ripple::detail::computePaymentComponents", + "shortage is rounded"); + + if (shortage < beast::zero) + { + // Deltas exceed payment amount - reduce them proportionally + Number excess = -shortage; + addressExcess(deltas, excess); + shortage = -excess; + } + + // At this point, shortage >= 0 means we're paying less than the full + // periodic payment (due to rounding or component caps). + // shortage < 0 would mean we're trying to pay more than allowed (bug). + XRPL_ASSERT_PARTS( + shortage >= beast::zero, + "ripple::detail::computePaymentComponents", + "no shortage or excess"); + + // Final validation that all components are valid + XRPL_ASSERT_PARTS( + deltas.total() == + deltas.principal + deltas.interest + deltas.managementFee, + "ripple::detail::computePaymentComponents", + "total value adds up"); + + XRPL_ASSERT_PARTS( + deltas.principal >= beast::zero && + deltas.principal <= currentLedgerState.principalOutstanding, + "ripple::detail::computePaymentComponents", + "valid principal result"); + XRPL_ASSERT_PARTS( + deltas.interest >= beast::zero && + deltas.interest <= currentLedgerState.interestDue, + "ripple::detail::computePaymentComponents", + "valid interest result"); + XRPL_ASSERT_PARTS( + deltas.managementFee >= beast::zero && + deltas.managementFee <= currentLedgerState.managementFeeDue, + "ripple::detail::computePaymentComponents", + "valid fee result"); + + XRPL_ASSERT_PARTS( + deltas.principal + deltas.interest + deltas.managementFee > beast::zero, + "ripple::detail::computePaymentComponents", + "payment parts add to payment"); + + // Final safety clamp to ensure no value exceeds its outstanding balance + return PaymentComponents{ + .trackedValueDelta = std::clamp( + deltas.total(), numZero, currentLedgerState.valueOutstanding), + .trackedPrincipalDelta = std::clamp( + deltas.principal, numZero, currentLedgerState.principalOutstanding), + .trackedManagementFeeDelta = std::clamp( + deltas.managementFee, numZero, currentLedgerState.managementFeeDue), + }; +} + +/* Computes payment components for an overpayment scenario. + * + * An overpayment occurs when a borrower pays more than the scheduled periodic + * payment amount. The overpayment is treated as extra principal reduction, + * but incurs a fee and potentially a penalty interest charge. + * + * The calculation (Section 3.2.4.2.3 from XLS-66 spec): + * 1. Calculate gross penalty interest on the overpayment amount + * 2. Split the gross interest into net interest and management fee + * 3. Calculate the penalty fee + * 4. Determine the principal portion by subtracting the interest (gross) and + * management fee from the overpayment amount + * + * Unlike regular payments which follow the amortization schedule, overpayments + * apply to principal, reducing the loan balance and future interest costs. + * + * Equations (20), (21) and (22) from XLS-66 spec, Section A-2 Equation Glossary + */ +ExtendedPaymentComponents +computeOverpaymentComponents( + Asset const& asset, + int32_t const loanScale, + Number const& overpayment, + TenthBips32 const overpaymentInterestRate, + TenthBips32 const overpaymentFeeRate, + TenthBips16 const managementFeeRate) +{ + XRPL_ASSERT( + overpayment > 0 && isRounded(asset, overpayment, loanScale), + "ripple::detail::computeOverpaymentComponents : valid overpayment " + "amount"); + + // First, deduct the fixed overpayment fee from the total amount. + // This reduces the effective payment that will be applied to the loan. + // Equation (22) from XLS-66 spec, Section A-2 Equation Glossary + Number const overpaymentFee = roundToAsset( + asset, tenthBipsOfValue(overpayment, overpaymentFeeRate), loanScale); + + // Calculate the penalty interest on the effective payment amount. + // 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); + return detail::computeInterestAndFeeParts( + asset, interest, managementFeeRate, loanScale); + }(); + + auto const result = detail::ExtendedPaymentComponents{ + // Build the payment components, after fees and penalty + // interest are deducted, the remainder goes entirely to principal + // reduction. + detail::PaymentComponents{ + .trackedValueDelta = overpayment - overpaymentFee, + .trackedPrincipalDelta = overpayment - roundedOverpaymentInterest - + roundedOverpaymentManagementFee - overpaymentFee, + .trackedManagementFeeDelta = roundedOverpaymentManagementFee, + .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. + roundedOverpaymentInterest}; + XRPL_ASSERT_PARTS( + result.trackedInterestPart() == roundedOverpaymentInterest, + "ripple::detail::computeOverpaymentComponents", + "valid interest computation"); + return result; +} + +} // namespace detail + +detail::LoanStateDeltas +operator-(LoanState const& lhs, LoanState const& rhs) +{ + detail::LoanStateDeltas result{ + .principal = lhs.principalOutstanding - rhs.principalOutstanding, + .interest = lhs.interestDue - rhs.interestDue, + .managementFee = lhs.managementFeeDue - rhs.managementFeeDue, + }; + + return result; +} + +LoanState +operator-(LoanState const& lhs, detail::LoanStateDeltas const& rhs) +{ + LoanState result{ + .valueOutstanding = lhs.valueOutstanding - rhs.total(), + .principalOutstanding = lhs.principalOutstanding - rhs.principal, + .interestDue = lhs.interestDue - rhs.interest, + .managementFeeDue = lhs.managementFeeDue - rhs.managementFee, + }; + + return result; +} + +LoanState +operator+(LoanState const& lhs, detail::LoanStateDeltas const& rhs) +{ + LoanState result{ + .valueOutstanding = lhs.valueOutstanding + rhs.total(), + .principalOutstanding = lhs.principalOutstanding + rhs.principal, + .interestDue = lhs.interestDue + rhs.interest, + .managementFeeDue = lhs.managementFeeDue + rhs.managementFee, + }; + + return result; +} + +TER +checkLoanGuards( + Asset const& vaultAsset, + Number const& principalRequested, + bool expectInterest, + std::uint32_t paymentTotal, + LoanProperties const& properties, + beast::Journal j) +{ + auto const totalInterestOutstanding = + properties.totalValueOutstanding - 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 + if (expectInterest && totalInterestOutstanding <= 0) + { + // Unless this is a zero-interest loan, there must be some interest + // due on the loan, even if it's (measurable) dust + JLOG(j.warn()) << "Loan for " << principalRequested + << " with interest has no interest due"; + return tecPRECISION_LOSS; + } + // Guard 1a: If there is any interest computed over the life of the + // loan, for a zero interest rate, something went sideways. + if (!expectInterest && totalInterestOutstanding > 0) + { + // LCOV_EXCL_START + JLOG(j.warn()) << "Loan for " << principalRequested + << " with no interest has interest due"; + return tecINTERNAL; + // LCOV_EXCL_STOP + } + + // Guard 2: if the principal portion of the first periodic payment is + // too small to be accurately represented with the given rounding mode, + // raise an error + if (properties.firstPaymentPrincipal <= 0) + { + // Check that some true (unrounded) principal is paid each period. + // Since the first payment pays the least principal, if it's good, + // they'll all be good. Note that the outstanding principal is + // rounded, and may not change right away. + JLOG(j.warn()) << "Loan is unable to pay principal."; + return tecPRECISION_LOSS; + } + + // Guard 3: If the periodic payment is so small that it can't even be + // rounded to a representable value, then the loan can't be paid. Also, + // avoids dividing by 0. + auto const roundedPayment = roundPeriodicPayment( + vaultAsset, properties.periodicPayment, properties.loanScale); + if (roundedPayment == beast::zero) + { + JLOG(j.warn()) << "Loan Periodic payment (" + << properties.periodicPayment << ") rounds to 0. "; + return tecPRECISION_LOSS; + } + + // Guard 4: if the rounded periodic payment is large enough that the + // loan can't be amortized in the specified number of payments, raise an + // error + { + NumberRoundModeGuard mg(Number::upward); + + if (std::int64_t const computedPayments{ + properties.totalValueOutstanding / roundedPayment}; + computedPayments != paymentTotal) + { + JLOG(j.warn()) << "Loan Periodic payment (" + << properties.periodicPayment << ") rounding (" + << roundedPayment << ") on a total value of " + << properties.totalValueOutstanding + << " can not complete the loan in the specified " + "number of payments (" + << computedPayments << " != " << paymentTotal << ")"; + return tecPRECISION_LOSS; + } + } + return tesSUCCESS; +} + +/* + * This function calculates the full payment interest accrued since the last + * payment, plus any prepayment penalty. + * + * Equations (27) and (28) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +computeFullPaymentInterest( + Number const& rawPrincipalOutstanding, + Number const& periodicRate, + NetClock::time_point parentCloseTime, + std::uint32_t paymentInterval, + std::uint32_t prevPaymentDate, + std::uint32_t startDate, + TenthBips32 closeInterestRate) +{ + auto const accruedInterest = detail::loanAccruedInterest( + rawPrincipalOutstanding, + periodicRate, + parentCloseTime, + startDate, + prevPaymentDate, + paymentInterval); + XRPL_ASSERT( + accruedInterest >= 0, + "ripple::detail::computeFullPaymentInterest : valid accrued " + "interest"); + + // Equation (28) from XLS-66 spec, Section A-2 Equation Glossary + auto const prepaymentPenalty = closeInterestRate == beast::zero + ? Number{} + : tenthBipsOfValue(rawPrincipalOutstanding, closeInterestRate); + + XRPL_ASSERT( + prepaymentPenalty >= 0, + "ripple::detail::computeFullPaymentInterest : valid prepayment " + "interest"); + + // Part of equation (27) from XLS-66 spec, Section A-2 Equation Glossary + 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 + * hasn't drifted too far from the theoretical values. + * + * The raw 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 + * 4. Ensuring the loan will be fully paid off at the end of its term + * + * If paymentRemaining is 0, returns a fully zeroed-out LoanState, + * representing a completely paid-off loan. + */ +LoanState +computeRawLoanState( + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t const paymentRemaining, + TenthBips32 const managementFeeRate) +{ + if (paymentRemaining == 0) + { + return LoanState{ + .valueOutstanding = 0, + .principalOutstanding = 0, + .interestDue = 0, + .managementFeeDue = 0}; + } + + // Equation (30) from XLS-66 spec, Section A-2 Equation Glossary + Number const rawTotalValueOutstanding = periodicPayment * paymentRemaining; + + Number const rawPrincipalOutstanding = + detail::loanPrincipalFromPeriodicPayment( + periodicPayment, periodicRate, paymentRemaining); + + // Equation (31) from XLS-66 spec, Section A-2 Equation Glossary + Number const rawInterestOutstandingGross = + rawTotalValueOutstanding - rawPrincipalOutstanding; + + // Equation (32) from XLS-66 spec, Section A-2 Equation Glossary + Number const rawManagementFeeOutstanding = + tenthBipsOfValue(rawInterestOutstandingGross, managementFeeRate); + + // Equation (33) from XLS-66 spec, Section A-2 Equation Glossary + Number const rawInterestOutstandingNet = + rawInterestOutstandingGross - rawManagementFeeOutstanding; + + return LoanState{ + .valueOutstanding = rawTotalValueOutstanding, + .principalOutstanding = rawPrincipalOutstanding, + .interestDue = rawInterestOutstandingNet, + .managementFeeDue = rawManagementFeeOutstanding}; +}; + +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 + * 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 + * - constructRoundedLoanState: Builds state from actual rounded ledger values + * + * The interestDue field is derived from the other three values rather than + * stored directly, since it can be calculated as: + * interestDue = totalValueOutstanding - principalOutstanding - + * managementFeeOutstanding + * + * This ensures consistency across the codebase and prevents copy-paste errors + * when creating LoanState objects from Loan ledger data. + */ +LoanState +constructLoanState( + Number const& totalValueOutstanding, + Number const& principalOutstanding, + Number const& managementFeeOutstanding) +{ + // This implementation is pretty trivial, but ensures the calculations + // are consistent everywhere, and reduces copy/paste errors. + return LoanState{ + .valueOutstanding = totalValueOutstanding, + .principalOutstanding = principalOutstanding, + .interestDue = totalValueOutstanding - principalOutstanding - + managementFeeOutstanding, + .managementFeeDue = managementFeeOutstanding}; +} + +LoanState +constructRoundedLoanState(SLE::const_ref loan) +{ + return constructLoanState( + loan->at(sfTotalValueOutstanding), + loan->at(sfPrincipalOutstanding), + loan->at(sfManagementFeeOutstanding)); +} + +/* + * This function calculates the fee owed to the broker based on the asset, + * value, and management fee rate. + * + * Equation (32) from XLS-66 spec, Section A-2 Equation Glossary + */ +Number +computeManagementFee( + Asset const& asset, + Number const& value, + TenthBips32 managementFeeRate, + std::int32_t scale) +{ + return roundToAsset( + asset, + tenthBipsOfValue(value, managementFeeRate), + scale, + Number::downward); +} + +/* + * Given the loan parameters, compute the derived properties of the loan. + */ +LoanProperties +computeLoanProperties( + Asset const& asset, + Number principalOutstanding, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining, + TenthBips32 managementFeeRate, + std::int32_t minimumScale) +{ + auto const periodicRate = loanPeriodicRate(interestRate, paymentInterval); + XRPL_ASSERT( + interestRate == 0 || periodicRate > 0, + "ripple::computeLoanProperties : valid rate"); + + auto const periodicPayment = detail::loanPeriodicPayment( + principalOutstanding, periodicRate, paymentsRemaining); + + auto const [totalValueOutstanding, loanScale] = [&]() { + NumberRoundModeGuard mg(Number::to_nearest); + // 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. + + // Equation (30) from XLS-66 spec, Section A-2 Equation Glossary + STAmount amount{asset, periodicPayment * paymentsRemaining}; + + // Base the loan scale on the total value, since that's going to be + // the biggest number involved (barring unusual parameters for late, + // full, or over payments) + auto const loanScale = std::max(minimumScale, amount.exponent()); + XRPL_ASSERT_PARTS( + (amount.integral() && loanScale == 0) || + (!amount.integral() && + loanScale >= static_cast(amount).exponent()), + "ripple::computeLoanProperties", + "loanScale value fits expectations"); + + // We may need to truncate the total value because of the minimum + // scale + amount = roundToAsset(asset, amount, loanScale, Number::to_nearest); + + return std::make_pair(amount, loanScale); + }(); + + // 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( + asset, principalOutstanding, loanScale, Number::to_nearest); + + // E +loanMakePayment( + Asset const& asset, + ApplyView& view, + SLE::ref loan, + SLE::const_ref brokerSle, + STAmount const& amount, + LoanPaymentType const paymentType, + beast::Journal j) +{ + using namespace Lending; + + auto principalOutstandingProxy = loan->at(sfPrincipalOutstanding); + auto paymentRemainingProxy = loan->at(sfPaymentRemaining); + + if (paymentRemainingProxy == 0 || principalOutstandingProxy == 0) + { + // Loan complete this is already checked in LoanPay::preclaim() + // LCOV_EXCL_START + JLOG(j.warn()) << "Loan is already paid off."; + return Unexpected(tecKILLED); + // LCOV_EXCL_STOP + } + + auto totalValueOutstandingProxy = loan->at(sfTotalValueOutstanding); + auto managementFeeOutstandingProxy = loan->at(sfManagementFeeOutstanding); + + // Next payment due date must be set unless the loan is complete + auto nextDueDateProxy = loan->at(sfNextPaymentDueDate); + if (*nextDueDateProxy == 0) + { + JLOG(j.warn()) << "Loan next payment due date is not set."; + return Unexpected(tecINTERNAL); + } + + std::int32_t const loanScale = loan->at(sfLoanScale); + + TenthBips32 const interestRate{loan->at(sfInterestRate)}; + + Number const serviceFee = loan->at(sfLoanServiceFee); + TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; + + Number const periodicPayment = loan->at(sfPeriodicPayment); + + auto prevPaymentDateProxy = loan->at(sfPreviousPaymentDate); + std::uint32_t const startDate = loan->at(sfStartDate); + + std::uint32_t const paymentInterval = loan->at(sfPaymentInterval); + + // Compute the periodic rate that will be used for calculations + // throughout + Number const periodicRate = loanPeriodicRate(interestRate, paymentInterval); + XRPL_ASSERT( + interestRate == 0 || periodicRate > 0, + "ripple::loanMakePayment : valid rate"); + + XRPL_ASSERT( + *totalValueOutstandingProxy > 0, + "ripple::loanMakePayment : valid total value"); + + view.update(loan); + + // ------------------------------------------------------------- + // A late payment not flagged as late overrides all other options. + if (paymentType != LoanPaymentType::late && + hasExpired(view, nextDueDateProxy)) + { + // If the payment is late, and the late flag was not set, it's not + // valid + JLOG(j.warn()) << "Loan payment is overdue. Use the tfLoanLatePayment " + "transaction " + "flag to make a late payment. Loan was created on " + << startDate << ", prev payment due date is " + << prevPaymentDateProxy << ", next payment due date is " + << nextDueDateProxy << ", ledger time is " + << view.parentCloseTime().time_since_epoch().count(); + return Unexpected(tecEXPIRED); + } + + // ------------------------------------------------------------- + // full payment handling + if (paymentType == LoanPaymentType::full) + { + TenthBips32 const closeInterestRate{loan->at(sfCloseInterestRate)}; + Number const closePaymentFee = + roundToAsset(asset, loan->at(sfClosePaymentFee), loanScale); + + LoanState const roundedLoanState = constructLoanState( + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy); + + if (auto const fullPaymentComponents = detail::computeFullPayment( + asset, + view, + principalOutstandingProxy, + managementFeeOutstandingProxy, + periodicPayment, + paymentRemainingProxy, + prevPaymentDateProxy, + startDate, + paymentInterval, + closeInterestRate, + loanScale, + roundedLoanState.interestDue, + periodicRate, + closePaymentFee, + amount, + managementFeeRate, + j)) + { + return doPayment( + *fullPaymentComponents, + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy, + paymentRemainingProxy, + prevPaymentDateProxy, + nextDueDateProxy, + paymentInterval); + } + else if (fullPaymentComponents.error()) + // error() will be the TER returned if a payment is not made. It + // will only evaluate to true if it's unsuccessful. Otherwise, + // tesSUCCESS means nothing was done, so continue. + return Unexpected(fullPaymentComponents.error()); + + // LCOV_EXCL_START + UNREACHABLE("ripple::loanMakePayment : invalid full payment result"); + JLOG(j.error()) << "Full payment computation failed unexpectedly."; + return Unexpected(tecINTERNAL); + // LCOV_EXCL_STOP + } + + // ------------------------------------------------------------- + // compute the periodic payment info that will be needed whether the + // payment is late or regular + detail::ExtendedPaymentComponents periodic{ + detail::computePaymentComponents( + asset, + loanScale, + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy, + periodicPayment, + periodicRate, + paymentRemainingProxy, + managementFeeRate), + serviceFee}; + XRPL_ASSERT_PARTS( + periodic.trackedPrincipalDelta >= 0, + "ripple::loanMakePayment", + "regular payment valid principal"); + + // ------------------------------------------------------------- + // late payment handling + if (paymentType == LoanPaymentType::late) + { + TenthBips32 const lateInterestRate{loan->at(sfLateInterestRate)}; + Number const latePaymentFee = loan->at(sfLatePaymentFee); + + if (auto const latePaymentComponents = detail::computeLatePayment( + asset, + view, + principalOutstandingProxy, + nextDueDateProxy, + periodic, + lateInterestRate, + loanScale, + latePaymentFee, + amount, + managementFeeRate, + j)) + { + return doPayment( + *latePaymentComponents, + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy, + paymentRemainingProxy, + prevPaymentDateProxy, + nextDueDateProxy, + paymentInterval); + } + else if (latePaymentComponents.error()) + { + // error() will be the TER returned if a payment is not made. It + // will only evaluate to true if it's unsuccessful. + return Unexpected(latePaymentComponents.error()); + } + + // LCOV_EXCL_START + UNREACHABLE("ripple::loanMakePayment : invalid late payment result"); + JLOG(j.error()) << "Late payment computation failed unexpectedly."; + return Unexpected(tecINTERNAL); + // LCOV_EXCL_STOP + } + + // ------------------------------------------------------------- + // regular periodic payment handling + + XRPL_ASSERT_PARTS( + paymentType == LoanPaymentType::regular || + paymentType == LoanPaymentType::overpayment, + "ripple::loanMakePayment", + "regular payment type"); + + // Keep a running total of the actual parts paid + LoanPaymentParts totalParts; + Number totalPaid; + std::size_t numPayments = 0; + + while ((amount >= (totalPaid + periodic.totalDue)) && + paymentRemainingProxy > 0 && + numPayments < loanMaximumPaymentsPerTransaction) + { + // Try to make more payments + XRPL_ASSERT_PARTS( + periodic.trackedPrincipalDelta >= 0, + "ripple::loanMakePayment", + "payment pays non-negative principal"); + + totalPaid += periodic.totalDue; + totalParts += detail::doPayment( + periodic, + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy, + paymentRemainingProxy, + prevPaymentDateProxy, + nextDueDateProxy, + paymentInterval); + ++numPayments; + + XRPL_ASSERT_PARTS( + (periodic.specialCase == detail::PaymentSpecialCase::final) == + (paymentRemainingProxy == 0), + "ripple::loanMakePayment", + "final payment is the final payment"); + + // Don't compute the next payment if this was the last payment + if (periodic.specialCase == detail::PaymentSpecialCase::final) + break; + + periodic = detail::ExtendedPaymentComponents{ + detail::computePaymentComponents( + asset, + loanScale, + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy, + periodicPayment, + periodicRate, + paymentRemainingProxy, + managementFeeRate), + serviceFee}; + } + + if (numPayments == 0) + { + JLOG(j.warn()) << "Regular loan payment amount is insufficient. Due: " + << periodic.totalDue << ", paid: " << amount; + return Unexpected(tecINSUFFICIENT_PAYMENT); + } + + XRPL_ASSERT_PARTS( + totalParts.principalPaid + totalParts.interestPaid + + totalParts.feePaid == + totalPaid, + "ripple::loanMakePayment", + "payment parts add up"); + XRPL_ASSERT_PARTS( + totalParts.valueChange == 0, + "ripple::loanMakePayment", + "no value change"); + + // ------------------------------------------------------------- + // overpayment handling + if (paymentType == LoanPaymentType::overpayment && + loan->isFlag(lsfLoanOverpayment) && paymentRemainingProxy > 0 && + totalPaid < amount && numPayments < loanMaximumPaymentsPerTransaction) + { + TenthBips32 const overpaymentInterestRate{ + loan->at(sfOverpaymentInterestRate)}; + TenthBips32 const overpaymentFeeRate{loan->at(sfOverpaymentFee)}; + + // It shouldn't be possible for the overpayment to be greater than + // totalValueOutstanding, because that would have been processed as + // another normal payment. But cap it just in case. + Number const overpayment = + std::min(amount - totalPaid, *totalValueOutstandingProxy); + + detail::ExtendedPaymentComponents const overpaymentComponents = + detail::computeOverpaymentComponents( + asset, + loanScale, + overpayment, + overpaymentInterestRate, + overpaymentFeeRate, + managementFeeRate); + + // Don't process an overpayment if the whole amount (or more!) + // gets eaten by fees and interest. + if (overpaymentComponents.trackedPrincipalDelta > 0) + { + XRPL_ASSERT_PARTS( + overpaymentComponents.untrackedInterest >= beast::zero, + "ripple::loanMakePayment", + "overpayment penalty did not reduce value of loan"); + // Can't just use `periodicPayment` here, because it might + // change + auto periodicPaymentProxy = loan->at(sfPeriodicPayment); + if (auto const overResult = detail::doOverpayment( + asset, + loanScale, + overpaymentComponents, + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy, + periodicPaymentProxy, + interestRate, + paymentInterval, + periodicRate, + paymentRemainingProxy, + prevPaymentDateProxy, + nextDueDateProxy, + managementFeeRate, + j)) + totalParts += *overResult; + else if (overResult.error()) + // error() will be the TER returned if a payment is not + // made. It will only evaluate to true if it's unsuccessful. + // Otherwise, tesSUCCESS means nothing was done, so + // continue. + return Unexpected(overResult.error()); + } + } + + // Check the final results are rounded, to double-check that the + // intermediate steps were rounded. + XRPL_ASSERT( + isRounded(asset, totalParts.principalPaid, loanScale) && + totalParts.principalPaid >= beast::zero, + "ripple::loanMakePayment : total principal paid is valid"); + XRPL_ASSERT( + isRounded(asset, totalParts.interestPaid, loanScale) && + totalParts.interestPaid >= beast::zero, + "ripple::loanMakePayment : total interest paid is valid"); + XRPL_ASSERT( + isRounded(asset, totalParts.valueChange, loanScale), + "ripple::loanMakePayment : loan value change is valid"); + XRPL_ASSERT( + isRounded(asset, totalParts.feePaid, loanScale) && + totalParts.feePaid >= beast::zero, + "ripple::loanMakePayment : fee paid is valid"); + return totalParts; +} +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/Batch.cpp b/src/xrpld/app/tx/detail/Batch.cpp index cba89348d0e..8c1c5150210 100644 --- a/src/xrpld/app/tx/detail/Batch.cpp +++ b/src/xrpld/app/tx/detail/Batch.cpp @@ -237,6 +237,39 @@ Batch::preflight(PreflightContext const& ctx) std::unordered_set uniqueHashes; std::unordered_map> accountSeqTicket; + auto checkSignatureFields = [&parentBatchId, &j = ctx.j]( + STObject const& sig, + uint256 const& hash, + char const* label = "") -> NotTEC { + if (sig.isFieldPresent(sfTxnSignature)) + { + JLOG(j.debug()) + << "BatchTrace[" << parentBatchId << "]: " + << "inner txn " << label << "cannot include TxnSignature. " + << "txID: " << hash; + return temBAD_SIGNATURE; + } + + if (sig.isFieldPresent(sfSigners)) + { + JLOG(j.debug()) + << "BatchTrace[" << parentBatchId << "]: " + << "inner txn " << label << " cannot include Signers. " + << "txID: " << hash; + return temBAD_SIGNER; + } + + if (!sig.getFieldVL(sfSigningPubKey).empty()) + { + JLOG(j.debug()) + << "BatchTrace[" << parentBatchId << "]: " + << "inner txn " << label << " SigningPubKey must be empty. " + << "txID: " << hash; + return temBAD_REGKEY; + } + + return tesSUCCESS; + }; for (STObject rb : rawTxns) { STTx const stx = STTx{std::move(rb)}; @@ -249,7 +282,8 @@ Batch::preflight(PreflightContext const& ctx) return temREDUNDANT; } - if (stx.getFieldU16(sfTransactionType) == ttBATCH) + auto const txType = stx.getFieldU16(sfTransactionType); + if (txType == ttBATCH) { JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " << "batch cannot have an inner batch txn. " @@ -257,6 +291,14 @@ Batch::preflight(PreflightContext const& ctx) return temINVALID; } + if (std::any_of( + disabledTxTypes.begin(), + disabledTxTypes.end(), + [txType](auto const& disabled) { return txType == disabled; })) + { + return temINVALID_INNER_BATCH; + } + if (!(stx.getFlags() & tfInnerBatchTxn)) { JLOG(ctx.j.debug()) @@ -266,28 +308,20 @@ Batch::preflight(PreflightContext const& ctx) return temINVALID_FLAG; } - if (stx.isFieldPresent(sfTxnSignature)) - { - JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " - << "inner txn cannot include TxnSignature. " - << "txID: " << hash; - return temBAD_SIGNATURE; - } + if (auto const ret = checkSignatureFields(stx, hash)) + return ret; - if (stx.isFieldPresent(sfSigners)) + // Note that the CounterpartySignature is optional, and should not be + // included, but if it is, ensure it doesn't contain a signature. + if (stx.isFieldPresent(sfCounterpartySignature)) { - JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " - << "inner txn cannot include Signers. " - << "txID: " << hash; - return temBAD_SIGNER; - } - - if (!stx.getSigningPubKey().empty()) - { - JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " - << "inner txn SigningPubKey must be empty. " - << "txID: " << hash; - return temBAD_REGKEY; + auto const counterpartySignature = + stx.getFieldObject(sfCounterpartySignature); + if (auto const ret = checkSignatureFields( + counterpartySignature, hash, "counterparty signature ")) + { + return ret; + } } auto const innerAccount = stx.getAccountID(sfAccount); @@ -385,6 +419,11 @@ Batch::preflightSigValidated(PreflightContext const& ctx) // inner account to the required signers set. if (innerAccount != outerAccount) requiredSigners.insert(innerAccount); + // Some transactions have a Counterparty, who must also sign the + // transaction if they are not the outer account + if (auto const counterparty = rb.at(~sfCounterparty); + counterparty && counterparty != outerAccount) + requiredSigners.insert(*counterparty); } // Validation Batch Signers diff --git a/src/xrpld/app/tx/detail/Batch.h b/src/xrpld/app/tx/detail/Batch.h index 07863a5f338..cf4ca87cdb1 100644 --- a/src/xrpld/app/tx/detail/Batch.h +++ b/src/xrpld/app/tx/detail/Batch.h @@ -54,6 +54,24 @@ class Batch : public Transactor TER doApply() override; + + static constexpr auto disabledTxTypes = std::to_array({ + ttVAULT_CREATE, + ttVAULT_SET, + ttVAULT_DELETE, + ttVAULT_DEPOSIT, + ttVAULT_WITHDRAW, + ttVAULT_CLAWBACK, + ttLOAN_BROKER_SET, + ttLOAN_BROKER_DELETE, + ttLOAN_BROKER_COVER_DEPOSIT, + ttLOAN_BROKER_COVER_WITHDRAW, + ttLOAN_BROKER_COVER_CLAWBACK, + ttLOAN_SET, + ttLOAN_DELETE, + ttLOAN_MANAGE, + ttLOAN_PAY, + }); }; } // namespace ripple diff --git a/src/xrpld/app/tx/detail/Change.cpp b/src/xrpld/app/tx/detail/Change.cpp index 77d098ea0f8..f78eaf0f614 100644 --- a/src/xrpld/app/tx/detail/Change.cpp +++ b/src/xrpld/app/tx/detail/Change.cpp @@ -38,7 +38,11 @@ NotTEC Transactor::invokePreflight(PreflightContext const& ctx) { // 0 means "Allow any flags" - if (auto const ret = preflight0(ctx, 0)) + // The check for tfChangeMask is gated by LendingProtocol because that + // feature introduced this parameter, and it's not worth adding another + // amendment just for this. + if (auto const ret = preflight0( + ctx, ctx.rules.enabled(featureLendingProtocol) ? tfChangeMask : 0)) return ret; auto account = ctx.tx.getAccountID(sfAccount); diff --git a/src/xrpld/app/tx/detail/InvariantCheck.cpp b/src/xrpld/app/tx/detail/InvariantCheck.cpp index f7f67ee1d67..c9632146b67 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.cpp +++ b/src/xrpld/app/tx/detail/InvariantCheck.cpp @@ -89,6 +89,8 @@ enum Privilege { 0x0400, // The transaction MAY delete an MPT object. May not create. mustModifyVault = 0x0800, // The transaction must modify, delete or create, a vault + mayModifyVault = + 0x1000, // The transaction MAY modify, delete or create, a vault }; constexpr Privilege operator|(Privilege lhs, Privilege rhs) @@ -498,10 +500,10 @@ void AccountRootsDeletedClean::visitEntry( bool isDelete, std::shared_ptr const& before, - std::shared_ptr const&) + std::shared_ptr const& after) { if (isDelete && before && before->getType() == ltACCOUNT_ROOT) - accountsDeleted_.emplace_back(before); + accountsDeleted_.emplace_back(before, after); } bool @@ -518,7 +520,8 @@ AccountRootsDeletedClean::finalize( // be logged [[maybe_unused]] bool const enforce = view.rules().enabled(featureInvariantsV1_1) || - view.rules().enabled(featureSingleAssetVault); + view.rules().enabled(featureSingleAssetVault) || + view.rules().enabled(featureLendingProtocol); auto const objectExists = [&view, enforce, &j](auto const& keylet) { (void)enforce; @@ -548,9 +551,33 @@ AccountRootsDeletedClean::finalize( return false; }; - for (auto const& accountSLE : accountsDeleted_) + for (auto const& [before, after] : accountsDeleted_) { - auto const accountID = accountSLE->getAccountID(sfAccount); + auto const accountID = before->getAccountID(sfAccount); + // An account should not be deleted with a balance + if (after->at(sfBalance) != beast::zero) + { + JLOG(j.fatal()) << "Invariant failed: account deletion left " + "behind a non-zero balance"; + XRPL_ASSERT( + enforce, + "ripple::AccountRootsDeletedClean::finalize : " + "deleted account has zero balance"); + if (enforce) + return false; + } + // An account should not be deleted with a non-zero owner count + if (after->at(sfOwnerCount) != 0) + { + JLOG(j.fatal()) << "Invariant failed: account deletion left " + "behind a non-zero owner count"; + XRPL_ASSERT( + enforce, + "ripple::AccountRootsDeletedClean::finalize : " + "deleted account has zero owner count"); + if (enforce) + return false; + } // Simple types for (auto const& [keyletfunc, _, __] : directAccountKeylets) { @@ -577,9 +604,9 @@ AccountRootsDeletedClean::finalize( // also be deleted. e.g. AMM, Vault, etc. for (auto const& field : getPseudoAccountFields()) { - if (accountSLE->isFieldPresent(*field)) + if (before->isFieldPresent(*field)) { - auto const key = accountSLE->getFieldH256(*field); + auto const key = before->getFieldH256(*field); if (objectExists(keylet::unchecked(key)) && enforce) return false; } @@ -1038,7 +1065,9 @@ ValidNewAccountRoot::finalize( if (hasPrivilege(tx, createAcct | createPseudoAcct) && result == tesSUCCESS) { bool const pseudoAccount = - (pseudoAccount_ && view.rules().enabled(featureSingleAssetVault)); + (pseudoAccount_ && + (view.rules().enabled(featureSingleAssetVault) || + view.rules().enabled(featureLendingProtocol))); if (pseudoAccount && !hasPrivilege(tx, createPseudoAcct)) { @@ -1450,7 +1479,12 @@ ValidMPTIssuance::visitEntry( if (isDelete) mptokensDeleted_++; else if (!before) + { mptokensCreated_++; + MPTIssue const mptIssue{after->at(sfMPTokenIssuanceID)}; + if (mptIssue.getIssuer() == after->at(sfAccount)) + mptCreatedByIssuer_ = true; + } } } @@ -1464,6 +1498,25 @@ ValidMPTIssuance::finalize( { if (result == tesSUCCESS) { + auto const& rules = view.rules(); + [[maybe_unused]] + bool enforceCreatedByIssuer = rules.enabled(featureSingleAssetVault) || + rules.enabled(featureLendingProtocol); + if (mptCreatedByIssuer_) + { + JLOG(j.fatal()) + << "Invariant failed: MPToken created for the MPT issuer"; + // The comment above starting with "assert(enforce)" explains this + // assert. + XRPL_ASSERT_PARTS( + enforceCreatedByIssuer, + "ripple::ValidMPTIssuance::finalize", + "no issuer MPToken"); + if (enforceCreatedByIssuer) + return false; + } + + auto const txnType = tx.getTxnType(); if (hasPrivilege(tx, createMPTIssuance)) { if (mptIssuancesCreated_ == 0) @@ -1506,15 +1559,14 @@ ValidMPTIssuance::finalize( return mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 1; } + bool const lendingProtocolEnabled = + view.rules().enabled(featureLendingProtocol); // ttESCROW_FINISH may authorize an MPT, but it can't have the // mayAuthorizeMPT privilege, because that may cause // non-amendment-gated side effects. - bool const enforceEscrowFinish = (tx.getTxnType() == ttESCROW_FINISH) && - (view.rules().enabled(featureSingleAssetVault) - /* - TODO: Uncomment when LendingProtocol is defined - || view.rules().enabled(featureLendingProtocol)*/ - ); + bool const enforceEscrowFinish = (txnType == ttESCROW_FINISH) && + (view.rules().enabled(featureSingleAssetVault) || + lendingProtocolEnabled); if (hasPrivilege(tx, mustAuthorizeMPT | mayAuthorizeMPT) || enforceEscrowFinish) { @@ -1532,6 +1584,14 @@ ValidMPTIssuance::finalize( "succeeded but deleted issuances"; return false; } + else if ( + lendingProtocolEnabled && + mptokensCreated_ + mptokensDeleted_ > 1) + { + JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded " + "but created/deleted bad number mptokens"; + return false; + } else if ( submittedByIssuer && (mptokensCreated_ > 0 || mptokensDeleted_ > 0)) @@ -1555,7 +1615,7 @@ ValidMPTIssuance::finalize( return true; } - if (tx.getTxnType() == ttESCROW_FINISH) + if (txnType == ttESCROW_FINISH) { // ttESCROW_FINISH may authorize an MPT, but it can't have the // mayAuthorizeMPT privilege, because that may cause @@ -1773,8 +1833,6 @@ ValidPseudoAccounts::finalize( beast::Journal const& j) { bool const enforce = view.rules().enabled(featureSingleAssetVault); - - // The comment above starting with "assert(enforce)" explains this assert. XRPL_ASSERT( errors_.empty() || enforce, "ripple::ValidPseudoAccounts::finalize : no bad " @@ -2183,6 +2241,401 @@ ValidAMM::finalize( //------------------------------------------------------------------------------ +void +NoModifiedUnmodifiableFields::visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) +{ + if (isDelete || !before) + // Creation and deletion are ignored + return; + + changedEntries_.emplace(before, after); +} + +bool +NoModifiedUnmodifiableFields::finalize( + STTx const& tx, + TER const, + XRPAmount const, + ReadView const& view, + beast::Journal const& j) +{ + static auto const fieldChanged = + [](auto const& before, auto const& after, auto const& field) { + bool const beforeField = before->isFieldPresent(field); + bool const afterField = after->isFieldPresent(field); + return beforeField != afterField || + (afterField && before->at(field) != after->at(field)); + }; + for (auto const& slePair : changedEntries_) + { + auto const& before = slePair.first; + auto const& after = slePair.second; + auto const type = after->getType(); + bool bad = false; + [[maybe_unused]] bool enforce = false; + switch (type) + { + case ltLOAN_BROKER: + /* + * We check this invariant regardless of lending protocol + * amendment status, allowing for detection and logging of + * potential issues even when the amendment is disabled. + */ + enforce = view.rules().enabled(featureLendingProtocol); + bad = fieldChanged(before, after, sfLedgerEntryType) || + fieldChanged(before, after, sfLedgerIndex) || + fieldChanged(before, after, sfSequence) || + fieldChanged(before, after, sfOwnerNode) || + fieldChanged(before, after, sfVaultNode) || + fieldChanged(before, after, sfVaultID) || + fieldChanged(before, after, sfAccount) || + fieldChanged(before, after, sfOwner) || + fieldChanged(before, after, sfManagementFeeRate) || + fieldChanged(before, after, sfCoverRateMinimum) || + fieldChanged(before, after, sfCoverRateLiquidation); + break; + case ltLOAN: + /* + * We check this invariant regardless of lending protocol + * amendment status, allowing for detection and logging of + * potential issues even when the amendment is disabled. + */ + enforce = view.rules().enabled(featureLendingProtocol); + bad = fieldChanged(before, after, sfLedgerEntryType) || + fieldChanged(before, after, sfLedgerIndex) || + fieldChanged(before, after, sfSequence) || + fieldChanged(before, after, sfOwnerNode) || + fieldChanged(before, after, sfLoanBrokerNode) || + fieldChanged(before, after, sfLoanBrokerID) || + fieldChanged(before, after, sfBorrower) || + fieldChanged(before, after, sfLoanOriginationFee) || + fieldChanged(before, after, sfLoanServiceFee) || + fieldChanged(before, after, sfLatePaymentFee) || + fieldChanged(before, after, sfClosePaymentFee) || + fieldChanged(before, after, sfOverpaymentFee) || + fieldChanged(before, after, sfInterestRate) || + fieldChanged(before, after, sfLateInterestRate) || + fieldChanged(before, after, sfCloseInterestRate) || + fieldChanged(before, after, sfOverpaymentInterestRate) || + fieldChanged(before, after, sfStartDate) || + fieldChanged(before, after, sfPaymentInterval) || + fieldChanged(before, after, sfGracePeriod) || + fieldChanged(before, after, sfLoanScale); + break; + default: + /* + * We check this invariant regardless of lending protocol + * amendment status, allowing for detection and logging of + * potential issues even when the amendment is disabled. + * + * We use the lending protocol as a gate, even though + * all transactions are affected because that's when it + * was added. + */ + enforce = view.rules().enabled(featureLendingProtocol); + bad = fieldChanged(before, after, sfLedgerEntryType) || + fieldChanged(before, after, sfLedgerIndex); + } + XRPL_ASSERT( + !bad || enforce, + "ripple::NoModifiedUnmodifiableFields::finalize : no bad " + "changes or enforce invariant"); + if (bad) + { + JLOG(j.fatal()) + << "Invariant failed: changed an unchangable field for " + << tx.getTransactionID(); + if (enforce) + return false; + } + } + return true; +} + +//------------------------------------------------------------------------------ + +void +ValidLoanBroker::visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) +{ + if (after) + { + if (after->getType() == ltLOAN_BROKER) + { + auto& broker = brokers_[after->key()]; + broker.brokerBefore = before; + broker.brokerAfter = after; + } + else if ( + after->getType() == ltACCOUNT_ROOT && + after->isFieldPresent(sfLoanBrokerID)) + { + auto const& loanBrokerID = after->at(sfLoanBrokerID); + // create an entry if one doesn't already exist + brokers_.emplace(loanBrokerID, BrokerInfo{}); + } + else if (after->getType() == ltRIPPLE_STATE) + { + lines_.emplace_back(after); + } + else if (after->getType() == ltMPTOKEN) + { + mpts_.emplace_back(after); + } + } +} + +bool +ValidLoanBroker::goodZeroDirectory( + ReadView const& view, + SLE::const_ref dir, + beast::Journal const& j) const +{ + auto const next = dir->at(~sfIndexNext); + auto const prev = dir->at(~sfIndexPrevious); + if ((prev && *prev) || (next && *next)) + { + JLOG(j.fatal()) << "Invariant failed: Loan Broker with zero " + "OwnerCount has multiple directory pages"; + return false; + } + auto indexes = dir->getFieldV256(sfIndexes); + if (indexes.size() > 1) + { + JLOG(j.fatal()) + << "Invariant failed: Loan Broker with zero " + "OwnerCount has multiple indexes in the Directory root"; + return false; + } + if (indexes.size() == 1) + { + auto const index = indexes.value().front(); + auto const sle = view.read(keylet::unchecked(index)); + if (!sle) + { + JLOG(j.fatal()) + << "Invariant failed: Loan Broker directory corrupt"; + return false; + } + if (sle->getType() != ltRIPPLE_STATE && sle->getType() != ltMPTOKEN) + { + JLOG(j.fatal()) + << "Invariant failed: Loan Broker with zero " + "OwnerCount has an unexpected entry in the directory"; + return false; + } + } + + return true; +} + +bool +ValidLoanBroker::finalize( + STTx const& tx, + TER const, + XRPAmount const, + ReadView const& view, + beast::Journal const& j) +{ + // Loan Brokers will not exist on ledger if the Lending Protocol amendment + // is not enabled, so there's no need to check it. + + for (auto const& line : lines_) + { + for (auto const& field : {&sfLowLimit, &sfHighLimit}) + { + auto const account = + view.read(keylet::account(line->at(*field).getIssuer())); + // This Invariant doesn't know about the rules for Trust Lines, so + // if the account is missing, don't treat it as an error. This + // loop is only concerned with finding Broker pseudo-accounts + if (account && account->isFieldPresent(sfLoanBrokerID)) + { + auto const& loanBrokerID = account->at(sfLoanBrokerID); + // create an entry if one doesn't already exist + brokers_.emplace(loanBrokerID, BrokerInfo{}); + } + } + } + for (auto const& mpt : mpts_) + { + auto const account = view.read(keylet::account(mpt->at(sfAccount))); + // This Invariant doesn't know about the rules for MPTokens, so + // if the account is missing, don't treat is as an error. This + // loop is only concerned with finding Broker pseudo-accounts + if (account && account->isFieldPresent(sfLoanBrokerID)) + { + auto const& loanBrokerID = account->at(sfLoanBrokerID); + // create an entry if one doesn't already exist + brokers_.emplace(loanBrokerID, BrokerInfo{}); + } + } + + for (auto const& [brokerID, broker] : brokers_) + { + auto const& after = broker.brokerAfter + ? broker.brokerAfter + : view.read(keylet::loanbroker(brokerID)); + + if (!after) + { + JLOG(j.fatal()) << "Invariant failed: Loan Broker missing"; + return false; + } + + auto const& before = broker.brokerBefore; + + // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3123-invariants + // If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most + // one node (the root), which will only hold entries for `RippleState` + // or `MPToken` objects. + if (after->at(sfOwnerCount) == 0) + { + auto const dir = view.read(keylet::ownerDir(after->at(sfAccount))); + if (dir) + { + if (!goodZeroDirectory(view, dir, j)) + { + return false; + } + } + } + if (before && before->at(sfLoanSequence) > after->at(sfLoanSequence)) + { + JLOG(j.fatal()) << "Invariant failed: Loan Broker sequence number " + "decreased"; + return false; + } + if (after->at(sfDebtTotal) < 0) + { + JLOG(j.fatal()) + << "Invariant failed: Loan Broker debt total is negative"; + return false; + } + if (after->at(sfCoverAvailable) < 0) + { + JLOG(j.fatal()) + << "Invariant failed: Loan Broker cover available is negative"; + return false; + } + auto const vault = view.read(keylet::vault(after->at(sfVaultID))); + if (!vault) + { + JLOG(j.fatal()) + << "Invariant failed: Loan Broker vault ID is invalid"; + return false; + } + auto const& vaultAsset = vault->at(sfAsset); + if (after->at(sfCoverAvailable) < accountHolds( + view, + after->at(sfAccount), + vaultAsset, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j)) + { + JLOG(j.fatal()) << "Invariant failed: Loan Broker cover available " + "is less than pseudo-account asset balance"; + return false; + } + } + return true; +} + +//------------------------------------------------------------------------------ + +void +ValidLoan::visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) +{ + if (after && after->getType() == ltLOAN) + { + loans_.emplace_back(before, after); + } +} + +bool +ValidLoan::finalize( + STTx const& tx, + TER const, + XRPAmount const, + ReadView const& view, + beast::Journal const& j) +{ + // Loans will not exist on ledger if the Lending Protocol amendment + // is not enabled, so there's no need to check it. + + for (auto const& [before, after] : loans_) + { + // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3223-invariants + // If `Loan.PaymentRemaining = 0` then the loan MUST be fully paid off + if (after->at(sfPaymentRemaining) == 0 && + (after->at(sfTotalValueOutstanding) != beast::zero || + after->at(sfPrincipalOutstanding) != beast::zero || + after->at(sfManagementFeeOutstanding) != beast::zero)) + { + JLOG(j.fatal()) << "Invariant failed: Loan with zero payments " + "remaining has not been paid off"; + return false; + } + // If `Loan.PaymentRemaining != 0` then the loan MUST NOT be fully paid + // off + if (after->at(sfPaymentRemaining) != 0 && + after->at(sfTotalValueOutstanding) == beast::zero && + after->at(sfPrincipalOutstanding) == beast::zero && + after->at(sfManagementFeeOutstanding) == beast::zero) + { + JLOG(j.fatal()) << "Invariant failed: Loan with zero payments " + "remaining has not been paid off"; + return false; + } + if (before && + (before->isFlag(lsfLoanOverpayment) != + after->isFlag(lsfLoanOverpayment))) + { + JLOG(j.fatal()) + << "Invariant failed: Loan Overpayment flag changed"; + return false; + } + // Must not be negative - STNumber + for (auto const field : + {&sfLoanServiceFee, + &sfLatePaymentFee, + &sfClosePaymentFee, + &sfPrincipalOutstanding, + &sfTotalValueOutstanding, + &sfManagementFeeOutstanding}) + { + if (after->at(*field) < 0) + { + JLOG(j.fatal()) << "Invariant failed: " << field->getName() + << " is negative "; + return false; + } + } + // Must be positive - STNumber + for (auto const field : { + &sfPeriodicPayment, + }) + { + if (after->at(*field) <= 0) + { + JLOG(j.fatal()) << "Invariant failed: " << field->getName() + << " is zero or negative "; + return false; + } + } + } + return true; +} + ValidVault::Vault ValidVault::Vault::make(SLE const& from) { @@ -2332,7 +2785,8 @@ ValidVault::finalize( return true; // Not a vault operation } - else if (!hasPrivilege(tx, mustModifyVault)) // TODO: mayModifyVault + else if (!(hasPrivilege(tx, mustModifyVault) || + hasPrivilege(tx, mayModifyVault))) { JLOG(j.fatal()) << // "Invariant failed: vault updated by a wrong transaction type"; @@ -2547,7 +3001,8 @@ ValidVault::finalize( } if (!beforeVault_.empty() && - afterVault.lossUnrealized != beforeVault_[0].lossUnrealized) + afterVault.lossUnrealized != beforeVault_[0].lossUnrealized && + txnType != ttLOAN_MANAGE && txnType != ttLOAN_PAY) { JLOG(j.fatal()) << // "Invariant failed: vault transaction must not change loss " @@ -3095,6 +3550,13 @@ ValidVault::finalize( return result; } + case ttLOAN_SET: + case ttLOAN_MANAGE: + case ttLOAN_PAY: { + // TBD + return true; + } + default: // LCOV_EXCL_START UNREACHABLE( diff --git a/src/xrpld/app/tx/detail/InvariantCheck.h b/src/xrpld/app/tx/detail/InvariantCheck.h index 97d51f0fabf..a142040a0ff 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.h +++ b/src/xrpld/app/tx/detail/InvariantCheck.h @@ -177,7 +177,14 @@ class AccountRootsNotDeleted */ class AccountRootsDeletedClean { - std::vector> accountsDeleted_; + // Pair is . Before is used for most of the checks, so that + // if, for example, an object ID field is cleared, but the object is not + // deleted, it can still be found. After is used specifically for any checks + // that are expected as part of the deletion, such as zeroing out the + // balance. + std::vector< + std::pair, std::shared_ptr>> + accountsDeleted_; public: void @@ -569,6 +576,9 @@ class ValidMPTIssuance std::uint32_t mptokensCreated_ = 0; std::uint32_t mptokensDeleted_ = 0; + // non-MPT transactions may attempt to create + // MPToken by an issuer + bool mptCreatedByIssuer_ = false; public: void @@ -735,6 +745,114 @@ class ValidAMM }; /** + * @brief Invariants: Some fields are unmodifiable + * + * Check that any fields specified as unmodifiable are not modified when the + * object is modified. Creation and deletion are ignored. + * + */ +class NoModifiedUnmodifiableFields +{ + // Pair is . + std::set> changedEntries_; + +public: + void + visitEntry( + bool, + std::shared_ptr const&, + std::shared_ptr const&); + + bool + finalize( + STTx const&, + TER const, + XRPAmount const, + ReadView const&, + beast::Journal const&); +}; + +/** + * @brief Invariants: Loan brokers are internally consistent + * + * 1. If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most one + * node (the root), which will only hold entries for `RippleState` or + * `MPToken` objects. + * + */ +class ValidLoanBroker +{ + // Not all of these elements will necessarily be populated. Remaining items + // will be looked up as needed. + struct BrokerInfo + { + SLE::const_pointer brokerBefore = nullptr; + // After is used for most of the checks, except + // those that check changed values. + SLE::const_pointer brokerAfter = nullptr; + }; + // Collect all the LoanBrokers found directly or indirectly through + // pseudo-accounts. Key is the brokerID / index. It will be used to find the + // LoanBroker object if brokerBefore and brokerAfter are nullptr + std::map brokers_; + // Collect all the modified trust lines. Their high and low accounts will be + // loaded to look for LoanBroker pseudo-accounts. + std::vector lines_; + // Collect all the modified MPTokens. Their accounts will be loaded to look + // for LoanBroker pseudo-accounts. + std::vector mpts_; + + bool + goodZeroDirectory( + ReadView const& view, + SLE::const_ref dir, + beast::Journal const& j) const; + +public: + void + visitEntry( + bool, + std::shared_ptr const&, + std::shared_ptr const&); + + bool + finalize( + STTx const&, + TER const, + XRPAmount const, + ReadView const&, + beast::Journal const&); +}; + +/** + * @brief Invariants: Loans are internally consistent + * + * 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0` + * + */ +class ValidLoan +{ + // Pair is . After is used for most of the checks, except + // those that check changed values. + std::vector> loans_; + +public: + void + visitEntry( + bool, + std::shared_ptr const&, + std::shared_ptr const&); + + bool + finalize( + STTx const&, + TER const, + XRPAmount const, + ReadView const&, + beast::Journal const&); +}; + +/* * @brief Invariants: Vault object and MPTokenIssuance for vault shares * * - vault deleted and vault created is empty @@ -824,7 +942,10 @@ using InvariantChecks = std::tuple< ValidPermissionedDomain, ValidPermissionedDEX, ValidAMM, + NoModifiedUnmodifiableFields, ValidPseudoAccounts, + ValidLoanBroker, + ValidLoan, ValidVault>; /** diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp new file mode 100644 index 00000000000..26e978697c6 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp @@ -0,0 +1,340 @@ +#include +// +#include + +namespace ripple { + +bool +LoanBrokerCoverClawback::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +NotTEC +LoanBrokerCoverClawback::preflight(PreflightContext const& ctx) +{ + auto const brokerID = ctx.tx[~sfLoanBrokerID]; + auto const amount = ctx.tx[~sfAmount]; + + if (!brokerID && !amount) + return temINVALID; + + if (brokerID && *brokerID == beast::zero) + return temINVALID; + + if (amount) + { + // XRP has no counterparty, and thus nobody can claw it back + if (amount->native()) + return temBAD_AMOUNT; + + // Zero is OK, and indicates "take it all" (down to the minimum cover) + if (*amount < beast::zero) + return temBAD_AMOUNT; + + // This should be redundant + if (!isLegalNet(*amount)) + return temBAD_AMOUNT; // LCOV_EXCL_LINE + + if (!brokerID) + { + if (amount->holds()) + return temINVALID; + + auto const account = ctx.tx[sfAccount]; + // Since we don't have a LoanBrokerID, holder _should_ be the loan + // broker's pseudo-account, but we don't know yet whether it is, so + // use a generic placeholder name. + auto const holder = amount->getIssuer(); + if (holder == account || holder == beast::zero) + return temINVALID; + } + } + + return tesSUCCESS; +} + +Expected +determineBrokerID(ReadView const& view, STTx const& tx) +{ + // If the broker ID was provided in the transaction, that's all we + // need. + if (auto const brokerID = tx[~sfLoanBrokerID]) + return *brokerID; + + // If the broker ID was not provided, and the amount is either + // absent or holds a non-IOU - including MPT, something went wrong, + // because that should have been rejected in preflight(). + auto const dstAmount = tx[~sfAmount]; + if (!dstAmount || !dstAmount->holds()) + return Unexpected{tecINTERNAL}; // LCOV_EXCL_LINE + + // Every trust line is bidirectional. Both sides are simultaneously + // issuer and holder. For this transaction, the Account is acting as + // a holder, and clawing back funds from the LoanBroker + // Pseudo-account acting as holder. If the Amount is an IOU, and the + // `issuer` field specified in that Amount is a LoanBroker + // Pseudo-account, we can get the LoanBrokerID from there. + // + // Thus, Amount.issuer _should_ be the loan broker's + // pseudo-account, but we don't know yet whether it is. + auto const maybePseudo = dstAmount->getIssuer(); + auto const sle = view.read(keylet::account(maybePseudo)); + + // If the account was not found, the transaction can't go further. + if (!sle) + return Unexpected{tecNO_ENTRY}; + + // If the account was found, and has a LoanBrokerID (and therefore + // is a pseudo-account), that's the + // answer we need. + if (auto const brokerID = sle->at(~sfLoanBrokerID)) + return *brokerID; + + // If the account does not have a LoanBrokerID, the transaction + // can't go further, even if it's a different type of Pseudo-account. + return Unexpected{tecOBJECT_NOT_FOUND}; + // Or tecWRONG_ASSET? +} + +Expected +determineAsset( + ReadView const& view, + AccountID const& account, + AccountID const& brokerPseudoAccountID, + STAmount const& amount) +{ + if (amount.holds()) + return amount.asset(); + + // An IOU has an issue, which could be either end of the trust line. + // This check only applies to IOUs + auto const holder = amount.getIssuer(); + + // holder can be the submitting account (the issuer of the asset) if a + // LoanBrokerID was provided in the transaction. + if (holder == account) + { + return amount.asset(); + } + else if (holder == brokerPseudoAccountID) + { + // We want the asset to match the vault asset, so use the account as the + // issuer + return Issue{amount.getCurrency(), account}; + } + else + return Unexpected(tecWRONG_ASSET); +} + +Expected +determineClawAmount( + SLE const& sleBroker, + Asset const& vaultAsset, + std::optional const& amount) +{ + auto const maxClawAmount = [&]() { + // Always round the minimum required up + NumberRoundModeGuard mg1(Number::upward); + auto const minRequiredCover = tenthBipsOfValue( + sleBroker[sfDebtTotal], TenthBips32(sleBroker[sfCoverRateMinimum])); + // The subtraction probably won't round, but round down if it does. + NumberRoundModeGuard mg2(Number::downward); + return sleBroker[sfCoverAvailable] - minRequiredCover; + }(); + if (maxClawAmount <= beast::zero) + return Unexpected(tecINSUFFICIENT_FUNDS); + + // Use the vaultAsset here, because it will be the right type in all + // circumstances. The amount may be an IOU indicating the pseudo-account's + // asset, which is correct, but not what is needed here. + if (!amount || *amount == beast::zero) + return STAmount{vaultAsset, maxClawAmount}; + Number const magnitude{*amount}; + if (magnitude > maxClawAmount) + return STAmount{vaultAsset, maxClawAmount}; + return STAmount{vaultAsset, magnitude}; +} + +template +static TER +preclaimHelper( + PreclaimContext const& ctx, + SLE const& sleIssuer, + STAmount const& clawAmount); + +template <> +TER +preclaimHelper( + PreclaimContext const& ctx, + SLE const& sleIssuer, + STAmount const& clawAmount) +{ + // If AllowTrustLineClawback is not set or NoFreeze is set, return no + // permission + if (!(sleIssuer.isFlag(lsfAllowTrustLineClawback)) || + (sleIssuer.isFlag(lsfNoFreeze))) + return tecNO_PERMISSION; + + return tesSUCCESS; +} + +template <> +TER +preclaimHelper( + PreclaimContext const& ctx, + SLE const& sleIssuer, + STAmount const& clawAmount) +{ + auto const issuanceKey = + keylet::mptIssuance(clawAmount.get().getMptID()); + auto const sleIssuance = ctx.view.read(issuanceKey); + if (!sleIssuance) + return tecOBJECT_NOT_FOUND; + + if (!sleIssuance->isFlag(lsfMPTCanClawback)) + return tecNO_PERMISSION; + + // With all the checking already done, this should be impossible + if (sleIssuance->at(sfIssuer) != sleIssuer[sfAccount]) + return tecINTERNAL; // LCOV_EXCL_LINE + + return tesSUCCESS; +} + +TER +LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const findBrokerID = determineBrokerID(ctx.view, tx); + if (!findBrokerID) + return findBrokerID.error(); + auto const brokerID = *findBrokerID; + auto const amount = tx[~sfAmount]; + + auto const sleBroker = ctx.view.read(keylet::loanbroker(brokerID)); + if (!sleBroker) + { + JLOG(ctx.j.warn()) << "LoanBroker does not exist."; + return tecNO_ENTRY; + } + + auto const brokerPseudoAccountID = sleBroker->at(sfAccount); + + auto const vault = ctx.view.read(keylet::vault(sleBroker->at(sfVaultID))); + if (!vault) + { + // LCOV_EXCL_START + JLOG(ctx.j.fatal()) << "Vault is missing for Broker " << brokerID; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + auto const vaultAsset = vault->at(sfAsset); + + if (vaultAsset.native()) + { + JLOG(ctx.j.warn()) << "Cannot clawback native asset."; + return tecNO_PERMISSION; + } + + // Only the issuer of the vault asset can claw it back from the broker's + // cover funds. + if (vaultAsset.getIssuer() != account) + { + JLOG(ctx.j.warn()) << "Account is not the issuer of the vault asset."; + return tecNO_PERMISSION; + } + + if (amount) + { + auto const findAsset = + determineAsset(ctx.view, account, brokerPseudoAccountID, *amount); + if (!findAsset) + return findAsset.error(); + auto const txAsset = *findAsset; + if (txAsset != vaultAsset) + { + JLOG(ctx.j.warn()) << "Account is the correct issuer, but trying " + "to clawback the wrong asset from LoanBroker"; + return tecWRONG_ASSET; + } + } + + auto const findClawAmount = + determineClawAmount(*sleBroker, vaultAsset, amount); + if (!findClawAmount) + { + JLOG(ctx.j.warn()) << "LoanBroker cover is already at minimum."; + return findClawAmount.error(); + } + 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 + // if there isn't, this is an internal error. + if (accountHolds( + ctx.view, + brokerPseudoAccountID, + vaultAsset, + fhIGNORE_FREEZE, + ahIGNORE_AUTH, + ctx.j) < clawAmount) + return tecINTERNAL; // tecINSUFFICIENT_FUNDS; LCOV_EXCL_LINE + + // Check if the vault asset issuer has the correct flags + auto const sleIssuer = + ctx.view.read(keylet::account(vaultAsset.getIssuer())); + return std::visit( + [&](T const&) { + return preclaimHelper(ctx, *sleIssuer, clawAmount); + }, + vaultAsset.value()); +} + +TER +LoanBrokerCoverClawback::doApply() +{ + auto const& tx = ctx_.tx; + auto const account = tx[sfAccount]; + auto const findBrokerID = determineBrokerID(view(), tx); + if (!findBrokerID) + return tecINTERNAL; // LCOV_EXCL_LINE + auto const brokerID = *findBrokerID; + auto const amount = tx[~sfAmount]; + + auto sleBroker = view().peek(keylet::loanbroker(brokerID)); + if (!sleBroker) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const brokerPseudoID = *sleBroker->at(sfAccount); + + auto const vault = view().read(keylet::vault(sleBroker->at(sfVaultID))); + if (!vault) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const vaultAsset = vault->at(sfAsset); + + auto const findClawAmount = + determineClawAmount(*sleBroker, vaultAsset, amount); + if (!findClawAmount) + return tecINTERNAL; // LCOV_EXCL_LINE + STAmount const clawAmount = *findClawAmount; + // Just for paranoia's sake + if (clawAmount.native()) + return tecINTERNAL; // LCOV_EXCL_LINE + + // Decrease the LoanBroker's CoverAvailable by Amount + sleBroker->at(sfCoverAvailable) -= clawAmount; + view().update(sleBroker); + + // Transfer assets from pseudo-account to depositor. + return accountSend( + view(), brokerPseudoID, account, clawAmount, j_, WaiveTransferFee::Yes); +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.h b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.h new file mode 100644 index 00000000000..183d3c44791 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.h @@ -0,0 +1,34 @@ +#ifndef XRPL_TX_LOANBROKERCOVERCLAWBACK_H_INCLUDED +#define XRPL_TX_LOANBROKERCOVERCLAWBACK_H_INCLUDED + +#include + +namespace ripple { + +class LoanBrokerCoverClawback : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanBrokerCoverClawback(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp new file mode 100644 index 00000000000..4e9e0e9c054 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp @@ -0,0 +1,123 @@ +#include +// +#include + +namespace ripple { + +bool +LoanBrokerCoverDeposit::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +NotTEC +LoanBrokerCoverDeposit::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfLoanBrokerID] == beast::zero) + return temINVALID; + + auto const dstAmount = ctx.tx[sfAmount]; + if (dstAmount <= beast::zero) + return temBAD_AMOUNT; + + if (!isLegalNet(dstAmount)) + return temBAD_AMOUNT; + + return tesSUCCESS; +} + +TER +LoanBrokerCoverDeposit::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const brokerID = tx[sfLoanBrokerID]; + auto const amount = tx[sfAmount]; + + auto const sleBroker = ctx.view.read(keylet::loanbroker(brokerID)); + if (!sleBroker) + { + JLOG(ctx.j.warn()) << "LoanBroker does not exist."; + return tecNO_ENTRY; + } + if (account != sleBroker->at(sfOwner)) + { + JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker."; + return tecNO_PERMISSION; + } + auto const vault = ctx.view.read(keylet::vault(sleBroker->at(sfVaultID))); + if (!vault) + { + // LCOV_EXCL_START + JLOG(ctx.j.fatal()) << "Vault is missing for Broker " << brokerID; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + auto const vaultAsset = vault->at(sfAsset); + if (amount.asset() != vaultAsset) + return tecWRONG_ASSET; + + auto const pseudoAccountID = sleBroker->at(sfAccount); + // Cannot transfer a non-transferable Asset + if (auto const ret = + canTransfer(ctx.view, vaultAsset, account, pseudoAccountID)) + return ret; + // Cannot transfer a frozen Asset + if (auto const ret = checkFrozen(ctx.view, account, vaultAsset)) + return ret; + // Pseudo-account cannot receive if asset is deep frozen + if (auto const ret = checkDeepFrozen(ctx.view, pseudoAccountID, vaultAsset)) + return ret; + // Cannot transfer unauthorized asset + if (auto const ret = + requireAuth(ctx.view, vaultAsset, account, AuthType::StrongAuth)) + return ret; + + if (accountHolds( + ctx.view, + account, + vaultAsset, + FreezeHandling::fhZERO_IF_FROZEN, + AuthHandling::ahZERO_IF_UNAUTHORIZED, + ctx.j) < amount) + return tecINSUFFICIENT_FUNDS; + + return tesSUCCESS; +} + +TER +LoanBrokerCoverDeposit::doApply() +{ + auto const& tx = ctx_.tx; + + auto const brokerID = tx[sfLoanBrokerID]; + auto const amount = tx[sfAmount]; + + auto broker = view().peek(keylet::loanbroker(brokerID)); + if (!broker) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const brokerPseudoID = broker->at(sfAccount); + + // Transfer assets from depositor to pseudo-account. + if (auto ter = accountSend( + view(), + account_, + brokerPseudoID, + amount, + j_, + WaiveTransferFee::Yes)) + return ter; + + // Increase the LoanBroker's CoverAvailable by Amount + broker->at(sfCoverAvailable) += amount; + view().update(broker); + + return tesSUCCESS; +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.h b/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.h new file mode 100644 index 00000000000..23863b479cb --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.h @@ -0,0 +1,34 @@ +#ifndef XRPL_TX_LOANBROKERCOVERDEPOSIT_H_INCLUDED +#define XRPL_TX_LOANBROKERCOVERDEPOSIT_H_INCLUDED + +#include + +namespace ripple { + +class LoanBrokerCoverDeposit : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanBrokerCoverDeposit(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp new file mode 100644 index 00000000000..1fd5a1a471b --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp @@ -0,0 +1,173 @@ +#include +// +#include +#include + +#include + +namespace ripple { + +bool +LoanBrokerCoverWithdraw::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +NotTEC +LoanBrokerCoverWithdraw::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfLoanBrokerID] == beast::zero) + return temINVALID; + + auto const dstAmount = ctx.tx[sfAmount]; + if (dstAmount <= beast::zero) + return temBAD_AMOUNT; + + if (!isLegalNet(dstAmount)) + return temBAD_AMOUNT; + + if (auto const destination = ctx.tx[~sfDestination]) + { + if (*destination == beast::zero) + { + return temMALFORMED; + } + } + + return tesSUCCESS; +} + +TER +LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const brokerID = tx[sfLoanBrokerID]; + auto const amount = tx[sfAmount]; + + auto const dstAcct = tx[~sfDestination].value_or(account); + + auto const sleBroker = ctx.view.read(keylet::loanbroker(brokerID)); + if (!sleBroker) + { + JLOG(ctx.j.warn()) << "LoanBroker does not exist."; + return tecNO_ENTRY; + } + if (account != sleBroker->at(sfOwner)) + { + JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker."; + return tecNO_PERMISSION; + } + auto const vault = ctx.view.read(keylet::vault(sleBroker->at(sfVaultID))); + if (!vault) + { + // LCOV_EXCL_START + JLOG(ctx.j.fatal()) << "Vault is missing for Broker " << brokerID; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + auto const vaultAsset = vault->at(sfAsset); + if (amount.asset() != vaultAsset) + return tecWRONG_ASSET; + + // The broker's pseudo-account is the source of funds. + auto const pseudoAccountID = sleBroker->at(sfAccount); + // Cannot transfer a non-transferable Asset + if (auto const ret = + canTransfer(ctx.view, vaultAsset, pseudoAccountID, dstAcct)) + return ret; + + // Withdrawal to a 3rd party destination account is essentially a transfer. + // Enforce all the usual asset transfer checks. + AuthType authType = AuthType::WeakAuth; + if (account != dstAcct) + { + if (auto const ret = canWithdraw(ctx.view, tx)) + return ret; + + // The destination account must have consented to receive the asset by + // creating a RippleState or MPToken + authType = AuthType::StrongAuth; + } + + // Destination MPToken must exist (if asset is an MPT) + if (auto const ter = requireAuth(ctx.view, vaultAsset, dstAcct, authType)) + return ter; + + // Check for freezes, unless sending directly to the issuer + if (dstAcct != vaultAsset.getIssuer()) + { + // Cannot send a frozen Asset + if (auto const ret = checkFrozen(ctx.view, pseudoAccountID, vaultAsset)) + return ret; + // Destination account cannot receive if asset is deep frozen + if (auto const ret = checkDeepFrozen(ctx.view, dstAcct, vaultAsset)) + return ret; + } + + auto const coverAvail = sleBroker->at(sfCoverAvailable); + // Cover Rate is in 1/10 bips units + auto const currentDebtTotal = sleBroker->at(sfDebtTotal); + auto const minimumCover = [&]() { + // Always round the minimum required up. + // Applies to `tenthBipsOfValue` as well as `roundToAsset`. + NumberRoundModeGuard mg(Number::upward); + return roundToAsset( + vaultAsset, + tenthBipsOfValue( + currentDebtTotal, + TenthBips32(sleBroker->at(sfCoverRateMinimum))), + currentDebtTotal.exponent()); + }(); + if (coverAvail < amount) + return tecINSUFFICIENT_FUNDS; + if ((coverAvail - amount) < minimumCover) + return tecINSUFFICIENT_FUNDS; + + if (accountHolds( + ctx.view, + pseudoAccountID, + vaultAsset, + FreezeHandling::fhZERO_IF_FROZEN, + AuthHandling::ahZERO_IF_UNAUTHORIZED, + ctx.j) < amount) + return tecINSUFFICIENT_FUNDS; + + return tesSUCCESS; +} + +TER +LoanBrokerCoverWithdraw::doApply() +{ + auto const& tx = ctx_.tx; + + auto const brokerID = tx[sfLoanBrokerID]; + auto const amount = tx[sfAmount]; + auto const dstAcct = tx[~sfDestination].value_or(account_); + + auto broker = view().peek(keylet::loanbroker(brokerID)); + if (!broker) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const brokerPseudoID = *broker->at(sfAccount); + + // Decrease the LoanBroker's CoverAvailable by Amount + broker->at(sfCoverAvailable) -= amount; + view().update(broker); + + return doWithdraw( + view(), + tx, + account_, + dstAcct, + brokerPseudoID, + mPriorBalance, + amount, + j_); +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.h b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.h new file mode 100644 index 00000000000..eab2c9e60f0 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.h @@ -0,0 +1,34 @@ +#ifndef XRPL_TX_LOANBROKERCOVERWITHDRAW_H_INCLUDED +#define XRPL_TX_LOANBROKERCOVERWITHDRAW_H_INCLUDED + +#include + +namespace ripple { + +class LoanBrokerCoverWithdraw : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanBrokerCoverWithdraw(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp new file mode 100644 index 00000000000..f3dd781bb5f --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp @@ -0,0 +1,197 @@ +#include +// +#include + +namespace ripple { + +bool +LoanBrokerDelete::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +NotTEC +LoanBrokerDelete::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfLoanBrokerID] == beast::zero) + return temINVALID; + + return tesSUCCESS; +} + +TER +LoanBrokerDelete::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const brokerID = tx[sfLoanBrokerID]; + + auto const sleBroker = ctx.view.read(keylet::loanbroker(brokerID)); + if (!sleBroker) + { + JLOG(ctx.j.warn()) << "LoanBroker does not exist."; + return tecNO_ENTRY; + } + + auto const brokerOwner = sleBroker->at(sfOwner); + + if (account != brokerOwner) + { + JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker."; + return tecNO_PERMISSION; + } + if (auto const ownerCount = sleBroker->at(sfOwnerCount); ownerCount != 0) + { + 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) + { + // LCOV_EXCL_START + JLOG(ctx.j.fatal()) << "Vault is missing for Broker " << brokerID; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + Asset const asset = vault->at(sfAsset); + + auto const coverAvailable = + STAmount{asset, sleBroker->at(sfCoverAvailable)}; + // If there are assets in the cover, broker will receive them on deletion. + // So we need to check if the broker owner is deep frozen for that asset. + if (coverAvailable > beast::zero) + { + if (auto const ret = checkDeepFrozen(ctx.view, brokerOwner, asset)) + { + JLOG(ctx.j.warn()) << "Broker owner account is frozen."; + return ret; + } + } + + return tesSUCCESS; +} + +TER +LoanBrokerDelete::doApply() +{ + auto const& tx = ctx_.tx; + + auto const brokerID = tx[sfLoanBrokerID]; + + // Delete the loan broker + auto broker = view().peek(keylet::loanbroker(brokerID)); + if (!broker) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const vaultID = broker->at(sfVaultID); + auto const sleVault = view().read(keylet::vault(vaultID)); + if (!sleVault) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const vaultPseudoID = sleVault->at(sfAccount); + auto const vaultAsset = sleVault->at(sfAsset); + + auto const brokerPseudoID = broker->at(sfAccount); + + if (!view().dirRemove( + keylet::ownerDir(account_), + broker->at(sfOwnerNode), + broker->key(), + false)) + { + return tefBAD_LEDGER; // LCOV_EXCL_LINE + } + if (!view().dirRemove( + keylet::ownerDir(vaultPseudoID), + broker->at(sfVaultNode), + broker->key(), + false)) + { + return tefBAD_LEDGER; // LCOV_EXCL_LINE + } + + { + auto const coverAvailable = + STAmount{vaultAsset, broker->at(sfCoverAvailable)}; + if (auto const ter = accountSend( + view(), + brokerPseudoID, + account_, + coverAvailable, + j_, + WaiveTransferFee::Yes)) + return ter; + } + + if (auto ter = removeEmptyHolding(view(), brokerPseudoID, vaultAsset, j_)) + return ter; + + auto brokerPseudoSLE = view().peek(keylet::account(brokerPseudoID)); + if (!brokerPseudoSLE) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // Making the payment and removing the empty holding should have deleted any + // obligations associated with the broker or broker pseudo-account. + if (*brokerPseudoSLE->at(sfBalance)) + { + JLOG(j_.warn()) << "LoanBrokerDelete: Pseudo-account has a balance"; + return tecHAS_OBLIGATIONS; // LCOV_EXCL_LINE + } + if (brokerPseudoSLE->at(sfOwnerCount) != 0) + { + JLOG(j_.warn()) + << "LoanBrokerDelete: Pseudo-account still owns objects"; + return tecHAS_OBLIGATIONS; // LCOV_EXCL_LINE + } + if (auto const directory = keylet::ownerDir(brokerPseudoID); + view().read(directory)) + { + JLOG(j_.warn()) << "LoanBrokerDelete: Pseudo-account has a directory"; + return tecHAS_OBLIGATIONS; // LCOV_EXCL_LINE + } + + view().erase(brokerPseudoSLE); + + view().erase(broker); + + { + auto owner = view().peek(keylet::account(account_)); + if (!owner) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // Decreases the owner count by two: one for the LoanBroker object, and + // one for the pseudo-account. + adjustOwnerCount(view(), owner, -2, j_); + } + + return tesSUCCESS; +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanBrokerDelete.h b/src/xrpld/app/tx/detail/LoanBrokerDelete.h new file mode 100644 index 00000000000..8466fe4f959 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerDelete.h @@ -0,0 +1,34 @@ +#ifndef XRPL_TX_LOANBROKERDELETE_H_INCLUDED +#define XRPL_TX_LOANBROKERDELETE_H_INCLUDED + +#include + +namespace ripple { + +class LoanBrokerDelete : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanBrokerDelete(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/LoanBrokerSet.cpp b/src/xrpld/app/tx/detail/LoanBrokerSet.cpp new file mode 100644 index 00000000000..c2e6effd7a4 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerSet.cpp @@ -0,0 +1,215 @@ +#include +// +#include + +namespace ripple { + +bool +LoanBrokerSet::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +NotTEC +LoanBrokerSet::preflight(PreflightContext const& ctx) +{ + using namespace Lending; + + auto const& tx = ctx.tx; + if (auto const data = tx[~sfData]; data && !data->empty() && + !validDataLength(tx[~sfData], maxDataPayloadLength)) + return temINVALID; + if (!validNumericRange(tx[~sfManagementFeeRate], maxManagementFeeRate)) + return temINVALID; + if (!validNumericRange(tx[~sfCoverRateMinimum], maxCoverRate)) + return temINVALID; + if (!validNumericRange(tx[~sfCoverRateLiquidation], maxCoverRate)) + return temINVALID; + if (!validNumericRange( + tx[~sfDebtMaximum], Number(maxMPTokenAmount), Number(0))) + return temINVALID; + + if (tx.isFieldPresent(sfLoanBrokerID)) + { + // Fixed fields can not be specified if we're modifying an existing + // LoanBroker Object + if (tx.isFieldPresent(sfManagementFeeRate) || + tx.isFieldPresent(sfCoverRateMinimum) || + tx.isFieldPresent(sfCoverRateLiquidation)) + return temINVALID; + + if (tx[sfLoanBrokerID] == beast::zero) + return temINVALID; + } + + if (auto const vaultID = tx.at(~sfVaultID)) + { + if (*vaultID == beast::zero) + return temINVALID; + } + + { + auto const minimumZero = tx[~sfCoverRateMinimum].value_or(0) == 0; + auto const liquidationZero = + tx[~sfCoverRateLiquidation].value_or(0) == 0; + // Both must be zero or non-zero. + if (minimumZero != liquidationZero) + { + return temINVALID; + } + } + + return tesSUCCESS; +} + +TER +LoanBrokerSet::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const vaultID = tx[sfVaultID]; + + if (auto const brokerID = tx[~sfLoanBrokerID]) + { + auto const sleBroker = ctx.view.read(keylet::loanbroker(*brokerID)); + if (!sleBroker) + { + JLOG(ctx.j.warn()) << "LoanBroker does not exist."; + return tecNO_ENTRY; + } + if (vaultID != sleBroker->at(sfVaultID)) + { + JLOG(ctx.j.warn()) + << "Can not change VaultID on an existing LoanBroker."; + return tecNO_PERMISSION; + } + if (account != sleBroker->at(sfOwner)) + { + JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker."; + return tecNO_PERMISSION; + } + } + 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))) + return ter; + } + return tesSUCCESS; +} + +TER +LoanBrokerSet::doApply() +{ + auto const& tx = ctx_.tx; + auto& view = ctx_.view(); + + if (auto const brokerID = tx[~sfLoanBrokerID]) + { + // Modify an existing LoanBroker + auto broker = view.peek(keylet::loanbroker(*brokerID)); + if (!broker) + { + // This should be impossible + // LCOV_EXCL_START + JLOG(j_.fatal()) << "LoanBroker does not exist."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + if (auto const data = tx[~sfData]) + broker->at(sfData) = *data; + if (auto const debtMax = tx[~sfDebtMaximum]) + broker->at(sfDebtMaximum) = *debtMax; + + view.update(broker); + } + else + { + // Create a new LoanBroker pointing back to the given Vault + auto const vaultID = tx[sfVaultID]; + auto const sleVault = view.read(keylet::vault(vaultID)); + if (!sleVault) + { + // This should be impossible + // LCOV_EXCL_START + JLOG(j_.fatal()) << "Vault does not exist."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + auto const vaultPseudoID = sleVault->at(sfAccount); + auto const sequence = tx.getSeqValue(); + + auto owner = view.peek(keylet::account(account_)); + if (!owner) + { + // This should be impossible + // LCOV_EXCL_START + JLOG(j_.fatal()) << "Account does not exist."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + auto broker = + std::make_shared(keylet::loanbroker(account_, sequence)); + + if (auto const ter = dirLink(view, account_, broker)) + return ter; // LCOV_EXCL_LINE + if (auto const ter = dirLink(view, vaultPseudoID, broker, sfVaultNode)) + return ter; // LCOV_EXCL_LINE + + // Increases the owner count by two: one for the LoanBroker object, and + // one for the pseudo-account. + adjustOwnerCount(view, owner, 2, j_); + auto const ownerCount = owner->at(sfOwnerCount); + if (mPriorBalance < view.fees().accountReserve(ownerCount)) + return tecINSUFFICIENT_RESERVE; + + auto maybePseudo = + createPseudoAccount(view, broker->key(), sfLoanBrokerID); + if (!maybePseudo) + return maybePseudo.error(); // LCOV_EXCL_LINE + auto& pseudo = *maybePseudo; + auto pseudoId = pseudo->at(sfAccount); + + if (auto ter = addEmptyHolding( + view, pseudoId, mPriorBalance, sleVault->at(sfAsset), j_)) + return ter; + + // Initialize data fields: + broker->at(sfSequence) = sequence; + broker->at(sfVaultID) = vaultID; + broker->at(sfOwner) = account_; + broker->at(sfAccount) = pseudoId; + // The LoanSequence indexes loans created by this broker, starting at 1 + broker->at(sfLoanSequence) = 1; + if (auto const data = tx[~sfData]) + broker->at(sfData) = *data; + if (auto const rate = tx[~sfManagementFeeRate]) + broker->at(sfManagementFeeRate) = *rate; + if (auto const debtMax = tx[~sfDebtMaximum]) + broker->at(sfDebtMaximum) = *debtMax; + if (auto const coverMin = tx[~sfCoverRateMinimum]) + broker->at(sfCoverRateMinimum) = *coverMin; + if (auto const coverLiq = tx[~sfCoverRateLiquidation]) + broker->at(sfCoverRateLiquidation) = *coverLiq; + + view.insert(broker); + } + + return tesSUCCESS; +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanBrokerSet.h b/src/xrpld/app/tx/detail/LoanBrokerSet.h new file mode 100644 index 00000000000..39ed9bcd614 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerSet.h @@ -0,0 +1,34 @@ +#ifndef XRPL_TX_LOANBROKERSET_H_INCLUDED +#define XRPL_TX_LOANBROKERSET_H_INCLUDED + +#include + +namespace ripple { + +class LoanBrokerSet : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanBrokerSet(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/LoanDelete.cpp b/src/xrpld/app/tx/detail/LoanDelete.cpp new file mode 100644 index 00000000000..87ff4d594bd --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanDelete.cpp @@ -0,0 +1,133 @@ +#include +// +#include + +namespace ripple { + +bool +LoanDelete::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +NotTEC +LoanDelete::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfLoanID] == beast::zero) + return temINVALID; + + return tesSUCCESS; +} + +TER +LoanDelete::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const loanID = tx[sfLoanID]; + + auto const loanSle = ctx.view.read(keylet::loan(loanID)); + if (!loanSle) + { + JLOG(ctx.j.warn()) << "Loan does not exist."; + return tecNO_ENTRY; + } + if (loanSle->at(sfPaymentRemaining) > 0) + { + JLOG(ctx.j.warn()) << "Active loan can not be deleted."; + return tecHAS_OBLIGATIONS; + } + + auto const loanBrokerID = loanSle->at(sfLoanBrokerID); + auto const loanBrokerSle = ctx.view.read(keylet::loanbroker(loanBrokerID)); + if (!loanBrokerSle) + { + // should be impossible + return tecINTERNAL; // LCOV_EXCL_LINE + } + if (loanBrokerSle->at(sfOwner) != account && + loanSle->at(sfBorrower) != account) + { + JLOG(ctx.j.warn()) + << "Account is not Loan Broker Owner or Loan Borrower."; + return tecNO_PERMISSION; + } + + return tesSUCCESS; +} + +TER +LoanDelete::doApply() +{ + auto const& tx = ctx_.tx; + auto& view = ctx_.view(); + + auto const loanID = tx[sfLoanID]; + auto const loanSle = view.peek(keylet::loan(loanID)); + if (!loanSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const borrower = loanSle->at(sfBorrower); + auto const borrowerSle = view.peek(keylet::account(borrower)); + if (!borrowerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + auto const brokerID = loanSle->at(sfLoanBrokerID); + auto const brokerSle = view.peek(keylet::loanbroker(brokerID)); + if (!brokerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const brokerPseudoAccount = brokerSle->at(sfAccount); + + auto const vaultSle = view.peek(keylet ::vault(brokerSle->at(sfVaultID))); + if (!vaultSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // Remove LoanID from Directory of the LoanBroker pseudo-account. + if (!view.dirRemove( + keylet::ownerDir(brokerPseudoAccount), + loanSle->at(sfLoanBrokerNode), + loanID, + false)) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + // Remove LoanID from Directory of the Borrower. + if (!view.dirRemove( + keylet::ownerDir(borrower), + loanSle->at(sfOwnerNode), + loanID, + false)) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // Delete the Loan object + view.erase(loanSle); + + // Decrement the LoanBroker's owner count. + // The broker's owner count is solely for the number of outstanding loans, + // and is distinct from the broker's pseudo-account's owner count + adjustOwnerCount(view, brokerSle, -1, j_); + // If there are no loans left, then any remaining debt must be forgiven, + // because there is no other way to pay it back. + if (brokerSle->at(sfOwnerCount) == 0) + { + auto debtTotalProxy = brokerSle->at(sfDebtTotal); + if (*debtTotalProxy != beast::zero) + { + XRPL_ASSERT_PARTS( + roundToAsset( + vaultSle->at(sfAsset), + debtTotalProxy, + getVaultScale(vaultSle), + Number::towards_zero) == beast::zero, + "ripple::LoanDelete::doApply", + "last loan, remaining debt rounds to zero"); + debtTotalProxy = 0; + } + } + // Decrement the borrower's owner count + adjustOwnerCount(view, borrowerSle, -1, j_); + + return tesSUCCESS; +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanDelete.h b/src/xrpld/app/tx/detail/LoanDelete.h new file mode 100644 index 00000000000..cbc37dec145 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanDelete.h @@ -0,0 +1,34 @@ +#ifndef XRPL_TX_LOANDELETE_H_INCLUDED +#define XRPL_TX_LOANDELETE_H_INCLUDED + +#include + +namespace ripple { + +class LoanDelete : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanDelete(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/LoanManage.cpp b/src/xrpld/app/tx/detail/LoanManage.cpp new file mode 100644 index 00000000000..adf08d71bfc --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanManage.cpp @@ -0,0 +1,420 @@ +#include +// +#include + +#include + +namespace ripple { + +bool +LoanManage::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +std::uint32_t +LoanManage::getFlagsMask(PreflightContext const& ctx) +{ + return tfLoanManageMask; +} + +NotTEC +LoanManage::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfLoanID] == beast::zero) + return temINVALID; + + // Flags are mutually exclusive + if (auto const flagField = ctx.tx[~sfFlags]; flagField && *flagField) + { + auto const flags = *flagField & tfUniversalMask; + if ((flags & (flags - 1)) != 0) + { + JLOG(ctx.j.warn()) + << "LoanManage: Only one of tfLoanDefault, tfLoanImpair, or " + "tfLoanUnimpair can be set."; + return temINVALID_FLAG; + } + } + + return tesSUCCESS; +} + +TER +LoanManage::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const loanID = tx[sfLoanID]; + + auto const loanSle = ctx.view.read(keylet::loan(loanID)); + if (!loanSle) + { + JLOG(ctx.j.warn()) << "Loan does not exist."; + return tecNO_ENTRY; + } + // Impairment only allows certain transitions. + // 1. Once it's in default, it can't be changed. + // 2. It can get worse: unimpaired -> impaired -> default + // or unimpaired -> default + // 3. It can get better: impaired -> unimpaired + // 4. If it's in a state, it can't be put in that state again. + if (loanSle->isFlag(lsfLoanDefault)) + { + JLOG(ctx.j.warn()) + << "Loan is in default. A defaulted loan can not be modified."; + return tecNO_PERMISSION; + } + if (loanSle->isFlag(lsfLoanImpaired) && tx.isFlag(tfLoanImpair)) + { + JLOG(ctx.j.warn()) + << "Loan is impaired. A loan can not be impaired twice."; + return tecNO_PERMISSION; + } + if (!(loanSle->isFlag(lsfLoanImpaired) || + loanSle->isFlag(lsfLoanDefault)) && + (tx.isFlag(tfLoanUnimpair))) + { + JLOG(ctx.j.warn()) + << "Loan is unimpaired. Can not be unimpaired again."; + return tecNO_PERMISSION; + } + if (loanSle->at(sfPaymentRemaining) == 0) + { + JLOG(ctx.j.warn()) << "Loan is fully paid. A loan can not be modified " + "after it is fully paid."; + return tecNO_PERMISSION; + } + if (tx.isFlag(tfLoanDefault) && + !hasExpired( + ctx.view, + loanSle->at(sfNextPaymentDueDate) + loanSle->at(sfGracePeriod))) + { + JLOG(ctx.j.warn()) + << "A loan can not be defaulted before the next payment due date."; + return tecTOO_SOON; + } + + auto const loanBrokerID = loanSle->at(sfLoanBrokerID); + auto const loanBrokerSle = ctx.view.read(keylet::loanbroker(loanBrokerID)); + if (!loanBrokerSle) + { + // should be impossible + return tecINTERNAL; // LCOV_EXCL_LINE + } + if (loanBrokerSle->at(sfOwner) != account) + { + JLOG(ctx.j.warn()) + << "LoanBroker for Loan does not belong to the account. LoanModify " + "can only be submitted by the Loan Broker."; + return tecNO_PERMISSION; + } + + return tesSUCCESS; +} + +static Number +owedToVault(SLE::ref loanSle) +{ + // Spec section 3.2.3.2, defines the default amount as + // + // DefaultAmount = (Loan.PrincipalOutstanding + Loan.InterestOutstanding) + // + // Loan.InterestOutstanding is not stored directly on ledger. + // It is computed as + // + // Loan.TotalValueOutstanding - Loan.PrincipalOutstanding - + // Loan.ManagementFeeOutstanding + // + // Add that to the original formula, and you get this: + return loanSle->at(sfTotalValueOutstanding) - + loanSle->at(sfManagementFeeOutstanding); +} + +TER +LoanManage::defaultLoan( + ApplyView& view, + SLE::ref loanSle, + SLE::ref brokerSle, + SLE::ref vaultSle, + Asset const& vaultAsset, + beast::Journal j) +{ + // Calculate the amount of the Default that First-Loss Capital covers: + + std::int32_t const loanScale = loanSle->at(sfLoanScale); + auto brokerDebtTotalProxy = brokerSle->at(sfDebtTotal); + + Number const totalDefaultAmount = owedToVault(loanSle); + + // Apply the First-Loss Capital to the Default Amount + TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; + TenthBips32 const coverRateLiquidation{ + brokerSle->at(sfCoverRateLiquidation)}; + auto const defaultCovered = [&]() { + // Always round the minimum required up. + NumberRoundModeGuard mg(Number::upward); + auto const minimumCover = + tenthBipsOfValue(brokerDebtTotalProxy.value(), coverRateMinimum); + // Round the liquidation amount up, too + return roundToAsset( + vaultAsset, + /* + * This formula is from the XLS-66 spec, section 3.2.3.2 (State + * Changes), specifically "if the `tfLoanDefault` flag is set" / + * "Apply the First-Loss Capital to the Default Amount" + */ + std::min( + tenthBipsOfValue(minimumCover, coverRateLiquidation), + totalDefaultAmount), + loanScale); + }(); + + auto const vaultDefaultAmount = totalDefaultAmount - defaultCovered; + + // Update the Vault object: + + // 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); + + { + // Decrease the Total Value of the Vault: + auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); + auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); + + if (vaultTotalProxy < vaultDefaultAmount) + { + // LCOV_EXCL_START + JLOG(j.warn()) + << "Vault total assets is less than the vault default amount"; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + auto const vaultDefaultRounded = roundToAsset( + vaultAsset, vaultDefaultAmount, vaultScale, Number::downward); + vaultTotalProxy -= vaultDefaultRounded; + // Increase the Asset Available of the Vault by liquidated First-Loss + // Capital and any unclaimed funds amount: + vaultAvailableProxy += defaultCovered; + if (*vaultAvailableProxy > *vaultTotalProxy && !vaultAsset.integral()) + { + auto const difference = vaultAvailableProxy - vaultTotalProxy; + JLOG(j.debug()) + << "Vault assets available: " << *vaultAvailableProxy << "(" + << vaultAvailableProxy.value().exponent() + << "), Total: " << *vaultTotalProxy << "(" + << vaultTotalProxy.value().exponent() + << "), Difference: " << difference << "(" + << difference.exponent() << ")"; + if (vaultAvailableProxy.value().exponent() - difference.exponent() > + 13) + { + // If the difference is dust, bring the total up to match + // the available + JLOG(j.debug()) + << "Difference between vault assets available and total is " + "dust. Set both to the larger value."; + vaultTotalProxy = vaultAvailableProxy; + } + } + if (*vaultAvailableProxy > *vaultTotalProxy) + { + JLOG(j.warn()) << "Vault assets available must not be greater " + "than assets outstanding. Available: " + << *vaultAvailableProxy + << ", Total: " << *vaultTotalProxy; + return tecLIMIT_EXCEEDED; + } + + // The loss has been realized + if (loanSle->isFlag(lsfLoanImpaired)) + { + auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); + if (vaultLossUnrealizedProxy < totalDefaultAmount) + { + // LCOV_EXCL_START + JLOG(j.warn()) + << "Vault unrealized loss is less than the default amount"; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + vaultLossUnrealizedProxy -= totalDefaultAmount; + } + view.update(vaultSle); + } + + // Update the LoanBroker object: + + { + auto const asset = *vaultSle->at(sfAsset); + + // Decrease the Debt of the LoanBroker: + adjustImpreciseNumber( + brokerDebtTotalProxy, -totalDefaultAmount, asset, vaultScale); + // Decrease the First-Loss Capital Cover Available: + auto coverAvailableProxy = brokerSle->at(sfCoverAvailable); + if (coverAvailableProxy < defaultCovered) + { + // LCOV_EXCL_START + JLOG(j.warn()) + << "LoanBroker cover available is less than amount covered"; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + coverAvailableProxy -= defaultCovered; + view.update(brokerSle); + } + + // Update the Loan object: + loanSle->setFlag(lsfLoanDefault); + + loanSle->at(sfTotalValueOutstanding) = 0; + loanSle->at(sfPaymentRemaining) = 0; + loanSle->at(sfPrincipalOutstanding) = 0; + loanSle->at(sfManagementFeeOutstanding) = 0; + // Zero out the next due date. Since it's default, it'll be removed from + // the object. + loanSle->at(sfNextPaymentDueDate) = 0; + view.update(loanSle); + + // Return funds from the LoanBroker pseudo-account to the + // Vault pseudo-account: + return accountSend( + view, + brokerSle->at(sfAccount), + vaultSle->at(sfAccount), + STAmount{vaultAsset, defaultCovered}, + j, + WaiveTransferFee::Yes); +} + +TER +LoanManage::impairLoan( + ApplyView& view, + SLE::ref loanSle, + SLE::ref vaultSle, + beast::Journal j) +{ + Number const lossUnrealized = owedToVault(loanSle); + + // Update the Vault object(set "paper loss") + auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); + vaultLossUnrealizedProxy += lossUnrealized; + if (vaultLossUnrealizedProxy > + vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable)) + { + // Having a loss greater than the vault's unavailable assets + // will leave the vault in an invalid / inconsistent state. + JLOG(j.warn()) << "Vault unrealized loss is too large, and will " + "corrupt the vault."; + return tecLIMIT_EXCEEDED; + } + view.update(vaultSle); + + // Update the Loan object + loanSle->setFlag(lsfLoanImpaired); + auto loanNextDueProxy = loanSle->at(sfNextPaymentDueDate); + if (!hasExpired(view, loanNextDueProxy)) + { + // loan payment is not yet late - + // move the next payment due date to now + loanNextDueProxy = view.parentCloseTime().time_since_epoch().count(); + } + view.update(loanSle); + + return tesSUCCESS; +} + +TER +LoanManage::unimpairLoan( + ApplyView& view, + SLE::ref loanSle, + SLE::ref vaultSle, + beast::Journal j) +{ + // Update the Vault object(clear "paper loss") + auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); + Number const lossReversed = owedToVault(loanSle); + if (vaultLossUnrealizedProxy < lossReversed) + { + // LCOV_EXCL_START + JLOG(j.warn()) + << "Vault unrealized loss is less than the amount to be cleared"; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + vaultLossUnrealizedProxy -= lossReversed; + 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)) + + paymentInterval; + if (!hasExpired(view, normalPaymentDueDate)) + { + // loan was unimpaired within the payment interval + loanSle->at(sfNextPaymentDueDate) = normalPaymentDueDate; + } + else + { + // loan was unimpaired after the original payment due date + loanSle->at(sfNextPaymentDueDate) = + view.parentCloseTime().time_since_epoch().count() + paymentInterval; + } + view.update(loanSle); + + return tesSUCCESS; +} + +TER +LoanManage::doApply() +{ + auto const& tx = ctx_.tx; + auto& view = ctx_.view(); + + auto const loanID = tx[sfLoanID]; + auto const loanSle = view.peek(keylet::loan(loanID)); + if (!loanSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + auto const brokerID = loanSle->at(sfLoanBrokerID); + auto const brokerSle = view.peek(keylet::loanbroker(brokerID)); + if (!brokerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + auto const vaultSle = view.peek(keylet ::vault(brokerSle->at(sfVaultID))); + if (!vaultSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const vaultAsset = vaultSle->at(sfAsset); + + // 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 tesSUCCESS; +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanManage.h b/src/xrpld/app/tx/detail/LoanManage.h new file mode 100644 index 00000000000..dde1023cad9 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanManage.h @@ -0,0 +1,66 @@ +#ifndef XRPL_TX_LOANMANAGE_H_INCLUDED +#define XRPL_TX_LOANMANAGE_H_INCLUDED + +#include + +namespace ripple { + +class LoanManage : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanManage(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static std::uint32_t + getFlagsMask(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + /** Helper function that might be needed by other transactors + */ + static TER + defaultLoan( + ApplyView& view, + SLE::ref loanSle, + SLE::ref brokerSle, + SLE::ref vaultSle, + Asset const& vaultAsset, + beast::Journal j); + + /** Helper function that might be needed by other transactors + */ + static TER + impairLoan( + ApplyView& view, + SLE::ref loanSle, + SLE::ref vaultSle, + beast::Journal j); + + /** Helper function that might be needed by other transactors + */ + static TER + unimpairLoan( + ApplyView& view, + SLE::ref loanSle, + SLE::ref vaultSle, + beast::Journal j); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/LoanPay.cpp b/src/xrpld/app/tx/detail/LoanPay.cpp new file mode 100644 index 00000000000..43f19743a76 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanPay.cpp @@ -0,0 +1,602 @@ +#include +// +#include +#include + +#include +#include +#include + +#include + +namespace ripple { + +bool +LoanPay::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +std::uint32_t +LoanPay::getFlagsMask(PreflightContext const& ctx) +{ + return tfLoanPayMask; +} + +NotTEC +LoanPay::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfLoanID] == beast::zero) + return temINVALID; + + if (ctx.tx[sfAmount] <= beast::zero) + return temBAD_AMOUNT; + + // The loan payment flags are all mutually exclusive. If more than one is + // set, the tx is malformed. + static_assert( + (tfLoanLatePayment | tfLoanFullPayment | tfLoanOverpayment) == + ~(tfLoanPayMask | tfUniversal)); + auto const flagsSet = ctx.tx.getFlags() & ~(tfLoanPayMask | tfUniversal); + if (std::popcount(flagsSet) > 1) + { + JLOG(ctx.j.warn()) << "Only one LoanPay flag can be set per tx. " + << flagsSet << " is too many."; + return temINVALID_FLAG; + } + + return tesSUCCESS; +} + +XRPAmount +LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx) +{ + using namespace Lending; + + auto const normalCost = Transactor::calculateBaseFee(view, tx); + + if (tx.isFlag(tfLoanFullPayment) || tx.isFlag(tfLoanLatePayment)) + // The loan will be making one set of calculations for one full or late + // payment + return normalCost; + + // The fee is based on the potential number of payments, unless the loan is + // being fully paid off. + auto const amount = tx[sfAmount]; + auto const loanID = tx[sfLoanID]; + + auto const loanSle = view.read(keylet::loan(loanID)); + if (!loanSle) + // Let preclaim worry about the error for this + return normalCost; + + if (loanSle->at(sfPaymentRemaining) <= loanPaymentsPerFeeIncrement) + { + // If there are fewer than loanPaymentsPerFeeIncrement payments left to + // pay, we can skip the computations. + return normalCost; + } + + if (hasExpired(view, loanSle->at(sfNextPaymentDueDate))) + // If the payment is late, and the late payment flag is not set, it'll + // fail + return normalCost; + + auto const brokerSle = + view.read(keylet::loanbroker(loanSle->at(sfLoanBrokerID))); + if (!brokerSle) + // Let preclaim worry about the error for this + return normalCost; + auto const vaultSle = view.read(keylet::vault(brokerSle->at(sfVaultID))); + if (!vaultSle) + // Let preclaim worry about the error for this + return normalCost; + + auto const asset = vaultSle->at(sfAsset); + + if (asset != amount.asset()) + // Let preclaim worry about the error for this + return normalCost; + + auto const scale = loanSle->at(sfLoanScale); + + auto const regularPayment = + roundPeriodicPayment(asset, loanSle->at(sfPeriodicPayment), scale) + + loanSle->at(sfLoanServiceFee); + + // If making an overpayment, count it as a full payment because it will do + // about the same amount of work, if not more. + NumberRoundModeGuard mg( + tx.isFlag(tfLoanOverpayment) ? Number::upward : Number::downward); + // Estimate how many payments will be made + Number const numPaymentEstimate = + static_cast(amount / regularPayment); + + // Charge one base fee per paymentsPerFeeIncrement payments, rounding up. + Number::setround(Number::upward); + auto const feeIncrements = std::max( + std::int64_t(1), + static_cast( + numPaymentEstimate / loanPaymentsPerFeeIncrement)); + + return feeIncrements * normalCost; +} + +TER +LoanPay::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const loanID = tx[sfLoanID]; + auto const amount = tx[sfAmount]; + + auto const loanSle = ctx.view.read(keylet::loan(loanID)); + if (!loanSle) + { + JLOG(ctx.j.warn()) << "Loan does not exist."; + return tecNO_ENTRY; + } + + if (loanSle->at(sfBorrower) != account) + { + JLOG(ctx.j.warn()) << "Loan does not belong to the account."; + return tecNO_PERMISSION; + } + + if (tx.isFlag(tfLoanOverpayment) && !loanSle->isFlag(lsfLoanOverpayment)) + { + JLOG(ctx.j.warn()) + << "Requested overpayment on a loan that doesn't allow it"; + return temINVALID_FLAG; + } + + 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) + { + JLOG(ctx.j.warn()) << "Loan is already paid off."; + return tecKILLED; + } + + auto const loanBrokerID = loanSle->at(sfLoanBrokerID); + auto const loanBrokerSle = ctx.view.read(keylet::loanbroker(loanBrokerID)); + if (!loanBrokerSle) + { + // This should be impossible + // LCOV_EXCL_START + JLOG(ctx.j.fatal()) << "LoanBroker does not exist."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + auto const vaultID = loanBrokerSle->at(sfVaultID); + auto const vaultSle = ctx.view.read(keylet::vault(vaultID)); + if (!vaultSle) + { + // This should be impossible + // LCOV_EXCL_START + JLOG(ctx.j.fatal()) << "Vault does not exist."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + auto const asset = vaultSle->at(sfAsset); + auto const vaultPseudoAccount = vaultSle->at(sfAccount); + + if (amount.asset() != asset) + { + JLOG(ctx.j.warn()) << "Loan amount does not match the Vault asset."; + return tecWRONG_ASSET; + } + + if (auto const ret = checkFrozen(ctx.view, account, asset)) + { + JLOG(ctx.j.warn()) << "Borrower account is frozen."; + return ret; + } + if (auto const ret = checkDeepFrozen(ctx.view, vaultPseudoAccount, asset)) + { + JLOG(ctx.j.warn()) + << "Vault pseudo-account can not receive funds (deep frozen)."; + return ret; + } + if (auto const ret = requireAuth(ctx.view, asset, account)) + { + JLOG(ctx.j.warn()) << "Borrower account is not authorized."; + return ret; + } + // Make sure the borrower has enough funds to make the payment! + // 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( + ctx.view, + account, + asset, + fhZERO_IF_FROZEN, + ahZERO_IF_UNAUTHORIZED, + ctx.j); + balance < amount) + { + JLOG(ctx.j.warn()) << "Payment amount too large. Amount: " + << to_string(amount.getJson()) + << ". Balance: " << to_string(balance.getJson()); + return tecINSUFFICIENT_FUNDS; + } + + return tesSUCCESS; +} + +TER +LoanPay::doApply() +{ + auto const& tx = ctx_.tx; + auto& view = ctx_.view(); + + auto const amount = tx[sfAmount]; + + auto const loanID = tx[sfLoanID]; + auto const loanSle = view.peek(keylet::loan(loanID)); + if (!loanSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + std::int32_t const loanScale = loanSle->at(sfLoanScale); + + auto const brokerID = loanSle->at(sfLoanBrokerID); + auto const brokerSle = view.peek(keylet::loanbroker(brokerID)); + if (!brokerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const brokerOwner = brokerSle->at(sfOwner); + auto const brokerPseudoAccount = brokerSle->at(sfAccount); + auto const vaultID = brokerSle->at(sfVaultID); + auto const vaultSle = view.peek(keylet::vault(vaultID)); + if (!vaultSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const vaultPseudoAccount = vaultSle->at(sfAccount); + auto const asset = *vaultSle->at(sfAsset); + + // Determine where to send the broker's fee + auto coverAvailableProxy = brokerSle->at(sfCoverAvailable); + TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; + 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). + // + // Normally freeze status is checked in preflight, 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 + // CoverAvailable never drops below the theoretical minimum, protecting + // the broker's solvency. + NumberRoundModeGuard mg(Number::upward); + return coverAvailableProxy >= + roundToAsset( + asset, + tenthBipsOfValue(debtTotalProxy.value(), coverRateMinimum), + loanScale) && + !isDeepFrozen(view, brokerOwner, asset); + }(); + + auto const brokerPayee = + sendBrokerFeeToOwner ? brokerOwner : brokerPseudoAccount; + auto const brokerPayeeSle = view.peek(keylet::account(brokerPayee)); + if (!sendBrokerFeeToOwner) + { + // If we can't send the fee to the owner, and the pseudo-account is + // frozen, then we have to fail the payment. + if (auto const ret = checkDeepFrozen(view, brokerPayee, asset)) + { + JLOG(j_.warn()) + << "Both Loan Broker and Loan Broker pseudo-account " + "can not receive funds (deep frozen)."; + return ret; + } + } + + //------------------------------------------------------ + // Loan object state changes + + // Unimpair the loan if it was impaired. Do this before the payment is + // attempted, so the original values can be used. If the payment fails, this + // change will be discarded. + if (loanSle->isFlag(lsfLoanImpaired)) + { + LoanManage::unimpairLoan(view, loanSle, vaultSle, j_); + } + + LoanPaymentType const paymentType = [&tx]() { + // preflight already checked that at most one flag is set. + if (tx.isFlag(tfLoanLatePayment)) + return LoanPaymentType::late; + if (tx.isFlag(tfLoanFullPayment)) + return LoanPaymentType::full; + if (tx.isFlag(tfLoanOverpayment)) + return LoanPaymentType::overpayment; + return LoanPaymentType::regular; + }(); + + Expected const paymentParts = loanMakePayment( + asset, view, loanSle, brokerSle, amount, paymentType, j_); + + if (!paymentParts) + { + XRPL_ASSERT_PARTS( + paymentParts.error(), + "ripple::LoanPay::doApply", + "payment error is an error"); + return paymentParts.error(); + } + + // If the payment computation completed without error, the loanSle object + // has been modified. + view.update(loanSle); + + XRPL_ASSERT_PARTS( + // It is possible to pay 0 principal + paymentParts->principalPaid >= 0, + "ripple::LoanPay::doApply", + "valid principal paid"); + XRPL_ASSERT_PARTS( + // It is possible to pay 0 interest + paymentParts->interestPaid >= 0, + "ripple::LoanPay::doApply", + "valid interest paid"); + XRPL_ASSERT_PARTS( + // It should not be possible to pay 0 total + paymentParts->principalPaid + paymentParts->interestPaid > 0, + "ripple::LoanPay::doApply", + "valid total paid"); + XRPL_ASSERT_PARTS( + paymentParts->feePaid >= 0, + "ripple::LoanPay::doApply", + "valid fee paid"); + + if (paymentParts->principalPaid < 0 || paymentParts->interestPaid < 0 || + paymentParts->feePaid < 0) + { + // LCOV_EXCL_START + JLOG(j_.fatal()) << "Loan payment computation returned invalid values."; + return tecLIMIT_EXCEEDED; + // LCOV_EXCL_STOP + } + + JLOG(j_.debug()) << "Loan Pay: principal paid: " + << paymentParts->principalPaid + << ", interest paid: " << paymentParts->interestPaid + << ", fee paid: " << paymentParts->feePaid + << ", value change: " << paymentParts->valueChange; + + //------------------------------------------------------ + // LoanBroker object state changes + view.update(brokerSle); + + auto assetsAvailableProxy = vaultSle->at(sfAssetsAvailable); + auto assetsTotalProxy = vaultSle->at(sfAssetsTotal); + + // 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 totalPaidToVaultRaw = + paymentParts->principalPaid + paymentParts->interestPaid; + auto const totalPaidToVaultRounded = + roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::downward); + XRPL_ASSERT_PARTS( + !asset.integral() || totalPaidToVaultRaw == totalPaidToVaultRounded, + "ripple::LoanPay::doApply", + "rounding does nothing for integral asset"); + // Account for value changes when reducing the broker's debt: + // - Positive value change (from full/late/overpayments): Subtract from the + // amount credited toward debt to avoid over-reducing the debt. + // - Negative value change (from full/overpayments): Add to the amount + // credited toward debt,effectively increasing the debt reduction. + auto const totalPaidToVaultForDebt = + totalPaidToVaultRaw - paymentParts->valueChange; + + auto const totalPaidToBroker = paymentParts->feePaid; + + XRPL_ASSERT_PARTS( + (totalPaidToVaultRaw + totalPaidToBroker) == + (paymentParts->principalPaid + paymentParts->interestPaid + + paymentParts->feePaid), + "ripple::LoanPay::doApply", + "payments add up"); + + // Decrease LoanBroker Debt by the amount paid, add the Loan value change + // (which might be negative). totalPaidToVaultForDebt may be negative, + // increasing the debt + XRPL_ASSERT_PARTS( + isRounded(asset, totalPaidToVaultForDebt, loanScale), + "ripple::LoanPay::doApply", + "totalPaidToVaultForDebt rounding good"); + // Despite our best efforts, it's possible for rounding errors to accumulate + // in the loan broker's debt total. This is because the broker may have more + // than one loan with significantly different scales. + adjustImpreciseNumber( + debtTotalProxy, -totalPaidToVaultForDebt, asset, vaultScale); + + //------------------------------------------------------ + // 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_); + + { + XRPL_ASSERT_PARTS( + assetsAvailableBefore == pseudoAccountBalanceBefore, + "ripple::LoanPay::doApply", + "vault pseudo balance agrees before"); + + assetsAvailableProxy += totalPaidToVaultRounded; + assetsTotalProxy += paymentParts->valueChange; + + XRPL_ASSERT_PARTS( + *assetsAvailableProxy <= *assetsTotalProxy, + "ripple::LoanPay::doApply", + "assets available must not be greater than assets outstanding"); + + if (*assetsAvailableProxy > *assetsTotalProxy) + { + // LCOV_EXCL_START + return tecINTERNAL; + // LCOV_EXCL_STOP + } + } + + JLOG(j_.debug()) << "total paid to vault raw: " << totalPaidToVaultRaw + << ", total paid to vault rounded: " + << totalPaidToVaultRounded + << ", total paid to broker: " << totalPaidToBroker + << ", amount from transaction: " << amount; + + // Move funds + XRPL_ASSERT_PARTS( + totalPaidToVaultRounded + totalPaidToBroker <= amount, + "ripple::LoanPay::doApply", + "amount is sufficient"); + + if (!sendBrokerFeeToOwner) + { + // If there is not enough first-loss capital, add the fee to First Loss + // Cover Pool. Note that this moves the entire fee - it does not attempt + // to split it. The broker can Withdraw it later if they want, or leave + // it for future needs. + coverAvailableProxy += totalPaidToBroker; + } + +#if !NDEBUG + auto const accountBalanceBefore = accountSpendable( + view, account_, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); + auto const vaultBalanceBefore = account_ == vaultPseudoAccount + ? STAmount{asset, 0} + : accountSpendable( + view, + vaultPseudoAccount, + asset, + fhIGNORE_FREEZE, + ahIGNORE_AUTH, + j_); + auto const brokerBalanceBefore = account_ == brokerPayee + ? STAmount{asset, 0} + : accountSpendable( + view, brokerPayee, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); +#endif + + if (totalPaidToVaultRounded != beast::zero) + { + if (auto const ter = requireAuth( + view, asset, vaultPseudoAccount, AuthType::StrongAuth)) + return ter; + } + + if (totalPaidToBroker != beast::zero) + { + if (brokerPayee == account_) + { + // The broker may have deleted their holding. Recreate it if needed + if (auto const ter = addEmptyHolding( + view, + brokerPayee, + brokerPayeeSle->at(sfBalance).value().xrp(), + asset, + j_); + ter && ter != tecDUPLICATE) + // ignore tecDUPLICATE. That means the holding already exists, + // and is fine here + return ter; + } + if (auto const ter = + requireAuth(view, asset, brokerPayee, AuthType::StrongAuth)) + return ter; + } + + if (auto const ter = accountSendMulti( + view, + account_, + asset, + {{vaultPseudoAccount, totalPaidToVaultRounded}, + {brokerPayee, totalPaidToBroker}}, + j_, + WaiveTransferFee::Yes)) + return ter; + + Number const assetsAvailableAfter = *assetsAvailableProxy; + Number const pseudoAccountBalanceAfter = accountHolds( + view, + vaultPseudoAccount, + asset, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j_); + XRPL_ASSERT_PARTS( + assetsAvailableAfter == pseudoAccountBalanceAfter, + "ripple::LoanPay::doApply", + "vault pseudo balance agrees after"); + +#if !NDEBUG + auto const accountBalanceAfter = accountSpendable( + view, account_, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); + auto const vaultBalanceAfter = account_ == vaultPseudoAccount + ? STAmount{asset, 0} + : accountSpendable( + view, + vaultPseudoAccount, + asset, + fhIGNORE_FREEZE, + ahIGNORE_AUTH, + j_); + auto const brokerBalanceAfter = account_ == brokerPayee + ? STAmount{asset, 0} + : accountSpendable( + view, brokerPayee, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); + + XRPL_ASSERT_PARTS( + accountBalanceBefore + vaultBalanceBefore + brokerBalanceBefore == + accountBalanceAfter + vaultBalanceAfter + brokerBalanceAfter, + "ripple::LoanPay::doApply", + "funds are conserved (with rounding)"); + XRPL_ASSERT_PARTS( + accountBalanceAfter >= beast::zero, + "ripple::LoanPay::doApply", + "positive account balance"); + XRPL_ASSERT_PARTS( + accountBalanceAfter < accountBalanceBefore || + account_ == asset.getIssuer(), + "ripple::LoanPay::doApply", + "account balance decreased"); + XRPL_ASSERT_PARTS( + vaultBalanceAfter >= beast::zero && brokerBalanceAfter >= beast::zero, + "ripple::LoanPay::doApply", + "positive vault and broker balances"); + XRPL_ASSERT_PARTS( + vaultBalanceAfter >= vaultBalanceBefore, + "ripple::LoanPay::doApply", + "vault balance did not decrease"); + XRPL_ASSERT_PARTS( + brokerBalanceAfter >= brokerBalanceBefore, + "ripple::LoanPay::doApply", + "broker balance did not decrease"); + XRPL_ASSERT_PARTS( + vaultBalanceAfter > vaultBalanceBefore || + brokerBalanceAfter > brokerBalanceBefore, + "ripple::LoanPay::doApply", + "vault and/or broker balance increased"); +#endif + + return tesSUCCESS; +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanPay.h b/src/xrpld/app/tx/detail/LoanPay.h new file mode 100644 index 00000000000..3f8eb16d044 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanPay.h @@ -0,0 +1,40 @@ +#ifndef XRPL_TX_LOANPAY_H_INCLUDED +#define XRPL_TX_LOANPAY_H_INCLUDED + +#include + +namespace ripple { + +class LoanPay : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanPay(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static std::uint32_t + getFlagsMask(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/LoanSet.cpp b/src/xrpld/app/tx/detail/LoanSet.cpp new file mode 100644 index 00000000000..838e774cae5 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanSet.cpp @@ -0,0 +1,632 @@ +#include +// +#include + +#include + +namespace ripple { + +bool +LoanSet::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx); +} + +std::uint32_t +LoanSet::getFlagsMask(PreflightContext const& ctx) +{ + return tfLoanSetMask; +} + +NotTEC +LoanSet::preflight(PreflightContext const& ctx) +{ + using namespace Lending; + + auto const& tx = ctx.tx; + + // Special case for Batch inner transactions + if (tx.isFlag(tfInnerBatchTxn) && ctx.rules.enabled(featureBatch) && + !tx.isFieldPresent(sfCounterparty)) + { + auto const parentBatchId = ctx.parentBatchId.value_or(uint256{0}); + JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " + << "no Counterparty for inner LoanSet transaction."; + return temBAD_SIGNER; + } + + // These extra hoops are because STObjects cannot be Proxy'd from STObject. + auto const counterPartySig = [&tx]() -> std::optional { + if (tx.isFieldPresent(sfCounterpartySignature)) + return tx.getFieldObject(sfCounterpartySignature); + return std::nullopt; + }(); + if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig) + { + JLOG(ctx.j.warn()) + << "LoanSet transaction must have a CounterpartySignature."; + return temBAD_SIGNER; + } + + if (counterPartySig) + { + if (auto const ret = ripple::detail::preflightCheckSigningKey( + *counterPartySig, ctx.j)) + return ret; + } + + if (auto const data = tx[~sfData]; data && !data->empty() && + !validDataLength(tx[~sfData], maxDataPayloadLength)) + return temINVALID; + for (auto const& field : + {&sfLoanServiceFee, &sfLatePaymentFee, &sfClosePaymentFee}) + { + if (!validNumericMinimum(tx[~*field])) + return temINVALID; + } + // Principal Requested is required + if (auto const p = tx[sfPrincipalRequested]; p <= 0) + return temINVALID; + else if (!validNumericRange(tx[~sfLoanOriginationFee], p)) + return temINVALID; + if (!validNumericRange(tx[~sfInterestRate], maxInterestRate)) + return temINVALID; + if (!validNumericRange(tx[~sfOverpaymentFee], maxOverpaymentFee)) + return temINVALID; + if (!validNumericRange(tx[~sfLateInterestRate], maxLateInterestRate)) + return temINVALID; + if (!validNumericRange(tx[~sfCloseInterestRate], maxCloseInterestRate)) + return temINVALID; + if (!validNumericRange( + tx[~sfOverpaymentInterestRate], maxOverpaymentInterestRate)) + return temINVALID; + + if (auto const paymentTotal = tx[~sfPaymentTotal]; + paymentTotal && *paymentTotal <= 0) + return temINVALID; + + if (auto const paymentInterval = tx[~sfPaymentInterval]; + !validNumericMinimum(paymentInterval, LoanSet::minPaymentInterval)) + return temINVALID; + + else if (!validNumericRange( + tx[~sfGracePeriod], + paymentInterval.value_or(LoanSet::defaultPaymentInterval))) + return temINVALID; + + // Copied from preflight2 + if (counterPartySig) + { + if (auto const ret = ripple::detail::preflightCheckSimulateKeys( + ctx.flags, *counterPartySig, ctx.j)) + return *ret; + } + + if (auto const brokerID = ctx.tx[~sfLoanBrokerID]; + brokerID && *brokerID == beast::zero) + return temINVALID; + + return tesSUCCESS; +} + +NotTEC +LoanSet::checkSign(PreclaimContext const& ctx) +{ + if (auto ret = Transactor::checkSign(ctx)) + return ret; + + // Counter signer is optional. If it's not specified, it's assumed to be + // `LoanBroker.Owner`. Note that we have not checked whether the + // loanbroker exists at this point. + auto const counterSigner = [&]() -> std::optional { + if (auto const c = ctx.tx.at(~sfCounterparty)) + return c; + + if (auto const broker = + ctx.view.read(keylet::loanbroker(ctx.tx[sfLoanBrokerID]))) + return broker->at(sfOwner); + return std::nullopt; + }(); + if (!counterSigner) + return temBAD_SIGNER; + + // Counterparty signature is optional. Presence is checked in preflight. + if (!ctx.tx.isFieldPresent(sfCounterpartySignature)) + return tesSUCCESS; + auto const counterSig = ctx.tx.getFieldObject(sfCounterpartySignature); + return Transactor::checkSign( + ctx.view, + ctx.flags, + ctx.parentBatchId, + *counterSigner, + counterSig, + ctx.j); +} + +XRPAmount +LoanSet::calculateBaseFee(ReadView const& view, STTx const& tx) +{ + auto const normalCost = Transactor::calculateBaseFee(view, tx); + + // Compute the additional cost of each signature in the + // CounterpartySignature, whether a single signature or a multisignature + XRPAmount const baseFee = view.fees().base; + + // Counterparty signature is optional, but getFieldObject will return an + // empty object if it's not present. + auto const counterSig = tx.getFieldObject(sfCounterpartySignature); + // Each signer adds one more baseFee to the minimum required fee + // for the transaction. Note that unlike the base class, the single signer + // is counted if present. It will only be absent in a batch inner + // transaction. + std::size_t const signerCount = [&counterSig]() { + // Compute defensively. Assure that "tx" cannot be accessed and cause + // confusion or miscalculations. + return counterSig.isFieldPresent(sfSigners) + ? counterSig.getFieldArray(sfSigners).size() + : (counterSig.isFieldPresent(sfTxnSignature) ? 1 : 0); + }(); + + return normalCost + (signerCount * baseFee); +} + +std::vector> const& +LoanSet::getValueFields() +{ + static std::vector> const valueFields{ + ~sfPrincipalRequested, + ~sfLoanOriginationFee, + ~sfLoanServiceFee, + ~sfLatePaymentFee, + ~sfClosePaymentFee + // Overpayment fee is really a rate. Don't check it here. + }; + + return valueFields; +} + +static std::uint32_t +getStartDate(ReadView const& view) +{ + return view.info().closeTime.time_since_epoch().count(); +} + +TER +LoanSet::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + { + // Check for numeric overflow of the schedule before we load any + // objects. The Grace Period for the last payment ends at: + // startDate + (paymentInterval * paymentTotal) + gracePeriod. + // If that value is larger than "maxTime", the value + // overflows, and we kill the transaction. + using timeType = decltype(sfNextPaymentDueDate)::type::value_type; + static_assert(std::is_same_v); + timeType constexpr maxTime = std::numeric_limits::max(); + static_assert(maxTime == 4'294'967'295); + + auto const timeAvailable = maxTime - getStartDate(ctx.view); + + auto const interval = + ctx.tx.at(~sfPaymentInterval).value_or(defaultPaymentInterval); + auto const total = + ctx.tx.at(~sfPaymentTotal).value_or(defaultPaymentTotal); + auto const grace = + ctx.tx.at(~sfGracePeriod).value_or(defaultGracePeriod); + + // The grace period can't be larger than the interval. Check it first, + // mostly so that unit tests can test that specific case. + if (grace > timeAvailable) + { + JLOG(ctx.j.warn()) << "Grace period exceeds protocol time limit."; + return tecKILLED; + } + + if (interval > timeAvailable) + { + JLOG(ctx.j.warn()) + << "Payment interval exceeds protocol time limit."; + return tecKILLED; + } + + if (total > timeAvailable) + { + JLOG(ctx.j.warn()) << "Payment total exceeds protocol time limit."; + return tecKILLED; + } + + auto const timeLastPayment = timeAvailable - grace; + + if (timeLastPayment / interval < total) + { + JLOG(ctx.j.warn()) << "Last payment due date, or grace period for " + "last payment exceeds protocol time limit."; + return tecKILLED; + } + } + + auto const account = tx[sfAccount]; + auto const brokerID = tx[sfLoanBrokerID]; + + auto const brokerSle = ctx.view.read(keylet::loanbroker(brokerID)); + if (!brokerSle) + { + // This can only be hit if there's a counterparty specified, otherwise + // it'll fail in the signature check + JLOG(ctx.j.warn()) << "LoanBroker does not exist."; + return tecNO_ENTRY; + } + auto const brokerOwner = brokerSle->at(sfOwner); + auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner); + if (account != brokerOwner && counterparty != brokerOwner) + { + JLOG(ctx.j.warn()) << "Neither Account nor Counterparty are the owner " + "of the LoanBroker."; + return tecNO_PERMISSION; + } + auto const brokerPseudo = brokerSle->at(sfAccount); + + auto const borrower = counterparty == brokerOwner ? account : counterparty; + if (auto const borrowerSle = ctx.view.read(keylet::account(borrower)); + !borrowerSle) + { + // It may not be possible to hit this case, because it'll fail the + // signature check with terNO_ACCOUNT. + JLOG(ctx.j.warn()) << "Borrower does not exist."; + return terNO_ACCOUNT; + } + + auto const vault = ctx.view.read(keylet::vault(brokerSle->at(sfVaultID))); + if (!vault) + // Should be impossible + return tefBAD_LEDGER; // LCOV_EXCL_LINE + Asset const asset = vault->at(sfAsset); + + auto const vaultPseudo = vault->at(sfAccount); + + // Check that relevant values can be represented as the vault asset type. + // 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()) + { + 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; + } + } + } + + if (auto const ter = canAddHolding(ctx.view, asset)) + return ter; + + // vaultPseudo is going to send funds, so it can't be frozen. + if (auto const ret = checkFrozen(ctx.view, vaultPseudo, asset)) + { + JLOG(ctx.j.warn()) << "Vault pseudo-account is frozen."; + return ret; + } + + // brokerPseudo is the fallback account to receive LoanPay fees, even if the + // broker owner is unable to accept them. Don't create the loan if it is + // deep frozen. + if (auto const ret = checkDeepFrozen(ctx.view, brokerPseudo, asset)) + { + JLOG(ctx.j.warn()) << "Broker pseudo-account is frozen."; + return ret; + } + + // borrower is eventually going to have to pay back the loan, so it can't be + // frozen now. It is also going to receive funds, so it can't be deep + // frozen, but being frozen is a prerequisite for being deep frozen, so + // checking the one is sufficient. + if (auto const ret = checkFrozen(ctx.view, borrower, asset)) + { + JLOG(ctx.j.warn()) << "Borrower account is frozen."; + return ret; + } + // brokerOwner is going to receive funds if there's an origination fee, so + // it can't be deep frozen + if (auto const ret = checkDeepFrozen(ctx.view, brokerOwner, asset)) + { + JLOG(ctx.j.warn()) << "Broker owner account is frozen."; + return ret; + } + + return tesSUCCESS; +} + +TER +LoanSet::doApply() +{ + auto const& tx = ctx_.tx; + auto& view = ctx_.view(); + + auto const brokerID = tx[sfLoanBrokerID]; + + auto const brokerSle = view.peek(keylet::loanbroker(brokerID)); + if (!brokerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const brokerOwner = brokerSle->at(sfOwner); + auto const brokerOwnerSle = view.peek(keylet::account(brokerOwner)); + if (!brokerOwnerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + auto const vaultSle = view.peek(keylet ::vault(brokerSle->at(sfVaultID))); + if (!vaultSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const vaultPseudo = vaultSle->at(sfAccount); + Asset const vaultAsset = vaultSle->at(sfAsset); + + auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner); + auto const borrower = counterparty == brokerOwner ? account_ : counterparty; + auto const borrowerSle = view.peek(keylet::account(borrower)); + if (!borrowerSle) + { + return tefBAD_LEDGER; // LCOV_EXCL_LINE + } + + auto const brokerPseudo = brokerSle->at(sfAccount); + auto const brokerPseudoSle = view.peek(keylet::account(brokerPseudo)); + if (!brokerPseudoSle) + { + return tefBAD_LEDGER; // LCOV_EXCL_LINE + } + auto const principalRequested = tx[sfPrincipalRequested]; + + auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); + auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); + auto const vaultScale = getVaultScale(vaultSle); + if (vaultAvailableProxy < principalRequested) + { + JLOG(j_.warn()) + << "Insufficient assets available in the Vault to fund the loan."; + return tecINSUFFICIENT_FUNDS; + } + + TenthBips32 const interestRate{tx[~sfInterestRate].value_or(0)}; + + auto const paymentInterval = + tx[~sfPaymentInterval].value_or(defaultPaymentInterval); + auto const paymentTotal = tx[~sfPaymentTotal].value_or(defaultPaymentTotal); + + auto const properties = computeLoanProperties( + vaultAsset, + principalRequested, + interestRate, + paymentInterval, + paymentTotal, + TenthBips16{brokerSle->at(sfManagementFeeRate)}, + vaultScale); + + // Check that relevant values won't lose precision. This is mostly only + // relevant for IOU assets. + { + for (auto const& field : getValueFields()) + { + 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; + } + } + } + + if (auto const ret = checkLoanGuards( + vaultAsset, + principalRequested, + interestRate != beast::zero, + paymentTotal, + properties, + j_)) + return ret; + + // Check that the other computed values are valid + if (properties.managementFeeOwedToBroker < 0 || + properties.totalValueOutstanding <= 0 || + properties.periodicPayment <= 0) + { + // LCOV_EXCL_START + JLOG(j_.warn()) + << "Computed loan properties are invalid. Does not compute."; + 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; + + auto const newDebtDelta = principalRequested + state.interestDue; + auto const newDebtTotal = brokerSle->at(sfDebtTotal) + newDebtDelta; + if (auto const debtMaximum = brokerSle->at(sfDebtMaximum); + debtMaximum != 0 && debtMaximum < newDebtTotal) + { + JLOG(j_.warn()) + << "Loan would exceed the maximum debt limit of the LoanBroker."; + return tecLIMIT_EXCEEDED; + } + TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; + { + // Round the minimum required cover up to be conservative. This ensures + // CoverAvailable never drops below the theoretical minimum, protecting + // the broker's solvency. + NumberRoundModeGuard mg(Number::upward); + if (brokerSle->at(sfCoverAvailable) < + tenthBipsOfValue(newDebtTotal, coverRateMinimum)) + { + JLOG(j_.warn()) + << "Insufficient first-loss capital to cover the loan."; + return tecINSUFFICIENT_FUNDS; + } + } + + adjustOwnerCount(view, borrowerSle, 1, j_); + { + auto const ownerCount = borrowerSle->at(sfOwnerCount); + auto const balance = account_ == borrower + ? mPriorBalance + : borrowerSle->at(sfBalance).value().xrp(); + if (balance < view.fees().accountReserve(ownerCount)) + return tecINSUFFICIENT_RESERVE; + } + + // Account for the origination fee using two payments + // + // 1. Transfer loanAssetsAvailable (principalRequested - originationFee) + // from vault pseudo-account to the borrower. + // Create a holding for the borrower if one does not already exist. + + XRPL_ASSERT_PARTS( + borrower == account_ || borrower == counterparty, + "ripple::LoanSet::doApply", + "borrower signed transaction"); + if (auto const ter = addEmptyHolding( + view, + borrower, + borrowerSle->at(sfBalance).value().xrp(), + vaultAsset, + j_); + ter && ter != tecDUPLICATE) + // ignore tecDUPLICATE. That means the holding already exists, and + // is fine here + return ter; + + if (auto const ter = + requireAuth(view, vaultAsset, borrower, AuthType::StrongAuth)) + return ter; + + // 2. Transfer originationFee, if any, from vault pseudo-account to + // LoanBroker owner. + if (originationFee != beast::zero) + { + // Create the holding if it doesn't already exist (necessary for MPTs). + // The owner may have deleted their MPT / line at some point. + XRPL_ASSERT_PARTS( + brokerOwner == account_ || brokerOwner == counterparty, + "ripple::LoanSet::doApply", + "broker owner signed transaction"); + + if (auto const ter = addEmptyHolding( + view, + brokerOwner, + brokerOwnerSle->at(sfBalance).value().xrp(), + vaultAsset, + j_); + ter && ter != tecDUPLICATE) + // 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 = accountSendMulti( + view, + vaultPseudo, + vaultAsset, + {{borrower, loanAssetsToBorrower}, {brokerOwner, originationFee}}, + j_, + WaiveTransferFee::Yes)) + return ter; + + // Get shortcuts to the loan property values + auto const startDate = getStartDate(view); + auto loanSequenceProxy = brokerSle->at(sfLoanSequence); + + // Create the loan + auto loan = + std::make_shared(keylet::loan(brokerID, *loanSequenceProxy)); + + // Prevent copy/paste errors + auto setLoanField = + [&loan, &tx](auto const& field, std::uint32_t const defValue = 0) { + // at() is smart enough to unseat a default field set to the default + // value + loan->at(field) = tx[field].value_or(defValue); + }; + + // Set required and fixed tx fields + loan->at(sfLoanScale) = properties.loanScale; + loan->at(sfStartDate) = startDate; + loan->at(sfPaymentInterval) = paymentInterval; + loan->at(sfLoanSequence) = *loanSequenceProxy; + loan->at(sfLoanBrokerID) = brokerID; + loan->at(sfBorrower) = borrower; + // Set all other transaction fields directly from the transaction + if (tx.isFlag(tfLoanOverpayment)) + loan->setFlag(lsfLoanOverpayment); + setLoanField(~sfLoanOriginationFee); + setLoanField(~sfLoanServiceFee); + setLoanField(~sfLatePaymentFee); + setLoanField(~sfClosePaymentFee); + setLoanField(~sfOverpaymentFee); + setLoanField(~sfInterestRate); + setLoanField(~sfLateInterestRate); + setLoanField(~sfCloseInterestRate); + setLoanField(~sfOverpaymentInterestRate); + setLoanField(~sfGracePeriod, defaultGracePeriod); + // 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(sfNextPaymentDueDate) = startDate + paymentInterval; + loan->at(sfPaymentRemaining) = paymentTotal; + view.insert(loan); + + // Update the balances in the vault + vaultAvailableProxy -= principalRequested; + vaultTotalProxy += state.interestDue; + XRPL_ASSERT_PARTS( + *vaultAvailableProxy <= *vaultTotalProxy, + "ripple::LoanSet::doApply", + "assets available must not be greater than assets outstanding"); + view.update(vaultSle); + + // Update the balances in the loan broker + adjustImpreciseNumber( + brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale); + // The broker's owner count is solely for the number of outstanding loans, + // and is distinct from the broker's pseudo-account's owner count + adjustOwnerCount(view, brokerSle, 1, j_); + loanSequenceProxy += 1; + // The sequence should be extremely unlikely to roll over, but fail if it + // does + if (loanSequenceProxy == 0) + return tecMAX_SEQUENCE_REACHED; + view.update(brokerSle); + + // Put the loan into the pseudo-account's directory + if (auto const ter = dirLink(view, brokerPseudo, loan, sfLoanBrokerNode)) + return ter; + // Borrower is the owner of the loan + if (auto const ter = dirLink(view, borrower, loan, sfOwnerNode)) + return ter; + + return tesSUCCESS; +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanSet.h b/src/xrpld/app/tx/detail/LoanSet.h new file mode 100644 index 00000000000..91f39608911 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanSet.h @@ -0,0 +1,59 @@ +#ifndef XRPL_TX_LOANSET_H_INCLUDED +#define XRPL_TX_LOANSET_H_INCLUDED + +#include +#include + +namespace ripple { + +class LoanSet : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanSet(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static std::uint32_t + getFlagsMask(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static NotTEC + checkSign(PreclaimContext const& ctx); + + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx); + + static std::vector> const& + getValueFields(); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + +public: + static std::uint32_t constexpr minPaymentTotal = 1; + static std::uint32_t constexpr defaultPaymentTotal = 1; + static_assert(defaultPaymentTotal >= minPaymentTotal); + + static std::uint32_t constexpr minPaymentInterval = 60; + static std::uint32_t constexpr defaultPaymentInterval = 60; + static_assert(defaultPaymentInterval >= minPaymentInterval); + + static std::uint32_t constexpr defaultGracePeriod = 60; + static_assert(defaultGracePeriod >= minPaymentInterval); +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/MPTokenAuthorize.cpp b/src/xrpld/app/tx/detail/MPTokenAuthorize.cpp index b8728d23cf9..ef4dc397334 100644 --- a/src/xrpld/app/tx/detail/MPTokenAuthorize.cpp +++ b/src/xrpld/app/tx/detail/MPTokenAuthorize.cpp @@ -113,7 +113,8 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } - if (!ctx.view.exists(keylet::account(*holderID))) + auto const sleHolder = ctx.view.read(keylet::account(*holderID)); + if (!sleHolder) return tecNO_DST; auto const sleMptIssuance = @@ -143,6 +144,12 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) keylet::mptoken(ctx.tx[sfMPTokenIssuanceID], *holderID))) return tecOBJECT_NOT_FOUND; + // Can't unauthorize the pseudo-accounts because they are implicitly + // always authorized. No need to amendment gate since Vault and LoanBroker + // can only be created if the Vault amendment is enabled. + if (isPseudoAccount(ctx.view, *holderID, {&sfVaultID, &sfLoanBrokerID})) + return tecNO_PERMISSION; + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/SetTrust.cpp b/src/xrpld/app/tx/detail/SetTrust.cpp index d881425960a..cc50e556d4b 100644 --- a/src/xrpld/app/tx/detail/SetTrust.cpp +++ b/src/xrpld/app/tx/detail/SetTrust.cpp @@ -295,7 +295,9 @@ SetTrust::preclaim(PreclaimContext const& ctx) else return tecINTERNAL; // LCOV_EXCL_LINE } - else if (sleDst->isFieldPresent(sfVaultID)) + else if ( + sleDst->isFieldPresent(sfVaultID) || + sleDst->isFieldPresent(sfLoanBrokerID)) { if (!ctx.view.exists(keylet::line(id, uDstAccountID, currency))) return tecNO_PERMISSION; diff --git a/src/xrpld/app/tx/detail/Transactor.cpp b/src/xrpld/app/tx/detail/Transactor.cpp index 2f62a142c0b..cef93418c29 100644 --- a/src/xrpld/app/tx/detail/Transactor.cpp +++ b/src/xrpld/app/tx/detail/Transactor.cpp @@ -231,13 +231,24 @@ Transactor::preflight2(PreflightContext const& ctx) // regardless of success or failure return *ret; + // Skip signature check on batch inner transactions + 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. + auto const sigValid = checkValidity( ctx.app.getHashRouter(), ctx.tx, ctx.rules, ctx.app.config()); if (sigValid.first == Validity::SigBad) - { + { // LCOV_EXCL_START JLOG(ctx.j.debug()) << "preflight2: bad signature. " << sigValid.second; - return temINVALID; // LCOV_EXCL_LINE + return temINVALID; + // LCOV_EXCL_STOP } + + // Do not add any checks after this point that are relevant for + // batch inner transactions. They will be skipped. + return tesSUCCESS; } @@ -670,13 +681,25 @@ NotTEC Transactor::checkSign( ReadView const& view, ApplyFlags flags, + std::optional const& parentBatchId, AccountID const& idAccount, STObject const& sigObject, beast::Journal const j) { + { + auto const sle = view.read(keylet::account(idAccount)); + + if (view.rules().enabled(featureLendingProtocol) && + isPseudoAccount(sle)) + // Pseudo-accounts can't sign transactions. This check is gated on + // the Lending Protocol amendment because that's the project it was + // added under, and it doesn't justify another amendment + return tefBAD_AUTH; + } + auto const pkSigner = sigObject.getFieldVL(sfSigningPubKey); // Ignore signature check on batch inner transactions - if (sigObject.isFlag(tfInnerBatchTxn) && view.rules().enabled(featureBatch)) + if (parentBatchId && view.rules().enabled(featureBatch)) { // Defensive Check: These values are also checked in Batch::preflight if (sigObject.isFieldPresent(sfTxnSignature) || !pkSigner.empty() || @@ -729,7 +752,8 @@ Transactor::checkSign(PreclaimContext const& ctx) auto const idAccount = ctx.tx.isFieldPresent(sfDelegate) ? ctx.tx.getAccountID(sfDelegate) : ctx.tx.getAccountID(sfAccount); - return checkSign(ctx.view, ctx.flags, idAccount, ctx.tx, ctx.j); + return checkSign( + ctx.view, ctx.flags, ctx.parentBatchId, idAccount, ctx.tx, ctx.j); } NotTEC diff --git a/src/xrpld/app/tx/detail/Transactor.h b/src/xrpld/app/tx/detail/Transactor.h index 17ef62e6074..0e67ba70c40 100644 --- a/src/xrpld/app/tx/detail/Transactor.h +++ b/src/xrpld/app/tx/detail/Transactor.h @@ -285,6 +285,7 @@ class Transactor checkSign( ReadView const& view, ApplyFlags flags, + std::optional const& parentBatchId, AccountID const& idAccount, STObject const& sigObject, beast::Journal const j); @@ -306,14 +307,26 @@ class Transactor template static bool - validNumericRange(std::optional value, T max, T min = {}); + validNumericRange(std::optional value, T max, T min = T{}); template static bool validNumericRange( std::optional value, unit::ValueUnit max, - unit::ValueUnit min = {}); + unit::ValueUnit min = unit::ValueUnit{}); + + /// Minimum will usually be zero. + template + static bool + validNumericMinimum(std::optional value, T min = T{}); + + /// Minimum will usually be zero. + template + static bool + validNumericMinimum( + std::optional value, + unit::ValueUnit min = unit::ValueUnit{}); private: std::pair @@ -440,6 +453,24 @@ Transactor::validNumericRange( return validNumericRange(value, max.value(), min.value()); } +template +bool +Transactor::validNumericMinimum(std::optional value, T min) +{ + if (!value) + return true; + return value >= min; +} + +template +bool +Transactor::validNumericMinimum( + std::optional value, + unit::ValueUnit min) +{ + return validNumericMinimum(value, min.value()); +} + } // namespace ripple #endif diff --git a/src/xrpld/app/tx/detail/VaultCreate.cpp b/src/xrpld/app/tx/detail/VaultCreate.cpp index 9447976a32b..ece7346541f 100644 --- a/src/xrpld/app/tx/detail/VaultCreate.cpp +++ b/src/xrpld/app/tx/detail/VaultCreate.cpp @@ -41,8 +41,11 @@ VaultCreate::checkExtraFeatures(PreflightContext const& ctx) if (!ctx.rules.enabled(featureMPTokensV1)) return false; - return !ctx.tx.isFieldPresent(sfDomainID) || - ctx.rules.enabled(featurePermissionedDomains); + if (ctx.tx.isFieldPresent(sfDomainID) && + !ctx.rules.enabled(featurePermissionedDomains)) + return false; + + return true; } std::uint32_t @@ -98,13 +101,6 @@ VaultCreate::preflight(PreflightContext const& ctx) return tesSUCCESS; } -XRPAmount -VaultCreate::calculateBaseFee(ReadView const& view, STTx const& tx) -{ - // One reserve increment is typically much greater than one base fee. - return calculateOwnerReserveFee(view, tx); -} - TER VaultCreate::preclaim(PreclaimContext const& ctx) { @@ -161,8 +157,9 @@ VaultCreate::doApply() if (auto ter = dirLink(view(), account_, vault)) return ter; - adjustOwnerCount(view(), owner, 1, j_); - auto ownerCount = owner->at(sfOwnerCount); + // We will create Vault and PseudoAccount, hence increase OwnerCount by 2 + adjustOwnerCount(view(), owner, 2, j_); + auto const ownerCount = owner->at(sfOwnerCount); if (mPriorBalance < view().fees().accountReserve(ownerCount)) return tecINSUFFICIENT_RESERVE; diff --git a/src/xrpld/app/tx/detail/VaultCreate.h b/src/xrpld/app/tx/detail/VaultCreate.h index 3f952d540a4..6ebab2f5f58 100644 --- a/src/xrpld/app/tx/detail/VaultCreate.h +++ b/src/xrpld/app/tx/detail/VaultCreate.h @@ -42,9 +42,6 @@ class VaultCreate : public Transactor static NotTEC preflight(PreflightContext const& ctx); - static XRPAmount - calculateBaseFee(ReadView const& view, STTx const& tx); - static TER preclaim(PreclaimContext const& ctx); diff --git a/src/xrpld/app/tx/detail/VaultDelete.cpp b/src/xrpld/app/tx/detail/VaultDelete.cpp index ab7db789561..da8500c513d 100644 --- a/src/xrpld/app/tx/detail/VaultDelete.cpp +++ b/src/xrpld/app/tx/detail/VaultDelete.cpp @@ -165,7 +165,35 @@ VaultDelete::doApply() return tecHAS_OBLIGATIONS; // LCOV_EXCL_LINE // Destroy the pseudo-account. - view().erase(view().peek(keylet::account(pseudoID))); + auto vaultPseudoSLE = view().peek(keylet::account(pseudoID)); + if (!vaultPseudoSLE || vaultPseudoSLE->at(~sfVaultID) != vault->key()) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // Making the payment and removing the empty holding should have deleted any + // obligations associated with the vault or vault pseudo-account. + if (*vaultPseudoSLE->at(sfBalance)) + { + // LCOV_EXCL_START + JLOG(j_.error()) << "VaultDelete: pseudo-account has a balance"; + return tecHAS_OBLIGATIONS; + // LCOV_EXCL_STOP + } + if (vaultPseudoSLE->at(sfOwnerCount) != 0) + { + // LCOV_EXCL_START + JLOG(j_.error()) << "VaultDelete: pseudo-account still owns objects"; + return tecHAS_OBLIGATIONS; + // LCOV_EXCL_STOP + } + if (view().exists(keylet::ownerDir(pseudoID))) + { + // LCOV_EXCL_START + JLOG(j_.error()) << "VaultDelete: pseudo-account has a directory"; + return tecHAS_OBLIGATIONS; + // LCOV_EXCL_STOP + } + + view().erase(vaultPseudoSLE); // Remove the vault from its owner's directory. auto const ownerID = vault->at(sfOwner); @@ -189,7 +217,9 @@ VaultDelete::doApply() return tefBAD_LEDGER; // LCOV_EXCL_STOP } - adjustOwnerCount(view(), owner, -1, j_); + + // We are destroying Vault and PseudoAccount, hence decrease by 2 + adjustOwnerCount(view(), owner, -2, j_); // Destroy the vault. view().erase(vault); diff --git a/src/xrpld/app/tx/detail/VaultDeposit.cpp b/src/xrpld/app/tx/detail/VaultDeposit.cpp index 05fd70de4ef..549f033f6a9 100644 --- a/src/xrpld/app/tx/detail/VaultDeposit.cpp +++ b/src/xrpld/app/tx/detail/VaultDeposit.cpp @@ -55,41 +55,19 @@ VaultDeposit::preclaim(PreclaimContext const& ctx) if (!vault) return tecNO_ENTRY; - auto const account = ctx.tx[sfAccount]; + auto const& account = ctx.tx[sfAccount]; auto const assets = ctx.tx[sfAmount]; auto const vaultAsset = vault->at(sfAsset); if (assets.asset() != vaultAsset) return tecWRONG_ASSET; - if (vaultAsset.native()) - ; // No special checks for XRP - else if (vaultAsset.holds()) - { - auto mptID = vaultAsset.get().getMptID(); - auto issuance = ctx.view.read(keylet::mptIssuance(mptID)); - if (!issuance) - return tecOBJECT_NOT_FOUND; - if (!issuance->isFlag(lsfMPTCanTransfer)) - { - // LCOV_EXCL_START - JLOG(ctx.j.error()) - << "VaultDeposit: vault assets are non-transferable."; - return tecNO_AUTH; - // LCOV_EXCL_STOP - } - } - else if (vaultAsset.holds()) + auto const& vaultAccount = vault->at(sfAccount); + if (auto ter = canTransfer(ctx.view, vaultAsset, account, vaultAccount); + !isTesSuccess(ter)) { - auto const issuer = - ctx.view.read(keylet::account(vaultAsset.getIssuer())); - if (!issuer) - { - // LCOV_EXCL_START - JLOG(ctx.j.error()) - << "VaultDeposit: missing issuer of vault assets."; - return tefINTERNAL; - // LCOV_EXCL_STOP - } + JLOG(ctx.j.debug()) + << "VaultDeposit: vault assets are non-transferable."; + return ter; } auto const mptIssuanceID = vault->at(sfShareMPTID); diff --git a/src/xrpld/app/tx/detail/VaultSet.cpp b/src/xrpld/app/tx/detail/VaultSet.cpp index 170a850a36c..eff47621fae 100644 --- a/src/xrpld/app/tx/detail/VaultSet.cpp +++ b/src/xrpld/app/tx/detail/VaultSet.cpp @@ -33,8 +33,11 @@ namespace ripple { bool VaultSet::checkExtraFeatures(PreflightContext const& ctx) { - return !ctx.tx.isFieldPresent(sfDomainID) || - ctx.rules.enabled(featurePermissionedDomains); + if (ctx.tx.isFieldPresent(sfDomainID) && + !ctx.rules.enabled(featurePermissionedDomains)) + return false; + + return true; } NotTEC diff --git a/src/xrpld/app/tx/detail/VaultWithdraw.cpp b/src/xrpld/app/tx/detail/VaultWithdraw.cpp index bdf00f5c561..f36508846f7 100644 --- a/src/xrpld/app/tx/detail/VaultWithdraw.cpp +++ b/src/xrpld/app/tx/detail/VaultWithdraw.cpp @@ -42,13 +42,10 @@ VaultWithdraw::preflight(PreflightContext const& ctx) if (ctx.tx[sfAmount] <= beast::zero) return temBAD_AMOUNT; - if (auto const destination = ctx.tx[~sfDestination]; - destination.has_value()) + if (auto const destination = ctx.tx[~sfDestination]) { if (*destination == beast::zero) { - JLOG(ctx.j.debug()) - << "VaultWithdraw: zero/empty destination account."; return temMALFORMED; } } @@ -69,35 +66,15 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) if (assets.asset() != vaultAsset && assets.asset() != vaultShare) return tecWRONG_ASSET; - if (vaultAsset.native()) - ; // No special checks for XRP - else if (vaultAsset.holds()) - { - auto mptID = vaultAsset.get().getMptID(); - auto issuance = ctx.view.read(keylet::mptIssuance(mptID)); - if (!issuance) - return tecOBJECT_NOT_FOUND; - if (!issuance->isFlag(lsfMPTCanTransfer)) - { - // LCOV_EXCL_START - JLOG(ctx.j.error()) - << "VaultWithdraw: vault assets are non-transferable."; - return tecNO_AUTH; - // LCOV_EXCL_STOP - } - } - else if (vaultAsset.holds()) + auto const& vaultAccount = vault->at(sfAccount); + auto const& account = ctx.tx[sfAccount]; + auto const& dstAcct = ctx.tx[~sfDestination].value_or(account); + if (auto ter = canTransfer(ctx.view, vaultAsset, vaultAccount, dstAcct); + !isTesSuccess(ter)) { - auto const issuer = - ctx.view.read(keylet::account(vaultAsset.getIssuer())); - if (!issuer) - { - // LCOV_EXCL_START - JLOG(ctx.j.error()) - << "VaultWithdraw: missing issuer of vault assets."; - return tefINTERNAL; - // LCOV_EXCL_STOP - } + JLOG(ctx.j.debug()) + << "VaultWithdraw: vault assets are non-transferable."; + return ter; } // Enforce valid withdrawal policy @@ -109,23 +86,8 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) // LCOV_EXCL_STOP } - auto const account = ctx.tx[sfAccount]; - auto const dstAcct = ctx.tx[~sfDestination].value_or(account); - auto const sleDst = ctx.view.read(keylet::account(dstAcct)); - if (sleDst == nullptr) - return account == dstAcct ? tecINTERNAL : tecNO_DST; - - if (sleDst->isFlag(lsfRequireDestTag) && - !ctx.tx.isFieldPresent(sfDestinationTag)) - return tecDST_TAG_NEEDED; // Cannot send without a tag - - // Withdrawal to a 3rd party destination account is essentially a transfer, - // via shares in the vault. Enforce all the usual asset transfer checks. - if (account != dstAcct && sleDst->isFlag(lsfDepositAuth)) - { - if (!ctx.view.exists(keylet::depositPreauth(dstAcct, account))) - return tecNO_PERMISSION; - } + if (auto const ret = canWithdraw(ctx.view, ctx.tx)) + return ret; // If sending to Account (i.e. not a transfer), we will also create (only // if authorized) a trust line or MPToken as needed, in doApply(). @@ -140,6 +102,8 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) if (auto const ret = checkFrozen(ctx.view, dstAcct, vaultAsset)) return ret; + // Cannot return shares to the vault, if the underlying asset was frozen for + // the submitter if (auto const ret = checkFrozen(ctx.view, account, vaultShare)) return ret; @@ -293,43 +257,16 @@ VaultWithdraw::doApply() } auto const dstAcct = ctx_.tx[~sfDestination].value_or(account_); - if (!vaultAsset.native() && // - dstAcct != vaultAsset.getIssuer() && // - dstAcct == account_) - { - if (auto const ter = addEmptyHolding( - view(), account_, mPriorBalance, vaultAsset, j_); - !isTesSuccess(ter) && ter != tecDUPLICATE) - return ter; - } - - // Transfer assets from vault to depositor or destination account. - if (auto const ter = accountSend( - view(), - vaultAccount, - dstAcct, - assetsWithdrawn, - j_, - WaiveTransferFee::Yes); - !isTesSuccess(ter)) - return ter; - - // Sanity check - if (accountHolds( - view(), - vaultAccount, - assetsWithdrawn.asset(), - FreezeHandling::fhIGNORE_FREEZE, - AuthHandling::ahIGNORE_AUTH, - j_) < beast::zero) - { - // LCOV_EXCL_START - JLOG(j_.error()) << "VaultWithdraw: negative balance of vault assets."; - return tefINTERNAL; - // LCOV_EXCL_STOP - } - return tesSUCCESS; + return doWithdraw( + view(), + ctx_.tx, + account_, + dstAcct, + vaultAccount, + mPriorBalance, + assetsWithdrawn, + j_); } } // namespace ripple diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index 76dca3970f7..eef199fa358 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -1286,8 +1286,23 @@ PeerImp::handleTransaction( // Charge strongly for attempting to relay a txn with tfInnerBatchTxn // LCOV_EXCL_START - if (stx->isFlag(tfInnerBatchTxn) && - getCurrentTransactionRules()->enabled(featureBatch)) + /* + There is no need to check whether the featureBatch amendment is + enabled. + + * If the `tfInnerBatchTxn` flag is set, and the amendment is + enabled, then it's an invalid transaction because inner batch + transactions should not be relayed. + * If the `tfInnerBatchTxn` flag is set, and the amendment is *not* + enabled, then the transaction is malformed because it's using an + "unknown" flag. There's no need to waste the resources to send it + to the transaction engine. + + We don't normally check transaction validity at this level, but + since we _need_ to check it when the amendment is enabled, we may as + well drop it if the flag is set regardless. + */ + if (stx->isFlag(tfInnerBatchTxn)) { JLOG(p_journal_.warn()) << "Ignoring Network relayed Tx containing " "tfInnerBatchTxn (handleTransaction)."; @@ -2857,8 +2872,23 @@ PeerImp::checkTransaction( { // charge strongly for relaying batch txns // LCOV_EXCL_START - if (stx->isFlag(tfInnerBatchTxn) && - getCurrentTransactionRules()->enabled(featureBatch)) + /* + There is no need to check whether the featureBatch amendment is + enabled. + + * If the `tfInnerBatchTxn` flag is set, and the amendment is + enabled, then it's an invalid transaction because inner batch + transactions should not be relayed. + * If the `tfInnerBatchTxn` flag is set, and the amendment is *not* + enabled, then the transaction is malformed because it's using an + "unknown" flag. There's no need to waste the resources to send it + to the transaction engine. + + We don't normally check transaction validity at this level, but + since we _need_ to check it when the amendment is enabled, we may as + well drop it if the flag is set regardless. + */ + if (stx->isFlag(tfInnerBatchTxn)) { JLOG(p_journal_.warn()) << "Ignoring Network relayed Tx containing " "tfInnerBatchTxn (checkSignature)."; diff --git a/src/xrpld/peerfinder/detail/SlotImp.h b/src/xrpld/peerfinder/detail/SlotImp.h index e5d8b1e6c5b..a6b86141ae9 100644 --- a/src/xrpld/peerfinder/detail/SlotImp.h +++ b/src/xrpld/peerfinder/detail/SlotImp.h @@ -189,7 +189,7 @@ class SlotImp : public Slot // DEPRECATED public data members // Tells us if we checked the connection. Outbound connections - // are always considered checked since we successfuly connected. + // are always considered checked since we successfully connected. bool checked; // Set to indicate if the connection can receive incoming at the diff --git a/src/xrpld/rpc/detail/RPCCall.cpp b/src/xrpld/rpc/detail/RPCCall.cpp index aa8c80fff70..fe2d3af1f35 100644 --- a/src/xrpld/rpc/detail/RPCCall.cpp +++ b/src/xrpld/rpc/detail/RPCCall.cpp @@ -965,7 +965,16 @@ class RPCParser Json::Value txJSON; Json::Reader reader; bool const bOffline = - 3 == jvParams.size() && jvParams[2u].asString() == "offline"; + jvParams.size() >= 3 && jvParams[2u].asString() == "offline"; + std::optional const field = + [&jvParams, bOffline]() -> std::optional { + if (jvParams.size() < 3) + return std::nullopt; + if (jvParams.size() < 4 && bOffline) + return std::nullopt; + Json::UInt index = bOffline ? 3u : 2u; + return jvParams[index].asString(); + }(); if (1 == jvParams.size()) { @@ -978,7 +987,7 @@ class RPCParser return jvRequest; } else if ( - (2 == jvParams.size() || bOffline) && + (jvParams.size() >= 2 || bOffline) && reader.parse(jvParams[1u].asString(), txJSON)) { // Signing or submitting tx_json. @@ -990,6 +999,9 @@ class RPCParser if (bOffline) jvRequest[jss::offline] = true; + if (field) + jvRequest[jss::signature_target] = *field; + return jvRequest; } @@ -1270,11 +1282,11 @@ class RPCParser {"server_definitions", &RPCParser::parseServerDefinitions, 0, 1}, {"server_info", &RPCParser::parseServerInfo, 0, 1}, {"server_state", &RPCParser::parseServerInfo, 0, 1}, - {"sign", &RPCParser::parseSignSubmit, 2, 3}, + {"sign", &RPCParser::parseSignSubmit, 2, 4}, {"sign_for", &RPCParser::parseSignFor, 3, 4}, {"stop", &RPCParser::parseAsIs, 0, 0}, {"simulate", &RPCParser::parseSimulate, 1, 2}, - {"submit", &RPCParser::parseSignSubmit, 1, 3}, + {"submit", &RPCParser::parseSignSubmit, 1, 4}, {"submit_multisigned", &RPCParser::parseSubmitMultiSigned, 1, 1}, {"transaction_entry", &RPCParser::parseTransactionEntry, 2, 2}, {"tx", &RPCParser::parseTx, 1, 4}, diff --git a/src/xrpld/rpc/detail/TransactionSign.cpp b/src/xrpld/rpc/detail/TransactionSign.cpp index 175fd84c9b0..9f4cec763b3 100644 --- a/src/xrpld/rpc/detail/TransactionSign.cpp +++ b/src/xrpld/rpc/detail/TransactionSign.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -54,6 +55,7 @@ class SigningForParams AccountID const* const multiSigningAcctID_; std::optional multiSignPublicKey_; Buffer multiSignature_; + std::optional> signatureTarget_; public: explicit SigningForParams() : multiSigningAcctID_(nullptr) @@ -116,12 +118,25 @@ class SigningForParams return multiSignature_; } + std::optional> const& + getSignatureTarget() const + { + return signatureTarget_; + } + void setPublicKey(PublicKey const& multiSignPublicKey) { multiSignPublicKey_ = multiSignPublicKey; } + void + setSignatureTarget( + std::optional> const& field) + { + signatureTarget_ = field; + } + void moveMultiSignature(Buffer&& multiSignature) { @@ -427,6 +442,29 @@ transactionPreProcessImpl( bool const verify = !(params.isMember(jss::offline) && params[jss::offline].asBool()); + auto const signatureTarget = + [¶ms]() -> std::optional> { + if (params.isMember(jss::signature_target)) + return SField::getField(params[jss::signature_target].asString()); + return std::nullopt; + }(); + + // Make sure the signature target field is valid, if specified, and save the + // template for use later + auto const signatureTemplate = signatureTarget + ? InnerObjectFormats::getInstance().findSOTemplateBySField( + *signatureTarget) + : nullptr; + if (signatureTarget) + { + if (!signatureTemplate) + { // Invalid target field + return RPC::make_error( + rpcINVALID_PARAMS, signatureTarget->get().getName()); + } + signingArgs.setSignatureTarget(signatureTarget); + } + if (!params.isMember(jss::tx_json)) return RPC::missing_field_error(jss::tx_json); @@ -541,9 +579,10 @@ transactionPreProcessImpl( JLOG(j.trace()) << "verify: " << toBase58(calcAccountID(pk)) << " : " << toBase58(srcAddressID); - // Don't do this test if multisigning since the account and secret - // probably don't belong together in that case. - if (!signingArgs.isMultiSigning()) + // Don't do this test if multisigning or if the signature is going into + // an alternate field since the account and secret probably don't belong + // together in that case. + if (!signingArgs.isMultiSigning() && !signatureTarget) { // Make sure the account and secret belong together. if (tx_json.isMember(sfDelegate.jsonName)) @@ -598,7 +637,17 @@ transactionPreProcessImpl( { // If we're generating a multi-signature the SigningPubKey must be // empty, otherwise it must be the master account's public key. - parsed.object->setFieldVL( + STObject* sigObject = &*parsed.object; + if (signatureTarget) + { + // If the target object doesn't exist, make one. + if (!parsed.object->isFieldPresent(*signatureTarget)) + parsed.object->setFieldObject( + *signatureTarget, + STObject{*signatureTemplate, *signatureTarget}); + sigObject = &parsed.object->peekFieldObject(*signatureTarget); + } + sigObject->setFieldVL( sfSigningPubKey, signingArgs.isMultiSigning() ? Slice(nullptr, 0) : pk.slice()); @@ -630,7 +679,7 @@ transactionPreProcessImpl( } else if (signingArgs.isSingleSigning()) { - stTx->sign(pk, sk); + stTx->sign(pk, sk, signatureTarget); } return transactionPreProcessResult{std::move(stTx)}; @@ -1048,7 +1097,11 @@ checkMultiSignFields(Json::Value const& jvRequest) if (!tx_json.isMember(sfSigningPubKey.getJsonName())) return RPC::missing_field_error("tx_json.SigningPubKey"); - if (!tx_json[sfSigningPubKey.getJsonName()].asString().empty()) + // Multi-signing into a signature_target object field is fine, + // because it means the signature is not for the transaction + // Account. + if (!jvRequest.isMember(jss::signature_target) && + !tx_json[sfSigningPubKey.getJsonName()].asString().empty()) return RPC::make_error( rpcINVALID_PARAMS, "When multi-signing 'tx_json.SigningPubKey' must be empty."); @@ -1195,11 +1248,17 @@ transactionSignFor( signer.setFieldVL( sfSigningPubKey, signForParams.getPublicKey().slice()); + STObject& sigTarget = [&]() -> STObject& { + auto const target = signForParams.getSignatureTarget(); + if (target) + return sttx->peekFieldObject(*target); + return *sttx; + }(); // If there is not yet a Signers array, make one. - if (!sttx->isFieldPresent(sfSigners)) - sttx->setFieldArray(sfSigners, {}); + if (!sigTarget.isFieldPresent(sfSigners)) + sigTarget.setFieldArray(sfSigners, {}); - auto& signers = sttx->peekFieldArray(sfSigners); + auto& signers = sigTarget.peekFieldArray(sfSigners); signers.emplace_back(std::move(signer)); // The array must be sorted and validated. diff --git a/src/xrpld/rpc/handlers/AccountInfo.cpp b/src/xrpld/rpc/handlers/AccountInfo.cpp index 0a36993d65b..62d54499b24 100644 --- a/src/xrpld/rpc/handlers/AccountInfo.cpp +++ b/src/xrpld/rpc/handlers/AccountInfo.cpp @@ -150,6 +150,28 @@ doAccountInfo(RPC::JsonContext& context) result[jss::account_flags] = std::move(acctFlags); + auto const pseudoFields = getPseudoAccountFields(); + for (auto const& pseudoField : pseudoFields) + { + if (sleAccepted->isFieldPresent(*pseudoField)) + { + std::string name = pseudoField->fieldName; + if (name.ends_with("ID")) + { + // Remove the ID suffix from the field name. + name = name.substr(0, name.size() - 2); + XRPL_ASSERT_PARTS( + !name.empty(), + "ripple::doAccountInfo", + "name is not empty"); + } + // ValidPseudoAccounts invariant guarantees that only one field + // can be set + result[jss::pseudo_account][jss::type] = name; + break; + } + } + // The document[https://xrpl.org/account_info.html#account_info] states // that signer_lists is a bool, however assigning any string value // works. Do not allow this. This check is for api Version 2 onwards diff --git a/src/xrpld/rpc/handlers/LedgerEntry.cpp b/src/xrpld/rpc/handlers/LedgerEntry.cpp index cead16c04d7..a16bbd3a970 100644 --- a/src/xrpld/rpc/handlers/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/LedgerEntry.cpp @@ -400,6 +400,46 @@ parseLedgerHashes(Json::Value const& params, Json::StaticString const fieldName) return parseObjectID(params, fieldName, "hex string"); } +static Expected +parseLoanBroker(Json::Value const& params, Json::StaticString const fieldName) +{ + if (!params.isObject()) + { + return parseObjectID(params, fieldName, "hex string"); + } + + auto const id = LedgerEntryHelpers::requiredAccountID( + params, jss::owner, "malformedOwner"); + if (!id) + return Unexpected(id.error()); + auto const seq = + LedgerEntryHelpers::requiredUInt32(params, jss::seq, "malformedSeq"); + if (!seq) + return Unexpected(seq.error()); + + return keylet::loanbroker(*id, *seq).key; +} + +static Expected +parseLoan(Json::Value const& params, Json::StaticString const fieldName) +{ + if (!params.isObject()) + { + return parseObjectID(params, fieldName, "hex string"); + } + + auto const id = LedgerEntryHelpers::requiredUInt256( + params, jss::loan_broker_id, "malformedOwner"); + if (!id) + return Unexpected(id.error()); + auto const seq = LedgerEntryHelpers::requiredUInt32( + params, jss::loan_seq, "malformedSeq"); + if (!seq) + return Unexpected(seq.error()); + + return keylet::loan(*id, *seq).key; +} + static Expected parseMPToken(Json::Value const& params, Json::StaticString const fieldName) {