From c24a6041f7fa8509a584de0c82c439b43197eddf Mon Sep 17 00:00:00 2001 From: oncecelll Date: Sat, 10 Jan 2026 02:15:05 +0800 Subject: [PATCH 01/14] docs: Fix minor spelling issues in comments (#6194) --- src/libxrpl/protocol/SecretKey.cpp | 4 ++-- src/test/app/HashRouter_test.cpp | 2 +- src/test/app/MPToken_test.cpp | 4 ++-- src/test/app/MultiSign_test.cpp | 2 +- src/test/basics/IntrusiveShared_test.cpp | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/libxrpl/protocol/SecretKey.cpp b/src/libxrpl/protocol/SecretKey.cpp index 88404a88a5..2507269407 100644 --- a/src/libxrpl/protocol/SecretKey.cpp +++ b/src/libxrpl/protocol/SecretKey.cpp @@ -77,7 +77,7 @@ deriveDeterministicRootKey(Seed const& seed) std::array buf; std::copy(seed.begin(), seed.end(), buf.begin()); - // The odds that this loop executes more than once are neglible + // The odds that this loop executes more than once are negligible // but *just* in case someone managed to generate a key that required // more iterations loop a few times. for (std::uint32_t seq = 0; seq != 128; ++seq) @@ -137,7 +137,7 @@ private: std::copy(generator_.begin(), generator_.end(), buf.begin()); copy_uint32(buf.data() + 33, seq); - // The odds that this loop executes more than once are neglible + // The odds that this loop executes more than once are negligible // but we impose a maximum limit just in case. for (std::uint32_t subseq = 0; subseq != 128; ++subseq) { diff --git a/src/test/app/HashRouter_test.cpp b/src/test/app/HashRouter_test.cpp index c428917fdc..2d1d37c3e3 100644 --- a/src/test/app/HashRouter_test.cpp +++ b/src/test/app/HashRouter_test.cpp @@ -349,7 +349,7 @@ class HashRouter_test : public beast::unit_test::suite h.set("hold_time", "alice"); h.set("relay_time", "bob"); auto const setup = setup_HashRouter(cfg); - // The set function ignores values that don't covert, so the + // The set function ignores values that don't convert, so the // defaults are left unchanged BEAST_EXPECT(setup.holdTime == 300s); BEAST_EXPECT(setup.relayTime == 30s); diff --git a/src/test/app/MPToken_test.cpp b/src/test/app/MPToken_test.cpp index ed6d861ffb..747f78ef6b 100644 --- a/src/test/app/MPToken_test.cpp +++ b/src/test/app/MPToken_test.cpp @@ -2651,7 +2651,7 @@ class MPToken_test : public beast::unit_test::suite STAmount const amt3{asset3, 10'000}; { - testcase("Test STAmount MPT arithmetics"); + testcase("Test STAmount MPT arithmetic"); using namespace std::string_literals; STAmount res = multiply(amt1, amt2, asset3); BEAST_EXPECT(res == amt3); @@ -2688,7 +2688,7 @@ class MPToken_test : public beast::unit_test::suite } { - testcase("Test MPTAmount arithmetics"); + testcase("Test MPTAmount arithmetic"); MPTAmount mptAmt1{100}; MPTAmount const mptAmt2{100}; BEAST_EXPECT((mptAmt1 += mptAmt2) == MPTAmount{200}); diff --git a/src/test/app/MultiSign_test.cpp b/src/test/app/MultiSign_test.cpp index 6950286b52..5c5404c17e 100644 --- a/src/test/app/MultiSign_test.cpp +++ b/src/test/app/MultiSign_test.cpp @@ -708,7 +708,7 @@ public: void testHeterogeneousSigners(FeatureBitset features) { - testcase("Heterogenous Signers"); + testcase("Heterogeneous Signers"); using namespace jtx; Env env{*this, features}; diff --git a/src/test/basics/IntrusiveShared_test.cpp b/src/test/basics/IntrusiveShared_test.cpp index b77325efa9..500b6e7e39 100644 --- a/src/test/basics/IntrusiveShared_test.cpp +++ b/src/test/basics/IntrusiveShared_test.cpp @@ -396,7 +396,7 @@ public: // This checks that partialDelete has run to completion // before the destructor is called. A sleep is inserted // inside the partial delete to make sure the destructor is - // given an opportunity to run durring partial delete. + // given an opportunity to run during partial delete. BEAST_EXPECT(cur == partiallyDeleted); } if (next == partiallyDeletedStarted) From fc0072383699cf9718af35a22e46df1bad7799bb Mon Sep 17 00:00:00 2001 From: Zhanibek Bakin <50952098+janibakin@users.noreply.github.com> Date: Fri, 9 Jan 2026 23:37:55 +0500 Subject: [PATCH 02/14] fix: Truncate thread name to 15 chars on Linux (#5758) This change: * Truncates thread names if more than 15 chars with `snprintf`. * Adds warnings for truncated thread names if `-DTRUNCATED_THREAD_NAME_LOGS=ON`. * Add a static assert for string literals to stop compiling if > 15 chars. * Shortens `Resource::Manager` to `Resource::Mngr` to fix the static assert failure. * Updates `CurrentThreadName_test` unit test specifically for Linux to verify truncation. --- cmake/XrplSettings.cmake | 15 ++++ include/xrpl/beast/core/CurrentThreadName.h | 27 +++++++ src/libxrpl/beast/core/CurrentThreadName.cpp | 24 +++++- src/libxrpl/resource/ResourceManager.cpp | 2 +- .../beast/beast_CurrentThreadName_test.cpp | 74 ++++++++++++++----- 5 files changed, 121 insertions(+), 21 deletions(-) diff --git a/cmake/XrplSettings.cmake b/cmake/XrplSettings.cmake index a16513afc5..c3f013c575 100644 --- a/cmake/XrplSettings.cmake +++ b/cmake/XrplSettings.cmake @@ -68,6 +68,21 @@ if(is_linux) option(perf "Enables flags that assist with perf recording" OFF) option(use_gold "enables detection of gold (binutils) linker" ON) option(use_mold "enables detection of mold (binutils) linker" ON) + # Set a default value for the log flag based on the build type. + # This provides a sensible default (on for debug, off for release) + # while still allowing the user to override it for any build. + if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(TRUNCATED_LOGS_DEFAULT ON) + else() + set(TRUNCATED_LOGS_DEFAULT OFF) + endif() + option(TRUNCATED_THREAD_NAME_LOGS + "Show warnings about truncated thread names on Linux." + ${TRUNCATED_LOGS_DEFAULT} + ) + if(TRUNCATED_THREAD_NAME_LOGS) + add_compile_definitions(TRUNCATED_THREAD_NAME_LOGS) + endif() else() # we are not ready to allow shared-libs on windows because it would require # export declarations. On macos it's more feasible, but static openssl diff --git a/include/xrpl/beast/core/CurrentThreadName.h b/include/xrpl/beast/core/CurrentThreadName.h index 8e9d58b649..703246a76a 100644 --- a/include/xrpl/beast/core/CurrentThreadName.h +++ b/include/xrpl/beast/core/CurrentThreadName.h @@ -5,6 +5,8 @@ #ifndef BEAST_CORE_CURRENT_THREAD_NAME_H_INCLUDED #define BEAST_CORE_CURRENT_THREAD_NAME_H_INCLUDED +#include + #include #include @@ -16,6 +18,31 @@ namespace beast { void setCurrentThreadName(std::string_view newThreadName); +#if BOOST_OS_LINUX + +// On Linux, thread names are limited to 16 bytes including the null terminator. +// Maximum number of characters is therefore 15. +constexpr std::size_t maxThreadNameLength = 15; + +/** Sets the name of the caller thread with compile-time size checking. + @tparam N The size of the string literal including null terminator + @param newThreadName A string literal to set as the thread name + + This template overload enforces that thread names are at most 16 characters + (including null terminator) at compile time, matching Linux's limit. +*/ +template +void +setCurrentThreadName(char const (&newThreadName)[N]) +{ + static_assert( + N <= maxThreadNameLength + 1, + "Thread name cannot exceed 15 characters"); + + setCurrentThreadName(std::string_view(newThreadName, N - 1)); +} +#endif + /** Returns the name of the caller thread. The name returned is the name as set by a call to setCurrentThreadName(). diff --git a/src/libxrpl/beast/core/CurrentThreadName.cpp b/src/libxrpl/beast/core/CurrentThreadName.cpp index 42dbb062b4..e8f7b629a7 100644 --- a/src/libxrpl/beast/core/CurrentThreadName.cpp +++ b/src/libxrpl/beast/core/CurrentThreadName.cpp @@ -1,7 +1,5 @@ #include -#include - #include #include @@ -73,12 +71,32 @@ setCurrentThreadNameImpl(std::string_view name) #if BOOST_OS_LINUX #include +#include + namespace beast::detail { inline void setCurrentThreadNameImpl(std::string_view name) { - pthread_setname_np(pthread_self(), name.data()); + // truncate and set the thread name. + char boundedName[maxThreadNameLength + 1]; + std::snprintf( + boundedName, + sizeof(boundedName), + "%.*s", + static_cast(maxThreadNameLength), + name.data()); + + pthread_setname_np(pthread_self(), boundedName); + +#ifdef TRUNCATED_THREAD_NAME_LOGS + if (name.size() > maxThreadNameLength) + { + std::cerr << "WARNING: Thread name \"" << name << "\" (length " + << name.size() << ") exceeds maximum of " + << maxThreadNameLength << " characters on Linux.\n"; + } +#endif } } // namespace beast::detail diff --git a/src/libxrpl/resource/ResourceManager.cpp b/src/libxrpl/resource/ResourceManager.cpp index 8582836611..15d31a558e 100644 --- a/src/libxrpl/resource/ResourceManager.cpp +++ b/src/libxrpl/resource/ResourceManager.cpp @@ -140,7 +140,7 @@ private: void run() { - beast::setCurrentThreadName("Resource::Manager"); + beast::setCurrentThreadName("Resource::Mngr"); for (;;) { logic_.periodicActivity(); diff --git a/src/test/beast/beast_CurrentThreadName_test.cpp b/src/test/beast/beast_CurrentThreadName_test.cpp index 3d33ecb602..dc12883a63 100644 --- a/src/test/beast/beast_CurrentThreadName_test.cpp +++ b/src/test/beast/beast_CurrentThreadName_test.cpp @@ -1,6 +1,8 @@ #include #include +#include + #include namespace xrpl { @@ -37,33 +39,71 @@ private: if (beast::getCurrentThreadName() == myName) *state = 2; } +#if BOOST_OS_LINUX + // Helper function to test a specific name. + // It creates a thread, sets the name, and checks if the OS-level + // name matches the expected (potentially truncated) name. + void + testName(std::string const& nameToSet, std::string const& expectedName) + { + std::thread t([&] { + beast::setCurrentThreadName(nameToSet); + + // Initialize buffer to be safe. + char actualName[beast::maxThreadNameLength + 1] = {}; + pthread_getname_np(pthread_self(), actualName, sizeof(actualName)); + + BEAST_EXPECT(std::string(actualName) == expectedName); + }); + t.join(); + } +#endif public: void run() override { - // Make two different threads with two different names. Make sure - // that the expected thread names are still there when the thread - // exits. - std::atomic stop{false}; + // Make two different threads with two different names. + // Make sure that the expected thread names are still there + // when the thread exits. + { + std::atomic stop{false}; - std::atomic stateA{0}; - std::thread tA(exerciseName, "tA", &stop, &stateA); + std::atomic stateA{0}; + std::thread tA(exerciseName, "tA", &stop, &stateA); - std::atomic stateB{0}; - std::thread tB(exerciseName, "tB", &stop, &stateB); + std::atomic stateB{0}; + std::thread tB(exerciseName, "tB", &stop, &stateB); - // Wait until both threads have set their names. - while (stateA == 0 || stateB == 0) - ; + // Wait until both threads have set their names. + while (stateA == 0 || stateB == 0) + ; - stop = true; - tA.join(); - tB.join(); + stop = true; + tA.join(); + tB.join(); - // Both threads should still have the expected name when they exit. - BEAST_EXPECT(stateA == 2); - BEAST_EXPECT(stateB == 2); + // Both threads should still have the expected name when they exit. + BEAST_EXPECT(stateA == 2); + BEAST_EXPECT(stateB == 2); + } +#if BOOST_OS_LINUX + // On Linux, verify that thread names longer than 15 characters + // are truncated to 15 characters (the 16th character is reserved for + // the null terminator). + { + testName( + "123456789012345", + "123456789012345"); // 15 chars, no truncation + testName( + "1234567890123456", "123456789012345"); // 16 chars, truncated + testName( + "ThisIsAVeryLongThreadNameExceedingLimit", + "ThisIsAVeryLong"); // 39 chars, truncated + testName("", ""); // empty name + testName("short", "short"); // short name, no truncation + } +#endif } }; From 14467fba5e5652d4976fdea0d312d64e6fb61242 Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Fri, 9 Jan 2026 20:58:02 +0100 Subject: [PATCH 03/14] VaultClawback: Burn shares of an empty vault (#6120) - Adds a mechanism for the vault owner to burn user shares when the vault is stuck. If the Vault has 0 AssetsAvailable and Total, the owner may submit a VaultClawback to reclaim the worthless fees, and thus allow the Vault to be deleted. The Amount must be left off (unless the owner is the asset issuer), specified as 0 Shares, or specified as the number of Shares held. --- src/test/app/Vault_test.cpp | 589 ++++++++++++++++++++- src/xrpld/app/tx/detail/InvariantCheck.cpp | 77 +-- src/xrpld/app/tx/detail/InvariantCheck.h | 1 + src/xrpld/app/tx/detail/VaultClawback.cpp | 473 +++++++++++------ src/xrpld/app/tx/detail/VaultClawback.h | 8 + 5 files changed, 931 insertions(+), 217 deletions(-) diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index f8d76623fd..d0a1450d6c 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -21,7 +21,6 @@ #include #include #include -#include #include #include #include @@ -940,25 +939,6 @@ class Vault_test : public beast::unit_test::suite } }); - testCase([&](Env& env, - Account const& issuer, - Account const& owner, - Asset const& asset, - Vault& vault) { - testcase("clawback from self"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - - { - auto tx = vault.clawback( - {.issuer = issuer, - .id = keylet.key, - .holder = issuer, - .amount = asset(10)}); - env(tx, ter{temMALFORMED}); - } - }); - testCase([&](Env& env, Account const&, Account const& owner, @@ -1197,11 +1177,13 @@ class Vault_test : public beast::unit_test::suite auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + // Preclaim only checks for native assets. + if (asset.native()) { auto tx = vault.clawback( - {.issuer = owner, + {.issuer = issuer, .id = keylet.key, - .holder = issuer, + .holder = owner, .amount = asset(50)}); env(tx, ter(temMALFORMED)); } @@ -1924,8 +1906,20 @@ class Vault_test : public beast::unit_test::suite env.close(); { - auto tx = vault.clawback( - {.issuer = owner, .id = keylet.key, .holder = depositor}); + auto tx = vault.clawback({ + .issuer = depositor, + .id = keylet.key, + .holder = depositor, + }); + env(tx, ter(tecNO_PERMISSION)); + } + + { + auto tx = vault.clawback({ + .issuer = owner, + .id = keylet.key, + .holder = depositor, + }); env(tx, ter(tecNO_PERMISSION)); } }); @@ -2377,6 +2371,15 @@ class Vault_test : public beast::unit_test::suite env(tx, ter(tecNO_AUTH)); } + { + // Cannot clawback if issuer is the holder + tx = vault.clawback( + {.issuer = issuer, + .id = keylet.key, + .holder = issuer, + .amount = asset(800)}); + env(tx, ter(tecNO_PERMISSION)); + } // Clawback works tx = vault.clawback( {.issuer = issuer, @@ -5243,6 +5246,542 @@ class Vault_test : public beast::unit_test::suite }); } + void + testVaultClawbackBurnShares() + { + using namespace test::jtx; + using namespace loanBroker; + using namespace loan; + Env env(*this, beast::severities::kWarning); + + auto const vaultAssetBalance = [&](Keylet const& vaultKeylet) { + auto const sleVault = env.le(vaultKeylet); + BEAST_EXPECT(sleVault != nullptr); + + return std::make_pair( + sleVault->at(sfAssetsAvailable), sleVault->at(sfAssetsTotal)); + }; + + auto const vaultShareBalance = [&](Keylet const& vaultKeylet) { + auto const sleVault = env.le(vaultKeylet); + BEAST_EXPECT(sleVault != nullptr); + + auto const sleIssuance = + env.le(keylet::mptIssuance(sleVault->at(sfShareMPTID))); + BEAST_EXPECT(sleIssuance != nullptr); + + return sleIssuance->at(sfOutstandingAmount); + }; + + auto const setupVault = + [&](PrettyAsset const& asset, + Account const& owner, + Account const& depositor) -> std::pair { + Vault vault{env}; + + auto const& [tx, vaultKeylet] = + vault.create({.owner = owner, .asset = asset}); + env(tx, ter(tesSUCCESS), THISLINE); + env.close(); + + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + + Asset share = vaultSle->at(sfShareMPTID); + + env(vault.deposit( + {.depositor = depositor, + .id = vaultKeylet.key, + .amount = asset(100)}), + ter(tesSUCCESS), + THISLINE); + env.close(); + + auto const& [availablePreDefault, totalPreDefault] = + vaultAssetBalance(vaultKeylet); + BEAST_EXPECT(availablePreDefault == totalPreDefault); + BEAST_EXPECT(availablePreDefault == asset(100).value()); + + // attempt to clawback shares while there are assets fails + env(vault.clawback( + {.issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(0).value()}), + ter(tecNO_PERMISSION), + THISLINE); + env.close(); + + auto const& sharesAvailable = vaultShareBalance(vaultKeylet); + auto const& brokerKeylet = + keylet::loanbroker(owner.id(), env.seq(owner)); + + env(set(owner, vaultKeylet.key), THISLINE); + env.close(); + + auto const& loanKeylet = keylet::loan(brokerKeylet.key, 1); + + // Create a simple Loan for the full amount of Vault assets + env(set(depositor, brokerKeylet.key, asset(100).value()), + loan::interestRate(TenthBips32(0)), + gracePeriod(10), + paymentInterval(120), + paymentTotal(10), + sig(sfCounterpartySignature, owner), + fee(env.current()->fees().base * 2), + ter(tesSUCCESS), + THISLINE); + env.close(); + + // attempt to clawback shares while there assetsAvailable == 0 and + // assetsTotal > 0 fails + env(vault.clawback( + {.issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(0).value()}), + ter(tecNO_PERMISSION), + THISLINE); + env.close(); + + env.close(std::chrono::seconds{120 + 10}); + + env(manage(owner, loanKeylet.key, tfLoanDefault), + ter(tesSUCCESS), + THISLINE); + + auto const& [availablePostDefault, totalPostDefault] = + vaultAssetBalance(vaultKeylet); + + BEAST_EXPECT(availablePostDefault == totalPostDefault); + BEAST_EXPECT(availablePostDefault == asset(0).value()); + BEAST_EXPECT(vaultShareBalance(vaultKeylet) == sharesAvailable); + + return std::make_pair(vault, vaultKeylet); + }; + + auto const testCase = [&](PrettyAsset const& asset, + std::string const& prefix, + Account const& owner, + Account const& depositor) { + { + testcase( + "VaultClawback (share) - " + prefix + + " owner asset clawback fails"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(100).value(), + }), + // when asset is XRP or owner is not issuer clawback fail + // when owner is issuer precision loss occurs as vault is + // empty + asset.native() ? ter(temMALFORMED) + : asset.raw().getIssuer() != owner.id() + ? ter(tecNO_PERMISSION) + : ter(tecPRECISION_LOSS), + THISLINE); + env.close(); + } + + { + testcase( + "VaultClawback (share) - " + prefix + + " owner incomplete share clawback fails"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + if (!vaultSle) + return; + Asset share = vaultSle->at(sfShareMPTID); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(1).value(), + }), + ter(tecLIMIT_EXCEEDED), + THISLINE); + env.close(); + } + + { + testcase( + "VaultClawback (share) - " + prefix + + " owner implicit complete share clawback"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + }), + // when owner is issuer implicit clawback fails + asset.native() || asset.raw().getIssuer() != owner.id() + ? ter(tesSUCCESS) + : ter(tecWRONG_ASSET), + THISLINE); + env.close(); + } + + { + testcase( + "VaultClawback (share) - " + prefix + + " owner explicit complete share clawback succeeds"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + if (!vaultSle) + return; + Asset share = vaultSle->at(sfShareMPTID); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(vaultShareBalance(vaultKeylet)).value(), + }), + ter(tesSUCCESS), + THISLINE); + env.close(); + } + { + testcase( + "VaultClawback (share) - " + prefix + + " owner can clawback own shares"); + auto [vault, vaultKeylet] = setupVault(asset, owner, owner); + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + if (!vaultSle) + return; + Asset share = vaultSle->at(sfShareMPTID); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = owner, + .amount = share(vaultShareBalance(vaultKeylet)).value(), + }), + ter(tesSUCCESS), + THISLINE); + env.close(); + } + + { + testcase( + "VaultClawback (share) - " + prefix + + " empty vault share clawback fails"); + auto [vault, vaultKeylet] = setupVault(asset, owner, owner); + auto const& vaultSle = env.le(vaultKeylet); + if (BEAST_EXPECT(vaultSle != nullptr)) + return; + Asset share = vaultSle->at(sfShareMPTID); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = owner, + .amount = share(vaultShareBalance(vaultKeylet)).value(), + }), + ter(tesSUCCESS), + THISLINE); + + // Now the vault is empty, clawback again fails + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = owner, + }), + ter(tecNO_PERMISSION), + THISLINE); + env.close(); + } + }; + + Account owner{"alice"}; + Account depositor{"bob"}; + Account issuer{"issuer"}; + + env.fund(XRP(10000), issuer, owner, depositor); + env.close(); + + // Test XRP + PrettyAsset xrp = xrpIssue(); + testCase(xrp, "XRP", owner, depositor); + testCase(xrp, "XRP (depositor is owner)", owner, owner); + + // Test IOU + PrettyAsset IOU = issuer["IOU"]; + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + env.trust(IOU(1000), owner); + env.trust(IOU(1000), depositor); + env(pay(issuer, owner, IOU(100))); + env(pay(issuer, depositor, IOU(100))); + env.close(); + testCase(IOU, "IOU", owner, depositor); + testCase(IOU, "IOU (owner is issuer)", issuer, depositor); + + // Test MPT + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset MPT = mptt.issuanceID(); + mptt.authorize({.account = owner}); + mptt.authorize({.account = depositor}); + env(pay(issuer, owner, MPT(1000))); + env(pay(issuer, depositor, MPT(1000))); + env.close(); + testCase(MPT, "MPT", owner, depositor); + testCase(MPT, "MPT (owner is issuer)", issuer, depositor); + } + + void + testVaultClawbackAssets() + { + using namespace test::jtx; + using namespace loanBroker; + using namespace loan; + Env env(*this); + + auto const setupVault = + [&](PrettyAsset const& asset, + Account const& owner, + Account const& depositor, + Account const& issuer) -> std::pair { + Vault vault{env}; + + auto const& [tx, vaultKeylet] = + vault.create({.owner = owner, .asset = asset}); + env(tx, ter(tesSUCCESS), THISLINE); + env.close(); + + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + env(vault.deposit( + {.depositor = depositor, + .id = vaultKeylet.key, + .amount = asset(100)}), + ter(tesSUCCESS), + THISLINE); + env.close(); + + return std::make_pair(vault, vaultKeylet); + }; + + auto const testCase = [&](PrettyAsset const& asset, + std::string const& prefix, + Account const& owner, + Account const& depositor, + Account const& issuer) { + if (asset.native()) + { + testcase( + "VaultClawback (asset) - " + prefix + + " issuer XRP clawback fails"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, depositor, issuer); + // If the asset is XRP, clawback with amount fails as malfored + // when asset is specified. + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = issuer, + .amount = asset(1).value(), + }), + ter(temMALFORMED), + THISLINE); + // When asset is implicit, clawback fails as no permission. + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = issuer, + }), + ter(tecNO_PERMISSION), + THISLINE); + return; + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " clawback for different asset fails"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, depositor, issuer); + + Account issuer2{"issuer2"}; + PrettyAsset asset2 = issuer2["FOO"]; + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset2(1).value(), + }), + ter(tecWRONG_ASSET), + THISLINE); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " ambiguous owner/issuer asset clawback fails"); + auto [vault, vaultKeylet] = + setupVault(asset, issuer, depositor, issuer); + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = issuer, + }), + ter(tecWRONG_ASSET), + THISLINE); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " non-issuer asset clawback fails"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, depositor, issuer); + + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + }), + ter(tecNO_PERMISSION), + THISLINE); + + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(1).value(), + }), + ter(tecNO_PERMISSION), + THISLINE); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " issuer clawback from self fails"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, issuer, issuer); + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = issuer, + }), + ter(tecNO_PERMISSION), + THISLINE); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " issuer share clawback fails"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, depositor, issuer); + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + if (!vaultSle) + return; + Asset share = vaultSle->at(sfShareMPTID); + + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(1).value(), + }), + ter(tecNO_PERMISSION), + THISLINE); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " partial issuer asset clawback succeeds"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, depositor, issuer); + + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(1).value(), + }), + ter(tesSUCCESS), + THISLINE); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " full issuer asset clawback succeeds"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, depositor, issuer); + + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(100).value(), + }), + ter(tesSUCCESS), + THISLINE); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " implicit full issuer asset clawback succeeds"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, depositor, issuer); + + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + }), + ter(tesSUCCESS), + THISLINE); + } + }; + + Account owner{"alice"}; + Account depositor{"bob"}; + Account issuer{"issuer"}; + + env.fund(XRP(10000), issuer, owner, depositor); + env.close(); + + // Test XRP + PrettyAsset xrp = xrpIssue(); + testCase(xrp, "XRP", owner, depositor, issuer); + + // Test IOU + PrettyAsset IOU = issuer["IOU"]; + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + env.trust(IOU(1000), owner); + env.trust(IOU(1000), depositor); + env(pay(issuer, owner, IOU(1000))); + env(pay(issuer, depositor, IOU(1000))); + env.close(); + testCase(IOU, "IOU", owner, depositor, issuer); + + // Test MPT + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset MPT = mptt.issuanceID(); + mptt.authorize({.account = owner}); + mptt.authorize({.account = depositor}); + env(pay(issuer, depositor, MPT(1000))); + env.close(); + testCase(MPT, "MPT", owner, depositor, issuer); + } + public: void run() override @@ -5261,6 +5800,8 @@ public: testScaleIOU(); testRPC(); testDelegate(); + testVaultClawbackBurnShares(); + testVaultClawbackAssets(); } }; diff --git a/src/xrpld/app/tx/detail/InvariantCheck.cpp b/src/xrpld/app/tx/detail/InvariantCheck.cpp index 0b237905e8..2e0b3cbfab 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.cpp +++ b/src/xrpld/app/tx/detail/InvariantCheck.cpp @@ -95,6 +95,7 @@ hasPrivilege(STTx const& tx, Privilege priv) switch (tx.getTxnType()) { #include + // Deprecated types default: return false; @@ -2622,6 +2623,7 @@ ValidVault::Vault::make(SLE const& from) self.key = from.key(); self.asset = from.at(sfAsset); self.pseudoId = from.getAccountID(sfAccount); + self.owner = from.at(sfOwner); self.shareMPTID = from.getFieldH192(sfShareMPTID); self.assetsTotal = from.at(sfAssetsTotal); self.assetsAvailable = from.at(sfAssetsAvailable); @@ -3066,6 +3068,10 @@ ValidVault::finalize( : std::nullopt; }; + auto const vaultHoldsNoAssets = [&](Vault const& vault) { + return vault.assetsAvailable == 0 && vault.assetsTotal == 0; + }; + // Technically this does not need to be a lambda, but it's more // convenient thanks to early "return false"; the not-so-nice // alternatives are several layers of nested if/else or more complex @@ -3448,29 +3454,56 @@ ValidVault::finalize( if (vaultAsset.native() || vaultAsset.getIssuer() != tx[sfAccount]) { - JLOG(j.fatal()) << // - "Invariant failed: clawback may only be performed by " - "the asset issuer"; - return false; // That's all we can do + // The owner can use clawback to force-burn shares when the + // vault is empty but there are outstanding shares + if (!(beforeShares && beforeShares->sharesTotal > 0 && + vaultHoldsNoAssets(beforeVault) && + beforeVault.owner == tx[sfAccount])) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback may only be performed " + "by the asset issuer, or by the vault owner of an " + "empty vault"; + return false; // That's all we can do + } } auto const vaultDeltaAssets = deltaAssets(afterVault.pseudoId); + if (vaultDeltaAssets) + { + if (*vaultDeltaAssets >= zero) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback must decrease vault " + "balance"; + result = false; + } - if (!vaultDeltaAssets) + if (beforeVault.assetsTotal + *vaultDeltaAssets != + afterVault.assetsTotal) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback and assets outstanding " + "must add up"; + result = false; + } + + if (beforeVault.assetsAvailable + *vaultDeltaAssets != + afterVault.assetsAvailable) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback and assets available " + "must add up"; + result = false; + } + } + else if (!vaultHoldsNoAssets(beforeVault)) { JLOG(j.fatal()) << // "Invariant failed: clawback must change vault balance"; return false; // That's all we can do } - if (*vaultDeltaAssets >= zero) - { - JLOG(j.fatal()) << // - "Invariant failed: clawback must decrease vault " - "balance"; - result = false; - } - auto const accountDeltaShares = deltaShares(tx[sfHolder]); if (!accountDeltaShares) { @@ -3503,24 +3536,6 @@ ValidVault::finalize( result = false; } - if (beforeVault.assetsTotal + *vaultDeltaAssets != - afterVault.assetsTotal) - { - JLOG(j.fatal()) << // - "Invariant failed: clawback and assets outstanding " - "must add up"; - result = false; - } - - if (beforeVault.assetsAvailable + *vaultDeltaAssets != - afterVault.assetsAvailable) - { - JLOG(j.fatal()) << // - "Invariant failed: clawback and assets available must " - "add up"; - result = false; - } - return result; } diff --git a/src/xrpld/app/tx/detail/InvariantCheck.h b/src/xrpld/app/tx/detail/InvariantCheck.h index ef9db373f5..87a1afb623 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.h +++ b/src/xrpld/app/tx/detail/InvariantCheck.h @@ -861,6 +861,7 @@ class ValidVault uint256 key = beast::zero; Asset asset = {}; AccountID pseudoId = {}; + AccountID owner = {}; uint192 shareMPTID = beast::zero; Number assetsTotal = 0; Number assetsAvailable = 0; diff --git a/src/xrpld/app/tx/detail/VaultClawback.cpp b/src/xrpld/app/tx/detail/VaultClawback.cpp index cc7dec993a..2552e8c1ff 100644 --- a/src/xrpld/app/tx/detail/VaultClawback.cpp +++ b/src/xrpld/app/tx/detail/VaultClawback.cpp @@ -1,18 +1,17 @@ #include - +// #include #include #include -#include #include #include #include #include #include -#include + +#include namespace xrpl { - NotTEC VaultClawback::preflight(PreflightContext const& ctx) { @@ -22,15 +21,6 @@ VaultClawback::preflight(PreflightContext const& ctx) return temMALFORMED; } - AccountID const issuer = ctx.tx[sfAccount]; - AccountID const holder = ctx.tx[sfHolder]; - - if (issuer == holder) - { - JLOG(ctx.j.debug()) << "VaultClawback: issuer cannot be holder."; - return temMALFORMED; - } - auto const amount = ctx.tx[~sfAmount]; if (amount) { @@ -42,17 +32,27 @@ VaultClawback::preflight(PreflightContext const& ctx) JLOG(ctx.j.debug()) << "VaultClawback: cannot clawback XRP."; return temMALFORMED; } - else if (amount->asset().getIssuer() != issuer) - { - JLOG(ctx.j.debug()) - << "VaultClawback: only asset issuer can clawback."; - return temMALFORMED; - } } return tesSUCCESS; } +[[nodiscard]] STAmount +clawbackAmount( + std::shared_ptr const& vault, + std::optional const& maybeAmount, + AccountID const& account) +{ + if (maybeAmount) + return *maybeAmount; + + Asset const share = MPTIssue{vault->at(sfShareMPTID)}; + if (account == vault->at(sfOwner)) + return STAmount{share}; + + return STAmount{vault->at(sfAsset)}; +} + TER VaultClawback::preclaim(PreclaimContext const& ctx) { @@ -60,61 +60,264 @@ VaultClawback::preclaim(PreclaimContext const& ctx) if (!vault) return tecNO_ENTRY; - auto account = ctx.tx[sfAccount]; - auto const issuer = ctx.view.read(keylet::account(account)); - if (!issuer) + Asset const vaultAsset = vault->at(sfAsset); + auto const account = ctx.tx[sfAccount]; + auto const holder = ctx.tx[sfHolder]; + auto const maybeAmount = ctx.tx[~sfAmount]; + auto const mptIssuanceID = vault->at(sfShareMPTID); + auto const sleShareIssuance = + ctx.view.read(keylet::mptIssuance(mptIssuanceID)); + if (!sleShareIssuance) { // LCOV_EXCL_START - JLOG(ctx.j.error()) << "VaultClawback: missing issuer account."; + JLOG(ctx.j.error()) + << "VaultClawback: missing issuance of vault shares."; return tefINTERNAL; // LCOV_EXCL_STOP } - Asset const vaultAsset = vault->at(sfAsset); - if (auto const amount = ctx.tx[~sfAmount]; - amount && vaultAsset != amount->asset()) + Asset const share = MPTIssue{mptIssuanceID}; + + // Ambiguous case: If Issuer is Owner they must specify the asset + if (!maybeAmount && !vaultAsset.native() && + vaultAsset.getIssuer() == vault->at(sfOwner)) + { + JLOG(ctx.j.debug()) + << "VaultClawback: must specify amount when issuer is owner."; return tecWRONG_ASSET; - - if (vaultAsset.native()) - { - JLOG(ctx.j.debug()) << "VaultClawback: cannot clawback XRP."; - return tecNO_PERMISSION; // Cannot clawback XRP. - } - else if (vaultAsset.getIssuer() != account) - { - JLOG(ctx.j.debug()) << "VaultClawback: only asset issuer can clawback."; - return tecNO_PERMISSION; // Only issuers can clawback. } - if (vaultAsset.holds()) - { - auto const mpt = vaultAsset.get(); - auto const mptIssue = - ctx.view.read(keylet::mptIssuance(mpt.getMptID())); - if (mptIssue == nullptr) - return tecOBJECT_NOT_FOUND; + auto const amount = clawbackAmount(vault, maybeAmount, account); - std::uint32_t const issueFlags = mptIssue->getFieldU32(sfFlags); - if (!(issueFlags & lsfMPTCanClawback)) + // There is a special case that allows the VaultOwner to use clawback to + // burn shares when Vault assets total and available are zero, but + // shares remain. However, that case is handled in doApply() directly, + // so here we just enforce checks. + if (amount.asset() == share) + { + // Only the Vault Owner may clawback shares + if (account != vault->at(sfOwner)) { JLOG(ctx.j.debug()) - << "VaultClawback: cannot clawback MPT vault asset."; + << "VaultClawback: only vault owner can clawback shares."; return tecNO_PERMISSION; } - } - else if (vaultAsset.holds()) - { - std::uint32_t const issuerFlags = issuer->getFieldU32(sfFlags); - if (!(issuerFlags & lsfAllowTrustLineClawback) || - (issuerFlags & lsfNoFreeze)) + + auto const assetsTotal = vault->at(sfAssetsTotal); + auto const assetsAvailable = vault->at(sfAssetsAvailable); + auto const sharesTotal = sleShareIssuance->at(sfOutstandingAmount); + + // Owner can clawback funds when the vault has shares but no assets + if (sharesTotal == 0 || (assetsTotal != 0 || assetsAvailable != 0)) { JLOG(ctx.j.debug()) - << "VaultClawback: cannot clawback IOU vault asset."; + << "VaultClawback: vault owner can clawback shares only" + " when vault has no assets."; return tecNO_PERMISSION; } + + // If amount is non-zero, the VaultOwner must burn all shares + if (amount != beast::zero) + { + Number const& sharesHeld = accountHolds( + ctx.view, + holder, + share, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + ctx.j); + + // The VaultOwner must burn all shares + if (amount != sharesHeld) + { + JLOG(ctx.j.debug()) + << "VaultClawback: vault owner must clawback all " + "shares."; + return tecLIMIT_EXCEEDED; + } + } + + return tesSUCCESS; } - return tesSUCCESS; + // The asset that is being clawed back is the vault asset + if (amount.asset() == vaultAsset) + { + // XRP cannot be clawed back + if (vaultAsset.native()) + { + JLOG(ctx.j.debug()) << "VaultClawback: cannot clawback XRP."; + return tecNO_PERMISSION; + } + + // Only the Asset Issuer may clawback the asset + if (account != vaultAsset.getIssuer()) + { + JLOG(ctx.j.debug()) + << "VaultClawback: only asset issuer can clawback asset."; + return tecNO_PERMISSION; + } + + // The issuer cannot clawback from itself + if (account == holder) + { + JLOG(ctx.j.debug()) + << "VaultClawback: issuer cannot be the holder."; + return tecNO_PERMISSION; + } + + return std::visit( + [&](TIss const& issue) -> TER { + if constexpr (std::is_same_v) + { + auto const mptIssue = + ctx.view.read(keylet::mptIssuance(issue.getMptID())); + if (mptIssue == nullptr) + return tecOBJECT_NOT_FOUND; + + std::uint32_t const issueFlags = + mptIssue->getFieldU32(sfFlags); + if (!(issueFlags & lsfMPTCanClawback)) + { + JLOG(ctx.j.debug()) << "VaultClawback: cannot clawback " + "MPT vault asset."; + return tecNO_PERMISSION; + } + } + else if constexpr (std::is_same_v) + { + auto const issuerSle = + ctx.view.read(keylet::account(account)); + if (!issuerSle) + { + // LCOV_EXCL_START + JLOG(ctx.j.error()) + << "VaultClawback: missing submitter account."; + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + std::uint32_t const issuerFlags = + issuerSle->getFieldU32(sfFlags); + if (!(issuerFlags & lsfAllowTrustLineClawback) || + (issuerFlags & lsfNoFreeze)) + { + JLOG(ctx.j.debug()) << "VaultClawback: cannot clawback " + "IOU vault asset."; + return tecNO_PERMISSION; + } + } + return tesSUCCESS; + }, + vaultAsset.value()); + } + + // Invalid asset + return tecWRONG_ASSET; +} + +Expected, TER> +VaultClawback::assetsToClawback( + std::shared_ptr const& vault, + std::shared_ptr const& sleShareIssuance, + AccountID const& holder, + STAmount const& clawbackAmount) +{ + if (clawbackAmount.asset() != vault->at(sfAsset)) + { + // preclaim should have blocked this , now it's an internal error + // LCOV_EXCL_START + JLOG(j_.error()) << "VaultClawback: asset mismatch in clawback."; + return Unexpected(tecINTERNAL); + // LCOV_EXCL_STOP + } + + auto const assetsAvailable = vault->at(sfAssetsAvailable); + auto const mptIssuanceID = *vault->at(sfShareMPTID); + MPTIssue const share{mptIssuanceID}; + + if (clawbackAmount == beast::zero) + { + auto const sharesDestroyed = accountHolds( + view(), + holder, + share, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j_); + auto const maybeAssets = + sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed); + if (!maybeAssets) + return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE + + return std::make_pair(*maybeAssets, sharesDestroyed); + } + + STAmount sharesDestroyed; + STAmount assetsRecovered = clawbackAmount; + try + { + { + auto const maybeShares = assetsToSharesWithdraw( + vault, sleShareIssuance, assetsRecovered); + if (!maybeShares) + return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE + sharesDestroyed = *maybeShares; + } + + auto const maybeAssets = + sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed); + if (!maybeAssets) + return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE + assetsRecovered = *maybeAssets; + + // Clamp to maximum. + if (assetsRecovered > *assetsAvailable) + { + assetsRecovered = *assetsAvailable; + // Note, it is important to truncate the number of shares, + // otherwise the corresponding assets might breach the + // AssetsAvailable + { + auto const maybeShares = assetsToSharesWithdraw( + vault, + sleShareIssuance, + assetsRecovered, + TruncateShares::yes); + if (!maybeShares) + return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE + sharesDestroyed = *maybeShares; + } + + auto const maybeAssets = sharesToAssetsWithdraw( + vault, sleShareIssuance, sharesDestroyed); + if (!maybeAssets) + return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE + assetsRecovered = *maybeAssets; + if (assetsRecovered > *assetsAvailable) + { + // LCOV_EXCL_START + JLOG(j_.error()) + << "VaultClawback: invalid rounding of shares."; + return Unexpected(tecINTERNAL); + // LCOV_EXCL_STOP + } + } + } + catch (std::overflow_error const&) + { + // It's easy to hit this exception from Number with large enough + // Scale so we avoid spamming the log and only use debug here. + JLOG(j_.debug()) // + << "VaultClawback: overflow error with" + << " scale=" << (int)vault->at(sfScale).value() // + << ", assetsTotal=" << vault->at(sfAssetsTotal).value() + << ", sharesTotal=" << sleShareIssuance->at(sfOutstandingAmount) + << ", amount=" << clawbackAmount.value(); + return Unexpected(tecPATH_DRY); + } + + return std::make_pair(assetsRecovered, sharesDestroyed); } TER @@ -125,7 +328,7 @@ VaultClawback::doApply() if (!vault) return tefINTERNAL; // LCOV_EXCL_LINE - auto const mptIssuanceID = *((*vault)[sfShareMPTID]); + auto const mptIssuanceID = *vault->at(sfShareMPTID); auto const sleIssuance = view().read(keylet::mptIssuance(mptIssuanceID)); if (!sleIssuance) { @@ -134,105 +337,47 @@ VaultClawback::doApply() return tefINTERNAL; // LCOV_EXCL_STOP } + MPTIssue const share{mptIssuanceID}; Asset const vaultAsset = vault->at(sfAsset); - STAmount const amount = [&]() -> STAmount { - auto const maybeAmount = tx[~sfAmount]; - if (maybeAmount) - return *maybeAmount; - return {sfAmount, vaultAsset, 0}; - }(); - XRPL_ASSERT( - amount.asset() == vaultAsset, - "xrpl::VaultClawback::doApply : matching asset"); + STAmount const amount = clawbackAmount(vault, tx[~sfAmount], account_); auto assetsAvailable = vault->at(sfAssetsAvailable); auto assetsTotal = vault->at(sfAssetsTotal); + [[maybe_unused]] auto const lossUnrealized = vault->at(sfLossUnrealized); XRPL_ASSERT( lossUnrealized <= (assetsTotal - assetsAvailable), "xrpl::VaultClawback::doApply : loss and assets do balance"); AccountID holder = tx[sfHolder]; - MPTIssue const share{mptIssuanceID}; STAmount sharesDestroyed = {share}; - STAmount assetsRecovered; - try + STAmount assetsRecovered = {vault->at(sfAsset)}; + + // The Owner is burning shares + if (account_ == vault->at(sfOwner) && amount.asset() == share) { - if (amount == beast::zero) - { - sharesDestroyed = accountHolds( - view(), - holder, - share, - FreezeHandling::fhIGNORE_FREEZE, - AuthHandling::ahIGNORE_AUTH, - j_); - - auto const maybeAssets = - sharesToAssetsWithdraw(vault, sleIssuance, sharesDestroyed); - if (!maybeAssets) - return tecINTERNAL; // LCOV_EXCL_LINE - assetsRecovered = *maybeAssets; - } - else - { - assetsRecovered = amount; - { - auto const maybeShares = - assetsToSharesWithdraw(vault, sleIssuance, assetsRecovered); - if (!maybeShares) - return tecINTERNAL; // LCOV_EXCL_LINE - sharesDestroyed = *maybeShares; - } - - auto const maybeAssets = - sharesToAssetsWithdraw(vault, sleIssuance, sharesDestroyed); - if (!maybeAssets) - return tecINTERNAL; // LCOV_EXCL_LINE - assetsRecovered = *maybeAssets; - } - - // Clamp to maximum. - if (assetsRecovered > *assetsAvailable) - { - assetsRecovered = *assetsAvailable; - // Note, it is important to truncate the number of shares, otherwise - // the corresponding assets might breach the AssetsAvailable - { - auto const maybeShares = assetsToSharesWithdraw( - vault, sleIssuance, assetsRecovered, TruncateShares::yes); - if (!maybeShares) - return tecINTERNAL; // LCOV_EXCL_LINE - sharesDestroyed = *maybeShares; - } - - auto const maybeAssets = - sharesToAssetsWithdraw(vault, sleIssuance, sharesDestroyed); - if (!maybeAssets) - return tecINTERNAL; // LCOV_EXCL_LINE - assetsRecovered = *maybeAssets; - if (assetsRecovered > *assetsAvailable) - { - // LCOV_EXCL_START - JLOG(j_.error()) - << "VaultClawback: invalid rounding of shares."; - return tecINTERNAL; - // LCOV_EXCL_STOP - } - } + sharesDestroyed = accountHolds( + view(), + holder, + share, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j_); } - catch (std::overflow_error const&) + else // The Issuer is clawbacking vault assets { - // It's easy to hit this exception from Number with large enough Scale - // so we avoid spamming the log and only use debug here. - JLOG(j_.debug()) // - << "VaultClawback: overflow error with" - << " scale=" << (int)vault->at(sfScale).value() // - << ", assetsTotal=" << vault->at(sfAssetsTotal).value() - << ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount) - << ", amount=" << amount.value(); - return tecPATH_DRY; + XRPL_ASSERT( + amount.asset() == vaultAsset, + "xrpl::VaultClawback::doApply : matching asset"); + + auto const clawbackParts = + assetsToClawback(vault, sleIssuance, holder, amount); + if (!clawbackParts) + return clawbackParts.error(); + + assetsRecovered = clawbackParts->first; + sharesDestroyed = clawbackParts->second; } if (sharesDestroyed == beast::zero) @@ -282,30 +427,34 @@ VaultClawback::doApply() // else quietly ignore, holder balance is not zero } - // Transfer assets from vault to issuer. - if (auto const ter = accountSend( - view(), - vaultAccount, - account_, - assetsRecovered, - j_, - WaiveTransferFee::Yes); - !isTesSuccess(ter)) - return ter; - - // Sanity check - if (accountHolds( - view(), - vaultAccount, - assetsRecovered.asset(), - FreezeHandling::fhIGNORE_FREEZE, - AuthHandling::ahIGNORE_AUTH, - j_) < beast::zero) + if (assetsRecovered > beast::zero) { - // LCOV_EXCL_START - JLOG(j_.error()) << "VaultClawback: negative balance of vault assets."; - return tefINTERNAL; - // LCOV_EXCL_STOP + // Transfer assets from vault to issuer. + if (auto const ter = accountSend( + view(), + vaultAccount, + account_, + assetsRecovered, + j_, + WaiveTransferFee::Yes); + !isTesSuccess(ter)) + return ter; + + // Sanity check + if (accountHolds( + view(), + vaultAccount, + assetsRecovered.asset(), + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j_) < beast::zero) + { + // LCOV_EXCL_START + JLOG(j_.error()) + << "VaultClawback: negative balance of vault assets."; + return tefINTERNAL; + // LCOV_EXCL_STOP + } } return tesSUCCESS; diff --git a/src/xrpld/app/tx/detail/VaultClawback.h b/src/xrpld/app/tx/detail/VaultClawback.h index 80a5f73ad0..d05f280e75 100644 --- a/src/xrpld/app/tx/detail/VaultClawback.h +++ b/src/xrpld/app/tx/detail/VaultClawback.h @@ -22,6 +22,14 @@ public: TER doApply() override; + +private: + Expected, TER> + assetsToClawback( + std::shared_ptr const& vault, + std::shared_ptr const& sleShareIssuance, + AccountID const& holder, + STAmount const& clawbackAmount); }; } // namespace xrpl From 7c1183547abb054878b24dd06fc2aab4e01ca350 Mon Sep 17 00:00:00 2001 From: Bart Date: Fri, 9 Jan 2026 16:44:43 -0500 Subject: [PATCH 04/14] chore: Change `/Zi` to `/Z7` for ccache, remove debug symbols in CI (#6198) As the `/Zi` compiler flag is unsupported by ccache, this change switches it to `/Z7` instead. For CI runs all debug info is omitted. --- .config/cspell.config.yaml | 1 + .github/workflows/reusable-build-test-config.yml | 2 +- cmake/Ccache.cmake | 12 +++++++++--- cmake/XrplCompiler.cmake | 1 + 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.config/cspell.config.yaml b/.config/cspell.config.yaml index 0cac82807d..edcdcc92ba 100644 --- a/.config/cspell.config.yaml +++ b/.config/cspell.config.yaml @@ -51,6 +51,7 @@ words: - Btrfs - canonicality - checkme + - choco - chrono - citardauq - clawback diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index fc80bbd216..ae91a8bf20 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -100,7 +100,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@65da1c59e81965eeb257caa3587b9d45066fb925 + uses: XRPLF/actions/prepare-runner@121d1de2775d486d46140b9a91b32d5002c08153 with: enable_ccache: ${{ inputs.ccache_enabled }} diff --git a/cmake/Ccache.cmake b/cmake/Ccache.cmake index 092212075c..aa8d3ac59d 100644 --- a/cmake/Ccache.cmake +++ b/cmake/Ccache.cmake @@ -46,6 +46,12 @@ set(CMAKE_VS_GLOBALS "TrackFileAccess=false" "UseMultiToolTask=true") -# By default Visual Studio generators will use /Zi, which is not compatible with -# ccache, so tell it to use /Z7 instead. -set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "$<$:Embedded>") +# By default Visual Studio generators will use /Zi to capture debug information, +# which is not compatible with ccache, so tell it to use /Z7 instead. +if (MSVC) + foreach (var_ + CMAKE_C_FLAGS_DEBUG CMAKE_C_FLAGS_RELEASE + CMAKE_CXX_FLAGS_DEBUG CMAKE_CXX_FLAGS_RELEASE) + string (REPLACE "/Zi" "/Z7" ${var_} "${${var_}}") + endforeach () +endif () diff --git a/cmake/XrplCompiler.cmake b/cmake/XrplCompiler.cmake index 622b2d2f74..0777bf948c 100644 --- a/cmake/XrplCompiler.cmake +++ b/cmake/XrplCompiler.cmake @@ -44,6 +44,7 @@ if (MSVC) # omit debug info completely under CI (not needed) if (is_ci) string (REPLACE "/Zi" " " ${var_} "${${var_}}") + string (REPLACE "/Z7" " " ${var_} "${${var_}}") endif () endforeach () From b2c5927b488fa0c306117de971f336c8d49317de Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Fri, 9 Jan 2026 23:10:04 -0400 Subject: [PATCH 05/14] fix: Inner batch transactions never have valid signatures (#6069) - Introduces amendment `fixBatchInnerSigs` - Update Batch unit tests - Fix all the Env instantiations to _use_ the "features" parameter. - testInnerSubmitRPC runs with Batch enabled and disabled. - Add a test to testInnerSubmitRPC for a correctly signed tx incorrectly using the tfInnerBatchTxn flag. - Generalize the submitAndValidate lambda in testInnerSubmitRPC. - With the fix amendment, a transaction never reaches the transaction engine (Transactor and derived classes.) - Test submitting a pseudo-transaction. Stopped before reaching the transaction engine, but with different errors. - The tests verify that without the amendment, a transaction with tfInnerBatchTxn is immediately rejected. Without the amendment, things are safe. The amendment just makes things safer and more future-proof. --- include/xrpl/protocol/detail/features.macro | 1 + src/test/app/Batch_test.cpp | 281 ++++++++++++++------ src/xrpld/app/misc/NetworkOPs.cpp | 2 +- src/xrpld/app/tx/detail/Transactor.cpp | 8 +- src/xrpld/app/tx/detail/apply.cpp | 21 +- 5 files changed, 221 insertions(+), 92 deletions(-) diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index 5c8d2aa198..932668c16f 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -16,6 +16,7 @@ // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. +XRPL_FIX (BatchInnerSigs, Supported::yes, VoteBehavior::DefaultNo) XRPL_FEATURE(LendingProtocol, Supported::no, VoteBehavior::DefaultNo) XRPL_FEATURE(PermissionDelegationV1_1, Supported::no, VoteBehavior::DefaultNo) XRPL_FIX (DirectoryLimit, Supported::yes, VoteBehavior::DefaultNo) diff --git a/src/test/app/Batch_test.cpp b/src/test/app/Batch_test.cpp index 6fbec52a93..68bf7e833b 100644 --- a/src/test/app/Batch_test.cpp +++ b/src/test/app/Batch_test.cpp @@ -148,15 +148,21 @@ class Batch_test : public beast::unit_test::suite void testEnable(FeatureBitset features) { - testcase("enabled"); - using namespace test::jtx; using namespace std::literals; + bool const withInnerSigFix = features[fixBatchInnerSigs]; + for (bool const withBatch : {true, false}) { + testcase << "enabled: Batch " + << (withBatch ? "enabled" : "disabled") + << ", Inner Sig Fix: " + << (withInnerSigFix ? "enabled" : "disabled"); + auto const amend = withBatch ? features : features - featureBatch; - test::jtx::Env env{*this, envconfig(), amend}; + + test::jtx::Env env{*this, amend}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -179,7 +185,7 @@ class Batch_test : public beast::unit_test::suite // tfInnerBatchTxn // If the feature is disabled, the transaction fails with - // temINVALID_FLAG If the feature is enabled, the transaction fails + // temINVALID_FLAG. If the feature is enabled, the transaction fails // early in checkValidity() { auto const txResult = @@ -205,7 +211,7 @@ class Batch_test : public beast::unit_test::suite //---------------------------------------------------------------------- // preflight - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -617,7 +623,7 @@ class Batch_test : public beast::unit_test::suite //---------------------------------------------------------------------- // preclaim - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -858,7 +864,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -949,7 +955,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1187,7 +1193,7 @@ class Batch_test : public beast::unit_test::suite // Bad Fee Without Signer { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1209,7 +1215,7 @@ class Batch_test : public beast::unit_test::suite // Bad Fee With MultiSign { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1236,7 +1242,7 @@ class Batch_test : public beast::unit_test::suite // Bad Fee With MultiSign + BatchSigners { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1265,7 +1271,7 @@ class Batch_test : public beast::unit_test::suite // Bad Fee With MultiSign + BatchSigners.Signers { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1297,7 +1303,7 @@ class Batch_test : public beast::unit_test::suite // Bad Fee With BatchSigners { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1321,7 +1327,7 @@ class Batch_test : public beast::unit_test::suite // Bad Fee Dynamic Fee Calculation { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1361,7 +1367,7 @@ class Batch_test : public beast::unit_test::suite // telENV_RPC_FAILED: Batch: txns array exceeds 8 entries. { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1386,7 +1392,7 @@ class Batch_test : public beast::unit_test::suite // temARRAY_TOO_LARGE: Batch: txns array exceeds 8 entries. { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1419,7 +1425,7 @@ class Batch_test : public beast::unit_test::suite // telENV_RPC_FAILED: Batch: signers array exceeds 8 entries. { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1438,7 +1444,7 @@ class Batch_test : public beast::unit_test::suite // temARRAY_TOO_LARGE: Batch: signers array exceeds 8 entries. { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1472,7 +1478,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1608,7 +1614,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1840,7 +1846,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2062,7 +2068,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2248,14 +2254,26 @@ class Batch_test : public beast::unit_test::suite } void - testInnerSubmitRPC(FeatureBitset features) + doTestInnerSubmitRPC(FeatureBitset features, bool withBatch) { - testcase("inner submit rpc"); + bool const withInnerSigFix = features[fixBatchInnerSigs]; + + std::string const testName = [&]() { + std::stringstream ss; + ss << "inner submit rpc: batch " + << (withBatch ? "enabled" : "disabled") << ", inner sig fix: " + << (withInnerSigFix ? "enabled" : "disabled") << ": "; + return ss.str(); + }(); + + auto const amend = withBatch ? features : features - featureBatch; using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, amend}; + if (!BEAST_EXPECT(amend[featureBatch] == withBatch)) + return; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2263,75 +2281,170 @@ class Batch_test : public beast::unit_test::suite env.fund(XRP(10000), alice, bob); env.close(); - auto submitAndValidate = [&](Slice const& slice) { - auto const jrr = env.rpc("submit", strHex(slice))[jss::result]; - BEAST_EXPECT( - jrr[jss::status] == "error" && - jrr[jss::error] == "invalidTransaction" && - jrr[jss::error_exception] == - "fails local checks: Malformed: Invalid inner batch " - "transaction."); - env.close(); - }; + auto submitAndValidate = + [&](std::string caseName, + Slice const& slice, + int line, + std::optional expectedEnabled = std::nullopt, + std::optional expectedDisabled = std::nullopt, + bool expectInvalidFlag = false) { + testcase << testName << caseName + << (expectInvalidFlag + ? " - Expected to reach tx engine!" + : ""); + auto const jrr = env.rpc("submit", strHex(slice))[jss::result]; + auto const expected = withBatch + ? expectedEnabled.value_or( + "fails local checks: Malformed: Invalid inner batch " + "transaction.") + : expectedDisabled.value_or( + "fails local checks: Empty SigningPubKey."); + if (expectInvalidFlag) + { + expect( + jrr[jss::status] == "success" && + jrr[jss::engine_result] == "temINVALID_FLAG", + pretty(jrr), + __FILE__, + line); + } + else + { + expect( + jrr[jss::status] == "error" && + jrr[jss::error] == "invalidTransaction" && + jrr[jss::error_exception] == expected, + pretty(jrr), + __FILE__, + line); + } + env.close(); + }; // Invalid RPC Submission: TxnSignature - // - has `TxnSignature` field + // + has `TxnSignature` field // - has no `SigningPubKey` field // - has no `Signers` field - // - has `tfInnerBatchTxn` flag + // + has `tfInnerBatchTxn` flag { auto txn = batch::inner(pay(alice, bob, XRP(1)), env.seq(alice)); txn[sfTxnSignature] = "DEADBEEF"; STParsedJSONObject parsed("test", txn.getTxn()); Serializer s; parsed.object->add(s); - submitAndValidate(s.slice()); + submitAndValidate("TxnSignature set", s.slice(), __LINE__); } // Invalid RPC Submission: SigningPubKey // - has no `TxnSignature` field - // - has `SigningPubKey` field + // + has `SigningPubKey` field // - has no `Signers` field - // - has `tfInnerBatchTxn` flag + // + has `tfInnerBatchTxn` flag { auto txn = batch::inner(pay(alice, bob, XRP(1)), env.seq(alice)); txn[sfSigningPubKey] = strHex(alice.pk()); STParsedJSONObject parsed("test", txn.getTxn()); Serializer s; parsed.object->add(s); - submitAndValidate(s.slice()); + submitAndValidate( + "SigningPubKey set", + s.slice(), + __LINE__, + std::nullopt, + "fails local checks: Invalid signature."); } // Invalid RPC Submission: Signers // - has no `TxnSignature` field - // - has empty `SigningPubKey` field - // - has `Signers` field - // - has `tfInnerBatchTxn` flag + // + has empty `SigningPubKey` field + // + has `Signers` field + // + has `tfInnerBatchTxn` flag { auto txn = batch::inner(pay(alice, bob, XRP(1)), env.seq(alice)); txn[sfSigners] = Json::arrayValue; STParsedJSONObject parsed("test", txn.getTxn()); Serializer s; parsed.object->add(s); - submitAndValidate(s.slice()); + submitAndValidate( + "Signers set", + s.slice(), + __LINE__, + std::nullopt, + "fails local checks: Invalid Signers array size."); + } + + { + // Fully signed inner batch transaction + auto const txn = + batch::inner(pay(alice, bob, XRP(1)), env.seq(alice)); + auto const jt = env.jt(txn.getTxn()); + + STParsedJSONObject parsed("test", jt.jv); + Serializer s; + parsed.object->add(s); + submitAndValidate( + "Fully signed", + s.slice(), + __LINE__, + std::nullopt, + std::nullopt, + !withBatch); } // Invalid RPC Submission: tfInnerBatchTxn // - has no `TxnSignature` field - // - has empty `SigningPubKey` field + // + has empty `SigningPubKey` field // - has no `Signers` field - // - has `tfInnerBatchTxn` flag + // + has `tfInnerBatchTxn` flag { auto txn = batch::inner(pay(alice, bob, XRP(1)), env.seq(alice)); STParsedJSONObject parsed("test", txn.getTxn()); Serializer s; parsed.object->add(s); - auto const jrr = env.rpc("submit", strHex(s.slice()))[jss::result]; - BEAST_EXPECT( - jrr[jss::status] == "success" && - jrr[jss::engine_result] == "temINVALID_FLAG"); + submitAndValidate( + "No signing fields set", + s.slice(), + __LINE__, + "fails local checks: Empty SigningPubKey.", + "fails local checks: Empty SigningPubKey.", + withBatch && !withInnerSigFix); + } - env.close(); + // Invalid RPC Submission: tfInnerBatchTxn pseudo-transaction + // - has no `TxnSignature` field + // + has empty `SigningPubKey` field + // - has no `Signers` field + // + has `tfInnerBatchTxn` flag + { + STTx amendTx( + ttAMENDMENT, [seq = env.closed()->header().seq + 1](auto& obj) { + obj.setAccountID(sfAccount, AccountID()); + obj.setFieldH256(sfAmendment, fixBatchInnerSigs); + obj.setFieldU32(sfLedgerSequence, seq); + obj.setFieldU32(sfFlags, tfInnerBatchTxn); + }); + auto txn = batch::inner( + amendTx.getJson(JsonOptions::none), env.seq(alice)); + STParsedJSONObject parsed("test", txn.getTxn()); + Serializer s; + parsed.object->add(s); + submitAndValidate( + "Pseudo-transaction", + s.slice(), + __LINE__, + withInnerSigFix + ? "fails local checks: Empty SigningPubKey." + : "fails local checks: Cannot submit pseudo transactions.", + "fails local checks: Empty SigningPubKey."); + } + } + + void + testInnerSubmitRPC(FeatureBitset features) + { + for (bool const withBatch : {true, false}) + { + doTestInnerSubmitRPC(features, withBatch); } } @@ -2343,7 +2456,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2390,7 +2503,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2443,7 +2556,7 @@ class Batch_test : public beast::unit_test::suite // tfIndependent: account delete success { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2484,7 +2597,7 @@ class Batch_test : public beast::unit_test::suite // tfIndependent: account delete fails { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2529,7 +2642,7 @@ class Batch_test : public beast::unit_test::suite // tfAllOrNothing: account delete fails { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2581,7 +2694,6 @@ class Batch_test : public beast::unit_test::suite test::jtx::Env env{ *this, - envconfig(), features | featureSingleAssetVault | featureLendingProtocol | featureMPTokensV1}; @@ -2776,7 +2888,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2889,7 +3001,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -2947,7 +3059,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -3009,7 +3121,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -3058,7 +3170,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -3106,7 +3218,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -3169,7 +3281,7 @@ class Batch_test : public beast::unit_test::suite // overwritten by the payment in the batch transaction. Because the // terPRE_SEQ is outside of the batch this noop transaction will ge // reapplied in the following ledger - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob, carol); env.close(); @@ -3216,7 +3328,7 @@ class Batch_test : public beast::unit_test::suite // IMPORTANT: The batch txn is applied first, then the noop txn. // Because of this ordering, the noop txn is not applied and is // overwritten by the payment in the batch transaction. - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob); env.close(); @@ -3258,7 +3370,7 @@ class Batch_test : public beast::unit_test::suite // IMPORTANT: The batch txn is applied first, then the noop txn. // Because of this ordering, the noop txn is not applied and is // overwritten by the payment in the batch transaction. - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob); env.close(); @@ -3295,7 +3407,7 @@ class Batch_test : public beast::unit_test::suite // Outer Batch terPRE_SEQ { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob, carol); env.close(); @@ -3353,7 +3465,7 @@ class Batch_test : public beast::unit_test::suite // IMPORTANT: The batch txn is applied first, then the noop txn. // Because of this ordering, the noop txn is not applied and is // overwritten by the payment in the batch transaction. - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob); env.close(); @@ -3402,7 +3514,7 @@ class Batch_test : public beast::unit_test::suite // IMPORTANT: The batch txn is applied first, then the noop txn. // Because of this ordering, the noop txn is not applied and is // overwritten by the payment in the batch transaction. - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob); env.close(); @@ -3464,7 +3576,7 @@ class Batch_test : public beast::unit_test::suite // batch will run in the close ledger process. The batch will be // allied and then retry this transaction in the current ledger. - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob); env.close(); @@ -3511,7 +3623,7 @@ class Batch_test : public beast::unit_test::suite // Create Object Before Batch Txn { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob); env.close(); @@ -3558,7 +3670,7 @@ class Batch_test : public beast::unit_test::suite // batch will run in the close ledger process. The batch will be // applied and then retry this transaction in the current ledger. - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; env.fund(XRP(10000), alice, bob); env.close(); @@ -3605,7 +3717,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -3644,7 +3756,7 @@ class Batch_test : public beast::unit_test::suite using namespace test::jtx; using namespace std::literals; - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; XRPAmount const baseFee = env.current()->fees().base; auto const alice = Account("alice"); @@ -3725,6 +3837,7 @@ class Batch_test : public beast::unit_test::suite *this, makeSmallQueueConfig( {{"minimum_txn_in_ledger_standalone", "2"}}), + features, nullptr, beast::severities::kError}; @@ -3785,6 +3898,7 @@ class Batch_test : public beast::unit_test::suite *this, makeSmallQueueConfig( {{"minimum_txn_in_ledger_standalone", "2"}}), + features, nullptr, beast::severities::kError}; @@ -3892,7 +4006,7 @@ class Batch_test : public beast::unit_test::suite // delegated non atomic inner { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -3937,7 +4051,7 @@ class Batch_test : public beast::unit_test::suite // delegated atomic inner { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -3989,7 +4103,7 @@ class Batch_test : public beast::unit_test::suite // this also makes sure tfInnerBatchTxn won't block delegated AccountSet // with granular permission { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -4038,7 +4152,7 @@ class Batch_test : public beast::unit_test::suite // this also makes sure tfInnerBatchTxn won't block delegated // MPTokenIssuanceSet with granular permission { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; Account alice{"alice"}; Account bob{"bob"}; env.fund(XRP(100000), alice, bob); @@ -4094,7 +4208,7 @@ class Batch_test : public beast::unit_test::suite // this also makes sure tfInnerBatchTxn won't block delegated TrustSet // with granular permission { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; Account gw{"gw"}; Account alice{"alice"}; Account bob{"bob"}; @@ -4134,7 +4248,7 @@ class Batch_test : public beast::unit_test::suite // inner transaction not authorized by the delegating account. { - test::jtx::Env env{*this, envconfig()}; + test::jtx::Env env{*this, features}; Account gw{"gw"}; Account alice{"alice"}; Account bob{"bob"}; @@ -4182,7 +4296,7 @@ class Batch_test : public beast::unit_test::suite testcase("Validate RPC response"); using namespace jtx; - Env env(*this); + Env env(*this, features); Account const alice("alice"); Account const bob("bob"); env.fund(XRP(10000), alice, bob); @@ -4259,7 +4373,7 @@ class Batch_test : public beast::unit_test::suite testBatchCalculateBaseFee(FeatureBitset features) { using namespace jtx; - Env env(*this); + Env env(*this, features); Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); @@ -4384,6 +4498,7 @@ public: { using namespace test::jtx; auto const sa = testable_amendments(); + testWithFeats(sa - fixBatchInnerSigs); testWithFeats(sa); } }; diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp index 6a00354b15..2422ec4ae6 100644 --- a/src/xrpld/app/misc/NetworkOPs.cpp +++ b/src/xrpld/app/misc/NetworkOPs.cpp @@ -1681,7 +1681,7 @@ NetworkOPsImp::apply(std::unique_lock& batchLock) // only be set if the Batch feature is enabled. If Batch is // not enabled, the flag is always invalid, so don't relay // it regardless. - !sttx.isFlag(tfInnerBatchTxn)) + !(sttx.isFlag(tfInnerBatchTxn))) { protocol::TMTransaction tx; Serializer s; diff --git a/src/xrpld/app/tx/detail/Transactor.cpp b/src/xrpld/app/tx/detail/Transactor.cpp index 851712fe90..a834f7c6c3 100644 --- a/src/xrpld/app/tx/detail/Transactor.cpp +++ b/src/xrpld/app/tx/detail/Transactor.cpp @@ -204,8 +204,14 @@ Transactor::preflight2(PreflightContext const& ctx) // regardless of success or failure return *ret; + // It should be impossible for the InnerBatchTxn flag to be set without + // featureBatch being enabled + XRPL_ASSERT_PARTS( + !ctx.tx.isFlag(tfInnerBatchTxn) || ctx.rules.enabled(featureBatch), + "xrpl::Transactor::preflight2", + "InnerBatch flag only set if feature enabled"); // Skip signature check on batch inner transactions - if (ctx.tx.isFlag(tfInnerBatchTxn) && !ctx.rules.enabled(featureBatch)) + if (ctx.tx.isFlag(tfInnerBatchTxn) && ctx.rules.enabled(featureBatch)) return tesSUCCESS; // Do not add any checks after this point that are relevant for // batch inner transactions. They will be skipped. diff --git a/src/xrpld/app/tx/detail/apply.cpp b/src/xrpld/app/tx/detail/apply.cpp index 5209c46f8f..a75f0cc967 100644 --- a/src/xrpld/app/tx/detail/apply.cpp +++ b/src/xrpld/app/tx/detail/apply.cpp @@ -41,15 +41,22 @@ checkValidity( Validity::SigBad, "Malformed: Invalid inner batch transaction."}; - std::string reason; - if (!passesLocalChecks(tx, reason)) + // This block should probably have never been included in the + // original `Batch` implementation. An inner transaction never + // has a valid signature. + bool const neverValid = rules.enabled(fixBatchInnerSigs); + if (!neverValid) { - router.setFlags(id, SF_LOCALBAD); - return {Validity::SigGoodOnly, reason}; - } + std::string reason; + if (!passesLocalChecks(tx, reason)) + { + router.setFlags(id, SF_LOCALBAD); + return {Validity::SigGoodOnly, reason}; + } - router.setFlags(id, SF_SIGGOOD); - return {Validity::Valid, ""}; + router.setFlags(id, SF_SIGGOOD); + return {Validity::Valid, ""}; + } } if (any(flags & SF_SIGBAD)) From 92d40de4cbb787474f90d6aa6a959963f8299992 Mon Sep 17 00:00:00 2001 From: Bart Date: Mon, 12 Jan 2026 12:53:46 -0500 Subject: [PATCH 06/14] chore: Pin pre-commit hooks to commit hashes (#6205) This change updates and pins the Black and CSpell pre-commit hooks. --- .config/cspell.config.yaml | 4 ++++ .pre-commit-config.yaml | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.config/cspell.config.yaml b/.config/cspell.config.yaml index edcdcc92ba..8f782d9960 100644 --- a/.config/cspell.config.yaml +++ b/.config/cspell.config.yaml @@ -69,6 +69,7 @@ words: - cryptoconditional - cryptoconditions - csprng + - ctest - ctid - currenttxhash - daria @@ -104,6 +105,7 @@ words: - iou - ious - isrdc + - itype - jemalloc - jlog - keylet @@ -192,10 +194,12 @@ words: - roundings - sahyadri - Satoshi + - scons - secp - sendq - seqit - sf + - SFIELD - shamap - shamapitem - sidechain diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 00bec32ed6..603cf39375 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,12 +32,12 @@ repos: - id: prettier - repo: https://github.com/psf/black-pre-commit-mirror - rev: 25.11.0 + rev: 831207fd435b47aeffdf6af853097e64322b4d44 # frozen: v25.12.0 hooks: - id: black - repo: https://github.com/streetsidesoftware/cspell-cli - rev: v9.2.0 + rev: 1cfa010f078c354f3ffb8413616280cc28f5ba21 # frozen: v9.4.0 hooks: - id: cspell # Spell check changed files exclude: .config/cspell.config.yaml From 4755bb86068d056258d643274f8e75c878560685 Mon Sep 17 00:00:00 2001 From: Bart Date: Mon, 12 Jan 2026 19:14:39 -0500 Subject: [PATCH 07/14] refactor: Remove unnecessary version number and options in cmake find_package (#6169) This change removes unnecessary version numbers in the OpenSSL and Boost `find_package` CMake statements. An unnecessary OpenSSL definition is removed, while Conan options for SSL are updated to disable insecure ciphers. Moreover, the statements are now ordered alphabetically and more logically. --- BUILD.md | 19 ++++++++----- CMakeLists.txt | 37 ++++++++++--------------- cmake/deps/Boost.cmake | 2 +- conan.lock | 62 +++++++++++++++++++++--------------------- conanfile.py | 6 ++++ 5 files changed, 65 insertions(+), 61 deletions(-) diff --git a/BUILD.md b/BUILD.md index 85b3e3ea74..2d1ac9b134 100644 --- a/BUILD.md +++ b/BUILD.md @@ -148,7 +148,8 @@ function extract_version { } # Define which recipes to export. -recipes=(ed25519 grpc secp256k1 snappy soci) +recipes=('ed25519' 'grpc' 'openssl' 'secp256k1' 'snappy' 'soci') +folders=('all' 'all' '3.x.x' 'all' 'all' 'all') # Selectively check out the recipes from our CCI fork. cd external @@ -157,20 +158,24 @@ cd conan-center-index git init git remote add origin git@github.com:XRPLF/conan-center-index.git git sparse-checkout init -for recipe in ${recipes[@]}; do - echo "Checking out ${recipe}..." - git sparse-checkout add recipes/${recipe}/all +for ((index = 1; index <= ${#recipes[@]}; index++)); do + recipe=${recipes[index]} + folder=${folders[index]} + echo "Checking out recipe '${recipe}' from folder '${folder}'..." + git sparse-checkout add recipes/${recipe}/${folder} done git fetch origin master git checkout master cd ../.. # Export the recipes into the local cache. -for recipe in ${recipes[@]}; do +for ((index = 1; index <= ${#recipes[@]}; index++)); do + recipe=${recipes[index]} + folder=${folders[index]} version=$(extract_version ${recipe}) - echo "Exporting ${recipe}/${version}..." + echo "Exporting '${recipe}/${version}' from '${recipe}/${folder}'..." conan export --version $(extract_version ${recipe}) \ - external/conan-center-index/recipes/${recipe}/all + external/conan-center-index/recipes/${recipe}/${folder} done ``` diff --git a/CMakeLists.txt b/CMakeLists.txt index 70bc02c66d..26fc310d39 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,34 +88,18 @@ endif() ### include(deps/Boost) -find_package(OpenSSL 1.1.1 REQUIRED) -set_target_properties(OpenSSL::SSL PROPERTIES - INTERFACE_COMPILE_DEFINITIONS OPENSSL_NO_SSL2 -) add_subdirectory(external/antithesis-sdk) -find_package(gRPC REQUIRED) -find_package(lz4 REQUIRED) -# Target names with :: are not allowed in a generator expression. -# We need to pull the include directories and imported location properties -# from separate targets. -find_package(LibArchive REQUIRED) -find_package(SOCI REQUIRED) -find_package(SQLite3 REQUIRED) - -option(rocksdb "Enable RocksDB" ON) -if(rocksdb) - find_package(RocksDB REQUIRED) - set_target_properties(RocksDB::rocksdb PROPERTIES - INTERFACE_COMPILE_DEFINITIONS XRPL_ROCKSDB_AVAILABLE=1 - ) - target_link_libraries(xrpl_libs INTERFACE RocksDB::rocksdb) -endif() - find_package(date REQUIRED) find_package(ed25519 REQUIRED) +find_package(gRPC REQUIRED) +find_package(LibArchive REQUIRED) +find_package(lz4 REQUIRED) find_package(nudb REQUIRED) +find_package(OpenSSL REQUIRED) find_package(secp256k1 REQUIRED) +find_package(SOCI REQUIRED) +find_package(SQLite3 REQUIRED) find_package(xxHash REQUIRED) target_link_libraries(xrpl_libs INTERFACE @@ -128,6 +112,15 @@ target_link_libraries(xrpl_libs INTERFACE SQLite::SQLite3 ) +option(rocksdb "Enable RocksDB" ON) +if(rocksdb) + find_package(RocksDB REQUIRED) + set_target_properties(RocksDB::rocksdb PROPERTIES + INTERFACE_COMPILE_DEFINITIONS XRPL_ROCKSDB_AVAILABLE=1 + ) + target_link_libraries(xrpl_libs INTERFACE RocksDB::rocksdb) +endif() + # Work around changes to Conan recipe for now. if(TARGET nudb::core) set(nudb nudb::core) diff --git a/cmake/deps/Boost.cmake b/cmake/deps/Boost.cmake index 475c1033b2..19263e0ac9 100644 --- a/cmake/deps/Boost.cmake +++ b/cmake/deps/Boost.cmake @@ -1,4 +1,4 @@ -find_package(Boost 1.82 REQUIRED +find_package(Boost REQUIRED COMPONENTS chrono container diff --git a/conan.lock b/conan.lock index 1385ad05bd..99522e79b2 100644 --- a/conan.lock +++ b/conan.lock @@ -1,44 +1,44 @@ { "version": "0.5", "requires": [ - "zlib/1.3.1#b8bc2603263cf7eccbd6e17e66b0ed76%1756234269.497", - "xxhash/0.8.3#681d36a0a6111fc56e5e45ea182c19cc%1756234289.683", - "sqlite3/3.49.1#8631739a4c9b93bd3d6b753bac548a63%1756234266.869", - "soci/4.0.3#a9f8d773cd33e356b5879a4b0564f287%1756234262.318", - "snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1756234314.246", - "secp256k1/0.7.0#9c4ab67bdc3860c16ea5b36aed8f74ea%1765202256.763", - "rocksdb/10.5.1#4a197eca381a3e5ae8adf8cffa5aacd0%1762797952.535", - "re2/20230301#ca3b241baec15bd31ea9187150e0b333%1764175362.029", - "protobuf/6.32.1#f481fd276fc23a33b85a3ed1e898b693%1764863245.83", - "openssl/3.5.4#a1d5835cc6ed5c5b8f3cd5b9b5d24205%1760106486.594", - "nudb/2.0.9#fb8dfd1a5557f5e0528114c2da17721e%1763150366.909", - "lz4/1.10.0#59fc63cac7f10fbe8e05c7e62c2f3504%1756234228.999", - "libiconv/1.17#1e65319e945f2d31941a9d28cc13c058%1756223727.64", - "libbacktrace/cci.20210118#a7691bfccd8caaf66309df196790a5a1%1756230911.03", - "libarchive/3.8.1#ffee18995c706e02bf96e7a2f7042e0d%1764175360.142", + "zlib/1.3.1#b8bc2603263cf7eccbd6e17e66b0ed76%1765850150.075", + "xxhash/0.8.3#681d36a0a6111fc56e5e45ea182c19cc%1765850149.987", + "sqlite3/3.49.1#8631739a4c9b93bd3d6b753bac548a63%1765850149.926", + "soci/4.0.3#a9f8d773cd33e356b5879a4b0564f287%1765850149.46", + "snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1765850147.878", + "secp256k1/0.7.0#9c4ab67bdc3860c16ea5b36aed8f74ea%1765850147.928", + "rocksdb/10.5.1#4a197eca381a3e5ae8adf8cffa5aacd0%1765850186.86", + "re2/20230301#ca3b241baec15bd31ea9187150e0b333%1765850148.103", + "protobuf/6.32.1#f481fd276fc23a33b85a3ed1e898b693%1765850161.038", + "openssl/3.5.4#58f5173c2ee51d6fc0f0c61b4eddadbb%1768259092.666", + "nudb/2.0.9#fb8dfd1a5557f5e0528114c2da17721e%1765850143.957", + "lz4/1.10.0#59fc63cac7f10fbe8e05c7e62c2f3504%1765850143.914", + "libiconv/1.17#1e65319e945f2d31941a9d28cc13c058%1765842973.492", + "libbacktrace/cci.20210118#a7691bfccd8caaf66309df196790a5a1%1765842973.03", + "libarchive/3.8.1#ffee18995c706e02bf96e7a2f7042e0d%1765850144.736", "jemalloc/5.3.0#e951da9cf599e956cebc117880d2d9f8%1729241615.244", - "grpc/1.72.0#f244a57bff01e708c55a1100b12e1589%1763158050.628", - "ed25519/2015.03#ae761bdc52730a843f0809bdf6c1b1f6%1764270189.893", - "doctest/2.4.12#eb9fb352fb2fdfc8abb17ec270945165%1762797941.757", - "date/3.0.4#862e11e80030356b53c2c38599ceb32b%1763584497.32", - "c-ares/1.34.5#5581c2b62a608b40bb85d965ab3ec7c8%1764175359.429", - "bzip2/1.0.8#c470882369c2d95c5c77e970c0c7e321%1764175359.429", - "boost/1.88.0#8852c0b72ce8271fb8ff7c53456d4983%1756223752.326", - "abseil/20250127.0#9e8e8cfc89a1324139fc0ee3bd4d8c8c%1753819045.301" + "grpc/1.72.0#f244a57bff01e708c55a1100b12e1589%1765850193.734", + "ed25519/2015.03#ae761bdc52730a843f0809bdf6c1b1f6%1765850143.772", + "doctest/2.4.12#eb9fb352fb2fdfc8abb17ec270945165%1765850143.95", + "date/3.0.4#862e11e80030356b53c2c38599ceb32b%1765850143.772", + "c-ares/1.34.5#5581c2b62a608b40bb85d965ab3ec7c8%1765850144.336", + "bzip2/1.0.8#c470882369c2d95c5c77e970c0c7e321%1765850143.837", + "boost/1.88.0#8852c0b72ce8271fb8ff7c53456d4983%1765850172.862", + "abseil/20250127.0#99262a368bd01c0ccca8790dfced9719%1766517936.993" ], "build_requires": [ - "zlib/1.3.1#b8bc2603263cf7eccbd6e17e66b0ed76%1756234269.497", - "strawberryperl/5.32.1.1#707032463aa0620fa17ec0d887f5fe41%1756234281.733", - "protobuf/6.32.1#f481fd276fc23a33b85a3ed1e898b693%1764863245.83", - "nasm/2.16.01#31e26f2ee3c4346ecd347911bd126904%1756234232.901", + "zlib/1.3.1#b8bc2603263cf7eccbd6e17e66b0ed76%1765850150.075", + "strawberryperl/5.32.1.1#707032463aa0620fa17ec0d887f5fe41%1765850165.196", + "protobuf/6.32.1#f481fd276fc23a33b85a3ed1e898b693%1765850161.038", + "nasm/2.16.01#31e26f2ee3c4346ecd347911bd126904%1765850144.707", "msys2/cci.latest#1996656c3c98e5765b25b60ff5cf77b4%1764840888.758", "m4/1.4.19#70dc8bbb33e981d119d2acc0175cf381%1763158052.846", - "cmake/4.2.0#ae0a44f44a1ef9ab68fd4b3e9a1f8671%1764175359.44", - "cmake/3.31.10#313d16a1aa16bbdb2ca0792467214b76%1764175359.429", - "b2/5.3.3#107c15377719889654eb9a162a673975%1756234226.28", + "cmake/4.2.0#ae0a44f44a1ef9ab68fd4b3e9a1f8671%1765850153.937", + "cmake/3.31.10#313d16a1aa16bbdb2ca0792467214b76%1765850153.479", + "b2/5.3.3#107c15377719889654eb9a162a673975%1765850144.355", "automake/1.16.5#b91b7c384c3deaa9d535be02da14d04f%1755524470.56", "autoconf/2.71#51077f068e61700d65bb05541ea1e4b0%1731054366.86", - "abseil/20250127.0#9e8e8cfc89a1324139fc0ee3bd4d8c8c%1753819045.301" + "abseil/20250127.0#99262a368bd01c0ccca8790dfced9719%1766517936.993" ], "python_requires": [], "overrides": { diff --git a/conanfile.py b/conanfile.py index 48e28cb275..96e3384979 100644 --- a/conanfile.py +++ b/conanfile.py @@ -87,7 +87,13 @@ class Xrpl(ConanFile): "libarchive/*:with_xattr": False, "libarchive/*:with_zlib": False, "lz4/*:shared": False, + "openssl/*:no_dtls": True, + "openssl/*:no_ssl": True, + "openssl/*:no_ssl3": True, + "openssl/*:no_tls1": True, + "openssl/*:no_tls1_1": True, "openssl/*:shared": False, + "openssl/*:tls_security_level": 2, "protobuf/*:shared": False, "protobuf/*:with_zlib": True, "rocksdb/*:enable_sse": False, From 0efae5d16e433d504064ff6f6174ed02a37c61ea Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Tue, 13 Jan 2026 16:52:10 +0000 Subject: [PATCH 08/14] ci: Update actions/images to use cmake 4.2.1 and conan 2.24.0 (#6209) --- .github/scripts/strategy-matrix/linux.json | 56 +++++++++---------- .github/workflows/pre-commit.yml | 4 +- .../workflows/reusable-build-test-config.yml | 2 +- .github/workflows/upload-conan-deps.yml | 2 +- 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 669754554c..e64a05f925 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -15,196 +15,196 @@ "distro_version": "bookworm", "compiler_name": "gcc", "compiler_version": "12", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "gcc", "compiler_version": "13", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "gcc", "compiler_version": "15", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "clang", "compiler_version": "16", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "clang", "compiler_version": "17", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "clang", "compiler_version": "18", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "clang", "compiler_version": "19", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "bookworm", "compiler_name": "clang", "compiler_version": "20", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "trixie", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "trixie", "compiler_name": "gcc", "compiler_version": "15", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "trixie", "compiler_name": "clang", "compiler_version": "20", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "debian", "distro_version": "trixie", "compiler_name": "clang", "compiler_version": "21", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "rhel", "distro_version": "8", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "rhel", "distro_version": "8", "compiler_name": "clang", "compiler_version": "any", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "rhel", "distro_version": "9", "compiler_name": "gcc", "compiler_version": "12", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "rhel", "distro_version": "9", "compiler_name": "gcc", "compiler_version": "13", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "rhel", "distro_version": "9", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "rhel", "distro_version": "9", "compiler_name": "clang", "compiler_version": "any", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "rhel", "distro_version": "10", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "rhel", "distro_version": "10", "compiler_name": "clang", "compiler_version": "any", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "ubuntu", "distro_version": "jammy", "compiler_name": "gcc", "compiler_version": "12", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "gcc", "compiler_version": "13", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "gcc", "compiler_version": "14", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "clang", "compiler_version": "16", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "clang", "compiler_version": "17", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "clang", "compiler_version": "18", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" }, { "distro_name": "ubuntu", "distro_version": "noble", "compiler_name": "clang", "compiler_version": "19", - "image_sha": "cc09fd3" + "image_sha": "ab4d1f0" } ], "build_type": ["Debug", "Release"], diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 41e82fb6bb..00754e5eae 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -9,7 +9,7 @@ on: jobs: # Call the workflow in the XRPLF/actions repo that runs the pre-commit hooks. run-hooks: - uses: XRPLF/actions/.github/workflows/pre-commit.yml@5ca417783f0312ab26d6f48b85c78edf1de99bbd + uses: XRPLF/actions/.github/workflows/pre-commit.yml@282890f46d6921249d5659dd38babcb0bd8aef48 with: runs_on: ubuntu-latest - container: '{ "image": "ghcr.io/xrplf/ci/tools-rippled-pre-commit:sha-a8c7be1" }' + container: '{ "image": "ghcr.io/xrplf/ci/tools-rippled-pre-commit:sha-ab4d1f0" }' diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index ae91a8bf20..bc0717e1a5 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -100,7 +100,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@121d1de2775d486d46140b9a91b32d5002c08153 + uses: XRPLF/actions/prepare-runner@f05cab7b8541eee6473aa42beb9d2fe35608a190 with: enable_ccache: ${{ inputs.ccache_enabled }} diff --git a/.github/workflows/upload-conan-deps.yml b/.github/workflows/upload-conan-deps.yml index 55a9ab8864..29ae95fce5 100644 --- a/.github/workflows/upload-conan-deps.yml +++ b/.github/workflows/upload-conan-deps.yml @@ -70,7 +70,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@65da1c59e81965eeb257caa3587b9d45066fb925 + uses: XRPLF/actions/prepare-runner@f05cab7b8541eee6473aa42beb9d2fe35608a190 with: enable_ccache: false From 96866049632f9691372ab3cce2a877664db7c056 Mon Sep 17 00:00:00 2001 From: Bart Date: Tue, 13 Jan 2026 12:29:04 -0500 Subject: [PATCH 09/14] fix: Update Conan lock file with changed OpenSSL recipe (#6211) This change updates the `conan.lock` file with a changed OpenSSL recipe that contains a fix regarding options passed to the compiler --- conan.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conan.lock b/conan.lock index 99522e79b2..44dc9031d2 100644 --- a/conan.lock +++ b/conan.lock @@ -10,7 +10,7 @@ "rocksdb/10.5.1#4a197eca381a3e5ae8adf8cffa5aacd0%1765850186.86", "re2/20230301#ca3b241baec15bd31ea9187150e0b333%1765850148.103", "protobuf/6.32.1#f481fd276fc23a33b85a3ed1e898b693%1765850161.038", - "openssl/3.5.4#58f5173c2ee51d6fc0f0c61b4eddadbb%1768259092.666", + "openssl/3.5.4#1b986e61b38fdfda3b40bebc1b234393%1768312656.257", "nudb/2.0.9#fb8dfd1a5557f5e0528114c2da17721e%1765850143.957", "lz4/1.10.0#59fc63cac7f10fbe8e05c7e62c2f3504%1765850143.914", "libiconv/1.17#1e65319e945f2d31941a9d28cc13c058%1765842973.492", From 2601442e16c11b8b174d154758abb8ac4a9a6564 Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Tue, 13 Jan 2026 15:42:58 -0400 Subject: [PATCH 10/14] Improve and fix bugs in Lending Protocol (#6102) - Spec: XLS-66 Fix overpayment asserts (#6084) MPTTester::operator() parameter should be std::int64_t - Originally defined as uint64_t, but the testIssuerLoan() test called it with a negative number, causing an overflow to a very large number that in some circumstances could be silently cast back to an int64_t, but might not be. I believe this is UB, and we don't want to rely on that. Review feedback from @Tapanito: overpayment value change - In overpayment results, the management fee was being calculated twice: once as part of the value change, and as part of the fees paid. Exclude it from the value change. Fix Overpayment Calculation (#6087) - Adds additional unit tests to cover math calculations. - Removes unused methods. Review feedback from @shawnxie999: even more rounding - Round the initial total value computation upward, unless there is 0-interest. - Rename getVaultScale to getAssetsTotalScale, and convert one incorrect computation to use it. - Use adjustImpreciseNumber for LossUnrealized. - Add some logging to computeLoanProperties. Fix LoanBrokerSet debtMaximum limits (#6116) Fix some minor bugs in Lending Protocol (#6101) - add nodiscard to unimpairLoan, and check result in LoanPay - add a check to verify that issuer exists - improve LoanManage error code for dust amounts Check permissions in LoanSet and LoanPay (#6108) Disallow pseudo accounts to be Destination for LoanBrokerCoverWithdraw (#6106) Ensure vault asset cap is not exceeded (#6124) Fix Overpayment ValueChange calculation in Lending Protocol (#6114) - Adds loan state to LoanProperties. - Cleans up computeLoanProperties. - Fixes missing management fee from overpayment. fix: Enable LP Deposits when the broker is the asset issuer (#6119) * Replace accountHolds with accountSpendable when checking for account funds in VaultDeposit and LoanBrokerCoverDeposit Add a few minor changes (#6158) - Updates or fixes a couple of things I noticed while reviewing changes to the spec. - Rename sfPreviousPaymentDate to sfPreviousPaymentDueDate. - Make the vault asset cap check added in #6124 a little more robust: 1. Check in preflight if the vault is _already_ over the limit. 2. Prevent overflow when checking with the loan value. (Subtract instead of adding, in case the values are near maxint. Both return the same result. Also add a unit test so each case is covered. Add minimum grace period validation (#6133) Fix bugs: frozen pseudo-account, and FLC cutoff (#6170) refactor: Rename raw state to theoretical state (#6187) Check if a withdrawal amount exceeds any applicable receiving limit. (#6117) Fix overpayment result calculation (#6195) Address review feedback from Lending Protocol re-review (#6161) --------- Co-authored-by: Gregory Tsipenyuk Co-authored-by: Bronek Kozicki Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Co-authored-by: Shawn Xie <35279399+shawnxie999@users.noreply.github.com> Co-authored-by: Jingchen --- .../paths => include/xrpl/ledger}/Credit.h | 4 +- include/xrpl/ledger/View.h | 86 +- .../xrpl/protocol/detail/ledger_entries.macro | 2 +- include/xrpl/protocol/detail/sfields.macro | 2 +- .../app/paths => libxrpl/ledger}/Credit.cpp | 0 src/libxrpl/ledger/View.cpp | 219 +-- src/test/app/LendingHelpers_test.cpp | 1351 +++++++++++++++++ src/test/app/LoanBroker_test.cpp | 468 ++++++ src/test/app/Loan_test.cpp | 765 ++++++++-- src/test/app/Vault_test.cpp | 4 +- src/test/jtx/impl/mpt.cpp | 2 +- src/test/jtx/mpt.h | 2 +- src/xrpld/app/misc/LendingHelpers.h | 180 ++- src/xrpld/app/misc/detail/LendingHelpers.cpp | 526 +++---- src/xrpld/app/paths/Flow.cpp | 2 +- src/xrpld/app/paths/detail/DirectStep.cpp | 2 +- src/xrpld/app/paths/detail/StrandFlow.h | 2 +- .../app/paths/detail/XRPEndpointStep.cpp | 2 +- .../app/tx/detail/LoanBrokerCoverClawback.cpp | 12 +- .../app/tx/detail/LoanBrokerCoverDeposit.cpp | 3 +- .../app/tx/detail/LoanBrokerCoverWithdraw.cpp | 5 + src/xrpld/app/tx/detail/LoanBrokerDelete.cpp | 44 +- src/xrpld/app/tx/detail/LoanBrokerSet.cpp | 19 + src/xrpld/app/tx/detail/LoanDelete.cpp | 2 +- src/xrpld/app/tx/detail/LoanManage.cpp | 78 +- src/xrpld/app/tx/detail/LoanManage.h | 4 +- src/xrpld/app/tx/detail/LoanPay.cpp | 128 +- src/xrpld/app/tx/detail/LoanSet.cpp | 69 +- src/xrpld/app/tx/detail/VaultDeposit.cpp | 8 +- src/xrpld/rpc/handlers/LedgerEntry.cpp | 2 +- 30 files changed, 3242 insertions(+), 751 deletions(-) rename {src/xrpld/app/paths => include/xrpl/ledger}/Credit.h (93%) rename src/{xrpld/app/paths => libxrpl/ledger}/Credit.cpp (100%) create mode 100644 src/test/app/LendingHelpers_test.cpp diff --git a/src/xrpld/app/paths/Credit.h b/include/xrpl/ledger/Credit.h similarity index 93% rename from src/xrpld/app/paths/Credit.h rename to include/xrpl/ledger/Credit.h index 5bdcd70e74..09b65b3dde 100644 --- a/src/xrpld/app/paths/Credit.h +++ b/include/xrpl/ledger/Credit.h @@ -1,5 +1,5 @@ -#ifndef XRPL_APP_PATHS_CREDIT_H_INCLUDED -#define XRPL_APP_PATHS_CREDIT_H_INCLUDED +#ifndef XRPL_LEDGER_CREDIT_H_INCLUDED +#define XRPL_LEDGER_CREDIT_H_INCLUDED #include #include diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 767622596b..707a08b890 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -61,6 +61,9 @@ enum FreezeHandling { fhIGNORE_FREEZE, fhZERO_IF_FROZEN }; /** Controls the treatment of unauthorized MPT balances */ enum AuthHandling { ahIGNORE_AUTH, ahZERO_IF_UNAUTHORIZED }; +/** Controls whether to include the account's full spendable balance */ +enum SpendableHandling { shSIMPLE_BALANCE, shFULL_BALANCE }; + [[nodiscard]] bool isGlobalFrozen(ReadView const& view, AccountID const& issuer); @@ -305,86 +308,57 @@ isLPTokenFrozen( Issue const& asset, Issue const& asset2); -// Returns the amount an account can spend without going into debt. +// Returns the amount an account can spend. // -// <-- saAmount: amount of currency held by account. May be negative. -[[nodiscard]] STAmount -accountHolds( - ReadView const& view, - AccountID const& account, - Currency const& currency, - AccountID const& issuer, - FreezeHandling zeroIfFrozen, - beast::Journal j); - -[[nodiscard]] STAmount -accountHolds( - ReadView const& view, - AccountID const& account, - Issue const& issue, - FreezeHandling zeroIfFrozen, - beast::Journal j); - -[[nodiscard]] STAmount -accountHolds( - ReadView const& view, - AccountID const& account, - MPTIssue const& mptIssue, - FreezeHandling zeroIfFrozen, - AuthHandling zeroIfUnauthorized, - beast::Journal j); - -[[nodiscard]] STAmount -accountHolds( - ReadView const& view, - AccountID const& account, - Asset const& asset, - FreezeHandling zeroIfFrozen, - AuthHandling zeroIfUnauthorized, - beast::Journal j); - -// Returns the amount an account can spend total. +// If shSIMPLE_BALANCE is specified, this is the amount the account can spend +// without going into debt. // -// These functions use accountHolds, but unlike accountHolds: -// * The account can go into debt. -// * If the account is the asset issuer the only limit is defined by the asset / +// If shFULL_BALANCE is specified, this is the amount the account can spend +// total. Specifically: +// * The account can go into debt if using a trust line, and the other side has +// a non-zero limit. +// * If the account is the asset issuer the limit is defined by the asset / // issuance. // // <-- saAmount: amount of currency held by account. May be negative. [[nodiscard]] STAmount -accountSpendable( +accountHolds( ReadView const& view, AccountID const& account, Currency const& currency, AccountID const& issuer, FreezeHandling zeroIfFrozen, - beast::Journal j); + beast::Journal j, + SpendableHandling includeFullBalance = shSIMPLE_BALANCE); [[nodiscard]] STAmount -accountSpendable( +accountHolds( ReadView const& view, AccountID const& account, Issue const& issue, FreezeHandling zeroIfFrozen, - beast::Journal j); + beast::Journal j, + SpendableHandling includeFullBalance = shSIMPLE_BALANCE); [[nodiscard]] STAmount -accountSpendable( +accountHolds( ReadView const& view, AccountID const& account, MPTIssue const& mptIssue, FreezeHandling zeroIfFrozen, AuthHandling zeroIfUnauthorized, - beast::Journal j); + beast::Journal j, + SpendableHandling includeFullBalance = shSIMPLE_BALANCE); [[nodiscard]] STAmount -accountSpendable( +accountHolds( ReadView const& view, AccountID const& account, Asset const& asset, FreezeHandling zeroIfFrozen, AuthHandling zeroIfUnauthorized, - beast::Journal j); + beast::Journal j, + SpendableHandling includeFullBalance = shSIMPLE_BALANCE); // Returns the amount an account can spend of the currency type saDefault, or // returns saDefault if this account is the issuer of the currency in @@ -655,7 +629,7 @@ createPseudoAccount( uint256 const& pseudoOwnerKey, SField const& ownerField); -// Returns true iff sleAcct is a pseudo-account or specific +// Returns true if and only if sleAcct is a pseudo-account or specific // pseudo-accounts in pseudoFieldFilter. // // Returns false if sleAcct is @@ -710,13 +684,16 @@ checkDestinationAndTag(SLE::const_ref toSle, bool hasDestinationTag); * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if * the sender has it. + * - Checks that the receiver will not exceed the limit (IOU trustline limit + * or MPT MaximumAmount). */ [[nodiscard]] TER canWithdraw( - AccountID const& from, ReadView const& view, + AccountID const& from, AccountID const& to, SLE::const_ref toSle, + STAmount const& amount, bool hasDestinationTag); /** Checks that can withdraw funds from an object to itself or a destination. @@ -730,12 +707,15 @@ canWithdraw( * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if * the sender has it. + * - Checks that the receiver will not exceed the limit (IOU trustline limit + * or MPT MaximumAmount). */ [[nodiscard]] TER canWithdraw( - AccountID const& from, ReadView const& view, + AccountID const& from, AccountID const& to, + STAmount const& amount, bool hasDestinationTag); /** Checks that can withdraw funds from an object to itself or a destination. @@ -749,6 +729,8 @@ canWithdraw( * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if * the sender has it. + * - Checks that the receiver will not exceed the limit (IOU trustline limit + * or MPT MaximumAmount). */ [[nodiscard]] TER canWithdraw(ReadView const& view, STTx const& tx); diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index de9c41bf52..216f404bec 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -541,7 +541,7 @@ LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({ {sfStartDate, soeREQUIRED}, {sfPaymentInterval, soeREQUIRED}, {sfGracePeriod, soeDEFAULT}, - {sfPreviousPaymentDate, soeDEFAULT}, + {sfPreviousPaymentDueDate, soeDEFAULT}, {sfNextPaymentDueDate, soeDEFAULT}, // The loan object tracks these values: // diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index d5c5d9447f..d0736469e4 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -102,7 +102,7 @@ TYPED_SFIELD(sfMutableFlags, UINT32, 53) TYPED_SFIELD(sfStartDate, UINT32, 54) TYPED_SFIELD(sfPaymentInterval, UINT32, 55) TYPED_SFIELD(sfGracePeriod, UINT32, 56) -TYPED_SFIELD(sfPreviousPaymentDate, UINT32, 57) +TYPED_SFIELD(sfPreviousPaymentDueDate, UINT32, 57) TYPED_SFIELD(sfNextPaymentDueDate, UINT32, 58) TYPED_SFIELD(sfPaymentRemaining, UINT32, 59) TYPED_SFIELD(sfPaymentTotal, UINT32, 60) diff --git a/src/xrpld/app/paths/Credit.cpp b/src/libxrpl/ledger/Credit.cpp similarity index 100% rename from src/xrpld/app/paths/Credit.cpp rename to src/libxrpl/ledger/Credit.cpp diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index 329d3cfcae..14246baf17 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -464,7 +465,8 @@ accountHolds( Currency const& currency, AccountID const& issuer, FreezeHandling zeroIfFrozen, - beast::Journal j) + beast::Journal j, + SpendableHandling includeFullBalance) { STAmount amount; if (isXRP(currency)) @@ -472,11 +474,19 @@ accountHolds( return {xrpLiquid(view, account, 0, j)}; } + bool const returnSpendable = (includeFullBalance == shFULL_BALANCE); + if (returnSpendable && account == issuer) + // If the account is the issuer, then their limit is effectively + // infinite + return STAmount{ + Issue{currency, issuer}, STAmount::cMaxValue, STAmount::cMaxOffset}; + // IOU: Return balance on trust line modulo freeze SLE::const_pointer const sle = getLineIfUsable(view, account, currency, issuer, zeroIfFrozen, j); - return getTrustLineBalance(view, sle, account, currency, issuer, false, j); + return getTrustLineBalance( + view, sle, account, currency, issuer, returnSpendable, j); } STAmount @@ -485,10 +495,17 @@ accountHolds( AccountID const& account, Issue const& issue, FreezeHandling zeroIfFrozen, - beast::Journal j) + beast::Journal j, + SpendableHandling includeFullBalance) { return accountHolds( - view, account, issue.currency, issue.account, zeroIfFrozen, j); + view, + account, + issue.currency, + issue.account, + zeroIfFrozen, + j, + includeFullBalance); } STAmount @@ -498,8 +515,28 @@ accountHolds( MPTIssue const& mptIssue, FreezeHandling zeroIfFrozen, AuthHandling zeroIfUnauthorized, - beast::Journal j) + beast::Journal j, + SpendableHandling includeFullBalance) { + bool const returnSpendable = (includeFullBalance == shFULL_BALANCE); + + if (returnSpendable && account == mptIssue.getIssuer()) + { + // if the account is the issuer, and the issuance exists, their limit is + // the issuance limit minus the outstanding value + auto const issuance = + view.read(keylet::mptIssuance(mptIssue.getMptID())); + + if (!issuance) + { + return STAmount{mptIssue}; + } + return STAmount{ + mptIssue, + issuance->at(~sfMaximumAmount).value_or(maxMPTokenAmount) - + issuance->at(sfOutstandingAmount)}; + } + STAmount amount; auto const sleMpt = @@ -547,108 +584,27 @@ accountHolds( Asset const& asset, FreezeHandling zeroIfFrozen, AuthHandling zeroIfUnauthorized, - beast::Journal j) + beast::Journal j, + SpendableHandling includeFullBalance) { return std::visit( - [&](auto const& value) { - if constexpr (std::is_same_v< - std::remove_cvref_t, - Issue>) + [&](TIss const& value) { + if constexpr (std::is_same_v) { - return accountHolds(view, account, value, zeroIfFrozen, j); + return accountHolds( + view, account, value, zeroIfFrozen, j, includeFullBalance); } - return accountHolds( - view, account, value, zeroIfFrozen, zeroIfUnauthorized, j); - }, - asset.value()); -} - -STAmount -accountSpendable( - ReadView const& view, - AccountID const& account, - Currency const& currency, - AccountID const& issuer, - FreezeHandling zeroIfFrozen, - beast::Journal j) -{ - if (isXRP(currency)) - return accountHolds(view, account, currency, issuer, zeroIfFrozen, j); - - if (account == issuer) - // If the account is the issuer, then their limit is effectively - // infinite - return STAmount{ - Issue{currency, issuer}, STAmount::cMaxValue, STAmount::cMaxOffset}; - - // IOU: Return balance on trust line modulo freeze - SLE::const_pointer const sle = - getLineIfUsable(view, account, currency, issuer, zeroIfFrozen, j); - - return getTrustLineBalance(view, sle, account, currency, issuer, true, j); -} - -STAmount -accountSpendable( - ReadView const& view, - AccountID const& account, - Issue const& issue, - FreezeHandling zeroIfFrozen, - beast::Journal j) -{ - return accountSpendable( - view, account, issue.currency, issue.account, zeroIfFrozen, j); -} - -STAmount -accountSpendable( - ReadView const& view, - AccountID const& account, - MPTIssue const& mptIssue, - FreezeHandling zeroIfFrozen, - AuthHandling zeroIfUnauthorized, - beast::Journal j) -{ - if (account == mptIssue.getIssuer()) - { - // if the account is the issuer, and the issuance exists, their limit is - // the issuance limit minus the outstanding value - auto const issuance = - view.read(keylet::mptIssuance(mptIssue.getMptID())); - - if (!issuance) - { - return STAmount{mptIssue}; - } - return STAmount{ - mptIssue, - issuance->at(~sfMaximumAmount).value_or(maxMPTokenAmount) - - issuance->at(sfOutstandingAmount)}; - } - - return accountHolds( - view, account, mptIssue, zeroIfFrozen, zeroIfUnauthorized, j); -} - -[[nodiscard]] STAmount -accountSpendable( - ReadView const& view, - AccountID const& account, - Asset const& asset, - FreezeHandling zeroIfFrozen, - AuthHandling zeroIfUnauthorized, - beast::Journal j) -{ - return std::visit( - [&](auto const& value) { - if constexpr (std::is_same_v< - std::remove_cvref_t, - Issue>) + else if constexpr (std::is_same_v) { - return accountSpendable(view, account, value, zeroIfFrozen, j); + return accountHolds( + view, + account, + value, + zeroIfFrozen, + zeroIfUnauthorized, + j, + includeFullBalance); } - return accountSpendable( - view, account, value, zeroIfFrozen, zeroIfUnauthorized, j); }, asset.value()); } @@ -1205,8 +1161,7 @@ getPseudoAccountFields() // LCOV_EXCL_START LogicError( "xrpl::getPseudoAccountFields : unable to find account root " - "ledger " - "format"); + "ledger format"); // LCOV_EXCL_STOP } auto const& soTemplate = ar->getSOTemplate(); @@ -1342,12 +1297,58 @@ checkDestinationAndTag(SLE::const_ref toSle, bool hasDestinationTag) return tesSUCCESS; } +/* + * Checks if a withdrawal amount into the destination account exceeds + * any applicable receiving limit. + * Called by VaultWithdraw and LoanBrokerCoverWithdraw. + * + * IOU : Performs the trustline check against the destination account's + * credit limit to ensure the account's trust maximum is not exceeded. + * + * MPT: The limit check is effectively skipped (returns true). This is + * because MPT MaximumAmount relates to token supply, and withdrawal does not + * involve minting new tokens that could exceed the global cap. + * On withdrawal, tokens are simply transferred from the vault's pseudo-account + * to the destination account. Since no new MPT tokens are minted during this + * transfer, the withdrawal cannot violate the MPT MaximumAmount/supply cap + * even if `from` is the issuer. + */ +static TER +withdrawToDestExceedsLimit( + ReadView const& view, + AccountID const& from, + AccountID const& to, + STAmount const& amount) +{ + auto const& issuer = amount.getIssuer(); + if (from == to || to == issuer || isXRP(issuer)) + return tesSUCCESS; + + return std::visit( + [&](TIss const& issue) -> TER { + if constexpr (std::is_same_v) + { + auto const& currency = issue.currency; + auto const owed = creditBalance(view, to, issuer, currency); + if (owed <= beast::zero) + { + auto const limit = creditLimit(view, to, issuer, currency); + if (-owed >= limit || amount > (limit + owed)) + return tecNO_LINE; + } + } + return tesSUCCESS; + }, + amount.asset().value()); +} + [[nodiscard]] TER canWithdraw( - AccountID const& from, ReadView const& view, + AccountID const& from, AccountID const& to, SLE::const_ref toSle, + STAmount const& amount, bool hasDestinationTag) { if (auto const ret = checkDestinationAndTag(toSle, hasDestinationTag)) @@ -1362,19 +1363,20 @@ canWithdraw( return tecNO_PERMISSION; } - return tesSUCCESS; + return withdrawToDestExceedsLimit(view, from, to, amount); } [[nodiscard]] TER canWithdraw( - AccountID const& from, ReadView const& view, + AccountID const& from, AccountID const& to, + STAmount const& amount, bool hasDestinationTag) { auto const toSle = view.read(keylet::account(to)); - return canWithdraw(from, view, to, toSle, hasDestinationTag); + return canWithdraw(view, from, to, toSle, amount, hasDestinationTag); } [[nodiscard]] TER @@ -1383,7 +1385,8 @@ canWithdraw(ReadView const& view, STTx const& tx) auto const from = tx[sfAccount]; auto const to = tx[~sfDestination].value_or(from); - return canWithdraw(from, view, to, tx.isFieldPresent(sfDestinationTag)); + return canWithdraw( + view, from, to, tx[sfAmount], tx.isFieldPresent(sfDestinationTag)); } TER diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp new file mode 100644 index 0000000000..50efe0ebe3 --- /dev/null +++ b/src/test/app/LendingHelpers_test.cpp @@ -0,0 +1,1351 @@ +#include +// DO NOT REMOVE +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace xrpl { +namespace test { + +class LendingHelpers_test : public beast::unit_test::suite +{ + void + testComputeRaisedRate() + { + using namespace jtx; + using namespace xrpl::detail; + struct TestCase + { + std::string name; + Number periodicRate; + std::uint32_t paymentsRemaining; + Number expectedRaisedRate; + }; + + auto const testCases = std::vector{ + { + .name = "Zero payments remaining", + .periodicRate = Number{5, -2}, + .paymentsRemaining = 0, + .expectedRaisedRate = Number{1}, // (1 + r)^0 = 1 + }, + { + .name = "One payment remaining", + .periodicRate = Number{5, -2}, + .paymentsRemaining = 1, + .expectedRaisedRate = Number{105, -2}, + }, // 1.05^1 + { + .name = "Multiple payments remaining", + .periodicRate = Number{5, -2}, + .paymentsRemaining = 3, + .expectedRaisedRate = Number{1157625, -6}, + }, // 1.05^3 + { + .name = "Zero periodic rate", + .periodicRate = Number{0}, + .paymentsRemaining = 5, + .expectedRaisedRate = Number{1}, // (1 + 0)^5 = 1 + }}; + + for (auto const& tc : testCases) + { + testcase("computeRaisedRate: " + tc.name); + + auto const computedRaisedRate = + computeRaisedRate(tc.periodicRate, tc.paymentsRemaining); + BEAST_EXPECTS( + computedRaisedRate == tc.expectedRaisedRate, + "Raised rate mismatch: expected " + + to_string(tc.expectedRaisedRate) + ", got " + + to_string(computedRaisedRate)); + } + } + + void + testComputePaymentFactor() + { + using namespace jtx; + using namespace xrpl::detail; + struct TestCase + { + std::string name; + Number periodicRate; + std::uint32_t paymentsRemaining; + Number expectedPaymentFactor; + }; + + auto const testCases = std::vector{ + { + .name = "Zero periodic rate", + .periodicRate = Number{0}, + .paymentsRemaining = 4, + .expectedPaymentFactor = Number{25, -2}, + }, // 1/4 = 0.25 + { + .name = "One payment remaining", + .periodicRate = Number{5, -2}, + .paymentsRemaining = 1, + .expectedPaymentFactor = Number{105, -2}, + }, // 0.05/1 = 1.05 + { + .name = "Multiple payments remaining", + .periodicRate = Number{5, -2}, + .paymentsRemaining = 3, + .expectedPaymentFactor = Number{367208564631245, -15}, + }, // from calc + { + .name = "Zero payments remaining", + .periodicRate = Number{5, -2}, + .paymentsRemaining = 0, + .expectedPaymentFactor = Number{0}, + } // edge case + }; + + for (auto const& tc : testCases) + { + testcase("computePaymentFactor: " + tc.name); + + auto const computedPaymentFactor = + computePaymentFactor(tc.periodicRate, tc.paymentsRemaining); + BEAST_EXPECTS( + computedPaymentFactor == tc.expectedPaymentFactor, + "Payment factor mismatch: expected " + + to_string(tc.expectedPaymentFactor) + ", got " + + to_string(computedPaymentFactor)); + } + } + + void + testLoanPeriodicPayment() + { + using namespace jtx; + using namespace xrpl::detail; + + struct TestCase + { + std::string name; + Number principalOutstanding; + Number periodicRate; + std::uint32_t paymentsRemaining; + Number expectedPeriodicPayment; + }; + + auto const testCases = std::vector{ + { + .name = "Zero principal outstanding", + .principalOutstanding = Number{0}, + .periodicRate = Number{5, -2}, + .paymentsRemaining = 5, + .expectedPeriodicPayment = Number{0}, + }, + { + .name = "Zero payments remaining", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .paymentsRemaining = 0, + .expectedPeriodicPayment = Number{0}, + }, + { + .name = "Zero periodic rate", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{0}, + .paymentsRemaining = 4, + .expectedPeriodicPayment = Number{250}, + }, + { + .name = "Standard case", + .principalOutstanding = Number{1'000}, + .periodicRate = + loanPeriodicRate(TenthBips32(100'000), 30 * 24 * 60 * 60), + .paymentsRemaining = 3, + .expectedPeriodicPayment = + Number{3895690663961231, -13}, // from calc + }, + }; + + for (auto const& tc : testCases) + { + testcase("loanPeriodicPayment: " + tc.name); + + auto const computedPeriodicPayment = loanPeriodicPayment( + tc.principalOutstanding, tc.periodicRate, tc.paymentsRemaining); + BEAST_EXPECTS( + computedPeriodicPayment == tc.expectedPeriodicPayment, + "Periodic payment mismatch: expected " + + to_string(tc.expectedPeriodicPayment) + ", got " + + to_string(computedPeriodicPayment)); + } + } + + void + testLoanPrincipalFromPeriodicPayment() + { + using namespace jtx; + using namespace xrpl::detail; + + struct TestCase + { + std::string name; + Number periodicPayment; + Number periodicRate; + std::uint32_t paymentsRemaining; + Number expectedPrincipalOutstanding; + }; + + auto const testCases = std::vector{ + { + .name = "Zero periodic payment", + .periodicPayment = Number{0}, + .periodicRate = Number{5, -2}, + .paymentsRemaining = 5, + .expectedPrincipalOutstanding = Number{0}, + }, + { + .name = "Zero payments remaining", + .periodicPayment = Number{1'000}, + .periodicRate = Number{5, -2}, + .paymentsRemaining = 0, + .expectedPrincipalOutstanding = Number{0}, + }, + { + .name = "Zero periodic rate", + .periodicPayment = Number{250}, + .periodicRate = Number{0}, + .paymentsRemaining = 4, + .expectedPrincipalOutstanding = Number{1'000}, + }, + { + .name = "Standard case", + .periodicPayment = Number{3895690663961231, -13}, // from calc + .periodicRate = + loanPeriodicRate(TenthBips32(100'000), 30 * 24 * 60 * 60), + .paymentsRemaining = 3, + .expectedPrincipalOutstanding = Number{1'000}, + }, + }; + + for (auto const& tc : testCases) + { + testcase("loanPrincipalFromPeriodicPayment: " + tc.name); + + auto const computedPrincipalOutstanding = + loanPrincipalFromPeriodicPayment( + tc.periodicPayment, tc.periodicRate, tc.paymentsRemaining); + BEAST_EXPECTS( + computedPrincipalOutstanding == tc.expectedPrincipalOutstanding, + "Principal outstanding mismatch: expected " + + to_string(tc.expectedPrincipalOutstanding) + ", got " + + to_string(computedPrincipalOutstanding)); + } + } + + void + testComputeOverpaymentComponents() + { + testcase("computeOverpaymentComponents"); + using namespace jtx; + using namespace xrpl::detail; + + Account const issuer{"issuer"}; + PrettyAsset const IOU = issuer["IOU"]; + int32_t const loanScale = 1; + auto const overpayment = Number{1'000}; + auto const overpaymentInterestRate = TenthBips32{10'000}; // 10% + auto const overpaymentFeeRate = TenthBips32{50'000}; // 50% + auto const managementFeeRate = TenthBips16{10'000}; // 10% + + auto const expectedOverpaymentFee = Number{500}; // 50% of 1,000 + auto const expectedOverpaymentInterestGross = + Number{100}; // 10% of 1,000 + auto const expectedOverpaymentInterestNet = + Number{90}; // 100 - 10% of 100 + auto const expectedOverpaymentManagementFee = Number{10}; // 10% of 100 + auto const expectedPrincipalPortion = Number{400}; // 1,000 - 100 - 500 + + auto const components = detail::computeOverpaymentComponents( + IOU, + loanScale, + overpayment, + overpaymentInterestRate, + overpaymentFeeRate, + managementFeeRate); + + BEAST_EXPECT( + components.untrackedManagementFee == expectedOverpaymentFee); + + BEAST_EXPECT( + components.untrackedInterest == expectedOverpaymentInterestNet); + + BEAST_EXPECT( + components.trackedInterestPart() == expectedOverpaymentInterestNet); + + BEAST_EXPECT( + components.trackedManagementFeeDelta == + expectedOverpaymentManagementFee); + BEAST_EXPECT( + components.trackedPrincipalDelta == expectedPrincipalPortion); + BEAST_EXPECT( + components.trackedManagementFeeDelta + + components.untrackedInterest == + expectedOverpaymentInterestGross); + + BEAST_EXPECT( + components.trackedManagementFeeDelta + + components.untrackedInterest + + components.trackedPrincipalDelta + + components.untrackedManagementFee == + overpayment); + } + + void + testComputeInterestAndFeeParts() + { + using namespace jtx; + using namespace xrpl::detail; + + struct TestCase + { + std::string name; + Number interest; + TenthBips16 managementFeeRate; + Number expectedInterestPart; + Number expectedFeePart; + }; + + Account const issuer{"issuer"}; + PrettyAsset const IOU = issuer["IOU"]; + std::int32_t const loanScale = 1; + + auto const testCases = std::vector{ + {.name = "Zero interest", + .interest = Number{0}, + .managementFeeRate = TenthBips16{10'000}, + .expectedInterestPart = Number{0}, + .expectedFeePart = Number{0}}, + {.name = "Zero fee rate", + .interest = Number{1'000}, + .managementFeeRate = TenthBips16{0}, + .expectedInterestPart = Number{1'000}, + .expectedFeePart = Number{0}}, + {.name = "10% fee rate", + .interest = Number{1'000}, + .managementFeeRate = TenthBips16{10'000}, + .expectedInterestPart = Number{900}, + .expectedFeePart = Number{100}}, + }; + + for (auto const& tc : testCases) + { + testcase("computeInterestAndFeeParts: " + tc.name); + + auto const [computedInterestPart, computedFeePart] = + computeInterestAndFeeParts( + IOU, tc.interest, tc.managementFeeRate, loanScale); + BEAST_EXPECTS( + computedInterestPart == tc.expectedInterestPart, + "Interest part mismatch: expected " + + to_string(tc.expectedInterestPart) + ", got " + + to_string(computedInterestPart)); + BEAST_EXPECTS( + computedFeePart == tc.expectedFeePart, + "Fee part mismatch: expected " + to_string(tc.expectedFeePart) + + ", got " + to_string(computedFeePart)); + } + } + + void + testLoanLatePaymentInterest() + { + using namespace jtx; + using namespace xrpl::detail; + struct TestCase + { + std::string name; + Number principalOutstanding; + TenthBips32 lateInterestRate; + NetClock::time_point parentCloseTime; + std::uint32_t nextPaymentDueDate; + Number expectedLateInterest; + }; + + auto const testCases = std::vector{ + { + .name = "On-time payment", + .principalOutstanding = Number{1'000}, + .lateInterestRate = TenthBips32{10'000}, // 10% + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .nextPaymentDueDate = 3'000, + .expectedLateInterest = Number{0}, + }, + { + .name = "Early payment", + .principalOutstanding = Number{1'000}, + .lateInterestRate = TenthBips32{10'000}, // 10% + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .nextPaymentDueDate = 4'000, + .expectedLateInterest = Number{0}, + }, + { + .name = "No principal outstanding", + .principalOutstanding = Number{0}, + .lateInterestRate = TenthBips32{10'000}, // 10% + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .nextPaymentDueDate = 2'000, + .expectedLateInterest = Number{0}, + }, + { + .name = "No late interest rate", + .principalOutstanding = Number{1'000}, + .lateInterestRate = TenthBips32{0}, // 0% + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .nextPaymentDueDate = 2'000, + .expectedLateInterest = Number{0}, + }, + { + .name = "Late payment", + .principalOutstanding = Number{1'000}, + .lateInterestRate = TenthBips32{100'000}, // 100% + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .nextPaymentDueDate = 2'000, + .expectedLateInterest = + Number{3170979198376459, -17}, // from calc + }, + }; + + for (auto const& tc : testCases) + { + testcase("loanLatePaymentInterest: " + tc.name); + + auto const computedLateInterest = loanLatePaymentInterest( + tc.principalOutstanding, + tc.lateInterestRate, + tc.parentCloseTime, + tc.nextPaymentDueDate); + BEAST_EXPECTS( + computedLateInterest == tc.expectedLateInterest, + "Late interest mismatch: expected " + + to_string(tc.expectedLateInterest) + ", got " + + to_string(computedLateInterest)); + } + } + + void + testLoanAccruedInterest() + { + using namespace jtx; + using namespace xrpl::detail; + struct TestCase + { + std::string name; + Number principalOutstanding; + Number periodicRate; + NetClock::time_point parentCloseTime; + std::uint32_t startDate; + std::uint32_t prevPaymentDate; + std::uint32_t paymentInterval; + Number expectedAccruedInterest; + }; + + auto const testCases = std::vector{ + { + .name = "Zero principal outstanding", + .principalOutstanding = Number{0}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .startDate = 2'000, + .prevPaymentDate = 2'500, + .paymentInterval = 30 * 24 * 60 * 60, + .expectedAccruedInterest = Number{0}, + }, + { + .name = "Before start date", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{1'000}}, + .startDate = 2'000, + .prevPaymentDate = 1'500, + .paymentInterval = 30 * 24 * 60 * 60, + .expectedAccruedInterest = Number{0}, + }, + { + .name = "Zero periodic rate", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{0}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .startDate = 2'000, + .prevPaymentDate = 2'500, + .paymentInterval = 30 * 24 * 60 * 60, + .expectedAccruedInterest = Number{0}, + }, + { + .name = "Zero payment interval", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .startDate = 2'000, + .prevPaymentDate = 2'500, + .paymentInterval = 0, + .expectedAccruedInterest = Number{0}, + }, + { + .name = "Standard case", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .startDate = 1'000, + .prevPaymentDate = 2'000, + .paymentInterval = 30 * 24 * 60 * 60, + .expectedAccruedInterest = + Number{1929012345679012, -17}, // from calc + }, + }; + + for (auto const& tc : testCases) + { + testcase("loanAccruedInterest: " + tc.name); + + auto const computedAccruedInterest = loanAccruedInterest( + tc.principalOutstanding, + tc.periodicRate, + tc.parentCloseTime, + tc.startDate, + tc.prevPaymentDate, + tc.paymentInterval); + BEAST_EXPECTS( + computedAccruedInterest == tc.expectedAccruedInterest, + "Accrued interest mismatch: expected " + + to_string(tc.expectedAccruedInterest) + ", got " + + to_string(computedAccruedInterest)); + } + } + + // This test overlaps with testLoanAccruedInterest, the test cases only + // exercise the computeFullPaymentInterest parts unique to it. + void + testComputeFullPaymentInterest() + { + using namespace jtx; + using namespace xrpl::detail; + + struct TestCase + { + std::string name; + Number rawPrincipalOutstanding; + Number periodicRate; + NetClock::time_point parentCloseTime; + std::uint32_t paymentInterval; + std::uint32_t prevPaymentDate; + std::uint32_t startDate; + TenthBips32 closeInterestRate; + Number expectedFullPaymentInterest; + }; + + auto const testCases = std::vector{ + { + .name = "Zero principal outstanding", + .rawPrincipalOutstanding = Number{0}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .paymentInterval = 30 * 24 * 60 * 60, + .prevPaymentDate = 2'000, + .startDate = 1'000, + .closeInterestRate = TenthBips32{10'000}, + .expectedFullPaymentInterest = Number{0}, + }, + { + .name = "Zero close interest rate", + .rawPrincipalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .paymentInterval = 30 * 24 * 60 * 60, + .prevPaymentDate = 2'000, + .startDate = 1'000, + .closeInterestRate = TenthBips32{0}, + .expectedFullPaymentInterest = + Number{1929012345679012, -17}, // from calc + }, + { + .name = "Standard case", + .rawPrincipalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .paymentInterval = 30 * 24 * 60 * 60, + .prevPaymentDate = 2'000, + .startDate = 1'000, + .closeInterestRate = TenthBips32{10'000}, + .expectedFullPaymentInterest = + Number{1000192901234568, -13}, // from calc + }, + }; + + for (auto const& tc : testCases) + { + testcase("computeFullPaymentInterest: " + tc.name); + + auto const computedFullPaymentInterest = computeFullPaymentInterest( + tc.rawPrincipalOutstanding, + tc.periodicRate, + tc.parentCloseTime, + tc.paymentInterval, + tc.prevPaymentDate, + tc.startDate, + tc.closeInterestRate); + BEAST_EXPECTS( + computedFullPaymentInterest == tc.expectedFullPaymentInterest, + "Full payment interest mismatch: expected " + + to_string(tc.expectedFullPaymentInterest) + ", got " + + to_string(computedFullPaymentInterest)); + } + } + + void + testTryOverpaymentNoInterestNoFee() + { + // This test ensures that overpayment with no interest works correctly. + testcase("tryOverpayment - No Interest No Fee"); + + using namespace jtx; + using namespace xrpl::detail; + + Env env{*this}; + Account const issuer{"issuer"}; + PrettyAsset const asset = issuer["USD"]; + std::int32_t const loanScale = -5; + TenthBips16 const managementFeeRate{0}; // 0% + TenthBips32 const loanInterestRate{0}; // 0% + Number const loanPrincipal{1'000}; + std::uint32_t const paymentInterval = 30 * 24 * 60 * 60; + std::uint32_t const paymentsRemaining = 10; + auto const periodicRate = + loanPeriodicRate(loanInterestRate, paymentInterval); + Number const overpaymentAmount{50}; + + ExtendedPaymentComponents const overpaymentComponents = + computeOverpaymentComponents( + asset, + loanScale, + overpaymentAmount, + TenthBips32(0), + TenthBips32(0), + managementFeeRate); + + auto const loanProperites = computeLoanProperties( + asset, + loanPrincipal, + loanInterestRate, + paymentInterval, + paymentsRemaining, + managementFeeRate, + loanScale); + + Number const periodicPayment = loanProperites.periodicPayment; + + auto const ret = tryOverpayment( + asset, + loanScale, + overpaymentComponents, + loanProperites.loanState, + periodicPayment, + periodicRate, + paymentsRemaining, + managementFeeRate, + env.journal); + + BEAST_EXPECT(ret); + + auto const& [actualPaymentParts, newLoanProperties] = *ret; + auto const& newState = newLoanProperties.loanState; + + // =========== VALIDATE PAYMENT PARTS =========== + BEAST_EXPECTS( + actualPaymentParts.valueChange == 0, + " valueChange mismatch: expected 0, got " + + to_string(actualPaymentParts.valueChange)); + + BEAST_EXPECTS( + actualPaymentParts.feePaid == 0, + " feePaid mismatch: expected 0, got " + + to_string(actualPaymentParts.feePaid)); + + BEAST_EXPECTS( + actualPaymentParts.interestPaid == 0, + " interestPaid mismatch: expected 0, got " + + to_string(actualPaymentParts.interestPaid)); + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == overpaymentAmount, + " principalPaid mismatch: expected " + + to_string(overpaymentAmount) + ", got " + + to_string(actualPaymentParts.principalPaid)); + + // =========== VALIDATE STATE CHANGES =========== + BEAST_EXPECTS( + loanProperites.loanState.interestDue - newState.interestDue == 0, + " interest change mismatch: expected 0, got " + + to_string( + loanProperites.loanState.interestDue - + newState.interestDue)); + + BEAST_EXPECTS( + loanProperites.loanState.managementFeeDue - + newState.managementFeeDue == + 0, + " management fee change mismatch: expected 0, got " + + to_string( + loanProperites.loanState.managementFeeDue - + newState.managementFeeDue)); + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding, + " principalPaid mismatch: expected " + + to_string( + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding) + + ", got " + to_string(actualPaymentParts.principalPaid)); + } + + void + testTryOverpaymentNoInterestOverpaymentFee() + { + testcase("tryOverpayment - No Interest With Overpayment Fee"); + + using namespace jtx; + using namespace xrpl::detail; + + Env env{*this}; + Account const issuer{"issuer"}; + PrettyAsset const asset = issuer["USD"]; + std::int32_t const loanScale = -5; + TenthBips16 const managementFeeRate{0}; // 0% + TenthBips32 const loanInterestRate{0}; // 0% + Number const loanPrincipal{1'000}; + std::uint32_t const paymentInterval = 30 * 24 * 60 * 60; + std::uint32_t const paymentsRemaining = 10; + auto const periodicRate = + loanPeriodicRate(loanInterestRate, paymentInterval); + + ExtendedPaymentComponents const overpaymentComponents = + computeOverpaymentComponents( + asset, + loanScale, + Number{50, 0}, + TenthBips32(0), + TenthBips32(10'000), // 10% overpayment fee + managementFeeRate); + + auto const loanProperites = computeLoanProperties( + asset, + loanPrincipal, + loanInterestRate, + paymentInterval, + paymentsRemaining, + managementFeeRate, + loanScale); + + Number const periodicPayment = loanProperites.periodicPayment; + + auto const ret = tryOverpayment( + asset, + loanScale, + overpaymentComponents, + loanProperites.loanState, + periodicPayment, + periodicRate, + paymentsRemaining, + managementFeeRate, + env.journal); + + BEAST_EXPECT(ret); + + auto const& [actualPaymentParts, newLoanProperties] = *ret; + auto const& newState = newLoanProperties.loanState; + + // =========== VALIDATE PAYMENT PARTS =========== + BEAST_EXPECTS( + actualPaymentParts.valueChange == 0, + " valueChange mismatch: expected 0, got " + + to_string(actualPaymentParts.valueChange)); + + BEAST_EXPECTS( + actualPaymentParts.feePaid == 5, + " feePaid mismatch: expected 5, got " + + to_string(actualPaymentParts.feePaid)); + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == 45, + " principalPaid mismatch: expected 45, got `" + + to_string(actualPaymentParts.principalPaid)); + + BEAST_EXPECTS( + actualPaymentParts.interestPaid == 0, + " interestPaid mismatch: expected 0, got " + + to_string(actualPaymentParts.interestPaid)); + + // =========== VALIDATE STATE CHANGES =========== + // With no Loan interest, interest outstanding should not change + BEAST_EXPECTS( + loanProperites.loanState.interestDue - newState.interestDue == 0, + " interest change mismatch: expected 0, got " + + to_string( + loanProperites.loanState.interestDue - + newState.interestDue)); + + // With no Loan management fee, management fee due should not change + BEAST_EXPECTS( + loanProperites.loanState.managementFeeDue - + newState.managementFeeDue == + 0, + " management fee change mismatch: expected 0, got " + + to_string( + loanProperites.loanState.managementFeeDue - + newState.managementFeeDue)); + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding, + " principalPaid mismatch: expected " + + to_string( + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding) + + ", got " + to_string(actualPaymentParts.principalPaid)); + } + + void + testTryOverpaymentLoanInterestNoOverpaymentFees() + { + testcase("tryOverpayment - Loan Interest, No Overpayment Fees"); + + using namespace jtx; + using namespace xrpl::detail; + + Env env{*this}; + Account const issuer{"issuer"}; + PrettyAsset const asset = issuer["USD"]; + std::int32_t const loanScale = -5; + TenthBips16 const managementFeeRate{0}; // 0% + TenthBips32 const loanInterestRate{10'000}; // 10% + Number const loanPrincipal{1'000}; + std::uint32_t const paymentInterval = 30 * 24 * 60 * 60; + std::uint32_t const paymentsRemaining = 10; + auto const periodicRate = + loanPeriodicRate(loanInterestRate, paymentInterval); + + ExtendedPaymentComponents const overpaymentComponents = + computeOverpaymentComponents( + asset, + loanScale, + Number{50, 0}, + TenthBips32(0), // no overpayment interest + TenthBips32(0), // 0% overpayment fee + managementFeeRate); + + auto const loanProperites = computeLoanProperties( + asset, + loanPrincipal, + loanInterestRate, + paymentInterval, + paymentsRemaining, + managementFeeRate, + loanScale); + + Number const periodicPayment = loanProperites.periodicPayment; + + auto const ret = tryOverpayment( + asset, + loanScale, + overpaymentComponents, + loanProperites.loanState, + periodicPayment, + periodicRate, + paymentsRemaining, + managementFeeRate, + env.journal); + + BEAST_EXPECT(ret); + + auto const& [actualPaymentParts, newLoanProperties] = *ret; + auto const& newState = newLoanProperties.loanState; + + // =========== VALIDATE PAYMENT PARTS =========== + // with no overpayment interest portion, value change should equal + // interest decrease + BEAST_EXPECTS( + (actualPaymentParts.valueChange == Number{-228802, -5}), + " valueChange mismatch: expected " + + to_string(Number{-228802, -5}) + ", got " + + to_string(actualPaymentParts.valueChange)); + + // with no fee portion, fee paid should be zero + BEAST_EXPECTS( + actualPaymentParts.feePaid == 0, + " feePaid mismatch: expected 0, got " + + to_string(actualPaymentParts.feePaid)); + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == 50, + " principalPaid mismatch: expected 50, got `" + + to_string(actualPaymentParts.principalPaid)); + + // with no interest portion, interest paid should be zero + BEAST_EXPECTS( + actualPaymentParts.interestPaid == 0, + " interestPaid mismatch: expected 0, got " + + to_string(actualPaymentParts.interestPaid)); + + // =========== VALIDATE STATE CHANGES =========== + BEAST_EXPECTS( + actualPaymentParts.principalPaid == + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding, + " principalPaid mismatch: expected " + + to_string( + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding) + + ", got " + to_string(actualPaymentParts.principalPaid)); + + BEAST_EXPECTS( + actualPaymentParts.valueChange == + newState.interestDue - loanProperites.loanState.interestDue, + " valueChange mismatch: expected " + + to_string( + newState.interestDue - + loanProperites.loanState.interestDue) + + ", got " + to_string(actualPaymentParts.valueChange)); + + // With no Loan management fee, management fee due should not change + BEAST_EXPECTS( + loanProperites.loanState.managementFeeDue - + newState.managementFeeDue == + 0, + " management fee change mismatch: expected 0, got " + + to_string( + loanProperites.loanState.managementFeeDue - + newState.managementFeeDue)); + } + + void + testTryOverpaymentLoanInterestOverpaymentInterest() + { + testcase( + "tryOverpayment - Loan Interest, Overpayment Interest, No Fee"); + + using namespace jtx; + using namespace xrpl::detail; + + Env env{*this}; + Account const issuer{"issuer"}; + PrettyAsset const asset = issuer["USD"]; + std::int32_t const loanScale = -5; + TenthBips16 const managementFeeRate{0}; // 0% + TenthBips32 const loanInterestRate{10'000}; // 10% + Number const loanPrincipal{1'000}; + std::uint32_t const paymentInterval = 30 * 24 * 60 * 60; + std::uint32_t const paymentsRemaining = 10; + auto const periodicRate = + loanPeriodicRate(loanInterestRate, paymentInterval); + + ExtendedPaymentComponents const overpaymentComponents = + computeOverpaymentComponents( + asset, + loanScale, + Number{50, 0}, + TenthBips32(10'000), // 10% overpayment interest + TenthBips32(0), // 0% overpayment fee + managementFeeRate); + + auto const loanProperites = computeLoanProperties( + asset, + loanPrincipal, + loanInterestRate, + paymentInterval, + paymentsRemaining, + managementFeeRate, + loanScale); + + Number const periodicPayment = loanProperites.periodicPayment; + + auto const ret = tryOverpayment( + asset, + loanScale, + overpaymentComponents, + loanProperites.loanState, + periodicPayment, + periodicRate, + paymentsRemaining, + managementFeeRate, + env.journal); + + BEAST_EXPECT(ret); + + auto const& [actualPaymentParts, newLoanProperties] = *ret; + auto const& newState = newLoanProperties.loanState; + + // =========== VALIDATE PAYMENT PARTS =========== + // with overpayment interest portion, interest paid should be 5 + BEAST_EXPECTS( + actualPaymentParts.interestPaid == 5, + " interestPaid mismatch: expected 5, got " + + to_string(actualPaymentParts.interestPaid)); + + // With overpayment interest portion, value change should equal the + // interest decrease plus overpayment interest portion + BEAST_EXPECTS( + (actualPaymentParts.valueChange == + Number{-205922, -5} + actualPaymentParts.interestPaid), + " valueChange mismatch: expected " + + to_string( + actualPaymentParts.valueChange - + actualPaymentParts.interestPaid) + + ", got " + to_string(actualPaymentParts.valueChange)); + + // with no fee portion, fee paid should be zero + BEAST_EXPECTS( + actualPaymentParts.feePaid == 0, + " feePaid mismatch: expected 0, got " + + to_string(actualPaymentParts.feePaid)); + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == 45, + " principalPaid mismatch: expected 45, got `" + + to_string(actualPaymentParts.principalPaid)); + + // =========== VALIDATE STATE CHANGES =========== + BEAST_EXPECTS( + actualPaymentParts.principalPaid == + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding, + " principalPaid mismatch: expected " + + to_string( + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding) + + ", got " + to_string(actualPaymentParts.principalPaid)); + + // The change in interest is equal to the value change sans the + // overpayment interest + BEAST_EXPECTS( + actualPaymentParts.valueChange - actualPaymentParts.interestPaid == + newState.interestDue - loanProperites.loanState.interestDue, + " valueChange mismatch: expected " + + to_string( + newState.interestDue - + loanProperites.loanState.interestDue + + actualPaymentParts.interestPaid) + + ", got " + to_string(actualPaymentParts.valueChange)); + + // With no Loan management fee, management fee due should not change + BEAST_EXPECTS( + loanProperites.loanState.managementFeeDue - + newState.managementFeeDue == + 0, + " management fee change mismatch: expected 0, got " + + to_string( + loanProperites.loanState.managementFeeDue - + newState.managementFeeDue)); + } + + void + testTryOverpaymentLoanInterestFeeOverpaymentInterestNoFee() + { + testcase( + "tryOverpayment - Loan Interest and Fee, Overpayment Interest, No " + "Fee"); + + using namespace jtx; + using namespace xrpl::detail; + + Env env{*this}; + Account const issuer{"issuer"}; + PrettyAsset const asset = issuer["USD"]; + std::int32_t const loanScale = -5; + TenthBips16 const managementFeeRate{10'000}; // 10% + TenthBips32 const loanInterestRate{10'000}; // 10% + Number const loanPrincipal{1'000}; + std::uint32_t const paymentInterval = 30 * 24 * 60 * 60; + std::uint32_t const paymentsRemaining = 10; + auto const periodicRate = + loanPeriodicRate(loanInterestRate, paymentInterval); + + ExtendedPaymentComponents const overpaymentComponents = + computeOverpaymentComponents( + asset, + loanScale, + Number{50, 0}, + TenthBips32(10'000), // 10% overpayment interest + TenthBips32(0), // 0% overpayment fee + managementFeeRate); + + auto const loanProperites = computeLoanProperties( + asset, + loanPrincipal, + loanInterestRate, + paymentInterval, + paymentsRemaining, + managementFeeRate, + loanScale); + + Number const periodicPayment = loanProperites.periodicPayment; + + auto const ret = tryOverpayment( + asset, + loanScale, + overpaymentComponents, + loanProperites.loanState, + periodicPayment, + periodicRate, + paymentsRemaining, + managementFeeRate, + env.journal); + + BEAST_EXPECT(ret); + + auto const& [actualPaymentParts, newLoanProperties] = *ret; + auto const& newState = newLoanProperties.loanState; + + // =========== VALIDATE PAYMENT PARTS =========== + + // Since there is loan management fee, the fee is charged against + // overpayment interest portion first, so interest paid remains 4.5 + BEAST_EXPECTS( + (actualPaymentParts.interestPaid == Number{45, -1}), + " interestPaid mismatch: expected 4.5, got " + + to_string(actualPaymentParts.interestPaid)); + + // With overpayment interest portion, value change should equal the + // interest decrease plus overpayment interest portion + BEAST_EXPECTS( + (actualPaymentParts.valueChange == + Number{-18533, -4} + actualPaymentParts.interestPaid), + " valueChange mismatch: expected " + + to_string( + Number{-18533, -4} + actualPaymentParts.interestPaid) + + ", got " + to_string(actualPaymentParts.valueChange)); + + // While there is no overpayment fee, fee paid should equal the + // management fee charged against the overpayment interest portion + BEAST_EXPECTS( + (actualPaymentParts.feePaid == Number{5, -1}), + " feePaid mismatch: expected 0.5, got " + + to_string(actualPaymentParts.feePaid)); + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == 45, + " principalPaid mismatch: expected 45, got `" + + to_string(actualPaymentParts.principalPaid)); + + // =========== VALIDATE STATE CHANGES =========== + BEAST_EXPECTS( + actualPaymentParts.principalPaid == + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding, + " principalPaid mismatch: expected " + + to_string( + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding) + + ", got " + to_string(actualPaymentParts.principalPaid)); + + // Note that the management fee value change is not captured, as this + // value is not needed to correctly update the Vault state. + BEAST_EXPECTS( + (newState.managementFeeDue - + loanProperites.loanState.managementFeeDue == + Number{-20592, -5}), + " management fee change mismatch: expected " + + to_string(Number{-20592, -5}) + ", got " + + to_string( + newState.managementFeeDue - + loanProperites.loanState.managementFeeDue)); + + BEAST_EXPECTS( + actualPaymentParts.valueChange - actualPaymentParts.interestPaid == + newState.interestDue - loanProperites.loanState.interestDue, + " valueChange mismatch: expected " + + to_string( + newState.interestDue - + loanProperites.loanState.interestDue) + + ", got " + + to_string( + actualPaymentParts.valueChange - + actualPaymentParts.interestPaid)); + } + + void + testTryOverpaymentLoanInterestFeeOverpaymentInterestFee() + { + testcase( + "tryOverpayment - Loan Interest, Fee, Overpayment Interest, Fee"); + + using namespace jtx; + using namespace xrpl::detail; + + Env env{*this}; + Account const issuer{"issuer"}; + PrettyAsset const asset = issuer["USD"]; + std::int32_t const loanScale = -5; + TenthBips16 const managementFeeRate{10'000}; // 10% + TenthBips32 const loanInterestRate{10'000}; // 10% + Number const loanPrincipal{1'000}; + std::uint32_t const paymentInterval = 30 * 24 * 60 * 60; + std::uint32_t const paymentsRemaining = 10; + auto const periodicRate = + loanPeriodicRate(loanInterestRate, paymentInterval); + + ExtendedPaymentComponents const overpaymentComponents = + computeOverpaymentComponents( + asset, + loanScale, + Number{50, 0}, + TenthBips32(10'000), // 10% overpayment interest + TenthBips32(10'000), // 10% overpayment fee + managementFeeRate); + + auto const loanProperites = computeLoanProperties( + asset, + loanPrincipal, + loanInterestRate, + paymentInterval, + paymentsRemaining, + managementFeeRate, + loanScale); + + Number const periodicPayment = loanProperites.periodicPayment; + + auto const ret = tryOverpayment( + asset, + loanScale, + overpaymentComponents, + loanProperites.loanState, + periodicPayment, + periodicRate, + paymentsRemaining, + managementFeeRate, + env.journal); + + BEAST_EXPECT(ret); + + auto const& [actualPaymentParts, newLoanProperties] = *ret; + auto const& newState = newLoanProperties.loanState; + + // =========== VALIDATE PAYMENT PARTS =========== + + // Since there is loan management fee, the fee is charged against + // overpayment interest portion first, so interest paid remains 4.5 + BEAST_EXPECTS( + (actualPaymentParts.interestPaid == Number{45, -1}), + " interestPaid mismatch: expected 4.5, got " + + to_string(actualPaymentParts.interestPaid)); + + // With overpayment interest portion, value change should equal the + // interest decrease plus overpayment interest portion + BEAST_EXPECTS( + (actualPaymentParts.valueChange == + Number{-164737, -5} + actualPaymentParts.interestPaid), + " valueChange mismatch: expected " + + to_string( + Number{-164737, -5} + actualPaymentParts.interestPaid) + + ", got " + to_string(actualPaymentParts.valueChange)); + + // While there is no overpayment fee, fee paid should equal the + // management fee charged against the overpayment interest portion + BEAST_EXPECTS( + (actualPaymentParts.feePaid == Number{55, -1}), + " feePaid mismatch: expected 5.5, got " + + to_string(actualPaymentParts.feePaid)); + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == 40, + " principalPaid mismatch: expected 40, got `" + + to_string(actualPaymentParts.principalPaid)); + + // =========== VALIDATE STATE CHANGES =========== + + BEAST_EXPECTS( + actualPaymentParts.principalPaid == + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding, + " principalPaid mismatch: expected " + + to_string( + loanProperites.loanState.principalOutstanding - + newState.principalOutstanding) + + ", got " + to_string(actualPaymentParts.principalPaid)); + + // Note that the management fee value change is not captured, as this + // value is not needed to correctly update the Vault state. + BEAST_EXPECTS( + (newState.managementFeeDue - + loanProperites.loanState.managementFeeDue == + Number{-18304, -5}), + " management fee change mismatch: expected " + + to_string(Number{-18304, -5}) + ", got " + + to_string( + newState.managementFeeDue - + loanProperites.loanState.managementFeeDue)); + + BEAST_EXPECTS( + actualPaymentParts.valueChange - actualPaymentParts.interestPaid == + newState.interestDue - loanProperites.loanState.interestDue, + " valueChange mismatch: expected " + + to_string( + newState.interestDue - + loanProperites.loanState.interestDue) + + ", got " + + to_string( + actualPaymentParts.valueChange - + actualPaymentParts.interestPaid)); + } + +public: + void + run() override + { + testTryOverpaymentNoInterestNoFee(); + testTryOverpaymentNoInterestOverpaymentFee(); + testTryOverpaymentLoanInterestNoOverpaymentFees(); + testTryOverpaymentLoanInterestOverpaymentInterest(); + testTryOverpaymentLoanInterestFeeOverpaymentInterestNoFee(); + testTryOverpaymentLoanInterestFeeOverpaymentInterestFee(); + + testComputeFullPaymentInterest(); + testLoanAccruedInterest(); + testLoanLatePaymentInterest(); + testLoanPeriodicPayment(); + testLoanPrincipalFromPeriodicPayment(); + testComputeRaisedRate(); + testComputePaymentFactor(); + testComputeOverpaymentComponents(); + testComputeInterestAndFeeParts(); + } +}; + +BEAST_DEFINE_TESTSUITE(LendingHelpers, app, xrpl); + +} // namespace test +} // namespace xrpl diff --git a/src/test/app/LoanBroker_test.cpp b/src/test/app/LoanBroker_test.cpp index 5915ebae91..93be28e9e9 100644 --- a/src/test/app/LoanBroker_test.cpp +++ b/src/test/app/LoanBroker_test.cpp @@ -1024,6 +1024,12 @@ class LoanBroker_test : public beast::unit_test::suite destination(dest), ter(tecFROZEN), THISLINE); + + // preclaim: tecPSEUDO_ACCOUNT + env(coverWithdraw(alice, brokerKeylet.key, asset(10)), + destination(vaultInfo.pseudoAccount), + ter(tecPSEUDO_ACCOUNT), + THISLINE); } if (brokerTest == CoverClawback) @@ -1436,10 +1442,467 @@ class LoanBroker_test : public beast::unit_test::suite }); } + void + testLoanBrokerSetDebtMaximum() + { + testcase("testLoanBrokerSetDebtMaximum"); + using namespace jtx; + using namespace loanBroker; + Account const issuer{"issuer"}; + Account const alice{"alice"}; + Env env(*this); + Vault vault{env}; + + env.fund(XRP(100'000), issuer, alice); + env.close(); + + PrettyAsset const asset = [&]() { + 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(); + + Account const borrower{"borrower"}; + env.fund(XRP(1'000), borrower); + env(loan::set(borrower, brokerKeylet.key, asset(50).value()), + sig(sfCounterpartySignature, alice), + fee(env.current()->fees().base * 2), + THISLINE); + auto const broker = env.le(brokerKeylet); + if (!BEAST_EXPECT(broker)) + return; + + BEAST_EXPECT(broker->at(sfDebtTotal) == 50); + auto debtTotal = broker->at(sfDebtTotal); + + auto tx2 = set(alice, vaultInfo.vaultID); + tx2[sfLoanBrokerID] = to_string(brokerKeylet.key); + tx2[sfDebtMaximum] = debtTotal - 1; + env(tx2, ter(tecLIMIT_EXCEEDED), THISLINE); + + tx2[sfDebtMaximum] = debtTotal + 1; + env(tx2, ter(tesSUCCESS), THISLINE); + + tx2[sfDebtMaximum] = 0; + env(tx2, ter(tesSUCCESS), THISLINE); + } + + void + testRIPD4323() + { + testcase << "RIPD-4323"; + using namespace jtx; + Account const issuer("issuer"); + Account const holder("holder"); + Account const& broker = issuer; + + auto test = [&](auto&& getToken) { + Env env(*this); + + env.fund(XRP(1'000), issuer, holder); + env.close(); + + auto const [token, deposit, err] = getToken(env); + + Vault vault(env); + auto const [tx, keylet] = + vault.create({.owner = broker, .asset = token.asset()}); + env(tx); + env.close(); + + env(vault.deposit( + {.depositor = broker, .id = keylet.key, .amount = deposit}), + ter(err)); + env.close(); + + auto const brokerKeylet = + keylet::loanbroker(broker, env.seq(broker)); + + env(loanBroker::set(broker, keylet.key)); + env.close(); + + env(loanBroker::coverDeposit(broker, brokerKeylet.key, deposit), + ter(err)); + env.close(); + }; + + test([&](Env&) { + // issuer can issue any amount + auto const token = issuer["IOU"]; + return std::make_tuple(token, token(1'000), tesSUCCESS); + }); + std::vector, // max amount + std::uint64_t, // deposit amount + TER>> // expected error + mptTests = { + // issuer can issue up to 2'000 tokens + {2'000, 4'000, 1'000, tesSUCCESS}, + // issuer can issue 500 tokens (250 VaultDeposit + + // 250 LoanBrokerCoverDeposit) + {2'000, 2'500, 250, tesSUCCESS}, + // issuer can issue 500 tokens (250 VaultDeposit + + // 250 LoanBrokerCoverDeposit). MaximumAmount is default. + {maxMPTokenAmount - 500, std::nullopt, 250, tesSUCCESS}, + // issuer can issue 500, and fails on depositing 1'000 + {2'000, 2'500, 1'000, tecINSUFFICIENT_FUNDS}, + // issuer has already issued MaximumAmount + {2'000, 2'000, 1'000, tecINSUFFICIENT_FUNDS}, + // issuer has already issued MaximumAmount. MaximumAmount is + // default. + {maxMPTokenAmount, std::nullopt, 250, tecINSUFFICIENT_FUNDS}, + }; + for (auto const& [pay, max, deposit, err] : mptTests) + { + test([&](Env& env) -> std::tuple { + MPT const token = MPTTester( + {.env = env, + .issuer = issuer, + .holders = {holder}, + .pay = pay, + .flags = MPTDEXFlags, + .maxAmt = max}); + return std::make_tuple(token, token(deposit), err); + }); + } + } + + void + testAMB06_VaultFreezeCheckMissing() + { + testcase << "RIPD-4466 - LoanBrokerSet disallows frozen vaults"; + using namespace jtx; + Env env(*this); + + Account const issuer{"issuer"}, lender{"lender"}, borrower{"borrower"}; + env.fund(XRP(20'000), issuer, lender, borrower); + auto const IOU = issuer["IOU"]; + + Vault vault{env}; + auto [tx, vaultKeylet] = + vault.create({.owner = lender, .asset = IOU.asset()}); + env(tx); + env.close(); + + // Get vault pseudo-account and FREEZE it + auto const vaultSle = env.le(vaultKeylet); + auto const vaultPseudo = vaultSle->at(sfAccount); + auto const vaultPseudoAcct = Account("VaultPseudo", vaultPseudo); + env(trust(issuer, vaultPseudoAcct["IOU"](0), tfSetFreeze)); + + env(loanBroker::set(lender, vaultKeylet.key), ter(tecFROZEN)); + } + + void + testRIPD4274IOU() + { + using namespace jtx; + Account issuer("broker"); + Account broker("issuer"); + Account dest("destination"); + auto const token = issuer["IOU"]; + + enum TrustState { + RequireAuth, + ZeroLimit, + ReachedLimit, + NearLimit, + NoTrustLine, + }; + + auto test = [&](TrustState trustState) { + Env env(*this); + + testcase << "RIPD-4274 IOU with state: " + << static_cast(trustState); + + auto setTrustLine = [&](Account const& acct, TrustState state) { + switch (state) + { + case RequireAuth: + env(trust(issuer, token(0), acct, tfSetfAuth)); + break; + case ZeroLimit: { + auto jv = trust(acct, token(0)); + // set QualityIn so that the trustline is not + // auto-deleted + jv[sfQualityIn] = 10'000'000; + env(jv); + } + break; + case ReachedLimit: { + env(trust(acct, token(1'000))); + env(pay(issuer, acct, token(1'000))); + env.close(); + } + break; + case NearLimit: { + env(trust(acct, token(1'000))); + env(pay(issuer, acct, token(950))); + env.close(); + } + break; + case NoTrustLine: + // don't create a trustline + break; + default: + BEAST_EXPECT(false); + } + env.close(); + }; + + env.fund(XRP(1'000), issuer, broker, dest); + env.close(); + + if (trustState == RequireAuth) + { + env(fset(issuer, asfRequireAuth)); + env.close(); + + setTrustLine(broker, RequireAuth); + } + + setTrustLine(dest, trustState); + + env(trust(broker, token(2'000), 0)); + env(pay(issuer, broker, token(2'000))); + env.close(); + + Vault vault(env); + auto const [tx, keylet] = + vault.create({.owner = broker, .asset = token.asset()}); + env(tx); + env.close(); + + // Test Vault withdraw + env(vault.deposit( + {.depositor = broker, + .id = keylet.key, + .amount = token(1'000)})); + env.close(); + + env(vault.withdraw( + {.depositor = broker, + .id = keylet.key, + .amount = token(1'000)}), + loanBroker::destination(dest), + ter(std::ignore)); + BEAST_EXPECT(env.ter() == tecNO_LINE); + env.close(); + + env(vault.withdraw( + {.depositor = broker, + .id = keylet.key, + .amount = token(1'000)})); + + // Test LoanBroker withdraw + auto const brokerKeylet = + keylet::loanbroker(broker, env.seq(broker)); + + env(loanBroker::set(broker, keylet.key)); + env.close(); + + env(loanBroker::coverDeposit( + broker, brokerKeylet.key, token(1'000))); + env.close(); + + env(loanBroker::coverWithdraw(broker, brokerKeylet.key, token(100)), + loanBroker::destination(dest), + ter(std::ignore)); + BEAST_EXPECT(env.ter() == tecNO_LINE); + env.close(); + + // Clearing RequireAuth shouldn't change the result + if (trustState == RequireAuth) + { + env(fclear(issuer, asfRequireAuth)); + env.close(); + + env(loanBroker::coverWithdraw( + broker, brokerKeylet.key, token(100)), + loanBroker::destination(dest), + ter(std::ignore)); + BEAST_EXPECT(env.ter() == tecNO_LINE); + env.close(); + } + }; + + test(RequireAuth); + test(ZeroLimit); + test(ReachedLimit); + test(NearLimit); + test(NoTrustLine); + } + + void + testRIPD4274MPT() + { + using namespace jtx; + Account issuer("broker"); + Account broker("issuer"); + Account dest("destination"); + + enum MPTState { + RequireAuth, + ReachedMAX, + NoMPT, + }; + + auto test = [&](MPTState MPTState) { + Env env(*this); + + testcase << "RIPD-4274 MPT with state: " + << static_cast(MPTState); + + env.fund(XRP(1'000), issuer, broker, dest); + env.close(); + + auto const maybeToken = [&]() -> std::optional { + switch (MPTState) + { + case RequireAuth: { + auto tester = MPTTester( + {.env = env, + .issuer = issuer, + .holders = {broker, dest}, + .pay = 2'000, + .flags = MPTDEXFlags | tfMPTRequireAuth, + .authHolder = true, + .maxAmt = 5'000}); + // unauthorize dest + tester.authorize( + {.account = issuer, + .holder = dest, + .flags = tfMPTUnauthorize}); + return tester; + } + case ReachedMAX: { + auto tester = MPTTester( + {.env = env, + .issuer = issuer, + .holders = {broker, dest}, + .pay = 2'000, + .flags = MPTDEXFlags, + .maxAmt = 4'000}); + BEAST_EXPECT( + env.balance(issuer, tester) == tester(-4'000)); + return tester; + } + case NoMPT: { + return MPTTester( + {.env = env, + .issuer = issuer, + .holders = {broker}, + .pay = 2'000, + .flags = MPTDEXFlags, + .maxAmt = 4'000}); + } + default: + return std::nullopt; + } + }(); + if (!BEAST_EXPECT(maybeToken)) + return; + + auto const& token = *maybeToken; + + Vault vault(env); + auto const [tx, keylet] = + vault.create({.owner = broker, .asset = token.asset()}); + env(tx); + env.close(); + + // Test Vault withdraw + env(vault.deposit( + {.depositor = broker, + .id = keylet.key, + .amount = token(1'000)})); + env.close(); + + env(vault.withdraw( + {.depositor = broker, + .id = keylet.key, + .amount = token(1'000)}), + loanBroker::destination(dest), + ter(std::ignore)); + + // Shouldn't fail if at MaximumAmount since no new tokens are issued + TER const err = + MPTState == ReachedMAX ? TER(tesSUCCESS) : tecNO_AUTH; + BEAST_EXPECT(env.ter() == err); + env.close(); + + if (err != tesSUCCESS) + { + env(vault.withdraw( + {.depositor = broker, + .id = keylet.key, + .amount = token(1'000)})); + } + + // Test LoanBroker withdraw + auto const brokerKeylet = + keylet::loanbroker(broker, env.seq(broker)); + + env(loanBroker::set(broker, keylet.key)); + env.close(); + + env(loanBroker::coverDeposit( + broker, brokerKeylet.key, token(1'000))); + env.close(); + + env(loanBroker::coverWithdraw(broker, brokerKeylet.key, token(100)), + loanBroker::destination(dest), + ter(std::ignore)); + BEAST_EXPECT(env.ter() == err); + env.close(); + }; + + test(RequireAuth); + test(ReachedMAX); + test(NoMPT); + } + + void + testRIPD4274() + { + testRIPD4274IOU(); + testRIPD4274MPT(); + } + public: void run() override { + testLoanBrokerSetDebtMaximum(); testLoanBrokerCoverDepositNullVault(); testDisabled(); @@ -1451,6 +1914,11 @@ public: testInvalidLoanBrokerSet(); testRequireAuth(); + testRIPD4323(); + testAMB06_VaultFreezeCheckMissing(); + + testRIPD4274(); + // TODO: Write clawback failure tests with an issuer / MPT that doesn't // have the right flags set. } diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 7c2e83aa19..e4f5360043 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -11,6 +11,8 @@ #include #include +#include + namespace xrpl { namespace test { @@ -141,7 +143,7 @@ protected: using namespace jtx; auto const vaultSle = env.le(keylet::vault(vaultID)); - return getVaultScale(vaultSle); + return getAssetsTotalScale(vaultSle); } }; @@ -372,7 +374,7 @@ protected: if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) { env.test.BEAST_EXPECT( - loan->at(sfPreviousPaymentDate) == previousPaymentDate); + loan->at(sfPreviousPaymentDueDate) == previousPaymentDate); env.test.BEAST_EXPECT( loan->at(sfPaymentRemaining) == paymentRemaining); env.test.BEAST_EXPECT( @@ -507,7 +509,7 @@ protected: if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) { return LoanState{ - .previousPaymentDate = loan->at(sfPreviousPaymentDate), + .previousPaymentDate = loan->at(sfPreviousPaymentDueDate), .startDate = tp{d{loan->at(sfStartDate)}}, .nextPaymentDate = loan->at(sfNextPaymentDueDate), .paymentRemaining = loan->at(sfPaymentRemaining), @@ -551,12 +553,15 @@ protected: broker.vaultScale(env), state.principalOutstanding.exponent()))); BEAST_EXPECT(state.paymentInterval == 600); - BEAST_EXPECT( - state.totalValue == - roundToAsset( - broker.asset, - state.periodicPayment * state.paymentRemaining, - state.loanScale)); + { + NumberRoundModeGuard mg(Number::upward); + BEAST_EXPECT( + state.totalValue == + roundToAsset( + broker.asset, + state.periodicPayment * state.paymentRemaining, + state.loanScale)); + } BEAST_EXPECT( state.managementFeeOutstanding == computeManagementFee( @@ -589,7 +594,7 @@ protected: auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + state.totalValue - state.managementFeeOutstanding; - if (unrealizedLoss > assetsUnavailable) + if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable)) { return false; } @@ -705,8 +710,9 @@ protected: << "\tManagement Fee Rate: " << feeRate << std::endl << "\tTotal Payments: " << total << std::endl << "\tPeriodic Payment: " << props.periodicPayment << std::endl - << "\tTotal Value: " << props.totalValueOutstanding << std::endl - << "\tManagement Fee: " << props.managementFeeOwedToBroker + << "\tTotal Value: " << props.loanState.valueOutstanding + << std::endl + << "\tManagement Fee: " << props.loanState.managementFeeDue << std::endl << "\tLoan Scale: " << props.loanScale << std::endl << "\tFirst payment principal: " << props.firstPaymentPrincipal @@ -856,9 +862,6 @@ protected: using namespace std::chrono_literals; using d = NetClock::duration; - // Account const evan{"evan"}; - // Account const alice{"alice"}; - bool const showStepBalances = paymentParams.showStepBalances; auto const currencyLabel = getCurrencyLabel(broker.asset); @@ -911,7 +914,7 @@ protected: state.principalOutstanding, state.managementFeeOutstanding); { - auto const raw = computeRawLoanState( + auto const raw = computeTheoreticalLoanState( state.periodicPayment, periodicRate, state.paymentRemaining, @@ -964,7 +967,7 @@ protected: Number totalFeesPaid = 0; std::size_t totalPaymentsMade = 0; - xrpl::LoanState currentTrueState = computeRawLoanState( + xrpl::LoanState currentTrueState = computeTheoreticalLoanState( state.periodicPayment, periodicRate, state.paymentRemaining, @@ -1019,7 +1022,7 @@ protected: paymentComponents.trackedInterestPart() + paymentComponents.trackedManagementFeeDelta); - xrpl::LoanState const nextTrueState = computeRawLoanState( + xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( state.periodicPayment, periodicRate, state.paymentRemaining - 1, @@ -1271,7 +1274,8 @@ protected: verifyLoanStatus, issuer, lender, - borrower); + borrower, + PaymentParameters{.showStepBalances = true}); } /** Runs through the complete lifecycle of a loan @@ -1452,7 +1456,7 @@ protected: BEAST_EXPECT( loan->at(sfPaymentInterval) == *loanParams.payInterval); BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd); - BEAST_EXPECT(loan->at(sfPreviousPaymentDate) == 0); + BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == 0); BEAST_EXPECT( loan->at(sfNextPaymentDueDate) == startDate + *loanParams.payInterval); @@ -1484,9 +1488,9 @@ protected: startDate + *loanParams.payInterval, *loanParams.payTotal, state.loanScale, - loanProperties.totalValueOutstanding, + loanProperties.loanState.valueOutstanding, principalRequestAmount, - loanProperties.managementFeeOwedToBroker, + loanProperties.loanState.managementFeeDue, loanProperties.periodicPayment, loanFlags | 0); @@ -1541,9 +1545,9 @@ protected: nextDueDate, *loanParams.payTotal, loanProperties.loanScale, - loanProperties.totalValueOutstanding, + loanProperties.loanState.valueOutstanding, principalRequestAmount, - loanProperties.managementFeeOwedToBroker, + loanProperties.loanState.managementFeeDue, loanProperties.periodicPayment, loanFlags | 0); @@ -2448,13 +2452,18 @@ protected: // Make all the payments in one transaction // service fee is 2 auto const startingPayments = state.paymentRemaining; - auto const rawPayoff = startingPayments * - (state.periodicPayment + broker.asset(2).value()); - STAmount const payoffAmount{broker.asset, rawPayoff}; - BEAST_EXPECT( - payoffAmount == - broker.asset(Number(1024014840139457, -12))); - BEAST_EXPECT(payoffAmount > state.principalOutstanding); + STAmount const payoffAmount = [&]() { + NumberRoundModeGuard mg(Number::upward); + auto const rawPayoff = startingPayments * + (state.periodicPayment + broker.asset(2).value()); + STAmount const payoffAmount{broker.asset, rawPayoff}; + BEAST_EXPECTS( + payoffAmount == + broker.asset(Number(1024014840139457, -12)), + to_string(payoffAmount)); + BEAST_EXPECT(payoffAmount > state.principalOutstanding); + return payoffAmount; + }(); singlePayment( loanKeylet, @@ -2662,7 +2671,7 @@ protected: Number::upward)); { - auto const raw = computeRawLoanState( + auto const raw = computeTheoreticalLoanState( state.periodicPayment, periodicRate, state.paymentRemaining, @@ -2705,7 +2714,7 @@ protected: Number totalInterestPaid = 0; std::size_t totalPaymentsMade = 0; - xrpl::LoanState currentTrueState = computeRawLoanState( + xrpl::LoanState currentTrueState = computeTheoreticalLoanState( state.periodicPayment, periodicRate, state.paymentRemaining, @@ -2730,11 +2739,12 @@ protected: paymentComponents.trackedValueDelta <= roundedPeriodicPayment); - xrpl::LoanState const nextTrueState = computeRawLoanState( - state.periodicPayment, - periodicRate, - state.paymentRemaining - 1, - broker.params.managementFeeRate); + xrpl::LoanState const nextTrueState = + computeTheoreticalLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining - 1, + broker.params.managementFeeRate); detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; @@ -3453,11 +3463,12 @@ protected: ter{tecNO_AUTH}); env.close(); - // Can create loan without origination fee + // Cannot create loan, even without an origination fee env(set(borrower, broker.brokerID, principalRequest), counterparty(lender), sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5)); + fee(env.current()->fees().base * 5), + ter{tecNO_AUTH}); env.close(); // No MPToken for lender - no authorization and no payment @@ -3578,6 +3589,52 @@ protected: fee(env.current()->fees().base * 5)); }, CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + Vault vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit; + env(tx); + env.close(); + + testcase("Vault at maximum value"); + env(set(issuer, broker.brokerID, principalRequest), + counterparty(lender), + interestRate(TenthBips32(10'000)), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + ter(tecLIMIT_EXCEEDED), + THISLINE); + }, + nullptr); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + Vault vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = + BrokerParameters::defaults().vaultDeposit + + broker.asset(1).number(); + env(tx); + env.close(); + + testcase("Vault maximum value exceeded"); + env(set(issuer, broker.brokerID, principalRequest), + counterparty(lender), + interestRate(TenthBips32(100'000)), + sig(sfCounterpartySignature, lender), + fee(env.current()->fees().base * 5), + paymentTotal(2), + paymentInterval(3600 * 24), + ter(tecLIMIT_EXCEEDED), + THISLINE); + }, + nullptr); } void @@ -3813,7 +3870,7 @@ protected: BEAST_EXPECT(loan[sfPaymentInterval] == 60); BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000"); BEAST_EXPECT(loan[sfPaymentRemaining] == 1); - BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDate)); + BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDueDate)); BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000"); BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000"); BEAST_EXPECT(!loan.isMember(sfLoanScale)); @@ -3994,7 +4051,6 @@ protected: createJson["CloseInterestRate"] = 55374; createJson["ClosePaymentFee"] = "3825205248"; - createJson["GracePeriod"] = 0; createJson["LatePaymentFee"] = "237"; createJson["LoanOriginationFee"] = "0"; createJson["OverpaymentFee"] = 35167; @@ -4009,7 +4065,7 @@ protected: createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); // Fails in preclaim because principal requested can't be // represented as XRP - env(createJson, ter(tecPRECISION_LOSS)); + env(createJson, ter(tecPRECISION_LOSS), THISLINE); env.close(); BEAST_EXPECT(!env.le(keylet)); @@ -4021,7 +4077,7 @@ protected: createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); // Fails in doApply because the payment is too small to be // represented as XRP. - env(createJson, ter(tecPRECISION_LOSS)); + env(createJson, ter(tecPRECISION_LOSS), THISLINE); env.close(); } @@ -4455,15 +4511,6 @@ protected: }; } - void - testBasicMath() - { - // Test the functions defined in LendingHelpers.h - testcase("Basic Math"); - - pass(); - } - void testIssuerLoan() { @@ -4679,7 +4726,30 @@ protected: jtx::fee const& loanSetFee, Number const& debtMaximumRequest) { // first temBAD_SIGNER: TODO + // invalid grace period + { + // zero grace period + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + gracePeriod(0), + loanSetFee, + ter(temINVALID)); + // grace period less than default minimum + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + gracePeriod(LoanSet::defaultGracePeriod - 1), + loanSetFee, + ter(temINVALID)); + + // grace period greater than payment interval + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + paymentInterval(120), + gracePeriod(121), + loanSetFee, + ter(temINVALID)); + } // empty/zero broker ID { auto jv = set(borrower, uint256{}, debtMaximumRequest); @@ -4980,7 +5050,6 @@ protected: createJson["CloseInterestRate"] = 47299; createJson["ClosePaymentFee"] = "3985819770"; - createJson["GracePeriod"] = 0; createJson["InterestRate"] = 92; createJson["LatePaymentFee"] = "3866894865"; createJson["LoanOriginationFee"] = "0"; @@ -4996,7 +5065,7 @@ protected: auto const keylet = keylet::loan(broker.brokerID, loanSequence); createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); - env(createJson, ter(tecPRECISION_LOSS)); + env(createJson, ter(tecPRECISION_LOSS), THISLINE); env.close(startDate); auto loanPayTx = env.json( @@ -5133,7 +5202,6 @@ protected: json(sfCounterpartySignature, Json::objectValue)); createJson["ClosePaymentFee"] = "0"; - createJson["GracePeriod"] = 0; createJson["InterestRate"] = 24346; createJson["LateInterestRate"] = 65535; createJson["LatePaymentFee"] = "0"; @@ -5253,7 +5321,6 @@ protected: json(sfCounterpartySignature, Json::objectValue)); createJson["ClosePaymentFee"] = "0"; - createJson["GracePeriod"] = 0; createJson["InterestRate"] = 12833; createJson["LateInterestRate"] = 77048; createJson["LatePaymentFee"] = "0"; @@ -5347,7 +5414,7 @@ protected: set(borrower, broker.brokerID, Number{55524'81, -2}), fee(loanSetFee), closePaymentFee(0), - gracePeriod(0), + gracePeriod(LoanSet::defaultGracePeriod), interestRate(TenthBips32(12833)), lateInterestRate(TenthBips32(77048)), latePaymentFee(0), @@ -5851,7 +5918,7 @@ protected: auto const periodicRate = loanPeriodicRate(interestRateValue, state.paymentInterval); - auto const rawLoanState = computeRawLoanState( + auto const rawLoanState = computeTheoreticalLoanState( state.periodicPayment, periodicRate, state.paymentRemaining, @@ -6029,7 +6096,7 @@ protected: { // --- PoC Summary ---------------------------------------------------- // Scenario: Borrower makes one periodic payment early (before next due) - // so doPayment sets sfPreviousPaymentDate to the (future) + // so doPayment sets sfPreviousPaymentDueDate to the (future) // sfNextPaymentDueDate and advances sfNextPaymentDueDate by one // interval. Borrower then immediately performs a full-payment // (tfLoanFullPayment). Why it matters: Full-payment interest accrual @@ -6144,15 +6211,16 @@ protected: // Accrued + prepayment-penalty interest based on current periodic // schedule auto const fullPaymentInterest = computeFullPaymentInterest( - after.periodicPayment, + detail::loanPrincipalFromPeriodicPayment( + after.periodicPayment, periodicRate2, after.paymentRemaining), periodicRate2, - after.paymentRemaining, env.current()->parentCloseTime(), after.paymentInterval, after.previousPaymentDate, static_cast( after.startDate.time_since_epoch().count()), closeInterestRate); + // Round to asset scale and split interest/fee parts auto const roundedInterest = roundToAsset(asset.raw(), fullPaymentInterest, after.loanScale); @@ -6180,9 +6248,9 @@ protected: // window by clamping prevPaymentDate to 'now' for the full-pay path. auto const prevClamped = std::min(after.previousPaymentDate, nowSecs); auto const fullPaymentInterestClamped = computeFullPaymentInterest( - after.periodicPayment, + detail::loanPrincipalFromPeriodicPayment( + after.periodicPayment, periodicRate2, after.paymentRemaining), periodicRate2, - after.paymentRemaining, env.current()->parentCloseTime(), after.paymentInterval, prevClamped, @@ -6436,8 +6504,7 @@ protected: .lateFee = Number{200, -6}, .interest = TenthBips32{50'000}, .payTotal = 10, - .payInterval = 150, - .gracePd = 0}; + .payInterval = 150}; auto const assetType = AssetType::XRP; @@ -6458,9 +6525,6 @@ protected: auto state = getCurrentState(env, broker, loanKeylet); if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) { - // log << "loan after create: " << to_string(loan->getJson()) - // << std::endl; - env.close(tp{d{ loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}}); } @@ -6475,16 +6539,10 @@ protected: { auto const submitParam = to_string(jv); - // log << "about to submit: " << submitParam << std::endl; auto const jr = env.rpc("submit", borrower.name(), submitParam); - // log << jr << std::endl; BEAST_EXPECT(jr.isMember(jss::result)); auto const jResult = jr[jss::result]; - // BEAST_EXPECT(jResult[jss::error] == "invalidTransaction"); - // BEAST_EXPECT( - // jResult[jss::error_exception] == - // "fails local checks: Transaction has bad signature."); } env.close(); @@ -6520,8 +6578,7 @@ protected: .counter = borrower, .principalRequest = Number{100'000, -4}, .interest = TenthBips32{100'000}, - .payTotal = 10, - .gracePd = 0}; + .payTotal = 10}; auto const assetType = AssetType::MPT; @@ -7007,11 +7064,8 @@ protected: env.close(); PaymentParameters paymentParams{ - //.overpaymentFactor = Number{15, -1}, - //.overpaymentExtra = Number{1, -6}, - //.flags = tfLoanOverpayment, - .showStepBalances = true, - //.validateBalances = false, + .showStepBalances = false, + .validateBalances = true, }; makeLoanPayments( @@ -7026,6 +7080,532 @@ protected: paymentParams); } + void + testOverpaymentManagementFee() + { + testcase("testOverpaymentManagementFee"); + + using namespace jtx; + using namespace loan; + + Env env(*this, all); + + Account const lender{"lender"}, borrower{"borrower"}; + + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + PrettyAsset const asset{xrpIssue(), 1000}; + + auto const result = createVaultAndBroker( + env, + asset, + lender, + { + .vaultDeposit = asset(100'000).value(), + .managementFeeRate = TenthBips16(10'000), + }); + + auto const loanSetFee = fee(env.current()->fees().base * 2); + + auto const loanKeylet = keylet::loan( + result.brokerKeylet().key, + (env.le(result.brokerKeylet()))->at(sfLoanSequence)); + env(loan::set( + borrower, + result.brokerKeylet().key, + asset(10'000).value(), + tfLoanOverpayment), + sig(sfCounterpartySignature, lender), + loan::paymentInterval(86400 * 30), + loan::paymentTotal(3), + loan::overpaymentInterestRate( + TenthBips32(percentageToTenthBips(20))), + loanSetFee); + + // From calculator + auto const expectedOverpaymentManagementFee = Number{33333, 0}; + auto const loanBrokerBalanceBefore = env.balance(lender); + + auto const loanPayFee = fee(env.current()->fees().base * 2); + env(pay(borrower, + loanKeylet.key, + asset(5'000).value(), + tfLoanOverpayment), + loanPayFee); + env.close(); + + BEAST_EXPECTS( + env.balance(lender) - loanBrokerBalanceBefore == + expectedOverpaymentManagementFee, + "overpayment management fee missmatch; expected:" + + to_string(expectedOverpaymentManagementFee) + " got: " + + to_string(env.balance(lender) - loanBrokerBalanceBefore)); + } + + void + testLoanPayBrokerOwnerMissingTrustline() + { + testcase << "LoanPay Broker Owner Missing Trustline (PoC)"; + using namespace jtx; + using namespace loan; + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + auto const IOU = issuer["IOU"]; + Env env(*this, all); + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + // Set up trustlines and fund accounts + env(trust(broker, IOU(20'000'000))); + env(trust(borrower, IOU(20'000'000))); + env(pay(issuer, broker, IOU(10'000'000))); + env(pay(issuer, borrower, IOU(1'000))); + env.close(); + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, IOU, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + sig(sfCounterpartySignature, broker), + loanServiceFee(IOU(100).value()), + paymentInterval(100), + fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = IOU(50'000).value(); + env(loanBroker::coverDeposit( + broker, brokerInfo.brokerID, STAmount{IOU, additionalCover})); + env.close(); + // Verify broker owner has a trustline + auto const brokerTrustline = keylet::line(broker, IOU); + BEAST_EXPECT(env.le(brokerTrustline) != nullptr); + // Broker owner deletes their trustline + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, IOU); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Remove the trustline by setting limit to 0 + env(trust(broker, IOU(0))); + env.close(); + // Verify trustline is deleted + BEAST_EXPECT(env.le(brokerTrustline) == nullptr); + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_LINE. + env(pay(borrower, keylet.key, IOU(10'100)), + fee(XRP(100)), + ter(tesSUCCESS)); + env.close(); + // Verify trustline is still deleted + BEAST_EXPECT(env.le(brokerTrustline) == nullptr); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = + env.le(keylet::loanbroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, IOU); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS( + balance == IOU(51'100), to_string(Json::Value(balance))); + } + } + + void + testLoanPayBrokerOwnerUnauthorizedMPT() + { + testcase << "LoanPay Broker Owner MPT unauthorized"; + using namespace jtx; + using namespace loan; + + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + + Env env(*this, all); + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + + PrettyAsset const MPT{mptt.issuanceID()}; + + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); + + env.close(); + + // Fund accounts + env(pay(issuer, broker, MPT(10'000'000))); + env(pay(issuer, borrower, MPT(1'000))); + env.close(); + + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, MPT, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + sig(sfCounterpartySignature, broker), + loanServiceFee(MPT(100).value()), + paymentInterval(100), + fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = MPT(50'000).value(); + env(loanBroker::coverDeposit( + broker, brokerInfo.brokerID, STAmount{MPT, additionalCover})); + env.close(); + // Verify broker owner is authorized + auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); + BEAST_EXPECT(env.le(brokerMpt) != nullptr); + // Broker owner unauthorizes. + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, MPT); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Then, unauthorize the MPT. + mptt.authorize({.account = broker, .flags = tfMPTUnauthorize}); + env.close(); + // Verify the MPT is unauthorized. + BEAST_EXPECT(env.le(brokerMpt) == nullptr); + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_AUTH. + auto const borrowerBalance = env.balance(borrower, MPT); + env(pay(borrower, keylet.key, MPT(10'100)), + fee(XRP(100)), + ter(tesSUCCESS)); + env.close(); + // Verify the MPT is still unauthorized. + BEAST_EXPECT(env.le(brokerMpt) == nullptr); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = + env.le(keylet::loanbroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, MPT); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS( + balance == MPT(51'100), to_string(Json::Value(balance))); + } + } + + void + testLoanPayBrokerOwnerNoPermissionedDomainMPT() + { + testcase + << "LoanPay Broker Owner without permissioned domain of the MPT"; + using namespace jtx; + using namespace loan; + + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + + Env env(*this, all); + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + auto credType = "credential1"; + + pdomain::Credentials const credentials1{{issuer, credType}}; + env(pdomain::setTx(issuer, credentials1)); + env.close(); + + auto domainID = pdomain::getNewDomain(env.meta()); + + env(credentials::create(broker, issuer, credType)); + env(credentials::accept(broker, issuer, credType)); + env.close(); + + env(credentials::create(borrower, issuer, credType)); + env(credentials::accept(borrower, issuer, credType)); + env.close(); + + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create({ + .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | + tfMPTCanLock, + .domainID = domainID, + }); + + PrettyAsset const MPT{mptt.issuanceID()}; + + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); + + env.close(); + + // Fund accounts + env(pay(issuer, broker, MPT(10'000'000))); + env(pay(issuer, borrower, MPT(1'000))); + env.close(); + + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, MPT, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + sig(sfCounterpartySignature, broker), + loanServiceFee(MPT(100).value()), + paymentInterval(100), + fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = MPT(50'000).value(); + env(loanBroker::coverDeposit( + broker, brokerInfo.brokerID, STAmount{MPT, additionalCover})); + env.close(); + // Verify broker owner is authorized + auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); + BEAST_EXPECT(env.le(brokerMpt) != nullptr); + // Remove the credentials for the Broker owner. + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, MPT); + env(pay(broker, issuer, brokerBalance)); + env.close(); + + env(credentials::deleteCred(broker, broker, issuer, credType)); + env.close(); + + // Make sure the broker is not authorized to hold the MPT after we + // deleted the credentials + env(pay(issuer, broker, MPT(1'000)), ter(tecNO_AUTH)); + + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_AUTH. + auto const borrowerBalance = env.balance(borrower, MPT); + env(pay(borrower, keylet.key, MPT(10'100)), + fee(XRP(100)), + ter(tesSUCCESS)); + env.close(); + // Verify broker is still not authorized + env(pay(issuer, broker, MPT(1'000)), ter(tecNO_AUTH)); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = + env.le(keylet::loanbroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, MPT); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS( + balance == MPT(51'100), to_string(Json::Value(balance))); + } + } + + void + testLoanSetBrokerOwnerNoPermissionedDomainMPT() + { + testcase + << "LoanSet Broker Owner without permissioned domain of the MPT"; + using namespace jtx; + using namespace loan; + + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + + Env env(*this, all); + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + auto credType = "credential1"; + + pdomain::Credentials const credentials1{{issuer, credType}}; + env(pdomain::setTx(issuer, credentials1)); + env.close(); + + auto domainID = pdomain::getNewDomain(env.meta()); + + // Add credentials for the broker and borrower + env(credentials::create(broker, issuer, credType)); + env(credentials::accept(broker, issuer, credType)); + env.close(); + + env(credentials::create(borrower, issuer, credType)); + env(credentials::accept(borrower, issuer, credType)); + env.close(); + + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create({ + .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | + tfMPTCanLock, + .domainID = domainID, + }); + + PrettyAsset const MPT{mptt.issuanceID()}; + + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); + env.close(); + + // Fund accounts + env(pay(issuer, broker, MPT(10'000'000))); + env(pay(issuer, borrower, MPT(1'000))); + env.close(); + + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, MPT, broker); + + // Remove the credentials for the Broker owner. + // Clear the balance first. + auto const brokerBalance = env.balance(broker, MPT); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Delete the credentials + env(credentials::deleteCred(broker, broker, issuer, credType)); + env.close(); + + // Create a loan, this should fail for tecNO_AUTH + env(set(borrower, brokerInfo.brokerID, 10'000), + sig(sfCounterpartySignature, broker), + loanServiceFee(MPT(100).value()), + paymentInterval(100), + fee(XRP(100)), + ter(tecNO_AUTH)); + env.close(); + } + + void + testSequentialFLCDepletion() + { + testcase << "First-Loss Capital Depletion on Sequential Defaults"; + + using namespace jtx; + using namespace loan; + using namespace loanBroker; + + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrowerA{"borrowerA"}; + Account const borrowerB{"borrowerB"}; + + env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB); + env.close(); + + PrettyAsset const asset = xrpIssue(); + auto const vaultDepositAmount = + asset(200'000); // Enough for 2 x 50k loans plus interest/fees + + auto const brokerInfo = createVaultAndBroker( + env, + asset, + lender, + { + .vaultDeposit = vaultDepositAmount.value(), + .debtMax = 0, + .coverRateMin = TenthBips32(20000), // 20% + .coverDeposit = 21'000, + .managementFeeRate = TenthBips16(100), // 0.1% + .coverRateLiquidation = TenthBips32(100000), + }); + auto const brokerKeylet = brokerInfo.brokerKeylet(); + + // Create two identical loans: each 50,000 XRP principal (scaled down to + // avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal + // + interest) Formula will calculate cover as: 100% × (20% × 100,000) = + // 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first + // default + auto const principalAmount = Number(50'000); + auto const loanPaymentInterval = 2592000; // 30 days + auto const loanGracePeriod = 604800; // 7 days + + // Create Loan A + auto loanATx = env.jt( + set(borrowerA, brokerKeylet.key, principalAmount), + sig(sfCounterpartySignature, lender), + interestRate(TenthBips32(500)), // 5% + paymentTotal(12), + loan::paymentInterval(loanPaymentInterval), + loan::gracePeriod(loanGracePeriod), + fee(XRP(10))); // Sufficient fee for multi-sig transaction + env(loanATx); + env.close(); + + auto const loanAKeylet = keylet::loan(brokerKeylet.key, 1); + + // Create Loan B + auto loanBTx = env.jt( + set(borrowerB, brokerKeylet.key, principalAmount), + sig(sfCounterpartySignature, lender), + interestRate(TenthBips32(500)), // 5% + paymentTotal(12), + loan::paymentInterval(loanPaymentInterval), + loan::gracePeriod(loanGracePeriod), + fee(XRP(10))); // Sufficient fee for multi-sig transaction + env(loanBTx); + env.close(); + + auto const loanBKeylet = keylet::loan(brokerKeylet.key, 2); + + auto loanASle = env.le(loanAKeylet); + if (!BEAST_EXPECT(loanASle)) + return; + + // Advance time past grace period for both loans to be defaultable + auto const loanANextDue = loanASle->at(sfNextPaymentDueDate); + auto const loanAGrace = loanASle->at(sfGracePeriod); + env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60}); + + env(manage(lender, loanAKeylet.key, tfLoanDefault), ter(tesSUCCESS)); + env.close(); + + // Verify Loan A is defaulted + loanASle = env.le(loanAKeylet); + if (!BEAST_EXPECT(loanASle)) + return; + BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault)); + BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0); + + // Check broker state after first default (from committed ledger) + auto brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return; + auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal); + auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable); + + // DebtTotal should have decreased by Loan A's debt + BEAST_EXPECT(afterFirstDebtTotal == 50'134); + + // CoverAvailable should have decreased significantly + BEAST_EXPECT(afterFirstCoverAvailable == 946); + + env(manage(lender, loanBKeylet.key, tfLoanDefault), ter(tesSUCCESS)); + + brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return; + auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal); + auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable); + + BEAST_EXPECT(afterSecondDebtTotal == 0); + + BEAST_EXPECT(afterSecondCoverAvailable == 0); + } + public: void run() override @@ -7034,6 +7614,8 @@ public: testLoanPayLateFullPaymentBypassesPenalties(); testLoanCoverMinimumRoundingExploit(); #endif + testInvalidLoanSet(); + testCoverDepositWithdrawNonTransferableMPT(); testPoC_UnsignedUnderflowOnFullPayAfterEarlyPeriodic(); @@ -7045,12 +7627,9 @@ public: testServiceFeeOnBrokerDeepFreeze(); testRPC(); - testBasicMath(); - testInvalidLoanDelete(); testInvalidLoanManage(); testInvalidLoanPay(); - testInvalidLoanSet(); testBatchBypassCounterparty(); testLoanPayComputePeriodicPaymentValidRateInvariant(); @@ -7074,6 +7653,12 @@ public: testBorrowerIsBroker(); testIssuerIsBorrower(); testLimitExceeded(); + testOverpaymentManagementFee(); + testLoanPayBrokerOwnerMissingTrustline(); + testLoanPayBrokerOwnerUnauthorizedMPT(); + testLoanPayBrokerOwnerNoPermissionedDomainMPT(); + testLoanSetBrokerOwnerNoPermissionedDomainMPT(); + testSequentialFLCDepletion(); } }; @@ -7193,15 +7778,15 @@ class LoanArbitrary_test : public LoanBatch_test .vaultDeposit = 10000, .debtMax = 0, .coverRateMin = TenthBips32{0}, - // .managementFeeRate = TenthBips16{5919}, + .managementFeeRate = TenthBips16{0}, .coverRateLiquidation = TenthBips32{0}}; LoanParameters const loanParams{ .account = Account("lender"), .counter = Account("borrower"), - .principalRequest = Number{10000, 0}, - // .interest = TenthBips32{0}, - // .payTotal = 5816, - .payInterval = 150}; + .principalRequest = Number{200000, -6}, + .interest = TenthBips32{50000}, + .payTotal = 2, + .payInterval = 200}; runLoan(AssetType::XRP, brokerParams, loanParams); } diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index d0a1450d6c..a6d08b6531 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -5324,7 +5324,7 @@ class Vault_test : public beast::unit_test::suite // Create a simple Loan for the full amount of Vault assets env(set(depositor, brokerKeylet.key, asset(100).value()), loan::interestRate(TenthBips32(0)), - gracePeriod(10), + gracePeriod(60), paymentInterval(120), paymentTotal(10), sig(sfCounterpartySignature, owner), @@ -5344,7 +5344,7 @@ class Vault_test : public beast::unit_test::suite THISLINE); env.close(); - env.close(std::chrono::seconds{120 + 10}); + env.close(std::chrono::seconds{120 + 60}); env(manage(owner, loanKeylet.key, tfLoanDefault), ter(tesSUCCESS), diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index adffa8548a..ceb60eb319 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -644,7 +644,7 @@ MPTTester::operator[](std::string const& name) const } PrettyAmount -MPTTester::operator()(std::uint64_t amount) const +MPTTester::operator()(std::int64_t amount) const { return MPT("", issuanceID())(amount); } diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index 2f6bbb9ea8..3eea362b58 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -272,7 +272,7 @@ public: operator[](std::string const& name) const; PrettyAmount - operator()(std::uint64_t amount) const; + operator()(std::int64_t amount) const; operator Asset() const; diff --git a/src/xrpld/app/misc/LendingHelpers.h b/src/xrpld/app/misc/LendingHelpers.h index 071466f05c..79fc617569 100644 --- a/src/xrpld/app/misc/LendingHelpers.h +++ b/src/xrpld/app/misc/LendingHelpers.h @@ -84,50 +84,10 @@ struct LoanPaymentParts operator==(LoanPaymentParts const& other) const; }; -/* Describes the initial computed properties of a loan. - * - * This structure contains the fundamental calculated values that define a - * loan's payment structure and amortization schedule. These properties are - * computed: - * - At loan creation (LoanSet transaction) - * - When loan terms change (e.g., after an overpayment that reduces the loan - * balance) - */ -struct LoanProperties -{ - // The unrounded amount to be paid at each regular payment period. - // Calculated using the standard amortization formula based on principal, - // interest rate, and number of payments. - // The actual amount paid in the LoanPay transaction must be rounded up to - // the precision of the asset and loan. - Number periodicPayment; - - // The total amount the borrower will pay over the life of the loan. - // Equal to periodicPayment * paymentsRemaining. - // This includes principal, interest, and management fees. - Number totalValueOutstanding; - - // The total management fee that will be paid to the broker over the - // loan's lifetime. This is a percentage of the total interest (gross) - // as specified by the broker's management fee rate. - Number managementFeeOwedToBroker; - - // The scale (decimal places) used for rounding all loan amounts. - // This is the maximum of: - // - The asset's native scale - // - A minimum scale required to represent the periodic payment accurately - // All loan state values (principal, interest, fees) are rounded to this - // scale. - std::int32_t loanScale; - - // The principal portion of the first payment. - Number firstPaymentPrincipal; -}; - /** This structure captures the parts of a loan state. * - * Whether the values are raw (unrounded) or rounded will depend on how it was - * computed. + * Whether the values are theoretical (unrounded) or rounded will depend on how + * it was computed. * * Many of the fields can be derived from each other, but they're all provided * here to reduce code duplication and possible mistakes. @@ -161,6 +121,39 @@ struct LoanState } }; +/* Describes the initial computed properties of a loan. + * + * This structure contains the fundamental calculated values that define a + * loan's payment structure and amortization schedule. These properties are + * computed: + * - At loan creation (LoanSet transaction) + * - When loan terms change (e.g., after an overpayment that reduces the loan + * balance) + */ +struct LoanProperties +{ + // The unrounded amount to be paid at each regular payment period. + // Calculated using the standard amortization formula based on principal, + // interest rate, and number of payments. + // The actual amount paid in the LoanPay transaction must be rounded up to + // the precision of the asset and loan. + Number periodicPayment; + + // The loan's current state, with all values rounded to the loan's scale. + LoanState loanState; + + // The scale (decimal places) used for rounding all loan amounts. + // This is the maximum of: + // - The asset's native scale + // - A minimum scale required to represent the periodic payment accurately + // All loan state values (principal, interest, fees) are rounded to this + // scale. + std::int32_t loanScale; + + // The principal portion of the first payment. + Number firstPaymentPrincipal; +}; + // Some values get re-rounded to the vault scale any time they are adjusted. In // addition, they are prevented from ever going below zero. This helps avoid // accumulated rounding errors and leftover dust amounts. @@ -179,11 +172,12 @@ adjustImpreciseNumber( } inline int -getVaultScale(SLE::const_ref vaultSle) +getAssetsTotalScale(SLE::const_ref vaultSle) { if (!vaultSle) return Number::minExponent - 1; // LCOV_EXCL_LINE - return vaultSle->at(sfAssetsTotal).exponent(); + return STAmount{vaultSle->at(sfAsset), vaultSle->at(sfAssetsTotal)} + .exponent(); } TER @@ -196,20 +190,12 @@ checkLoanGuards( beast::Journal j); LoanState -computeRawLoanState( +computeTheoreticalLoanState( Number const& periodicPayment, Number const& periodicRate, std::uint32_t const paymentRemaining, TenthBips32 const managementFeeRate); -LoanState -computeRawLoanState( - Number const& periodicPayment, - TenthBips32 interestRate, - std::uint32_t paymentInterval, - std::uint32_t const paymentRemaining, - TenthBips32 const managementFeeRate); - // Constructs a valid LoanState object from arbitrary inputs LoanState constructLoanState( @@ -231,7 +217,7 @@ computeManagementFee( Number computeFullPaymentInterest( - Number const& rawPrincipalOutstanding, + Number const& theoreticalPrincipalOutstanding, Number const& periodicRate, NetClock::time_point parentCloseTime, std::uint32_t paymentInterval, @@ -239,17 +225,6 @@ computeFullPaymentInterest( std::uint32_t startDate, TenthBips32 closeInterestRate); -Number -computeFullPaymentInterest( - Number const& periodicPayment, - Number const& periodicRate, - std::uint32_t paymentRemaining, - NetClock::time_point parentCloseTime, - std::uint32_t paymentInterval, - std::uint32_t prevPaymentDate, - std::uint32_t startDate, - TenthBips32 closeInterestRate); - namespace detail { // These classes and functions should only be accessed by LendingHelper // functions and unit tests @@ -387,6 +362,70 @@ struct LoanStateDeltas nonNegative(); }; +Expected, TER> +tryOverpayment( + Asset const& asset, + std::int32_t loanScale, + ExtendedPaymentComponents const& overpaymentComponents, + LoanState const& roundedLoanState, + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentRemaining, + TenthBips16 const managementFeeRate, + beast::Journal j); + +Number +computeRaisedRate(Number const& periodicRate, std::uint32_t paymentsRemaining); + +Number +computePaymentFactor( + Number const& periodicRate, + std::uint32_t paymentsRemaining); + +std::pair +computeInterestAndFeeParts( + Asset const& asset, + Number const& interest, + TenthBips16 managementFeeRate, + std::int32_t loanScale); + +Number +loanPeriodicPayment( + Number const& principalOutstanding, + Number const& periodicRate, + std::uint32_t paymentsRemaining); + +Number +loanPrincipalFromPeriodicPayment( + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentsRemaining); + +Number +loanLatePaymentInterest( + Number const& principalOutstanding, + TenthBips32 lateInterestRate, + NetClock::time_point parentCloseTime, + std::uint32_t nextPaymentDueDate); + +Number +loanAccruedInterest( + Number const& principalOutstanding, + Number const& periodicRate, + NetClock::time_point parentCloseTime, + std::uint32_t startDate, + std::uint32_t prevPaymentDate, + std::uint32_t paymentInterval); + +ExtendedPaymentComponents +computeOverpaymentComponents( + Asset const& asset, + int32_t const loanScale, + Number const& overpayment, + TenthBips32 const overpaymentInterestRate, + TenthBips32 const overpaymentFeeRate, + TenthBips16 const managementFeeRate); + PaymentComponents computePaymentComponents( Asset const& asset, @@ -413,13 +452,22 @@ operator+(LoanState const& lhs, detail::LoanStateDeltas const& rhs); LoanProperties computeLoanProperties( Asset const& asset, - Number principalOutstanding, + Number const& principalOutstanding, TenthBips32 interestRate, std::uint32_t paymentInterval, std::uint32_t paymentsRemaining, TenthBips32 managementFeeRate, std::int32_t minimumScale); +LoanProperties +computeLoanProperties( + Asset const& asset, + Number const& principalOutstanding, + Number const& periodicRate, + std::uint32_t paymentsRemaining, + TenthBips32 managementFeeRate, + std::int32_t minimumScale); + bool isRounded(Asset const& asset, Number const& value, std::int32_t scale); diff --git a/src/xrpld/app/misc/detail/LendingHelpers.cpp b/src/xrpld/app/misc/detail/LendingHelpers.cpp index 37385583e7..a8354ff049 100644 --- a/src/xrpld/app/misc/detail/LendingHelpers.cpp +++ b/src/xrpld/app/misc/detail/LendingHelpers.cpp @@ -100,6 +100,9 @@ computePaymentFactor( Number const& periodicRate, std::uint32_t paymentsRemaining) { + if (paymentsRemaining == 0) + return numZero; + // For zero interest, payment factor is simply 1/paymentsRemaining if (periodicRate == beast::zero) return Number{1} / paymentsRemaining; @@ -132,27 +135,6 @@ loanPeriodicPayment( computePaymentFactor(periodicRate, paymentsRemaining); } -/* Calculates the periodic payment amount from annualized interest rate. - * Converts the annual rate to periodic rate before computing payment. - * - * Equation (7) from XLS-66 spec, Section A-2 Equation Glossary - */ -Number -loanPeriodicPayment( - Number const& principalOutstanding, - TenthBips32 interestRate, - std::uint32_t paymentInterval, - std::uint32_t paymentsRemaining) -{ - if (principalOutstanding == 0 || paymentsRemaining == 0) - return 0; - - Number const periodicRate = loanPeriodicRate(interestRate, paymentInterval); - - return loanPeriodicPayment( - principalOutstanding, periodicRate, paymentsRemaining); -} - /* Reverse-calculates principal from periodic payment amount. * Used to determine theoretical principal at any point in the schedule. * @@ -164,6 +146,9 @@ loanPrincipalFromPeriodicPayment( Number const& periodicRate, std::uint32_t paymentsRemaining) { + if (paymentsRemaining == 0) + return numZero; + if (periodicRate == 0) return periodicPayment * paymentsRemaining; @@ -171,21 +156,6 @@ loanPrincipalFromPeriodicPayment( computePaymentFactor(periodicRate, paymentsRemaining); } -/* Splits gross interest into net interest (to vault) and management fee (to - * broker). Returns pair of (net interest, management fee). - * - * Equation (33) from XLS-66 spec, Section A-2 Equation Glossary - */ -std::pair -computeInterestAndFeeParts( - Number const& interest, - TenthBips16 managementFeeRate) -{ - auto const fee = tenthBipsOfValue(interest, managementFeeRate); - - return std::make_pair(interest - fee, fee); -} - /* * Computes the interest and management fee parts from interest amount. * @@ -216,6 +186,12 @@ loanLatePaymentInterest( NetClock::time_point parentCloseTime, std::uint32_t nextPaymentDueDate) { + if (principalOutstanding == beast::zero) + return numZero; + + if (lateInterestRate == TenthBips32{0}) + return numZero; + auto const now = parentCloseTime.time_since_epoch().count(); // If the payment is not late by any amount of time, then there's no late @@ -248,6 +224,9 @@ loanAccruedInterest( if (periodicRate == beast::zero) return numZero; + if (paymentInterval == 0) + return numZero; + auto const lastPaymentDate = std::max(prevPaymentDate, startDate); auto const now = parentCloseTime.time_since_epoch().count(); @@ -401,42 +380,33 @@ doPayment( * The function preserves accumulated rounding errors across the re-amortization * to ensure the loan state remains consistent with its payment history. */ -Expected +Expected, TER> tryOverpayment( Asset const& asset, std::int32_t loanScale, ExtendedPaymentComponents const& overpaymentComponents, - Number& totalValueOutstanding, - Number& principalOutstanding, - Number& managementFeeOutstanding, - Number& periodicPayment, - TenthBips32 interestRate, - std::uint32_t paymentInterval, + LoanState const& roundedOldState, + Number const& periodicPayment, Number const& periodicRate, std::uint32_t paymentRemaining, - std::uint32_t prevPaymentDate, - std::optional nextDueDate, TenthBips16 const managementFeeRate, beast::Journal j) { // Calculate what the loan state SHOULD be theoretically (at full precision) - auto const raw = computeRawLoanState( + auto const theoreticalState = computeTheoreticalLoanState( periodicPayment, periodicRate, paymentRemaining, managementFeeRate); - // Get the actual loan state (with accumulated rounding from past payments) - auto const rounded = constructLoanState( - totalValueOutstanding, principalOutstanding, managementFeeOutstanding); - // Calculate the accumulated rounding errors. These need to be preserved // across the re-amortization to maintain consistency with the loan's // payment history. Without preserving these errors, the loan could end // up with a different total value than what the borrower has actually paid. - auto const errors = rounded - raw; + auto const errors = roundedOldState - theoreticalState; - // Compute the new principal by applying the overpayment to the raw - // (theoretical) principal. Use max with 0 to ensure we never go negative. - auto const newRawPrincipal = std::max( - raw.principalOutstanding - overpaymentComponents.trackedPrincipalDelta, + // Compute the new principal by applying the overpayment to the theoretical + // principal. Use max with 0 to ensure we never go negative. + auto const newTheoreticalPrincipal = std::max( + theoreticalState.principalOutstanding - + overpaymentComponents.trackedPrincipalDelta, Number{0}); // Compute new loan properties based on the reduced principal. This @@ -444,9 +414,8 @@ tryOverpayment( // for the remaining payment schedule. auto newLoanProperties = computeLoanProperties( asset, - newRawPrincipal, - interestRate, - paymentInterval, + newTheoreticalPrincipal, + periodicRate, paymentRemaining, managementFeeRate, loanScale); @@ -454,56 +423,60 @@ tryOverpayment( JLOG(j.debug()) << "new periodic payment: " << newLoanProperties.periodicPayment << ", new total value: " - << newLoanProperties.totalValueOutstanding + << newLoanProperties.loanState.valueOutstanding << ", first payment principal: " << newLoanProperties.firstPaymentPrincipal; // Calculate what the new loan state should be with the new periodic payment - auto const newRaw = computeRawLoanState( - newLoanProperties.periodicPayment, - periodicRate, - paymentRemaining, - managementFeeRate) + + // including rounding errors + auto const newTheoreticalState = computeTheoreticalLoanState( + newLoanProperties.periodicPayment, + periodicRate, + paymentRemaining, + managementFeeRate) + errors; - JLOG(j.debug()) << "new raw value: " << newRaw.valueOutstanding - << ", principal: " << newRaw.principalOutstanding - << ", interest gross: " << newRaw.interestOutstanding(); - // Update the loan state variables with the new values PLUS the preserved - // rounding errors. This ensures the loan's tracked state remains - // consistent with its payment history. + JLOG(j.debug()) << "new theoretical value: " + << newTheoreticalState.valueOutstanding << ", principal: " + << newTheoreticalState.principalOutstanding + << ", interest gross: " + << newTheoreticalState.interestOutstanding(); - principalOutstanding = std::clamp( - roundToAsset( - asset, newRaw.principalOutstanding, loanScale, Number::upward), - numZero, - rounded.principalOutstanding); - totalValueOutstanding = std::clamp( + // Update the loan state variables with the new values that include the + // preserved rounding errors. This ensures the loan's tracked state remains + // consistent with its payment history. + auto const principalOutstanding = std::clamp( roundToAsset( asset, - principalOutstanding + newRaw.interestOutstanding(), + newTheoreticalState.principalOutstanding, loanScale, Number::upward), numZero, - rounded.valueOutstanding); - managementFeeOutstanding = std::clamp( - roundToAsset(asset, newRaw.managementFeeDue, loanScale), + roundedOldState.principalOutstanding); + auto const totalValueOutstanding = std::clamp( + roundToAsset( + asset, + principalOutstanding + newTheoreticalState.interestOutstanding(), + loanScale, + Number::upward), numZero, - rounded.managementFeeDue); + roundedOldState.valueOutstanding); + auto const managementFeeOutstanding = std::clamp( + roundToAsset(asset, newTheoreticalState.managementFeeDue, loanScale), + numZero, + roundedOldState.managementFeeDue); - auto const newRounded = constructLoanState( + auto const roundedNewState = constructLoanState( totalValueOutstanding, principalOutstanding, managementFeeOutstanding); // Update newLoanProperties so that checkLoanGuards can make an accurate // evaluation. - newLoanProperties.totalValueOutstanding = newRounded.valueOutstanding; + newLoanProperties.loanState = roundedNewState; - JLOG(j.debug()) << "new rounded value: " << newRounded.valueOutstanding - << ", principal: " << newRounded.principalOutstanding - << ", interest gross: " << newRounded.interestOutstanding(); - - // Update the periodic payment to reflect the re-amortized schedule - periodicPayment = newLoanProperties.periodicPayment; + JLOG(j.debug()) << "new rounded value: " << roundedNewState.valueOutstanding + << ", principal: " << roundedNewState.principalOutstanding + << ", interest gross: " + << roundedNewState.interestOutstanding(); // check that the loan is still valid if (auto const ter = checkLoanGuards( @@ -513,7 +486,7 @@ tryOverpayment( // small interest amounts, that may have already been paid // off. Check what's still outstanding. This should // guarantee that the interest checks pass. - newRounded.interestOutstanding() != beast::zero, + roundedNewState.interestOutstanding() != beast::zero, paymentRemaining, newLoanProperties, j)) @@ -527,32 +500,40 @@ tryOverpayment( // Validate that all computed properties are reasonable. These checks should // never fail under normal circumstances, but we validate defensively. if (newLoanProperties.periodicPayment <= 0 || - newLoanProperties.totalValueOutstanding <= 0 || - newLoanProperties.managementFeeOwedToBroker < 0) + newLoanProperties.loanState.valueOutstanding <= 0 || + newLoanProperties.loanState.managementFeeDue < 0) { // LCOV_EXCL_START JLOG(j.warn()) << "Overpayment not allowed: Computed loan " "properties are invalid. Does " "not compute. TotalValueOutstanding: " - << newLoanProperties.totalValueOutstanding + << newLoanProperties.loanState.valueOutstanding << ", PeriodicPayment : " << newLoanProperties.periodicPayment << ", ManagementFeeOwedToBroker: " - << newLoanProperties.managementFeeOwedToBroker; + << newLoanProperties.loanState.managementFeeDue; return Unexpected(tesSUCCESS); // LCOV_EXCL_STOP } - auto const deltas = rounded - newRounded; + auto const deltas = roundedOldState - roundedNewState; - auto const hypotheticalValueOutstanding = - rounded.valueOutstanding - deltas.principal; + // The change in loan management fee is equal to the change between the old + // and the new outstanding management fees + XRPL_ASSERT_PARTS( + deltas.managementFee == + roundedOldState.managementFeeDue - managementFeeOutstanding, + "xrpl::detail::tryOverpayment", + "no fee change"); // Calculate how the loan's value changed due to the overpayment. // This should be negative (value decreased) or zero. A principal // overpayment should never increase the loan's value. - auto const valueChange = - newRounded.valueOutstanding - hypotheticalValueOutstanding; + // The value change is derived from the reduction in interest due to + // the lower principal. + // We do not consider the change in management fee here, since + // management fees are excluded from the valueOutstanding. + auto const valueChange = -deltas.interest; if (valueChange > 0) { JLOG(j.warn()) << "Principal overpayment would increase the value of " @@ -560,21 +541,23 @@ tryOverpayment( return Unexpected(tesSUCCESS); } - return LoanPaymentParts{ - // Principal paid is the reduction in principal outstanding - .principalPaid = deltas.principal, - // Interest paid is the reduction in interest due - .interestPaid = - deltas.interest + overpaymentComponents.untrackedInterest, - // Value change includes both the reduction from paying down principal - // (negative) and any untracked interest penalties (positive, e.g., if - // the overpayment itself incurs a fee) - .valueChange = - valueChange + overpaymentComponents.trackedInterestPart(), - // Fee paid includes both the reduction in tracked management fees and - // any untracked fees on the overpayment itself - .feePaid = deltas.managementFee + - overpaymentComponents.untrackedManagementFee}; + return std::make_pair( + LoanPaymentParts{ + // Principal paid is the reduction in principal outstanding + .principalPaid = deltas.principal, + // Interest paid is the reduction in interest due + .interestPaid = overpaymentComponents.untrackedInterest, + // Value change includes both the reduction from paying down + // principal (negative) and any untracked interest penalties + // (positive, e.g., if the overpayment itself incurs a fee) + .valueChange = + valueChange + overpaymentComponents.untrackedInterest, + // Fee paid includes both the reduction in tracked management fees + // and any untracked fees on the overpayment itself + .feePaid = overpaymentComponents.untrackedManagementFee + + overpaymentComponents.trackedManagementFeeDelta, + }, + newLoanProperties); } /* Validates and applies an overpayment to the loan state. @@ -598,23 +581,16 @@ doOverpayment( NumberProxy& principalOutstandingProxy, NumberProxy& managementFeeOutstandingProxy, NumberProxy& periodicPaymentProxy, - TenthBips32 const interestRate, - std::uint32_t const paymentInterval, Number const& periodicRate, std::uint32_t const paymentRemaining, - std::uint32_t const prevPaymentDate, - std::optional const nextDueDate, TenthBips16 const managementFeeRate, beast::Journal j) { - // Create temporary copies of the loan state that can be safely modified - // and discarded if the overpayment doesn't work out. This prevents - // corrupting the actual ledger data if validation fails. - Number totalValueOutstanding = totalValueOutstandingProxy; - Number principalOutstanding = principalOutstandingProxy; - Number managementFeeOutstanding = managementFeeOutstandingProxy; - Number periodicPayment = periodicPaymentProxy; - + auto const loanState = constructLoanState( + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy); + auto const periodicPayment = periodicPaymentProxy; JLOG(j.debug()) << "overpayment components:" << ", totalValue before: " << *totalValueOutstandingProxy @@ -633,33 +609,28 @@ doOverpayment( asset, loanScale, overpaymentComponents, - totalValueOutstanding, - principalOutstanding, - managementFeeOutstanding, + loanState, periodicPayment, - interestRate, - paymentInterval, periodicRate, paymentRemaining, - prevPaymentDate, - nextDueDate, managementFeeRate, j); if (!ret) return Unexpected(ret.error()); - auto const& loanPaymentParts = *ret; + auto const& [loanPaymentParts, newLoanProperties] = *ret; + auto const newRoundedLoanState = newLoanProperties.loanState; // Safety check: the principal must have decreased. If it didn't (or // increased!), something went wrong in the calculation and we should // reject the overpayment. - if (principalOutstandingProxy <= principalOutstanding) + if (principalOutstandingProxy <= newRoundedLoanState.principalOutstanding) { // LCOV_EXCL_START JLOG(j.warn()) << "Overpayment not allowed: principal " << "outstanding did not decrease. Before: " - << *principalOutstandingProxy - << ". After: " << principalOutstanding; + << *principalOutstandingProxy << ". After: " + << newRoundedLoanState.principalOutstanding; return Unexpected(tesSUCCESS); // LCOV_EXCL_STOP } @@ -670,34 +641,29 @@ doOverpayment( XRPL_ASSERT_PARTS( overpaymentComponents.trackedPrincipalDelta == - principalOutstandingProxy - principalOutstanding, + principalOutstandingProxy - + newRoundedLoanState.principalOutstanding, "xrpl::detail::doOverpayment", "principal change agrees"); - XRPL_ASSERT_PARTS( - overpaymentComponents.trackedManagementFeeDelta == - managementFeeOutstandingProxy - managementFeeOutstanding, - "xrpl::detail::doOverpayment", - "no fee change"); - // I'm not 100% sure the following asserts are correct. If in doubt, and // everything else works, remove any that cause trouble. - JLOG(j.debug()) << "valueChange: " << loanPaymentParts.valueChange - << ", totalValue before: " << *totalValueOutstandingProxy - << ", totalValue after: " << totalValueOutstanding - << ", totalValue delta: " - << (totalValueOutstandingProxy - totalValueOutstanding) - << ", principalDelta: " - << overpaymentComponents.trackedPrincipalDelta - << ", principalPaid: " << loanPaymentParts.principalPaid - << ", Computed difference: " - << overpaymentComponents.trackedPrincipalDelta - - (totalValueOutstandingProxy - totalValueOutstanding); + JLOG(j.debug()) + << "valueChange: " << loanPaymentParts.valueChange + << ", totalValue before: " << *totalValueOutstandingProxy + << ", totalValue after: " << newRoundedLoanState.valueOutstanding + << ", totalValue delta: " + << (totalValueOutstandingProxy - newRoundedLoanState.valueOutstanding) + << ", principalDelta: " << overpaymentComponents.trackedPrincipalDelta + << ", principalPaid: " << loanPaymentParts.principalPaid + << ", Computed difference: " + << overpaymentComponents.trackedPrincipalDelta - + (totalValueOutstandingProxy - newRoundedLoanState.valueOutstanding); XRPL_ASSERT_PARTS( loanPaymentParts.valueChange == - totalValueOutstanding - + newRoundedLoanState.valueOutstanding - (totalValueOutstandingProxy - overpaymentComponents.trackedPrincipalDelta) + overpaymentComponents.trackedInterestPart(), @@ -710,19 +676,12 @@ doOverpayment( "xrpl::detail::doOverpayment", "principal payment matches"); - XRPL_ASSERT_PARTS( - loanPaymentParts.feePaid == - overpaymentComponents.untrackedManagementFee + - overpaymentComponents.trackedManagementFeeDelta, - "xrpl::detail::doOverpayment", - "fee payment matches"); - // All validations passed, so update the proxy objects (which will // modify the actual Loan ledger object) - totalValueOutstandingProxy = totalValueOutstanding; - principalOutstandingProxy = principalOutstanding; - managementFeeOutstandingProxy = managementFeeOutstanding; - periodicPaymentProxy = periodicPayment; + totalValueOutstandingProxy = newRoundedLoanState.valueOutstanding; + principalOutstandingProxy = newRoundedLoanState.principalOutstanding; + managementFeeOutstandingProxy = newRoundedLoanState.managementFeeDue; + periodicPaymentProxy = newLoanProperties.periodicPayment; return loanPaymentParts; } @@ -789,25 +748,21 @@ computeLatePayment( // this to keep the logic clear. This preserves all the other fields without // having to enumerate them. - ExtendedPaymentComponents const late = [&]() { - auto inner = periodic; + ExtendedPaymentComponents const late{ + periodic, + // Untracked management fee includes: + // 1. Regular service fee (from periodic.untrackedManagementFee) + // 2. Late payment fee (fixed penalty) + // 3. Management fee portion of late interest + periodic.untrackedManagementFee + latePaymentFee + + roundedLateManagementFee, - return ExtendedPaymentComponents{ - inner, - // Untracked management fee includes: - // 1. Regular service fee (from periodic.untrackedManagementFee) - // 2. Late payment fee (fixed penalty) - // 3. Management fee portion of late interest - periodic.untrackedManagementFee + latePaymentFee + - roundedLateManagementFee, - - // Untracked interest includes: - // 1. Any untracked interest from the regular payment (usually 0) - // 2. Late penalty interest (increases loan value) - // This positive value indicates the loan's value increased due - // to the late payment. - periodic.untrackedInterest + roundedLateInterest}; - }(); + // Untracked interest includes: + // 1. Any untracked interest from the regular payment (usually 0) + // 2. Late penalty interest (increases loan value) + // This positive value indicates the loan's value increased due + // to the late payment. + periodic.untrackedInterest + roundedLateInterest}; XRPL_ASSERT_PARTS( isRounded(asset, late.totalDue, loanScale), @@ -875,15 +830,16 @@ computeFullPayment( } // Calculate the theoretical principal based on the payment schedule. - // This raw (unrounded) value is used to compute interest and penalties - // accurately. - Number const rawPrincipalOutstanding = loanPrincipalFromPeriodicPayment( - periodicPayment, periodicRate, paymentRemaining); + // This theoretical (unrounded) value is used to compute interest and + // penalties accurately. + Number const theoreticalPrincipalOutstanding = + loanPrincipalFromPeriodicPayment( + periodicPayment, periodicRate, paymentRemaining); // Full payment interest includes both accrued interest (time since last // payment) and prepayment penalty (for closing early). auto const fullPaymentInterest = computeFullPaymentInterest( - rawPrincipalOutstanding, + theoreticalPrincipalOutstanding, periodicRate, view.parentCloseTime(), paymentInterval, @@ -896,9 +852,8 @@ computeFullPayment( auto const [roundedFullInterest, roundedFullManagementFee] = [&]() { auto const interest = roundToAsset( asset, fullPaymentInterest, loanScale, Number::downward); - auto const parts = computeInterestAndFeeParts( + return computeInterestAndFeeParts( asset, interest, managementFeeRate, loanScale); - return std::make_tuple(parts.first, parts.second); }(); ExtendedPaymentComponents const full{ @@ -943,7 +898,8 @@ computeFullPayment( JLOG(j.trace()) << "computeFullPayment result: periodicPayment: " << periodicPayment << ", periodicRate: " << periodicRate << ", paymentRemaining: " << paymentRemaining - << ", rawPrincipalOutstanding: " << rawPrincipalOutstanding + << ", theoreticalPrincipalOutstanding: " + << theoreticalPrincipalOutstanding << ", fullPaymentInterest: " << fullPaymentInterest << ", roundedFullInterest: " << roundedFullInterest << ", roundedFullManagementFee: " @@ -980,6 +936,8 @@ PaymentComponents::trackedInterestPart() const * * Special handling for the final payment: all remaining balances are paid off * regardless of the periodic payment amount. + * + * Implements the pseudo-code function `compute_payment_due()`. */ PaymentComponents computePaymentComponents( @@ -1023,7 +981,7 @@ computePaymentComponents( // Calculate what the loan state SHOULD be after this payment (the target). // This is computed at full precision using the theoretical amortization. - LoanState const trueTarget = computeRawLoanState( + LoanState const trueTarget = computeTheoreticalLoanState( periodicPayment, periodicRate, paymentRemaining - 1, managementFeeRate); // Round the target to the loan's scale to match how actual loan values @@ -1229,17 +1187,12 @@ computeOverpaymentComponents( // This interest doesn't follow the normal amortization schedule - it's // a one-time charge for paying early. // Equation (20) and (21) from XLS-66 spec, Section A-2 Equation Glossary - auto const [rawOverpaymentInterest, _] = [&]() { - Number const interest = - tenthBipsOfValue(overpayment, overpaymentInterestRate); - return detail::computeInterestAndFeeParts(interest, managementFeeRate); - }(); - - // Round the penalty interest components to the loan scale auto const [roundedOverpaymentInterest, roundedOverpaymentManagementFee] = [&]() { - Number const interest = - roundToAsset(asset, rawOverpaymentInterest, loanScale); + auto const interest = roundToAsset( + asset, + tenthBipsOfValue(overpayment, overpaymentInterestRate), + loanScale); return detail::computeInterestAndFeeParts( asset, interest, managementFeeRate, loanScale); }(); @@ -1256,12 +1209,11 @@ computeOverpaymentComponents( .specialCase = detail::PaymentSpecialCase::extra}, // Untracked management fee is the fixed overpayment fee overpaymentFee, - // Untracked interest is the penalty interest charged for - // overpaying. - // This is positive, representing a one-time cost, but it's - // typically - // much smaller than the interest savings from reducing - // principal. + // Untracked interest is the penalty interest charged for overpaying. + // This is positive, representing a one-time cost, but it's typically + // much smaller than the interest savings from reducing principal. + // It is equal to the paymentComponents.trackedInterestPart() + // but is kept separate for clarity. roundedOverpaymentInterest}; XRPL_ASSERT_PARTS( result.trackedInterestPart() == roundedOverpaymentInterest, @@ -1320,7 +1272,7 @@ checkLoanGuards( beast::Journal j) { auto const totalInterestOutstanding = - properties.totalValueOutstanding - principalRequested; + properties.loanState.valueOutstanding - principalRequested; // Guard 1: if there is no computed total interest over the life of the // loan for a non-zero interest rate, we cannot properly amortize the // loan @@ -1375,13 +1327,13 @@ checkLoanGuards( NumberRoundModeGuard mg(Number::upward); if (std::int64_t const computedPayments{ - properties.totalValueOutstanding / roundedPayment}; + properties.loanState.valueOutstanding / roundedPayment}; computedPayments != paymentTotal) { JLOG(j.warn()) << "Loan Periodic payment (" << properties.periodicPayment << ") rounding (" << roundedPayment << ") on a total value of " - << properties.totalValueOutstanding + << properties.loanState.valueOutstanding << " can not complete the loan in the specified " "number of payments (" << computedPayments << " != " << paymentTotal << ")"; @@ -1399,7 +1351,7 @@ checkLoanGuards( */ Number computeFullPaymentInterest( - Number const& rawPrincipalOutstanding, + Number const& theoreticalPrincipalOutstanding, Number const& periodicRate, NetClock::time_point parentCloseTime, std::uint32_t paymentInterval, @@ -1408,7 +1360,7 @@ computeFullPaymentInterest( TenthBips32 closeInterestRate) { auto const accruedInterest = detail::loanAccruedInterest( - rawPrincipalOutstanding, + theoreticalPrincipalOutstanding, periodicRate, parentCloseTime, startDate, @@ -1422,7 +1374,7 @@ computeFullPaymentInterest( // Equation (28) from XLS-66 spec, Section A-2 Equation Glossary auto const prepaymentPenalty = closeInterestRate == beast::zero ? Number{} - : tenthBipsOfValue(rawPrincipalOutstanding, closeInterestRate); + : tenthBipsOfValue(theoreticalPrincipalOutstanding, closeInterestRate); XRPL_ASSERT( prepaymentPenalty >= 0, @@ -1433,42 +1385,17 @@ computeFullPaymentInterest( return accruedInterest + prepaymentPenalty; } -Number -computeFullPaymentInterest( - Number const& periodicPayment, - Number const& periodicRate, - std::uint32_t paymentRemaining, - NetClock::time_point parentCloseTime, - std::uint32_t paymentInterval, - std::uint32_t prevPaymentDate, - std::uint32_t startDate, - TenthBips32 closeInterestRate) -{ - Number const rawPrincipalOutstanding = - detail::loanPrincipalFromPeriodicPayment( - periodicPayment, periodicRate, paymentRemaining); - - return computeFullPaymentInterest( - rawPrincipalOutstanding, - periodicRate, - parentCloseTime, - paymentInterval, - prevPaymentDate, - startDate, - closeInterestRate); -} - /* Calculates the theoretical loan state at maximum precision for a given point * in the amortization schedule. * * This function computes what the loan's outstanding balances should be based * on the periodic payment amount and number of payments remaining, * without considering any rounding that may have been applied to the actual - * Loan object's state. This "raw" (unrounded) state is used as a target for - * computing payment components and validating that the loan's tracked state + * Loan object's state. This "theoretical" (unrounded) state is used as a target + * for computing payment components and validating that the loan's tracked state * hasn't drifted too far from the theoretical values. * - * The raw state serves several purposes: + * The theoretical state serves several purposes: * 1. Computing the expected payment breakdown (principal, interest, fees) * 2. Detecting and correcting rounding errors that accumulate over time * 3. Validating that overpayments are calculated correctly @@ -1476,9 +1403,12 @@ computeFullPaymentInterest( * * If paymentRemaining is 0, returns a fully zeroed-out LoanState, * representing a completely paid-off loan. + * + * Implements the `calculate_true_loan_state` function from the XLS-66 spec + * section 3.2.4.4 Transaction Pseudo-code */ LoanState -computeRawLoanState( +computeTheoreticalLoanState( Number const& periodicPayment, Number const& periodicRate, std::uint32_t const paymentRemaining, @@ -1494,55 +1424,42 @@ computeRawLoanState( } // Equation (30) from XLS-66 spec, Section A-2 Equation Glossary - Number const rawTotalValueOutstanding = periodicPayment * paymentRemaining; + Number const totalValueOutstanding = periodicPayment * paymentRemaining; - Number const rawPrincipalOutstanding = + Number const principalOutstanding = detail::loanPrincipalFromPeriodicPayment( periodicPayment, periodicRate, paymentRemaining); // Equation (31) from XLS-66 spec, Section A-2 Equation Glossary - Number const rawInterestOutstandingGross = - rawTotalValueOutstanding - rawPrincipalOutstanding; + Number const interestOutstandingGross = + totalValueOutstanding - principalOutstanding; // Equation (32) from XLS-66 spec, Section A-2 Equation Glossary - Number const rawManagementFeeOutstanding = - tenthBipsOfValue(rawInterestOutstandingGross, managementFeeRate); + Number const managementFeeOutstanding = + tenthBipsOfValue(interestOutstandingGross, managementFeeRate); // Equation (33) from XLS-66 spec, Section A-2 Equation Glossary - Number const rawInterestOutstandingNet = - rawInterestOutstandingGross - rawManagementFeeOutstanding; + Number const interestOutstandingNet = + interestOutstandingGross - managementFeeOutstanding; return LoanState{ - .valueOutstanding = rawTotalValueOutstanding, - .principalOutstanding = rawPrincipalOutstanding, - .interestDue = rawInterestOutstandingNet, - .managementFeeDue = rawManagementFeeOutstanding}; + .valueOutstanding = totalValueOutstanding, + .principalOutstanding = principalOutstanding, + .interestDue = interestOutstandingNet, + .managementFeeDue = managementFeeOutstanding, + }; }; -LoanState -computeRawLoanState( - Number const& periodicPayment, - TenthBips32 interestRate, - std::uint32_t paymentInterval, - std::uint32_t const paymentRemaining, - TenthBips32 const managementFeeRate) -{ - return computeRawLoanState( - periodicPayment, - loanPeriodicRate(interestRate, paymentInterval), - paymentRemaining, - managementFeeRate); -} - /* Constructs a LoanState from rounded Loan ledger object values. * * This function creates a LoanState structure from the three tracked values - * stored in a Loan ledger object. Unlike calculateRawLoanState(), which + * stored in a Loan ledger object. Unlike calculateTheoreticalLoanState(), which * computes theoretical unrounded values, this function works with values * that have already been rounded to the loan's scale. * - * The key difference from calculateRawLoanState(): - * - calculateRawLoanState: Computes theoretical values at full precision + * The key difference from calculateTheoreticalLoanState(): + * - calculateTheoreticalLoanState: Computes theoretical values at full + * precision * - constructRoundedLoanState: Builds state from actual rounded ledger values * * The interestDue field is derived from the other three values rather than @@ -1600,11 +1517,16 @@ computeManagementFee( /* * Given the loan parameters, compute the derived properties of the loan. + * + * Pulls together several formulas from the XLS-66 spec, which are noted at each + * step, plus the concepts from 3.2.4.3 Conceptual Loan Value. They are used for + * to check some of the conditions in 3.2.1.5 Failure Conditions for the LoanSet + * transaction. */ LoanProperties computeLoanProperties( Asset const& asset, - Number principalOutstanding, + Number const& principalOutstanding, TenthBips32 interestRate, std::uint32_t paymentInterval, std::uint32_t paymentsRemaining, @@ -1615,12 +1537,39 @@ computeLoanProperties( XRPL_ASSERT( interestRate == 0 || periodicRate > 0, "xrpl::computeLoanProperties : valid rate"); + return computeLoanProperties( + asset, + principalOutstanding, + periodicRate, + paymentsRemaining, + managementFeeRate, + minimumScale); +} +/* + * Given the loan parameters, compute the derived properties of the loan. + * + * Pulls together several formulas from the XLS-66 spec, which are noted at each + * step, plus the concepts from 3.2.4.3 Conceptual Loan Value. They are used for + * to check some of the conditions in 3.2.1.5 Failure Conditions for the LoanSet + * transaction. + */ +LoanProperties +computeLoanProperties( + Asset const& asset, + Number const& principalOutstanding, + Number const& periodicRate, + std::uint32_t paymentsRemaining, + TenthBips32 managementFeeRate, + std::int32_t minimumScale) +{ auto const periodicPayment = detail::loanPeriodicPayment( principalOutstanding, periodicRate, paymentsRemaining); auto const [totalValueOutstanding, loanScale] = [&]() { - NumberRoundModeGuard mg(Number::to_nearest); + // only round up if there should be interest + NumberRoundModeGuard mg( + periodicRate == 0 ? Number::to_nearest : Number::upward); // Use STAmount's internal rounding instead of roundToAsset, because // we're going to use this result to determine the scale for all the // other rounding. @@ -1641,7 +1590,7 @@ computeLoanProperties( // We may need to truncate the total value because of the minimum // scale - amount = roundToAsset(asset, amount, loanScale, Number::to_nearest); + amount = roundToAsset(asset, amount, loanScale); return std::make_pair(amount, loanScale); }(); @@ -1649,12 +1598,12 @@ computeLoanProperties( // Since we just figured out the loan scale, we haven't been able to // validate that the principal fits in it, so to allow this function to // succeed, round it here, and let the caller do the validation. - principalOutstanding = roundToAsset( + auto const roundedPrincipalOutstanding = roundToAsset( asset, principalOutstanding, loanScale, Number::to_nearest); // Equation (31) from XLS-66 spec, Section A-2 Equation Glossary auto const totalInterestOutstanding = - totalValueOutstanding - principalOutstanding; + totalValueOutstanding - roundedPrincipalOutstanding; auto const feeOwedToBroker = computeManagementFee( asset, totalInterestOutstanding, managementFeeRate, loanScale); @@ -1664,13 +1613,13 @@ computeLoanProperties( auto const firstPaymentPrincipal = [&]() { // Compute the parts for the first payment. Ensure that the // principal payment will actually change the principal. - auto const startingState = computeRawLoanState( + auto const startingState = computeTheoreticalLoanState( periodicPayment, periodicRate, paymentsRemaining, managementFeeRate); - auto const firstPaymentState = computeRawLoanState( + auto const firstPaymentState = computeTheoreticalLoanState( periodicPayment, periodicRate, paymentsRemaining - 1, @@ -1684,10 +1633,13 @@ computeLoanProperties( return LoanProperties{ .periodicPayment = periodicPayment, - .totalValueOutstanding = totalValueOutstanding, - .managementFeeOwedToBroker = feeOwedToBroker, + .loanState = constructLoanState( + totalValueOutstanding, + roundedPrincipalOutstanding, + feeOwedToBroker), .loanScale = loanScale, - .firstPaymentPrincipal = firstPaymentPrincipal}; + .firstPaymentPrincipal = firstPaymentPrincipal, + }; } /* @@ -1740,7 +1692,7 @@ loanMakePayment( Number const periodicPayment = loan->at(sfPeriodicPayment); - auto prevPaymentDateProxy = loan->at(sfPreviousPaymentDate); + auto prevPaymentDateProxy = loan->at(sfPreviousPaymentDueDate); std::uint32_t const startDate = loan->at(sfStartDate); std::uint32_t const paymentInterval = loan->at(sfPaymentInterval); @@ -2016,12 +1968,8 @@ loanMakePayment( principalOutstandingProxy, managementFeeOutstandingProxy, periodicPaymentProxy, - interestRate, - paymentInterval, periodicRate, paymentRemainingProxy, - prevPaymentDateProxy, - nextDueDateProxy, managementFeeRate, j)) totalParts += *overResult; diff --git a/src/xrpld/app/paths/Flow.cpp b/src/xrpld/app/paths/Flow.cpp index a102e44854..b5088d15b3 100644 --- a/src/xrpld/app/paths/Flow.cpp +++ b/src/xrpld/app/paths/Flow.cpp @@ -1,11 +1,11 @@ #include -#include #include #include #include #include #include +#include #include #include diff --git a/src/xrpld/app/paths/detail/DirectStep.cpp b/src/xrpld/app/paths/detail/DirectStep.cpp index 4e701d348f..3d3a76f42d 100644 --- a/src/xrpld/app/paths/detail/DirectStep.cpp +++ b/src/xrpld/app/paths/detail/DirectStep.cpp @@ -1,8 +1,8 @@ -#include #include #include #include +#include #include #include #include diff --git a/src/xrpld/app/paths/detail/StrandFlow.h b/src/xrpld/app/paths/detail/StrandFlow.h index fab92dca35..ca4b18f0a3 100644 --- a/src/xrpld/app/paths/detail/StrandFlow.h +++ b/src/xrpld/app/paths/detail/StrandFlow.h @@ -3,7 +3,6 @@ #include #include -#include #include #include #include @@ -11,6 +10,7 @@ #include #include +#include #include #include #include diff --git a/src/xrpld/app/paths/detail/XRPEndpointStep.cpp b/src/xrpld/app/paths/detail/XRPEndpointStep.cpp index 83271321be..ed1866bf24 100644 --- a/src/xrpld/app/paths/detail/XRPEndpointStep.cpp +++ b/src/xrpld/app/paths/detail/XRPEndpointStep.cpp @@ -1,9 +1,9 @@ -#include #include #include #include #include +#include #include #include #include diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp index 32c8fecf20..1cc6b1f5ae 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp @@ -270,7 +270,7 @@ LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx) JLOG(ctx.j.warn()) << "LoanBroker cover is already at minimum."; return findClawAmount.error(); } - STAmount const clawAmount = *findClawAmount; + STAmount const& clawAmount = *findClawAmount; // Explicitly check the balance of the trust line / MPT to make sure the // balance is actually there. It should always match `sfCoverAvailable`, so @@ -287,6 +287,14 @@ LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx) // Check if the vault asset issuer has the correct flags auto const sleIssuer = ctx.view.read(keylet::account(vaultAsset.getIssuer())); + if (!sleIssuer) + { + // LCOV_EXCL_START + JLOG(ctx.j.fatal()) << "Issuer account does not exist."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + return std::visit( [&](T const&) { return preclaimHelper(ctx, *sleIssuer, clawAmount); @@ -321,7 +329,7 @@ LoanBrokerCoverClawback::doApply() determineClawAmount(*sleBroker, vaultAsset, amount); if (!findClawAmount) return tecINTERNAL; // LCOV_EXCL_LINE - STAmount const clawAmount = *findClawAmount; + STAmount const& clawAmount = *findClawAmount; // Just for paranoia's sake if (clawAmount.native()) return tecINTERNAL; // LCOV_EXCL_LINE diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp index c894df2c2b..b68cf46a00 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp @@ -81,7 +81,8 @@ LoanBrokerCoverDeposit::preclaim(PreclaimContext const& ctx) vaultAsset, FreezeHandling::fhZERO_IF_FROZEN, AuthHandling::ahZERO_IF_UNAUTHORIZED, - ctx.j) < amount) + ctx.j, + SpendableHandling::shFULL_BALANCE) < amount) return tecINSUFFICIENT_FUNDS; return tesSUCCESS; diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp index 4c0b3e9af5..830f9e26c1 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp @@ -48,6 +48,11 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx) auto const dstAcct = tx[~sfDestination].value_or(account); + if (isPseudoAccount(ctx.view, dstAcct)) + { + JLOG(ctx.j.warn()) << "Trying to withdraw into a pseudo-account."; + return tecPSEUDO_ACCOUNT; + } auto const sleBroker = ctx.view.read(keylet::loanbroker(brokerID)); if (!sleBroker) { diff --git a/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp index 76773037fa..227bad10a9 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp @@ -46,30 +46,6 @@ LoanBrokerDelete::preclaim(PreclaimContext const& ctx) JLOG(ctx.j.warn()) << "LoanBrokerDelete: Owner count is " << ownerCount; return tecHAS_OBLIGATIONS; } - if (auto const debtTotal = sleBroker->at(sfDebtTotal); - debtTotal != beast::zero) - { - // Any remaining debt should have been wiped out by the last Loan - // Delete. This check is purely defensive. - auto const vault = - ctx.view.read(keylet::vault(sleBroker->at(sfVaultID))); - if (!vault) - return tefINTERNAL; // LCOV_EXCL_LINE - auto const asset = vault->at(sfAsset); - auto const scale = getVaultScale(vault); - - auto const rounded = - roundToAsset(asset, debtTotal, scale, Number::towards_zero); - - if (rounded != beast::zero) - { - // LCOV_EXCL_START - JLOG(ctx.j.warn()) << "LoanBrokerDelete: Debt total is " - << debtTotal << ", which rounds to " << rounded; - return tecHAS_OBLIGATIONS; - // LCOV_EXCL_START - } - } auto const vault = ctx.view.read(keylet::vault(sleBroker->at(sfVaultID))); if (!vault) @@ -82,6 +58,26 @@ LoanBrokerDelete::preclaim(PreclaimContext const& ctx) Asset const asset = vault->at(sfAsset); + if (auto const debtTotal = sleBroker->at(sfDebtTotal); + debtTotal != beast::zero) + { + // Any remaining debt should have been wiped out by the last Loan + // Delete. This check is purely defensive. + auto const scale = getAssetsTotalScale(vault); + + auto const rounded = + roundToAsset(asset, debtTotal, scale, Number::towards_zero); + + if (rounded != beast::zero) + { + // LCOV_EXCL_START + JLOG(ctx.j.warn()) << "LoanBrokerDelete: Debt total is " + << debtTotal << ", which rounds to " << rounded; + return tecHAS_OBLIGATIONS; + // LCOV_EXCL_STOP + } + } + auto const coverAvailable = STAmount{asset, sleBroker->at(sfCoverAvailable)}; // If there are assets in the cover, broker will receive them on deletion. diff --git a/src/xrpld/app/tx/detail/LoanBrokerSet.cpp b/src/xrpld/app/tx/detail/LoanBrokerSet.cpp index 7b12a6cf39..0cbae4d779 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerSet.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerSet.cpp @@ -89,6 +89,18 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx) JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker."; return tecNO_PERMISSION; } + + if (auto const debtMax = tx[~sfDebtMaximum]) + { + // Can't reduce the debt maximum below the current total debt + auto const currentDebtTotal = sleBroker->at(sfDebtTotal); + if (*debtMax != 0 && *debtMax < currentDebtTotal) + { + JLOG(ctx.j.warn()) + << "Cannot reduce DebtMaximum below current DebtTotal."; + return tecLIMIT_EXCEEDED; + } + } } else { @@ -105,6 +117,13 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx) } if (auto const ter = canAddHolding(ctx.view, sleVault->at(sfAsset))) return ter; + + if (auto const ter = checkFrozen( + ctx.view, sleVault->at(sfAccount), sleVault->at(sfAsset))) + { + JLOG(ctx.j.warn()) << "Vault pseudo-account is frozen."; + return ter; + } } return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/LoanDelete.cpp b/src/xrpld/app/tx/detail/LoanDelete.cpp index 659f0bba8b..3643e6331b 100644 --- a/src/xrpld/app/tx/detail/LoanDelete.cpp +++ b/src/xrpld/app/tx/detail/LoanDelete.cpp @@ -115,7 +115,7 @@ LoanDelete::doApply() roundToAsset( vaultSle->at(sfAsset), debtTotalProxy, - getVaultScale(vaultSle), + getAssetsTotalScale(vaultSle), Number::towards_zero) == beast::zero, "xrpl::LoanDelete::doApply", "last loan, remaining debt rounds to zero"); diff --git a/src/xrpld/app/tx/detail/LoanManage.cpp b/src/xrpld/app/tx/detail/LoanManage.cpp index 2d405f204b..bd0539ae4e 100644 --- a/src/xrpld/app/tx/detail/LoanManage.cpp +++ b/src/xrpld/app/tx/detail/LoanManage.cpp @@ -106,7 +106,7 @@ LoanManage::preclaim(PreclaimContext const& ctx) if (loanBrokerSle->at(sfOwner) != account) { JLOG(ctx.j.warn()) - << "LoanBroker for Loan does not belong to the account. LoanModify " + << "LoanBroker for Loan does not belong to the account. LoanManage " "can only be submitted by the Loan Broker."; return tecNO_PERMISSION; } @@ -158,7 +158,7 @@ LoanManage::defaultLoan( auto const minimumCover = tenthBipsOfValue(brokerDebtTotalProxy.value(), coverRateMinimum); // Round the liquidation amount up, too - return roundToAsset( + auto const covered = roundToAsset( vaultAsset, /* * This formula is from the XLS-66 spec, section 3.2.3.2 (State @@ -169,6 +169,9 @@ LoanManage::defaultLoan( tenthBipsOfValue(minimumCover, coverRateLiquidation), totalDefaultAmount), loanScale); + auto const coverAvailable = *brokerSle->at(sfCoverAvailable); + + return std::min(covered, coverAvailable); }(); auto const vaultDefaultAmount = totalDefaultAmount - defaultCovered; @@ -178,7 +181,7 @@ LoanManage::defaultLoan( // The vault may be at a different scale than the loan. Reduce rounding // errors during the accounting by rounding some of the values to that // scale. - auto const vaultScale = getVaultScale(vaultSle); + auto const vaultScale = getAssetsTotalScale(vaultSle); { // Decrease the Total Value of the Vault: @@ -223,11 +226,13 @@ LoanManage::defaultLoan( } if (*vaultAvailableProxy > *vaultTotalProxy) { - JLOG(j.warn()) << "Vault assets available must not be greater " - "than assets outstanding. Available: " - << *vaultAvailableProxy - << ", Total: " << *vaultTotalProxy; - return tecLIMIT_EXCEEDED; + // LCOV_EXCL_START + JLOG(j.fatal()) + << "Vault assets available must not be greater " + "than assets outstanding. Available: " + << *vaultAvailableProxy << ", Total: " << *vaultTotalProxy; + return tecINTERNAL; + // LCOV_EXCL_STOP } // The loss has been realized @@ -242,7 +247,11 @@ LoanManage::defaultLoan( return tefBAD_LEDGER; // LCOV_EXCL_STOP } - vaultLossUnrealizedProxy -= totalDefaultAmount; + adjustImpreciseNumber( + vaultLossUnrealizedProxy, + -totalDefaultAmount, + vaultAsset, + vaultScale); } view.update(vaultSle); } @@ -250,11 +259,9 @@ LoanManage::defaultLoan( // Update the LoanBroker object: { - auto const asset = *vaultSle->at(sfAsset); - // Decrease the Debt of the LoanBroker: adjustImpreciseNumber( - brokerDebtTotalProxy, -totalDefaultAmount, asset, vaultScale); + brokerDebtTotalProxy, -totalDefaultAmount, vaultAsset, vaultScale); // Decrease the First-Loss Capital Cover Available: auto coverAvailableProxy = brokerSle->at(sfCoverAvailable); if (coverAvailableProxy < defaultCovered) @@ -297,13 +304,20 @@ LoanManage::impairLoan( ApplyView& view, SLE::ref loanSle, SLE::ref vaultSle, + Asset const& vaultAsset, beast::Journal j) { Number const lossUnrealized = owedToVault(loanSle); + // The vault may be at a different scale than the loan. Reduce rounding + // errors during the accounting by rounding some of the values to that + // scale. + auto const vaultScale = getAssetsTotalScale(vaultSle); + // Update the Vault object(set "paper loss") auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); - vaultLossUnrealizedProxy += lossUnrealized; + adjustImpreciseNumber( + vaultLossUnrealizedProxy, lossUnrealized, vaultAsset, vaultScale); if (vaultLossUnrealizedProxy > vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable)) { @@ -329,13 +343,19 @@ LoanManage::impairLoan( return tesSUCCESS; } -TER +[[nodiscard]] TER LoanManage::unimpairLoan( ApplyView& view, SLE::ref loanSle, SLE::ref vaultSle, + Asset const& vaultAsset, beast::Journal j) { + // The vault may be at a different scale than the loan. Reduce rounding + // errors during the accounting by rounding some of the values to that + // scale. + auto const vaultScale = getAssetsTotalScale(vaultSle); + // Update the Vault object(clear "paper loss") auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); Number const lossReversed = owedToVault(loanSle); @@ -347,14 +367,18 @@ LoanManage::unimpairLoan( return tefBAD_LEDGER; // LCOV_EXCL_STOP } - vaultLossUnrealizedProxy -= lossReversed; + // Reverse the "paper loss" + adjustImpreciseNumber( + vaultLossUnrealizedProxy, -lossReversed, vaultAsset, vaultScale); + view.update(vaultSle); // Update the Loan object loanSle->clearFlag(lsfLoanImpaired); auto const paymentInterval = loanSle->at(sfPaymentInterval); auto const normalPaymentDueDate = - std::max(loanSle->at(sfPreviousPaymentDate), loanSle->at(sfStartDate)) + + std::max( + loanSle->at(sfPreviousPaymentDueDate), loanSle->at(sfStartDate)) + paymentInterval; if (!hasExpired(view, normalPaymentDueDate)) { @@ -396,22 +420,12 @@ LoanManage::doApply() // Valid flag combinations are checked in preflight. No flags is valid - // just a noop. if (tx.isFlag(tfLoanDefault)) - { - if (auto const ter = - defaultLoan(view, loanSle, brokerSle, vaultSle, vaultAsset, j_)) - return ter; - } - else if (tx.isFlag(tfLoanImpair)) - { - if (auto const ter = impairLoan(view, loanSle, vaultSle, j_)) - return ter; - } - else if (tx.isFlag(tfLoanUnimpair)) - { - if (auto const ter = unimpairLoan(view, loanSle, vaultSle, j_)) - return ter; - } - + return defaultLoan(view, loanSle, brokerSle, vaultSle, vaultAsset, j_); + if (tx.isFlag(tfLoanImpair)) + return impairLoan(view, loanSle, vaultSle, vaultAsset, j_); + if (tx.isFlag(tfLoanUnimpair)) + return unimpairLoan(view, loanSle, vaultSle, vaultAsset, j_); + // Noop, as described above. return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/LoanManage.h b/src/xrpld/app/tx/detail/LoanManage.h index 7a02c7a16f..155611580f 100644 --- a/src/xrpld/app/tx/detail/LoanManage.h +++ b/src/xrpld/app/tx/detail/LoanManage.h @@ -44,15 +44,17 @@ public: ApplyView& view, SLE::ref loanSle, SLE::ref vaultSle, + Asset const& vaultAsset, beast::Journal j); /** Helper function that might be needed by other transactors */ - static TER + [[nodiscard]] static TER unimpairLoan( ApplyView& view, SLE::ref loanSle, SLE::ref vaultSle, + Asset const& vaultAsset, beast::Journal j); TER diff --git a/src/xrpld/app/tx/detail/LoanPay.cpp b/src/xrpld/app/tx/detail/LoanPay.cpp index d34a766d70..f3973d9488 100644 --- a/src/xrpld/app/tx/detail/LoanPay.cpp +++ b/src/xrpld/app/tx/detail/LoanPay.cpp @@ -152,9 +152,7 @@ LoanPay::preclaim(PreclaimContext const& ctx) } auto const principalOutstanding = loanSle->at(sfPrincipalOutstanding); - TenthBips32 const interestRate{loanSle->at(sfInterestRate)}; auto const paymentRemaining = loanSle->at(sfPaymentRemaining); - TenthBips32 const lateInterestRate{loanSle->at(sfLateInterestRate)}; if (paymentRemaining == 0 || principalOutstanding == 0) { @@ -211,13 +209,14 @@ LoanPay::preclaim(PreclaimContext const& ctx) // Do not support "partial payments" - if the transaction says to pay X, // then the account must have X available, even if the loan payment takes // less. - if (auto const balance = accountSpendable( + if (auto const balance = accountHolds( ctx.view, account, asset, fhZERO_IF_FROZEN, ahZERO_IF_UNAUTHORIZED, - ctx.j); + ctx.j, + SpendableHandling::shFULL_BALANCE); balance < amount) { JLOG(ctx.j.warn()) << "Payment amount too large. Amount: " @@ -262,11 +261,12 @@ LoanPay::doApply() auto debtTotalProxy = brokerSle->at(sfDebtTotal); // Send the broker fee to the owner if they have sufficient cover available, - // _and_ if the owner can receive funds. If not, so as not to block the - // payment, add it to the cover balance (send it to the broker pseudo - // account). + // _and_ if the owner can receive funds + // _and_ if the broker is authorized to hold funds. If not, so as not to + // block the payment, add it to the cover balance (send it to the broker + // pseudo account). // - // Normally freeze status is checked in preflight, but we do it here to + // Normally freeze status is checked in preclaim, but we do it here to // avoid duplicating the check. It'll claim a fee either way. bool const sendBrokerFeeToOwner = [&]() { // Round the minimum required cover up to be conservative. This ensures @@ -278,7 +278,8 @@ LoanPay::doApply() asset, tenthBipsOfValue(debtTotalProxy.value(), coverRateMinimum), loanScale) && - !isDeepFrozen(view, brokerOwner, asset); + !isDeepFrozen(view, brokerOwner, asset) && + !requireAuth(view, asset, brokerOwner, AuthType::StrongAuth); }(); auto const brokerPayee = @@ -305,7 +306,12 @@ LoanPay::doApply() // change will be discarded. if (loanSle->isFlag(lsfLoanImpaired)) { - LoanManage::unimpairLoan(view, loanSle, vaultSle, j_); + if (auto const ret = + LoanManage::unimpairLoan(view, loanSle, vaultSle, asset, j_)) + { + JLOG(j_.fatal()) << "Failed to unimpair loan before payment."; + return ret; // LCOV_EXCL_LINE + } } LoanPaymentType const paymentType = [&tx]() { @@ -377,7 +383,7 @@ LoanPay::doApply() // The vault may be at a different scale than the loan. Reduce rounding // errors during the payment by rounding some of the values to that scale. - auto const vaultScale = assetsTotalProxy.value().exponent(); + auto const vaultScale = getAssetsTotalScale(vaultSle); auto const totalPaidToVaultRaw = paymentParts->principalPaid + paymentParts->interestPaid; @@ -421,35 +427,41 @@ LoanPay::doApply() // Vault object state changes view.update(vaultSle); - Number const assetsAvailableBefore = *assetsAvailableProxy; - Number const pseudoAccountBalanceBefore = accountHolds( - view, - vaultPseudoAccount, - asset, - FreezeHandling::fhIGNORE_FREEZE, - AuthHandling::ahIGNORE_AUTH, - j_); - +#if !NDEBUG { + Number const assetsAvailableBefore = *assetsAvailableProxy; + Number const pseudoAccountBalanceBefore = accountHolds( + view, + vaultPseudoAccount, + asset, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j_); + XRPL_ASSERT_PARTS( assetsAvailableBefore == pseudoAccountBalanceBefore, "xrpl::LoanPay::doApply", "vault pseudo balance agrees before"); + } +#endif - assetsAvailableProxy += totalPaidToVaultRounded; - assetsTotalProxy += paymentParts->valueChange; + assetsAvailableProxy += totalPaidToVaultRounded; + assetsTotalProxy += paymentParts->valueChange; - XRPL_ASSERT_PARTS( - *assetsAvailableProxy <= *assetsTotalProxy, - "xrpl::LoanPay::doApply", - "assets available must not be greater than assets outstanding"); + XRPL_ASSERT_PARTS( + *assetsAvailableProxy <= *assetsTotalProxy, + "xrpl::LoanPay::doApply", + "assets available must not be greater than assets outstanding"); - if (*assetsAvailableProxy > *assetsTotalProxy) - { - // LCOV_EXCL_START - return tecINTERNAL; - // LCOV_EXCL_STOP - } + if (*assetsAvailableProxy > *assetsTotalProxy) + { + // LCOV_EXCL_START + JLOG(j_.fatal()) << "Vault assets available must not be greater " + "than assets outstanding. Available: " + << *assetsAvailableProxy + << ", Total: " << *assetsTotalProxy; + return tecINTERNAL; + // LCOV_EXCL_STOP } JLOG(j_.debug()) << "total paid to vault raw: " << totalPaidToVaultRaw @@ -474,21 +486,34 @@ LoanPay::doApply() } #if !NDEBUG - auto const accountBalanceBefore = accountSpendable( - view, account_, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); + auto const accountBalanceBefore = accountHolds( + view, + account_, + asset, + fhIGNORE_FREEZE, + ahIGNORE_AUTH, + j_, + SpendableHandling::shFULL_BALANCE); auto const vaultBalanceBefore = account_ == vaultPseudoAccount ? STAmount{asset, 0} - : accountSpendable( + : accountHolds( view, vaultPseudoAccount, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, - j_); + j_, + SpendableHandling::shFULL_BALANCE); auto const brokerBalanceBefore = account_ == brokerPayee ? STAmount{asset, 0} - : accountSpendable( - view, brokerPayee, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); + : accountHolds( + view, + brokerPayee, + asset, + fhIGNORE_FREEZE, + ahIGNORE_AUTH, + j_, + SpendableHandling::shFULL_BALANCE); #endif if (totalPaidToVaultRounded != beast::zero) @@ -529,6 +554,7 @@ LoanPay::doApply() WaiveTransferFee::Yes)) return ter; +#if !NDEBUG Number const assetsAvailableAfter = *assetsAvailableProxy; Number const pseudoAccountBalanceAfter = accountHolds( view, @@ -542,22 +568,34 @@ LoanPay::doApply() "xrpl::LoanPay::doApply", "vault pseudo balance agrees after"); -#if !NDEBUG - auto const accountBalanceAfter = accountSpendable( - view, account_, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); + auto const accountBalanceAfter = accountHolds( + view, + account_, + asset, + fhIGNORE_FREEZE, + ahIGNORE_AUTH, + j_, + SpendableHandling::shFULL_BALANCE); auto const vaultBalanceAfter = account_ == vaultPseudoAccount ? STAmount{asset, 0} - : accountSpendable( + : accountHolds( view, vaultPseudoAccount, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, - j_); + j_, + SpendableHandling::shFULL_BALANCE); auto const brokerBalanceAfter = account_ == brokerPayee ? STAmount{asset, 0} - : accountSpendable( - view, brokerPayee, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); + : accountHolds( + view, + brokerPayee, + asset, + fhIGNORE_FREEZE, + ahIGNORE_AUTH, + j_, + SpendableHandling::shFULL_BALANCE); XRPL_ASSERT_PARTS( accountBalanceBefore + vaultBalanceBefore + brokerBalanceBefore == diff --git a/src/xrpld/app/tx/detail/LoanSet.cpp b/src/xrpld/app/tx/detail/LoanSet.cpp index e8fe76a48f..4c14cde421 100644 --- a/src/xrpld/app/tx/detail/LoanSet.cpp +++ b/src/xrpld/app/tx/detail/LoanSet.cpp @@ -88,10 +88,12 @@ LoanSet::preflight(PreflightContext const& ctx) if (auto const paymentInterval = tx[~sfPaymentInterval]; !validNumericMinimum(paymentInterval, LoanSet::minPaymentInterval)) return temINVALID; - - else if (!validNumericRange( - tx[~sfGracePeriod], - paymentInterval.value_or(LoanSet::defaultPaymentInterval))) + // Grace period is between min default value and payment interval + else if (auto const gracePeriod = tx[~sfGracePeriod]; // + !validNumericRange( + gracePeriod, + paymentInterval.value_or(LoanSet::defaultPaymentInterval), + defaultGracePeriod)) return temINVALID; // Copied from preflight2 @@ -282,6 +284,15 @@ LoanSet::preclaim(PreclaimContext const& ctx) if (!vault) // Should be impossible return tefBAD_LEDGER; // LCOV_EXCL_LINE + + if (vault->at(sfAssetsMaximum) != 0 && + vault->at(sfAssetsTotal) >= vault->at(sfAssetsMaximum)) + { + JLOG(ctx.j.warn()) + << "Vault at maximum assets limit. Can't add another loan."; + return tecLIMIT_EXCEEDED; + } + Asset const asset = vault->at(sfAsset); auto const vaultPseudo = vault->at(sfAccount); @@ -383,7 +394,7 @@ LoanSet::doApply() auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); - auto const vaultScale = getVaultScale(vaultSle); + auto const vaultScale = getAssetsTotalScale(vaultSle); if (vaultAvailableProxy < principalRequested) { JLOG(j_.warn()) @@ -406,6 +417,21 @@ LoanSet::doApply() TenthBips16{brokerSle->at(sfManagementFeeRate)}, vaultScale); + LoanState const state = constructLoanState( + properties.loanState.valueOutstanding, + principalRequested, + properties.loanState.managementFeeDue); + + auto const vaultMaximum = *vaultSle->at(sfAssetsMaximum); + XRPL_ASSERT_PARTS( + vaultMaximum == 0 || vaultMaximum > *vaultTotalProxy, + "xrpl::LoanSet::doApply", + "Vault is below maximum limit"); + if (vaultMaximum != 0 && state.interestDue > vaultMaximum - vaultTotalProxy) + { + JLOG(j_.warn()) << "Loan would exceed the maximum assets of the vault"; + return tecLIMIT_EXCEEDED; + } // Check that relevant values won't lose precision. This is mostly only // relevant for IOU assets. { @@ -417,8 +443,8 @@ LoanSet::doApply() JLOG(j_.warn()) << field.f->getName() << " (" << *value << ") has too much precision. Total loan value is " - << properties.totalValueOutstanding << " with a scale of " - << properties.loanScale; + << properties.loanState.valueOutstanding + << " with a scale of " << properties.loanScale; return tecPRECISION_LOSS; } } @@ -434,22 +460,20 @@ LoanSet::doApply() return ret; // Check that the other computed values are valid - if (properties.managementFeeOwedToBroker < 0 || - properties.totalValueOutstanding <= 0 || + if (properties.loanState.managementFeeDue < 0 || + properties.loanState.valueOutstanding <= 0 || properties.periodicPayment <= 0) { // LCOV_EXCL_START JLOG(j_.warn()) - << "Computed loan properties are invalid. Does not compute."; + << "Computed loan properties are invalid. Does not compute." + << " Management fee: " << properties.loanState.managementFeeDue + << ". Total Value: " << properties.loanState.valueOutstanding + << ". PeriodicPayment: " << properties.periodicPayment; return tecINTERNAL; // LCOV_EXCL_STOP } - LoanState const state = constructLoanState( - properties.totalValueOutstanding, - principalRequested, - properties.managementFeeOwedToBroker); - auto const originationFee = tx[~sfLoanOriginationFee].value_or(Number{}); auto const loanAssetsToBorrower = principalRequested - originationFee; @@ -534,12 +558,12 @@ LoanSet::doApply() // ignore tecDUPLICATE. That means the holding already exists, // and is fine here return ter; - - if (auto const ter = requireAuth( - view, vaultAsset, brokerOwner, AuthType::StrongAuth)) - return ter; } + if (auto const ter = + requireAuth(view, vaultAsset, brokerOwner, AuthType::StrongAuth)) + return ter; + if (auto const ter = accountSendMulti( view, vaultPseudo, @@ -588,9 +612,10 @@ LoanSet::doApply() // Set dynamic / computed fields to their initial values loan->at(sfPrincipalOutstanding) = principalRequested; loan->at(sfPeriodicPayment) = properties.periodicPayment; - loan->at(sfTotalValueOutstanding) = properties.totalValueOutstanding; - loan->at(sfManagementFeeOutstanding) = properties.managementFeeOwedToBroker; - loan->at(sfPreviousPaymentDate) = 0; + loan->at(sfTotalValueOutstanding) = properties.loanState.valueOutstanding; + loan->at(sfManagementFeeOutstanding) = + properties.loanState.managementFeeDue; + loan->at(sfPreviousPaymentDueDate) = 0; loan->at(sfNextPaymentDueDate) = startDate + paymentInterval; loan->at(sfPaymentRemaining) = paymentTotal; view.insert(loan); diff --git a/src/xrpld/app/tx/detail/VaultDeposit.cpp b/src/xrpld/app/tx/detail/VaultDeposit.cpp index d9471a38f5..51b38afc36 100644 --- a/src/xrpld/app/tx/detail/VaultDeposit.cpp +++ b/src/xrpld/app/tx/detail/VaultDeposit.cpp @@ -115,16 +115,14 @@ VaultDeposit::preclaim(PreclaimContext const& ctx) !isTesSuccess(ter)) return ter; - // Asset issuer does not have any balance, they can just create funds by - // depositing in the vault. - if ((vaultAsset.native() || vaultAsset.getIssuer() != account) && - accountHolds( + if (accountHolds( ctx.view, account, vaultAsset, FreezeHandling::fhZERO_IF_FROZEN, AuthHandling::ahZERO_IF_UNAUTHORIZED, - ctx.j) < assets) + ctx.j, + SpendableHandling::shFULL_BALANCE) < assets) return tecINSUFFICIENT_FUNDS; return tesSUCCESS; diff --git a/src/xrpld/rpc/handlers/LedgerEntry.cpp b/src/xrpld/rpc/handlers/LedgerEntry.cpp index 2e9d5b35bf..d9ae357b1a 100644 --- a/src/xrpld/rpc/handlers/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/LedgerEntry.cpp @@ -425,7 +425,7 @@ parseLoan(Json::Value const& params, Json::StaticString const fieldName) } auto const id = LedgerEntryHelpers::requiredUInt256( - params, jss::loan_broker_id, "malformedLoanBrokerID"); + params, jss::loan_broker_id, "malformedBroker"); if (!id) return Unexpected(id.error()); auto const seq = LedgerEntryHelpers::requiredUInt32( From 33f4c92b6122ef9072b189ff8e49d161229dd7c0 Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Tue, 13 Jan 2026 17:01:11 -0400 Subject: [PATCH 11/14] Expand Number to support the full integer range (#6025) - Refactor Number internals away from int64 to uint64 & a sign flag - ctors and accessors use `rep`. Very few things expose `internalrep`. - An exception is "unchecked" and the new "normalized", which explicitly take an internalrep. But with those special control flags, it's easier to distinguish and control when they are used. - For now, skip the larger mantissas in AMM transactions and tests - Remove trailing zeros from scientific notation Number strings - Update tests. This has the happy side effect of making some of the string representations _more_ consistent between the small and large mantissa ranges. - Add semi-automatic rounding of STNumbers based on Asset types - Create a new SField metadata enum, sMD_NeedsAsset, which indicates the field should be associated with an Asset so it can be rounded. - Add a new STTakesAsset intermediate class to handle the Asset association to a derived ST class. Currently only used in STNumber, but could be used by other types in the future. - Add "associateAsset" which takes an SLE and an Asset, finds the sMD_NeedsAsset fields, and associates the Asset to them. In the case of STNumber, that both stores the Asset, and rounds the value immediately. - Transactors only need to add a call to associateAsset _after_ all of the STNumbers have been set. Unfortunately, the inner workings of STObject do not do the association correctly with uninitialized fields. - When serializing an STNumber that has an Asset, round it before serializing. - Add an override of roundToAsset, which rounds a Number value in place to an Asset, but without any additional scale. - Update and fix a bunch of Loan-related tests to accommodate the expanded Number class. --------- Co-authored-by: Vito <5780819+Tapanito@users.noreply.github.com> --- include/xrpl/basics/Number.h | 529 ++++++- include/xrpl/protocol/AmountConversions.h | 2 +- include/xrpl/protocol/IOUAmount.h | 21 +- include/xrpl/protocol/Issue.h | 3 + include/xrpl/protocol/MPTIssue.h | 6 + include/xrpl/protocol/Protocol.h | 1 + include/xrpl/protocol/SField.h | 5 +- include/xrpl/protocol/STAmount.h | 71 +- include/xrpl/protocol/STNumber.h | 17 +- include/xrpl/protocol/STTakesAsset.h | 63 + include/xrpl/protocol/SystemParameters.h | 2 + include/xrpl/protocol/detail/sfields.macro | 20 +- src/libxrpl/basics/Number.cpp | 677 ++++++-- src/libxrpl/protocol/IOUAmount.cpp | 32 +- src/libxrpl/protocol/Issue.cpp | 6 + src/libxrpl/protocol/Rules.cpp | 14 +- src/libxrpl/protocol/STAmount.cpp | 39 +- src/libxrpl/protocol/STNumber.cpp | 94 +- src/libxrpl/protocol/STTakesAsset.cpp | 29 + src/test/app/AMMClawback_test.cpp | 5 +- src/test/app/AMMExtended_test.cpp | 42 +- src/test/app/AMM_test.cpp | 53 +- src/test/app/EscrowToken_test.cpp | 20 +- src/test/app/LendingHelpers_test.cpp | 15 +- src/test/app/LoanBroker_test.cpp | 89 +- src/test/app/Loan_test.cpp | 91 +- src/test/app/MPToken_test.cpp | 4 +- src/test/basics/IOUAmount_test.cpp | 31 +- src/test/basics/Number_test.cpp | 1402 ++++++++++++++--- src/test/jtx/AMMTest.h | 16 +- src/test/jtx/amount.h | 16 + src/test/jtx/impl/AMMTest.cpp | 7 +- src/test/protocol/STNumber_test.cpp | 59 +- src/test/rpc/GetAggregatePrice_test.cpp | 68 +- .../app/tx/detail/LoanBrokerCoverClawback.cpp | 4 + .../app/tx/detail/LoanBrokerCoverDeposit.cpp | 10 + .../app/tx/detail/LoanBrokerCoverWithdraw.cpp | 9 + src/xrpld/app/tx/detail/LoanBrokerDelete.cpp | 4 + src/xrpld/app/tx/detail/LoanBrokerSet.cpp | 66 +- src/xrpld/app/tx/detail/LoanBrokerSet.h | 3 + src/xrpld/app/tx/detail/LoanDelete.cpp | 10 +- src/xrpld/app/tx/detail/LoanManage.cpp | 8 +- src/xrpld/app/tx/detail/LoanPay.cpp | 11 + src/xrpld/app/tx/detail/LoanSet.cpp | 40 +- src/xrpld/app/tx/detail/Transactor.cpp | 6 +- src/xrpld/app/tx/detail/VaultClawback.cpp | 3 + src/xrpld/app/tx/detail/VaultCreate.cpp | 3 + src/xrpld/app/tx/detail/VaultDelete.cpp | 4 + src/xrpld/app/tx/detail/VaultDeposit.cpp | 4 + src/xrpld/app/tx/detail/VaultSet.cpp | 5 + src/xrpld/app/tx/detail/VaultWithdraw.cpp | 4 + src/xrpld/app/tx/detail/applySteps.cpp | 118 +- 52 files changed, 3151 insertions(+), 710 deletions(-) create mode 100644 include/xrpl/protocol/STTakesAsset.h create mode 100644 src/libxrpl/protocol/STTakesAsset.cpp diff --git a/include/xrpl/basics/Number.h b/include/xrpl/basics/Number.h index 4420530239..d1ef749784 100644 --- a/include/xrpl/basics/Number.h +++ b/include/xrpl/basics/Number.h @@ -1,8 +1,11 @@ #ifndef XRPL_BASICS_NUMBER_H_INCLUDED #define XRPL_BASICS_NUMBER_H_INCLUDED +#include + #include #include +#include #include #include @@ -13,42 +16,252 @@ class Number; std::string to_string(Number const& amount); +template +constexpr std::optional +logTen(T value) +{ + int log = 0; + while (value >= 10 && value % 10 == 0) + { + value /= 10; + ++log; + } + if (value == 1) + return log; + return std::nullopt; +} + template constexpr bool isPowerOfTen(T value) { - while (value >= 10 && value % 10 == 0) - value /= 10; - return value == 1; + return logTen(value).has_value(); } +/** MantissaRange defines a range for the mantissa of a normalized Number. + * + * The mantissa is in the range [min, max], where + * * min is a power of 10, and + * * max = min * 10 - 1. + * + * The mantissa_scale enum indicates whether the range is "small" or "large". + * This intentionally restricts the number of MantissaRanges that can be + * instantiated to two: one for each scale. + * + * The "small" scale is based on the behavior of STAmount for IOUs. It has a min + * value of 10^15, and a max value of 10^16-1. This was sufficient for + * uses before Lending Protocol was implemented, mostly related to AMM. + * + * However, it does not have sufficient precision to represent the full integer + * range of int64_t values (-2^63 to 2^63-1), which are needed for XRP and MPT + * values. The implementation of SingleAssetVault, and LendingProtocol need to + * represent those integer values accurately and precisely, both for the + * STNumber field type, and for internal calculations. That necessitated the + * "large" scale. + * + * The "large" scale is intended to represent all values that can be represented + * by an STAmount - IOUs, XRP, and MPTs. It has a min value of 10^18, and a max + * value of 10^19-1. + * + * Note that if the mentioned amendments are eventually retired, this class + * should be left in place, but the "small" scale option should be removed. This + * will allow for future expansion beyond 64-bits if it is ever needed. + */ +struct MantissaRange +{ + using rep = std::uint64_t; + enum mantissa_scale { small, large }; + + explicit constexpr MantissaRange(mantissa_scale scale_) + : min(getMin(scale_)) + , max(min * 10 - 1) + , log(logTen(min).value_or(-1)) + , scale(scale_) + { + } + + rep min; + rep max; + int log; + mantissa_scale scale; + +private: + static constexpr rep + getMin(mantissa_scale scale_) + { + switch (scale_) + { + case small: + return 1'000'000'000'000'000ULL; + case large: + return 1'000'000'000'000'000'000ULL; + default: + // Since this can never be called outside a non-constexpr + // context, this throw assures that the build fails if an + // invalid scale is used. + throw std::runtime_error("Unknown mantissa scale"); + } + } +}; + +// Like std::integral, but only 64-bit integral types. +template +concept Integral64 = + std::is_same_v || std::is_same_v; + +/** Number is a floating point type that can represent a wide range of values. + * + * It can represent all values that can be represented by an STAmount - + * regardless of asset type - XRPAmount, MPTAmount, and IOUAmount, with at least + * as much precision as those types require. + * + * ---- Internal Representation ---- + * + * Internally, Number is represented with three values: + * 1. a bool sign flag, + * 2. a std::uint64_t mantissa, + * 3. an int exponent. + * + * The internal mantissa is an unsigned integer in the range defined by the + * current MantissaRange. The exponent is an integer in the range + * [minExponent, maxExponent]. + * + * See the description of MantissaRange for more details on the ranges. + * + * A non-zero mantissa is (almost) always normalized, meaning it and the + * exponent are grown or shrunk until the mantissa is in the range + * [MantissaRange.min, MantissaRange.max]. + * + * Note: + * 1. Normalization can be disabled by using the "unchecked" ctor tag. This + * should only be used at specific conversion points, some constexpr + * values, and in unit tests. + * 2. The max of the "large" range, 10^19-1, is the largest 10^X-1 value that + * fits in an unsigned 64-bit number. (10^19-1 < 2^64-1 and + * 10^20-1 > 2^64-1). This avoids under- and overflows. + * + * ---- External Interface ---- + * + * The external interface of Number consists of a std::int64_t mantissa, which + * is restricted to 63-bits, and an int exponent, which must be in the range + * [minExponent, maxExponent]. The range of the mantissa depends on which + * MantissaRange is currently active. For the "short" range, the mantissa will + * be between 10^15 and 10^16-1. For the "large" range, the mantissa will be + * between -(2^63-1) and 2^63-1. As noted above, the "large" range is needed to + * represent the full range of valid XRP and MPT integer values accurately. + * + * Note: + * 1. 2^63-1 is between 10^18 and 10^19-1, which are the limits of the "large" + * mantissa range. + * 2. The functions mantissa() and exponent() return the external view of the + * Number value, specifically using a signed 63-bit mantissa. This may + * require altering the internal representation to fit into that range + * before the value is returned. The interface guarantees consistency of + * the two values. + * 3. Number cannot represent -2^63 (std::numeric_limits::min()) + * as an exact integer, but it doesn't need to, because all asset values + * on-ledger are non-negative. This is due to implementation details of + * several operations which use unsigned arithmetic internally. This is + * sufficient to represent all valid XRP values (where the absolute value + * can not exceed INITIAL_XRP: 10^17), and MPT values (where the absolute + * value can not exceed maxMPTokenAmount: 2^63-1). + * + * ---- Mantissa Range Switching ---- + * + * The mantissa range may be changed at runtime via setMantissaScale(). The + * default mantissa range is "large". The range is updated whenever transaction + * processing begins, based on whether SingleAssetVault or LendingProtocol are + * enabled. If either is enabled, the mantissa range is set to "large". If not, + * it is set to "small", preserving backward compatibility and correct + * "amendment-gating". + * + * It is extremely unlikely that any more calls to setMantissaScale() will be + * needed outside of unit tests. + * + * ---- Usage With Different Ranges ---- + * + * Outside of unit tests, and existing checks, code that uses Number should not + * know or care which mantissa range is active. + * + * The results of computations using Numbers with a small mantissa may differ + * from computations using Numbers with a large mantissa, specifically as it + * effects the results after rounding. That is why the large mantissa range is + * amendment gated in transaction processing. + * + * It is extremely unlikely that any more calls to getMantissaScale() will be + * needed outside of unit tests. + * + * Code that uses Number should not assume or check anything about the + * mantissa() or exponent() except that they fit into the "large" range + * specified in the "External Interface" section. + * + * ----- Unit Tests ----- + * + * Within unit tests, it may be useful to explicitly switch between the two + * ranges, or to check which range is active when checking the results of + * computations. If the test is doing the math directly, the + * set/getMantissaScale() functions may be most appropriate. However, if the + * test has anything to do with transaction processing, it should enable or + * disable the amendments that control the mantissa range choice + * (SingleAssetVault and LendingProtocol), and/or check if either of those + * amendments are enabled to determine which result to expect. + * + */ class Number { using rep = std::int64_t; - rep mantissa_{0}; + using internalrep = MantissaRange::rep; + + bool negative_{false}; + internalrep mantissa_{0}; int exponent_{std::numeric_limits::lowest()}; public: - // The range for the mantissa when normalized - constexpr static std::int64_t minMantissa = 1'000'000'000'000'000LL; - static_assert(isPowerOfTen(minMantissa)); - constexpr static std::int64_t maxMantissa = minMantissa * 10 - 1; - static_assert(maxMantissa == 9'999'999'999'999'999LL); - // The range for the exponent when normalized constexpr static int minExponent = -32768; constexpr static int maxExponent = 32768; + constexpr static internalrep maxRep = std::numeric_limits::max(); + static_assert(maxRep == 9'223'372'036'854'775'807); + static_assert(-maxRep == std::numeric_limits::min() + 1); + + // May need to make unchecked private struct unchecked { explicit unchecked() = default; }; + // Like unchecked, normalized is used with the ctors that take an + // internalrep mantissa. Unlike unchecked, those ctors will normalize the + // value. + // Only unit tests are expected to use this class + struct normalized + { + explicit normalized() = default; + }; + explicit constexpr Number() = default; Number(rep mantissa); explicit Number(rep mantissa, int exponent); - explicit constexpr Number(rep mantissa, int exponent, unchecked) noexcept; + explicit constexpr Number( + bool negative, + internalrep mantissa, + int exponent, + unchecked) noexcept; + // Assume unsigned values are... unsigned. i.e. positive + explicit constexpr Number( + internalrep mantissa, + int exponent, + unchecked) noexcept; + // Only unit tests are expected to use this ctor + explicit Number( + bool negative, + internalrep mantissa, + int exponent, + normalized); + // Assume unsigned values are... unsigned. i.e. positive + explicit Number(internalrep mantissa, int exponent, normalized); constexpr rep mantissa() const noexcept; @@ -78,11 +291,11 @@ public: Number& operator/=(Number const& x); - static constexpr Number + static Number min() noexcept; - static constexpr Number + static Number max() noexcept; - static constexpr Number + static Number lowest() noexcept; /** Conversions to Number are implicit and conversions away from Number @@ -96,7 +309,8 @@ public: friend constexpr bool operator==(Number const& x, Number const& y) noexcept { - return x.mantissa_ == y.mantissa_ && x.exponent_ == y.exponent_; + return x.negative_ == y.negative_ && x.mantissa_ == y.mantissa_ && + x.exponent_ == y.exponent_; } friend constexpr bool @@ -110,8 +324,8 @@ public: { // If the two amounts have different signs (zero is treated as positive) // then the comparison is true iff the left is negative. - bool const lneg = x.mantissa_ < 0; - bool const rneg = y.mantissa_ < 0; + bool const lneg = x.negative_; + bool const rneg = y.negative_; if (lneg != rneg) return lneg; @@ -139,7 +353,7 @@ public: constexpr int signum() const noexcept { - return (mantissa_ < 0) ? -1 : (mantissa_ ? 1 : 0); + return negative_ ? -1 : (mantissa_ ? 1 : 0); } Number @@ -169,6 +383,15 @@ public: return os << to_string(x); } + friend std::string + to_string(Number const& amount); + + friend Number + root(Number f, unsigned d); + + friend Number + root2(Number f); + // Thread local rounding control. Default is to_nearest enum rounding_mode { to_nearest, towards_zero, downward, upward }; static rounding_mode @@ -177,44 +400,206 @@ public: static rounding_mode setround(rounding_mode mode); + /** Returns which mantissa scale is currently in use for normalization. + * + * If you think you need to call this outside of unit tests, no you don't. + */ + static MantissaRange::mantissa_scale + getMantissaScale(); + /** Changes which mantissa scale is used for normalization. + * + * If you think you need to call this outside of unit tests, no you don't. + */ + static void + setMantissaScale(MantissaRange::mantissa_scale scale); + + inline static internalrep + minMantissa() + { + return range_.get().min; + } + + inline static internalrep + maxMantissa() + { + return range_.get().max; + } + + inline static int + mantissaLog() + { + return range_.get().log; + } + + /// oneSmall is needed because the ranges are private + constexpr static Number + oneSmall(); + /// oneLarge is needed because the ranges are private + constexpr static Number + oneLarge(); + + // And one is needed because it needs to choose between oneSmall and + // oneLarge based on the current range + static Number + one(); + + template + [[nodiscard]] + std::pair + normalizeToRange(T minMantissa, T maxMantissa) const; + private: static thread_local rounding_mode mode_; + // The available ranges for mantissa + + constexpr static MantissaRange smallRange{MantissaRange::small}; + static_assert(isPowerOfTen(smallRange.min)); + static_assert(smallRange.min == 1'000'000'000'000'000LL); + static_assert(smallRange.max == 9'999'999'999'999'999LL); + static_assert(smallRange.log == 15); + static_assert(smallRange.min < maxRep); + static_assert(smallRange.max < maxRep); + constexpr static MantissaRange largeRange{MantissaRange::large}; + static_assert(isPowerOfTen(largeRange.min)); + static_assert(largeRange.min == 1'000'000'000'000'000'000ULL); + static_assert(largeRange.max == internalrep(9'999'999'999'999'999'999ULL)); + static_assert(largeRange.log == 18); + static_assert(largeRange.min < maxRep); + static_assert(largeRange.max > maxRep); + + // The range for the mantissa when normalized. + // Use reference_wrapper to avoid making copies, and prevent accidentally + // changing the values inside the range. + static thread_local std::reference_wrapper range_; void normalize(); - constexpr bool + + /** Normalize Number components to an arbitrary range. + * + * min/maxMantissa are parameters because this function is used by both + * normalize(), which reads from range_, and by normalizeToRange, + * which is public and can accept an arbitrary range from the caller. + */ + template + static void + normalize( + bool& negative, + T& mantissa, + int& exponent, + internalrep const& minMantissa, + internalrep const& maxMantissa); + + template + friend void + doNormalize( + bool& negative, + T& mantissa_, + int& exponent_, + MantissaRange::rep const& minMantissa, + MantissaRange::rep const& maxMantissa); + + bool isnormal() const noexcept; + // Copy the number, but modify the exponent by "exponentDelta". Because the + // mantissa doesn't change, the result will be "mostly" normalized, but the + // exponent could go out of range, so it will be checked. + Number + shiftExponent(int exponentDelta) const; + + // Safely convert rep (int64) mantissa to internalrep (uint64). If the rep + // is negative, returns the positive value. This takes a little extra work + // because converting std::numeric_limits::min() flirts with + // UB, and can vary across compilers. + static internalrep + externalToInternal(rep mantissa); + class Guard; }; +inline constexpr Number::Number( + bool negative, + internalrep mantissa, + int exponent, + unchecked) noexcept + : negative_(negative), mantissa_{mantissa}, exponent_{exponent} +{ +} + +inline constexpr Number::Number( + internalrep mantissa, + int exponent, + unchecked) noexcept + : Number(false, mantissa, exponent, unchecked{}) +{ +} + constexpr static Number numZero{}; -inline constexpr Number::Number(rep mantissa, int exponent, unchecked) noexcept - : mantissa_{mantissa}, exponent_{exponent} +inline Number::Number( + bool negative, + internalrep mantissa, + int exponent, + normalized) + : Number(negative, mantissa, exponent, unchecked{}) +{ + normalize(); +} + +inline Number::Number(internalrep mantissa, int exponent, normalized) + : Number(false, mantissa, exponent, normalized{}) { } inline Number::Number(rep mantissa, int exponent) - : mantissa_{mantissa}, exponent_{exponent} + : Number(mantissa < 0, externalToInternal(mantissa), exponent, normalized{}) { - normalize(); } inline Number::Number(rep mantissa) : Number{mantissa, 0} { } +/** Returns the mantissa of the external view of the Number. + * + * Please see the "---- External Interface ----" section of the class + * documentation for an explanation of why the internal value may be modified. + */ inline constexpr Number::rep Number::mantissa() const noexcept { - return mantissa_; + auto m = mantissa_; + if (m > maxRep) + { + XRPL_ASSERT_PARTS( + !isnormal() || (m % 10 == 0 && m / 10 <= maxRep), + "xrpl::Number::mantissa", + "large normalized mantissa has no remainder"); + m /= 10; + } + auto const sign = negative_ ? -1 : 1; + return sign * static_cast(m); } +/** Returns the exponent of the external view of the Number. + * + * Please see the "---- External Interface ----" section of the class + * documentation for an explanation of why the internal value may be modified. + */ inline constexpr int Number::exponent() const noexcept { - return exponent_; + auto e = exponent_; + if (mantissa_ > maxRep) + { + XRPL_ASSERT_PARTS( + !isnormal() || (mantissa_ % 10 == 0 && mantissa_ / 10 <= maxRep), + "xrpl::Number::exponent", + "large normalized mantissa has no remainder"); + ++e; + } + return e; } inline constexpr Number @@ -226,15 +611,17 @@ Number::operator+() const noexcept inline constexpr Number Number::operator-() const noexcept { + if (mantissa_ == 0) + return Number{}; auto x = *this; - x.mantissa_ = -x.mantissa_; + x.negative_ = !x.negative_; return x; } inline Number& Number::operator++() { - *this += Number{1000000000000000, -15, unchecked{}}; + *this += one(); return *this; } @@ -249,7 +636,7 @@ Number::operator++(int) inline Number& Number::operator--() { - *this -= Number{1000000000000000, -15, unchecked{}}; + *this -= one(); return *this; } @@ -299,30 +686,54 @@ operator/(Number const& x, Number const& y) return z; } -inline constexpr Number +inline Number Number::min() noexcept { - return Number{minMantissa, minExponent, unchecked{}}; + return Number{false, range_.get().min, minExponent, unchecked{}}; } -inline constexpr Number +inline Number Number::max() noexcept { - return Number{maxMantissa, maxExponent, unchecked{}}; + return Number{ + false, std::min(range_.get().max, maxRep), maxExponent, unchecked{}}; } -inline constexpr Number +inline Number Number::lowest() noexcept { - return -Number{maxMantissa, maxExponent, unchecked{}}; + return Number{ + true, std::min(range_.get().max, maxRep), maxExponent, unchecked{}}; } -inline constexpr bool +inline bool Number::isnormal() const noexcept { - auto const abs_m = mantissa_ < 0 ? -mantissa_ : mantissa_; - return minMantissa <= abs_m && abs_m <= maxMantissa && - minExponent <= exponent_ && exponent_ <= maxExponent; + MantissaRange const& range = range_; + auto const abs_m = mantissa_; + return *this == Number{} || + (range.min <= abs_m && abs_m <= range.max && + (abs_m <= maxRep || abs_m % 10 == 0) && minExponent <= exponent_ && + exponent_ <= maxExponent); +} + +template +std::pair +Number::normalizeToRange(T minMantissa, T maxMantissa) const +{ + bool negative = negative_; + internalrep mantissa = mantissa_; + int exponent = exponent_; + + if constexpr (std::is_unsigned_v) + XRPL_ASSERT_PARTS( + !negative, + "xrpl::Number::normalizeToRange", + "Number is non-negative for unsigned range."); + Number::normalize(negative, mantissa, exponent, minMantissa, maxMantissa); + + auto const sign = negative ? -1 : 1; + return std::make_pair(static_cast(sign * mantissa), exponent); } inline constexpr Number @@ -364,6 +775,20 @@ squelch(Number const& x, Number const& limit) noexcept return x; } +inline std::string +to_string(MantissaRange::mantissa_scale const& scale) +{ + switch (scale) + { + case MantissaRange::small: + return "small"; + case MantissaRange::large: + return "large"; + default: + throw std::runtime_error("Bad scale"); + } +} + class saveNumberRoundMode { Number::rounding_mode mode_; @@ -402,6 +827,34 @@ public: operator=(NumberRoundModeGuard const&) = delete; }; +/** Sets the new scale and restores the old scale when it leaves scope. + * + * If you think you need to use this class outside of unit tests, no you don't. + * + */ +class NumberMantissaScaleGuard +{ + MantissaRange::mantissa_scale const saved_; + +public: + explicit NumberMantissaScaleGuard( + MantissaRange::mantissa_scale scale) noexcept + : saved_{Number::getMantissaScale()} + { + Number::setMantissaScale(scale); + } + + ~NumberMantissaScaleGuard() + { + Number::setMantissaScale(saved_); + } + + NumberMantissaScaleGuard(NumberMantissaScaleGuard const&) = delete; + + NumberMantissaScaleGuard& + operator=(NumberMantissaScaleGuard const&) = delete; +}; + } // namespace xrpl #endif // XRPL_BASICS_NUMBER_H_INCLUDED diff --git a/include/xrpl/protocol/AmountConversions.h b/include/xrpl/protocol/AmountConversions.h index 195e373fa0..2cdccecabb 100644 --- a/include/xrpl/protocol/AmountConversions.h +++ b/include/xrpl/protocol/AmountConversions.h @@ -121,7 +121,7 @@ toAmount( { if (isXRP(issue)) return STAmount(issue, static_cast(n)); - return STAmount(issue, n.mantissa(), n.exponent()); + return STAmount(issue, n); } else { diff --git a/include/xrpl/protocol/IOUAmount.h b/include/xrpl/protocol/IOUAmount.h index c04cb5cf70..405de18e29 100644 --- a/include/xrpl/protocol/IOUAmount.h +++ b/include/xrpl/protocol/IOUAmount.h @@ -26,8 +26,10 @@ class IOUAmount : private boost::totally_ordered, private boost::additive { private: - std::int64_t mantissa_; - int exponent_; + using mantissa_type = std::int64_t; + using exponent_type = int; + mantissa_type mantissa_; + exponent_type exponent_; /** Adjusts the mantissa and exponent to the proper range. @@ -38,11 +40,14 @@ private: void normalize(); + static IOUAmount + fromNumber(Number const& number); + public: IOUAmount() = default; explicit IOUAmount(Number const& other); IOUAmount(beast::Zero); - IOUAmount(std::int64_t mantissa, int exponent); + IOUAmount(mantissa_type mantissa, exponent_type exponent); IOUAmount& operator=(beast::Zero); @@ -71,10 +76,10 @@ public: int signum() const noexcept; - int + exponent_type exponent() const noexcept; - std::int64_t + mantissa_type mantissa() const noexcept; static IOUAmount @@ -92,7 +97,7 @@ inline IOUAmount::IOUAmount(beast::Zero) *this = beast::zero; } -inline IOUAmount::IOUAmount(std::int64_t mantissa, int exponent) +inline IOUAmount::IOUAmount(mantissa_type mantissa, exponent_type exponent) : mantissa_(mantissa), exponent_(exponent) { normalize(); @@ -149,13 +154,13 @@ IOUAmount::signum() const noexcept return (mantissa_ < 0) ? -1 : (mantissa_ ? 1 : 0); } -inline int +inline IOUAmount::exponent_type IOUAmount::exponent() const noexcept { return exponent_; } -inline std::int64_t +inline IOUAmount::mantissa_type IOUAmount::mantissa() const noexcept { return mantissa_; diff --git a/include/xrpl/protocol/Issue.h b/include/xrpl/protocol/Issue.h index 519d7a96f3..a76b7a8316 100644 --- a/include/xrpl/protocol/Issue.h +++ b/include/xrpl/protocol/Issue.h @@ -37,6 +37,9 @@ public: bool native() const; + bool + integral() const; + friend constexpr std::weak_ordering operator<=>(Issue const& lhs, Issue const& rhs); }; diff --git a/include/xrpl/protocol/MPTIssue.h b/include/xrpl/protocol/MPTIssue.h index b89b59ee0d..ca81548a29 100644 --- a/include/xrpl/protocol/MPTIssue.h +++ b/include/xrpl/protocol/MPTIssue.h @@ -46,6 +46,12 @@ public: { return false; } + + bool + integral() const + { + return true; + } }; constexpr bool diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 0c72b80de4..43be9d3b45 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -233,6 +233,7 @@ std::size_t constexpr maxMPTokenMetadataLength = 1024; /** The maximum amount of MPTokenIssuance */ std::uint64_t constexpr maxMPTokenAmount = 0x7FFF'FFFF'FFFF'FFFFull; +static_assert(Number::maxRep >= maxMPTokenAmount); /** The maximum length of Data payload */ std::size_t constexpr maxDataPayloadLength = 256; diff --git a/include/xrpl/protocol/SField.h b/include/xrpl/protocol/SField.h index b1d353196d..7f404b4d5f 100644 --- a/include/xrpl/protocol/SField.h +++ b/include/xrpl/protocol/SField.h @@ -135,7 +135,10 @@ public: sMD_Always = 0x10, // value when node containing it is affected at all sMD_BaseTen = 0x20, // value is treated as base 10, overriding behavior sMD_PseudoAccount = 0x40, // if this field is set in an ACCOUNT_ROOT - // _only_, then it is a pseudo-account + // _only_, then it is a pseudo-account + sMD_NeedsAsset = 0x80, // This field needs to be associated with an + // asset before it is serialized as a ledger + // object. Intended for STNumber. sMD_Default = sMD_ChangeOrig | sMD_ChangeNew | sMD_DeleteFinal | sMD_Create }; diff --git a/include/xrpl/protocol/STAmount.h b/include/xrpl/protocol/STAmount.h index 79cbf51436..4d86aed2ec 100644 --- a/include/xrpl/protocol/STAmount.h +++ b/include/xrpl/protocol/STAmount.h @@ -138,7 +138,7 @@ public: template STAmount(A const& asset, Number const& number) - : STAmount(asset, number.mantissa(), number.exponent()) + : STAmount(fromNumber(asset, number)) { } @@ -282,6 +282,10 @@ public: mpt() const; private: + template + static STAmount + fromNumber(A const& asset, Number const& number); + static std::unique_ptr construct(SerialIter&, SField const& name); @@ -345,10 +349,19 @@ STAmount::STAmount( , mIsNegative(negative) { // mValue is uint64, but needs to fit in the range of int64 - XRPL_ASSERT( - mValue <= std::numeric_limits::max(), - "xrpl::STAmount::STAmount(SField, A, std::uint64_t, int, bool) : " - "maximum mantissa input"); + if (Number::getMantissaScale() == MantissaRange::small) + { + XRPL_ASSERT( + mValue <= std::numeric_limits::max(), + "xrpl::STAmount::STAmount(SField, A, std::uint64_t, int, bool) : " + "maximum mantissa input"); + } + else + { + if (integral() && mValue > std::numeric_limits::max()) + throw std::overflow_error( + "STAmount mantissa is too large " + std::to_string(mantissa)); + } canonicalize(); } @@ -542,14 +555,23 @@ STAmount::operator=(XRPAmount const& amount) return *this; } -inline STAmount& -STAmount::operator=(Number const& number) +template +inline STAmount +STAmount::fromNumber(A const& a, Number const& number) { - mIsNegative = number.mantissa() < 0; - mValue = mIsNegative ? -number.mantissa() : number.mantissa(); - mOffset = number.exponent(); - canonicalize(); - return *this; + bool const negative = number.mantissa() < 0; + Number const working{negative ? -number : number}; + Asset asset{a}; + if (asset.integral()) + { + std::uint64_t const intValue = static_cast(working); + return STAmount{asset, intValue, 0, negative}; + } + + auto const [mantissa, exponent] = + working.normalizeToRange(cMinValue, cMaxValue); + + return STAmount{asset, mantissa, exponent, negative}; } inline void @@ -699,17 +721,32 @@ getRate(STAmount const& offerOut, STAmount const& offerIn); * @param rounding Optional Number rounding mode * */ -STAmount +[[nodiscard]] STAmount roundToScale( STAmount const& value, std::int32_t scale, Number::rounding_mode rounding = Number::getround()); +/** Round an arbitrary precision Number IN PLACE to the precision of a given + * Asset. + * + * This is used to ensure that calculations do not collect dust for IOUs, or + * fractional amounts for the integral types XRP and MPT. + * + * @param asset The relevant asset + * @param value The lvalue to be rounded + */ +template +void +roundToAsset(A const& asset, Number& value) +{ + value = STAmount{asset, value}; +} + /** Round an arbitrary precision Number to the precision of a given Asset. * - * This is used to ensure that calculations do not collect dust beyond the - * precision of the reference value for IOUs, or fractional amounts for the - * integral types XRP and MPT. + * This is used to ensure that calculations do not collect dust beyond specified + * scale for IOUs, or fractional amounts for the integral types XRP and MPT. * * @param asset The relevant asset * @param value The value to be rounded @@ -718,7 +755,7 @@ roundToScale( * @param rounding Optional Number rounding mode */ template -Number +[[nodiscard]] Number roundToAsset( A const& asset, Number const& value, diff --git a/include/xrpl/protocol/STNumber.h b/include/xrpl/protocol/STNumber.h index dfdb16af93..39b0c3b042 100644 --- a/include/xrpl/protocol/STNumber.h +++ b/include/xrpl/protocol/STNumber.h @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -19,8 +20,19 @@ namespace xrpl { * it can represent a value of any token type (XRP, IOU, or MPT) * without paying the storage cost of duplicating asset information * that may be deduced from the context. + * + * STNumber derives from STTakesAsset, so that it can be associated with the + * related Asset during transaction processing. Which asset is relevant depends + * on the object and transaction. As of this writing, only Vault, LoanBroker, + * and Loan objects use STNumber fields. All of those fields represent amounts + * of the Vault's Asset, so they should be associated with the Vault's Asset. + * + * e.g. + * associateAsset(*loanSle, asset); + * associateAsset(*brokerSle, asset); + * associateAsset(*vaultSle, asset); */ -class STNumber : public STBase, public CountedObject +class STNumber : public STTakesAsset, public CountedObject { private: Number value_; @@ -56,6 +68,9 @@ public: bool isDefault() const override; + void + associateAsset(Asset const& a) override; + operator Number() const { return value_; diff --git a/include/xrpl/protocol/STTakesAsset.h b/include/xrpl/protocol/STTakesAsset.h new file mode 100644 index 0000000000..767223b97d --- /dev/null +++ b/include/xrpl/protocol/STTakesAsset.h @@ -0,0 +1,63 @@ +#ifndef XRPL_PROTOCOL_STTAKESASSET_H_INCLUDED +#define XRPL_PROTOCOL_STTAKESASSET_H_INCLUDED + +#include +#include + +namespace xrpl { + +/** Intermediate class for any STBase-derived class to store an Asset. + * + * In the class definition, this class should be specified as a base class + * _instead_ of STBase. + * + * Specifically, the Asset is only stored and used at runtime. It should not be + * serialized to the ledger. + * + * The derived class decides what to do with the Asset, and when. It will not + * necessarily be set at any given time. As of this writing, only STNumber uses + * it to round the stored Number to the Asset's precision both when associated, + * and when serializing the Number. + */ +class STTakesAsset : public STBase +{ +protected: + std::optional asset_; + +public: + using STBase::STBase; + using STBase::operator=; + + virtual void + associateAsset(Asset const& a); +}; + +inline void +STTakesAsset::associateAsset(Asset const& a) +{ + asset_.emplace(a); +} + +class STLedgerEntry; + +/** Associate an Asset with all sMD_NeedsAsset fields in a ledger entry. + * + * This function iterates over all fields in the given ledger entry. For each + * field that is set and has the SField::sMD_NeedsAsset metadata flag, it calls + * `associateAsset` on that field with the given Asset. Such field must be + * derived from STTakesAsset - if it is not, the conversion will throw. + * + * Typically, associateAsset should be called near the end of doApply() of any + * Transactor classes on the SLEs of any new or modified ledger entries + * containing STNumber fields, after doing all of the modifications t the SLEs. + * + * @param sle The ledger entry whose fields will be updated. + * @param asset The Asset to associate with the relevant fields. + * + */ +void +associateAsset(STLedgerEntry& sle, Asset const& asset); + +} // namespace xrpl + +#endif diff --git a/include/xrpl/protocol/SystemParameters.h b/include/xrpl/protocol/SystemParameters.h index c0732bc9fe..c2f66e9ea1 100644 --- a/include/xrpl/protocol/SystemParameters.h +++ b/include/xrpl/protocol/SystemParameters.h @@ -23,6 +23,8 @@ systemName() /** Number of drops in the genesis account. */ constexpr XRPAmount INITIAL_XRP{100'000'000'000 * DROPS_PER_XRP}; +static_assert(INITIAL_XRP.drops() == 100'000'000'000'000'000); +static_assert(Number::maxRep >= INITIAL_XRP.drops()); /** Returns true if the amount does not exceed the initial XRP in existence. */ inline bool diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index d0736469e4..712cf568af 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -207,22 +207,22 @@ TYPED_SFIELD(sfLoanID, UINT256, 38) // number (common) TYPED_SFIELD(sfNumber, NUMBER, 1) -TYPED_SFIELD(sfAssetsAvailable, NUMBER, 2) -TYPED_SFIELD(sfAssetsMaximum, NUMBER, 3) -TYPED_SFIELD(sfAssetsTotal, NUMBER, 4) -TYPED_SFIELD(sfLossUnrealized, NUMBER, 5) -TYPED_SFIELD(sfDebtTotal, NUMBER, 6) -TYPED_SFIELD(sfDebtMaximum, NUMBER, 7) -TYPED_SFIELD(sfCoverAvailable, NUMBER, 8) +TYPED_SFIELD(sfAssetsAvailable, NUMBER, 2, SField::sMD_NeedsAsset | SField::sMD_Default) +TYPED_SFIELD(sfAssetsMaximum, NUMBER, 3, SField::sMD_NeedsAsset | SField::sMD_Default) +TYPED_SFIELD(sfAssetsTotal, NUMBER, 4, SField::sMD_NeedsAsset | SField::sMD_Default) +TYPED_SFIELD(sfLossUnrealized, NUMBER, 5, SField::sMD_NeedsAsset | SField::sMD_Default) +TYPED_SFIELD(sfDebtTotal, NUMBER, 6, SField::sMD_NeedsAsset | SField::sMD_Default) +TYPED_SFIELD(sfDebtMaximum, NUMBER, 7, SField::sMD_NeedsAsset | SField::sMD_Default) +TYPED_SFIELD(sfCoverAvailable, NUMBER, 8, SField::sMD_NeedsAsset | SField::sMD_Default) TYPED_SFIELD(sfLoanOriginationFee, NUMBER, 9) TYPED_SFIELD(sfLoanServiceFee, NUMBER, 10) TYPED_SFIELD(sfLatePaymentFee, NUMBER, 11) TYPED_SFIELD(sfClosePaymentFee, NUMBER, 12) -TYPED_SFIELD(sfPrincipalOutstanding, NUMBER, 13) +TYPED_SFIELD(sfPrincipalOutstanding, NUMBER, 13, SField::sMD_NeedsAsset | SField::sMD_Default) TYPED_SFIELD(sfPrincipalRequested, NUMBER, 14) -TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15) +TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15, SField::sMD_NeedsAsset | SField::sMD_Default) TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16) -TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17) +TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17, SField::sMD_NeedsAsset | SField::sMD_Default) // int32 TYPED_SFIELD(sfLoanScale, INT32, 1) diff --git a/src/libxrpl/basics/Number.cpp b/src/libxrpl/basics/Number.cpp index 9984b26ffe..436ebf6779 100644 --- a/src/libxrpl/basics/Number.cpp +++ b/src/libxrpl/basics/Number.cpp @@ -1,4 +1,6 @@ #include +// Keep Number.h first to ensure it can build without hidden dependencies +#include #include #include @@ -13,16 +15,20 @@ #include #ifdef _MSC_VER -#pragma message("Using boost::multiprecision::uint128_t") +#pragma message("Using boost::multiprecision::uint128_t and int128_t") #include using uint128_t = boost::multiprecision::uint128_t; +using int128_t = boost::multiprecision::int128_t; #else // !defined(_MSC_VER) using uint128_t = __uint128_t; +using int128_t = __int128_t; #endif // !defined(_MSC_VER) namespace xrpl { thread_local Number::rounding_mode Number::mode_ = Number::to_nearest; +thread_local std::reference_wrapper Number::range_ = + largeRange; Number::rounding_mode Number::getround() @@ -36,12 +42,30 @@ Number::setround(rounding_mode mode) return std::exchange(mode_, mode); } +MantissaRange::mantissa_scale +Number::getMantissaScale() +{ + return range_.get().scale; +} + +void +Number::setMantissaScale(MantissaRange::mantissa_scale scale) +{ + if (scale != MantissaRange::small && scale != MantissaRange::large) + LogicError("Unknown mantissa scale"); + range_ = scale == MantissaRange::small ? smallRange : largeRange; +} + // Guard // The Guard class is used to temporarily add extra digits of // precision to an operation. This enables the final result // to be correctly rounded to the internal precision of Number. +template +concept UnsignedMantissa = + std::is_unsigned_v || std::is_same_v; + class Number::Guard { std::uint64_t digits_; // 16 decimal guard digits @@ -62,8 +86,9 @@ public: is_negative() const noexcept; // add a digit + template void - push(unsigned d) noexcept; + push(T d) noexcept; // recover a digit unsigned @@ -76,16 +101,40 @@ public: round() noexcept; // Modify the result to the correctly rounded value + template void - doRoundUp(rep& mantissa, int& exponent, std::string location); + doRoundUp( + bool& negative, + T& mantissa, + int& exponent, + internalrep const& minMantissa, + internalrep const& maxMantissa, + std::string location); + + // Modify the result to the correctly rounded value + template + void + doRoundDown( + bool& negative, + T& mantissa, + int& exponent, + internalrep const& minMantissa); // Modify the result to the correctly rounded value void - doRoundDown(rep& mantissa, int& exponent); + doRound(rep& drops, std::string location); - // Modify the result to the correctly rounded value +private: void - doRound(rep& drops); + doPush(unsigned d) noexcept; + + template + void + bringIntoRange( + bool& negative, + T& mantissa, + int& exponent, + internalrep const& minMantissa); }; inline void @@ -107,13 +156,20 @@ Number::Guard::is_negative() const noexcept } inline void -Number::Guard::push(unsigned d) noexcept +Number::Guard::doPush(unsigned d) noexcept { - xbit_ = xbit_ || (digits_ & 0x0000'0000'0000'000F) != 0; + xbit_ = xbit_ || ((digits_ & 0x0000'0000'0000'000F) != 0); digits_ >>= 4; digits_ |= (d & 0x0000'0000'0000'000FULL) << 60; } +template +inline void +Number::Guard::push(T d) noexcept +{ + doPush(static_cast(d)); +} + inline unsigned Number::Guard::pop() noexcept { @@ -163,30 +219,65 @@ Number::Guard::round() noexcept return 0; } +template void -Number::Guard::doRoundUp(rep& mantissa, int& exponent, std::string location) +Number::Guard::bringIntoRange( + bool& negative, + T& mantissa, + int& exponent, + internalrep const& minMantissa) +{ + // Bring mantissa back into the minMantissa / maxMantissa range AFTER + // rounding + if (mantissa < minMantissa) + { + mantissa *= 10; + --exponent; + } + if (exponent < minExponent) + { + constexpr Number zero = Number{}; + + negative = zero.negative_; + mantissa = zero.mantissa_; + exponent = zero.exponent_; + } +} + +template +void +Number::Guard::doRoundUp( + bool& negative, + T& mantissa, + int& exponent, + internalrep const& minMantissa, + internalrep const& maxMantissa, + std::string location) { auto r = round(); if (r == 1 || (r == 0 && (mantissa & 1) == 1)) { ++mantissa; - if (mantissa > maxMantissa) + // Ensure mantissa after incrementing fits within both the + // min/maxMantissa range and is a valid "rep". + if (mantissa > maxMantissa || mantissa > maxRep) { mantissa /= 10; ++exponent; } } - if (exponent < minExponent) - { - mantissa = 0; - exponent = Number{}.exponent_; - } + bringIntoRange(negative, mantissa, exponent, minMantissa); if (exponent > maxExponent) throw std::overflow_error(location); } +template void -Number::Guard::doRoundDown(rep& mantissa, int& exponent) +Number::Guard::doRoundDown( + bool& negative, + T& mantissa, + int& exponent, + internalrep const& minMantissa) { auto r = round(); if (r == 1 || (r == 0 && (mantissa & 1) == 1)) @@ -198,20 +289,27 @@ Number::Guard::doRoundDown(rep& mantissa, int& exponent) --exponent; } } - if (exponent < minExponent) - { - mantissa = 0; - exponent = Number{}.exponent_; - } + bringIntoRange(negative, mantissa, exponent, minMantissa); } // Modify the result to the correctly rounded value void -Number::Guard::doRound(rep& drops) +Number::Guard::doRound(rep& drops, std::string location) { auto r = round(); if (r == 1 || (r == 0 && (drops & 1) == 1)) { + if (drops >= maxRep) + { + static_assert(sizeof(internalrep) == sizeof(rep)); + // This should be impossible, because it's impossible to represent + // "maxRep + 0.6" in Number, regardless of the scale. There aren't + // enough digits available. You'd either get a mantissa of "maxRep" + // or "(maxRep + 1) / 10", neither of which will round up when + // converting to rep, though the latter might overflow _before_ + // rounding. + throw std::overflow_error(location); // LCOV_EXCL_LINE + } ++drops; } if (is_negative()) @@ -220,20 +318,88 @@ Number::Guard::doRound(rep& drops) // Number -constexpr Number one{1000000000000000, -15, Number::unchecked{}}; - -void -Number::normalize() +// Safely convert rep (int64) mantissa to internalrep (uint64). If the rep is +// negative, returns the positive value. This takes a little extra work because +// converting std::numeric_limits::min() flirts with UB, and can +// vary across compilers. +Number::internalrep +Number::externalToInternal(rep mantissa) { + // If the mantissa is already positive, just return it + if (mantissa >= 0) + return mantissa; + // If the mantissa is negative, but fits within the positive range of rep, + // return it negated + if (mantissa >= -std::numeric_limits::max()) + return -mantissa; + + // If the mantissa doesn't fit within the positive range, convert to + // int128_t, negate that, and cast it back down to the internalrep + // In practice, this is only going to cover the case of + // std::numeric_limits::min(). + int128_t temp = mantissa; + return static_cast(-temp); +} + +constexpr Number +Number::oneSmall() +{ + return Number{ + false, + Number::smallRange.min, + -Number::smallRange.log, + Number::unchecked{}}; +}; + +constexpr Number oneSml = Number::oneSmall(); + +constexpr Number +Number::oneLarge() +{ + return Number{ + false, + Number::largeRange.min, + -Number::largeRange.log, + Number::unchecked{}}; +}; + +constexpr Number oneLrg = Number::oneLarge(); + +Number +Number::one() +{ + if (&range_.get() == &smallRange) + return oneSml; + XRPL_ASSERT(&range_.get() == &largeRange, "Number::one() : valid range_"); + return oneLrg; +} + +// Use the member names in this static function for now so the diff is cleaner +// TODO: Rename the function parameters to get rid of the "_" suffix +template +void +doNormalize( + bool& negative, + T& mantissa_, + int& exponent_, + MantissaRange::rep const& minMantissa, + MantissaRange::rep const& maxMantissa) +{ + auto constexpr minExponent = Number::minExponent; + auto constexpr maxExponent = Number::maxExponent; + auto constexpr maxRep = Number::maxRep; + + using Guard = Number::Guard; + + constexpr Number zero = Number{}; if (mantissa_ == 0) { - *this = Number{}; + mantissa_ = zero.mantissa_; + exponent_ = zero.exponent_; + negative = zero.negative_; return; } - bool const negative = (mantissa_ < 0); - auto m = static_cast>(mantissa_); - if (negative) - m = -m; + auto m = mantissa_; while ((m < minMantissa) && (exponent_ > minExponent)) { m *= 10; @@ -250,57 +416,161 @@ Number::normalize() m /= 10; ++exponent_; } - mantissa_ = m; - if ((exponent_ < minExponent) || (mantissa_ < minMantissa)) + if ((exponent_ < minExponent) || (m < minMantissa)) { - *this = Number{}; + mantissa_ = zero.mantissa_; + exponent_ = zero.exponent_; + negative = zero.negative_; return; } - g.doRoundUp(mantissa_, exponent_, "Number::normalize 2"); + // When using the largeRange, "m" needs fit within an int64, even if + // the final mantissa_ is going to end up larger to fit within the + // MantissaRange. Cut it down here so that the rounding will be done while + // it's smaller. + // + // Example: 9,900,000,000,000,123,456 > 9,223,372,036,854,775,807, + // so "m" will be modified to 990,000,000,000,012,345. Then that value + // will be rounded to 990,000,000,000,012,345 or + // 990,000,000,000,012,346, depending on the rounding mode. Finally, + // mantissa_ will be "m*10" so it fits within the range, and end up as + // 9,900,000,000,000,123,450 or 9,900,000,000,000,123,460. + // mantissa() will return mantissa_ / 10, and exponent() will return + // exponent_ + 1. + if (m > maxRep) + { + if (exponent_ >= maxExponent) + throw std::overflow_error("Number::normalize 1.5"); + g.push(m % 10); + m /= 10; + ++exponent_; + } + // Before modification, m should be within the min/max range. After + // modification, it must be less than maxRep. In other words, the original + // value should have been no more than maxRep * 10. + // (maxRep * 10 > maxMantissa) + XRPL_ASSERT_PARTS( + m <= maxRep, + "xrpl::doNormalize", + "intermediate mantissa fits in int64"); + mantissa_ = m; - if (negative) - mantissa_ = -mantissa_; + g.doRoundUp( + negative, + mantissa_, + exponent_, + minMantissa, + maxMantissa, + "Number::normalize 2"); + XRPL_ASSERT_PARTS( + mantissa_ >= minMantissa && mantissa_ <= maxMantissa, + "xrpl::doNormalize", + "final mantissa fits in range"); +} + +template <> +void +Number::normalize( + bool& negative, + uint128_t& mantissa, + int& exponent, + internalrep const& minMantissa, + internalrep const& maxMantissa) +{ + doNormalize(negative, mantissa, exponent, minMantissa, maxMantissa); +} + +template <> +void +Number::normalize( + bool& negative, + unsigned long long& mantissa, + int& exponent, + internalrep const& minMantissa, + internalrep const& maxMantissa) +{ + doNormalize(negative, mantissa, exponent, minMantissa, maxMantissa); +} + +template <> +void +Number::normalize( + bool& negative, + unsigned long& mantissa, + int& exponent, + internalrep const& minMantissa, + internalrep const& maxMantissa) +{ + doNormalize(negative, mantissa, exponent, minMantissa, maxMantissa); +} + +void +Number::normalize() +{ + auto const& range = range_.get(); + normalize(negative_, mantissa_, exponent_, range.min, range.max); +} + +// Copy the number, but set a new exponent. Because the mantissa doesn't change, +// the result will be "mostly" normalized, but the exponent could go out of +// range. +Number +Number::shiftExponent(int exponentDelta) const +{ + XRPL_ASSERT_PARTS(isnormal(), "xrpl::Number::shiftExponent", "normalized"); + auto const newExponent = exponent_ + exponentDelta; + if (newExponent >= maxExponent) + throw std::overflow_error("Number::shiftExponent"); + if (newExponent < minExponent) + { + return Number{}; + } + Number const result{negative_, mantissa_, newExponent, unchecked{}}; + XRPL_ASSERT_PARTS( + result.isnormal(), + "xrpl::Number::shiftExponent", + "result is normalized"); + return result; } Number& Number::operator+=(Number const& y) { - if (y == Number{}) + constexpr Number zero = Number{}; + if (y == zero) return *this; - if (*this == Number{}) + if (*this == zero) { *this = y; return *this; } if (*this == -y) { - *this = Number{}; + *this = zero; return *this; } + XRPL_ASSERT( isnormal() && y.isnormal(), "xrpl::Number::operator+=(Number) : is normal"); - auto xm = mantissa(); - auto xe = exponent(); - int xn = 1; - if (xm < 0) - { - xm = -xm; - xn = -1; - } - auto ym = y.mantissa(); - auto ye = y.exponent(); - int yn = 1; - if (ym < 0) - { - ym = -ym; - yn = -1; - } + // *n = negative + // *s = sign + // *m = mantissa + // *e = exponent + + // Need to use uint128_t, because large mantissas can overflow when added + // together. + bool xn = negative_; + uint128_t xm = mantissa_; + auto xe = exponent_; + + bool yn = y.negative_; + uint128_t ym = y.mantissa_; + auto ye = y.exponent_; Guard g; if (xe < ye) { - if (xn == -1) + if (xn) g.set_negative(); do { @@ -311,7 +581,7 @@ Number::operator+=(Number const& y) } else if (xe > ye) { - if (yn == -1) + if (yn) g.set_negative(); do { @@ -320,16 +590,22 @@ Number::operator+=(Number const& y) ++ye; } while (xe > ye); } + + auto const& range = range_.get(); + auto const& minMantissa = range.min; + auto const& maxMantissa = range.max; + if (xn == yn) { xm += ym; - if (xm > maxMantissa) + if (xm > maxMantissa || xm > maxRep) { g.push(xm % 10); xm /= 10; ++xe; } - g.doRoundUp(xm, xe, "Number::addition overflow"); + g.doRoundUp( + xn, xm, xe, minMantissa, maxMantissa, "Number::addition overflow"); } else { @@ -343,16 +619,19 @@ Number::operator+=(Number const& y) xe = ye; xn = yn; } - while (xm < minMantissa) + while (xm < minMantissa && xm * 10 <= maxRep) { xm *= 10; xm -= g.pop(); --xe; } - g.doRoundDown(xm, xe); + g.doRoundDown(xn, xm, xe, minMantissa); } - mantissa_ = xm * xn; + + negative_ = xn; + mantissa_ = static_cast(xm); exponent_ = xe; + normalize(); return *this; } @@ -387,39 +666,42 @@ divu10(uint128_t& u) Number& Number::operator*=(Number const& y) { - if (*this == Number{}) + constexpr Number zero = Number{}; + if (*this == zero) return *this; - if (y == Number{}) + if (y == zero) { *this = y; return *this; } - XRPL_ASSERT( - isnormal() && y.isnormal(), - "xrpl::Number::operator*=(Number) : is normal"); - auto xm = mantissa(); - auto xe = exponent(); - int xn = 1; - if (xm < 0) - { - xm = -xm; - xn = -1; - } - auto ym = y.mantissa(); - auto ye = y.exponent(); - int yn = 1; - if (ym < 0) - { - ym = -ym; - yn = -1; - } + // *n = negative + // *s = sign + // *m = mantissa + // *e = exponent + + bool xn = negative_; + int xs = xn ? -1 : 1; + internalrep xm = mantissa_; + auto xe = exponent_; + + bool yn = y.negative_; + int ys = yn ? -1 : 1; + internalrep ym = y.mantissa_; + auto ye = y.exponent_; + auto zm = uint128_t(xm) * uint128_t(ym); auto ze = xe + ye; - auto zn = xn * yn; + auto zs = xs * ys; + bool zn = (zs == -1); Guard g; - if (zn == -1) + if (zn) g.set_negative(); - while (zm > maxMantissa) + + auto const& range = range_.get(); + auto const& minMantissa = range.min; + auto const& maxMantissa = range.max; + + while (zm > maxMantissa || zm > maxRep) { // The following is optimization for: // g.push(static_cast(zm % 10)); @@ -427,61 +709,129 @@ Number::operator*=(Number const& y) g.push(divu10(zm)); ++ze; } - xm = static_cast(zm); + xm = static_cast(zm); xe = ze; g.doRoundUp( + zn, xm, xe, + minMantissa, + maxMantissa, "Number::multiplication overflow : exponent is " + std::to_string(xe)); - mantissa_ = xm * zn; + negative_ = zn; + mantissa_ = xm; exponent_ = xe; - XRPL_ASSERT( - isnormal() || *this == Number{}, - "xrpl::Number::operator*=(Number) : result is normal"); + + normalize(); return *this; } Number& Number::operator/=(Number const& y) { - if (y == Number{}) + constexpr Number zero = Number{}; + if (y == zero) throw std::overflow_error("Number: divide by 0"); - if (*this == Number{}) + if (*this == zero) return *this; - int np = 1; - auto nm = mantissa(); - auto ne = exponent(); - if (nm < 0) + // n* = numerator + // d* = denominator + // *p = negative (positive?) + // *s = sign + // *m = mantissa + // *e = exponent + + bool np = negative_; + int ns = (np ? -1 : 1); + auto nm = mantissa_; + auto ne = exponent_; + + bool dp = y.negative_; + int ds = (dp ? -1 : 1); + auto dm = y.mantissa_; + auto de = y.exponent_; + + auto const& range = range_.get(); + auto const& minMantissa = range.min; + auto const& maxMantissa = range.max; + + // Shift by 10^17 gives greatest precision while not overflowing + // uint128_t or the cast back to int64_t + // TODO: Can/should this be made bigger for largeRange? + // log(2^128,10) ~ 38.5 + // largeRange.log = 18, fits in 10^19 + // f can be up to 10^(38-19) = 10^19 safely + static_assert(smallRange.log == 15); + static_assert(largeRange.log == 18); + bool small = Number::getMantissaScale() == MantissaRange::small; + uint128_t const f = + small ? 100'000'000'000'000'000 : 10'000'000'000'000'000'000ULL; + XRPL_ASSERT_PARTS( + f >= minMantissa * 10, "Number::operator/=", "factor expected size"); + + // unsigned denominator + auto const dmu = static_cast(dm); + // correctionFactor can be anything between 10 and f, depending on how much + // extra precision we want to only use for rounding with the + // largeRange. Three digits seems like plenty, and is more than + // the smallRange uses. + uint128_t const correctionFactor = 1'000; + + auto const numerator = uint128_t(nm) * f; + + auto zm = numerator / dmu; + auto ze = ne - de - (small ? 17 : 19); + bool zn = (ns * ds) < 0; + if (!small) { - nm = -nm; - np = -1; + // Virtually multiply numerator by correctionFactor. Since that would + // overflow in the existing uint128_t, we'll do that part separately. + // The math for this would work for small mantissas, but we need to + // preserve existing behavior. + // + // Consider: + // ((numerator * correctionFactor) / dmu) / correctionFactor + // = ((numerator / dmu) * correctionFactor) / correctionFactor) + // + // But that assumes infinite precision. With integer math, this is + // equivalent to + // + // = ((numerator / dmu * correctionFactor) + // + ((numerator % dmu) * correctionFactor) / dmu) / correctionFactor + // + // We have already set `mantissa_ = numerator / dmu`. Now we + // compute `remainder = numerator % dmu`, and if it is + // nonzero, we do the rest of the arithmetic. If it's zero, we can skip + // it. + auto const remainder = (numerator % dmu); + if (remainder != 0) + { + zm *= correctionFactor; + auto const correction = remainder * correctionFactor / dmu; + zm += correction; + // divide by 1000 by moving the exponent, so we don't lose the + // integer value we just computed + ze -= 3; + } } - int dp = 1; - auto dm = y.mantissa(); - auto de = y.exponent(); - if (dm < 0) - { - dm = -dm; - dp = -1; - } - // Shift by 10^17 gives greatest precision while not overflowing uint128_t - // or the cast back to int64_t - uint128_t const f = 100'000'000'000'000'000; - mantissa_ = static_cast(uint128_t(nm) * f / uint128_t(dm)); - exponent_ = ne - de - 17; - mantissa_ *= np * dp; - normalize(); + normalize(zn, zm, ze, minMantissa, maxMantissa); + negative_ = zn; + mantissa_ = static_cast(zm); + exponent_ = ze; + XRPL_ASSERT_PARTS( + isnormal(), "xrpl::Number::operator/=", "result is normalized"); + return *this; } Number::operator rep() const { - rep drops = mantissa_; - int offset = exponent_; + rep drops = mantissa(); + int offset = exponent(); Guard g; if (drops != 0) { - if (drops < 0) + if (negative_) { g.set_negative(); drops = -drops; @@ -493,11 +843,11 @@ Number::operator rep() const } for (; offset > 0; --offset) { - if (drops > std::numeric_limits::max() / 10) + if (drops > maxRep / 10) throw std::overflow_error("Number::operator rep() overflow"); drops *= 10; } - g.doRound(drops); + g.doRound(drops, "Number::operator rep() rounding overflow"); } return drops; } @@ -524,34 +874,37 @@ std::string to_string(Number const& amount) { // keep full internal accuracy, but make more human friendly if possible - if (amount == Number{}) + constexpr Number zero = Number{}; + if (amount == zero) return "0"; - auto const exponent = amount.exponent(); - auto mantissa = amount.mantissa(); + auto exponent = amount.exponent_; + auto mantissa = amount.mantissa_; + bool const negative = amount.negative_; // Use scientific notation for exponents that are too small or too large - if (((exponent != 0) && ((exponent < -25) || (exponent > -5)))) + auto const rangeLog = Number::mantissaLog(); + if (((exponent != 0) && + ((exponent < -(rangeLog + 10)) || (exponent > -(rangeLog - 10))))) { - std::string ret = std::to_string(mantissa); + while (mantissa != 0 && mantissa % 10 == 0 && + exponent < Number::maxExponent) + { + mantissa /= 10; + ++exponent; + } + std::string ret = negative ? "-" : ""; + ret.append(std::to_string(mantissa)); ret.append(1, 'e'); ret.append(std::to_string(exponent)); return ret; } - bool negative = false; - - if (mantissa < 0) - { - mantissa = -mantissa; - negative = true; - } - XRPL_ASSERT( exponent + 43 > 0, "xrpl::to_string(Number) : minimum exponent"); - ptrdiff_t const pad_prefix = 27; - ptrdiff_t const pad_suffix = 23; + ptrdiff_t const pad_prefix = rangeLog + 12; + ptrdiff_t const pad_suffix = rangeLog + 8; std::string const raw_value(std::to_string(mantissa)); std::string val; @@ -561,7 +914,7 @@ to_string(Number const& amount) val.append(raw_value); val.append(pad_suffix, '0'); - ptrdiff_t const offset(exponent + 43); + ptrdiff_t const offset(exponent + pad_prefix + rangeLog + 1); auto pre_from(val.begin()); auto const pre_to(val.begin() + offset); @@ -621,7 +974,7 @@ Number power(Number const& f, unsigned n) { if (n == 0) - return one; + return Number::one(); if (n == 1) return f; auto r = power(f, n / 2); @@ -643,6 +996,9 @@ power(Number const& f, unsigned n) Number root(Number f, unsigned d) { + constexpr Number zero = Number{}; + auto const one = Number::one(); + if (f == one || d == 1) return f; if (d == 0) @@ -650,16 +1006,16 @@ root(Number f, unsigned d) if (f == -one) return one; if (abs(f) < one) - return Number{}; + return zero; throw std::overflow_error("Number::root infinity"); } - if (f < Number{} && d % 2 == 0) + if (f < zero && d % 2 == 0) throw std::overflow_error("Number::root nan"); - if (f == Number{}) + if (f == zero) return f; // Scale f into the range (0, 1) such that f's exponent is a multiple of d - auto e = f.exponent() + 16; + auto e = f.exponent_ + Number::mantissaLog() + 1; auto const di = static_cast(d); auto ex = [e = e, di = di]() // Euclidean remainder of e/d { @@ -670,9 +1026,12 @@ root(Number f, unsigned d) return di - k2; }(); e += ex; - f = Number{f.mantissa(), f.exponent() - e}; // f /= 10^e; + f = f.shiftExponent(-e); // f /= 10^e; + + XRPL_ASSERT_PARTS( + f.isnormal(), "xrpl::root(Number, unsigned)", "f is normalized"); bool neg = false; - if (f < Number{}) + if (f < zero) { neg = true; f = -f; @@ -702,24 +1061,33 @@ root(Number f, unsigned d) } while (r != rm1 && r != rm2); // return r * 10^(e/d) to reverse scaling - return Number{r.mantissa(), r.exponent() + e / di}; + auto const result = r.shiftExponent(e / di); + XRPL_ASSERT_PARTS( + result.isnormal(), + "xrpl::root(Number, unsigned)", + "result is normalized"); + return result; } Number root2(Number f) { + constexpr Number zero = Number{}; + auto const one = Number::one(); + if (f == one) return f; - if (f < Number{}) + if (f < zero) throw std::overflow_error("Number::root nan"); - if (f == Number{}) + if (f == zero) return f; // Scale f into the range (0, 1) such that f's exponent is a multiple of d - auto e = f.exponent() + 16; + auto e = f.exponent_ + Number::mantissaLog() + 1; if (e % 2 != 0) ++e; - f = Number{f.mantissa(), f.exponent() - e}; // f /= 10^e; + f = f.shiftExponent(-e); // f /= 10^e; + XRPL_ASSERT_PARTS(f.isnormal(), "xrpl::root2(Number)", "f is normalized"); // Quadratic least squares curve fit of f^(1/d) in the range [0, 1] auto const D = 105; @@ -740,7 +1108,11 @@ root2(Number f) } while (r != rm1 && r != rm2); // return r * 10^(e/2) to reverse scaling - return Number{r.mantissa(), r.exponent() + e / 2}; + auto const result = r.shiftExponent(e / 2); + XRPL_ASSERT_PARTS( + result.isnormal(), "xrpl::root2(Number)", "result is normalized"); + + return result; } // Returns f^(n/d) @@ -748,6 +1120,9 @@ root2(Number f) Number power(Number const& f, unsigned n, unsigned d) { + constexpr Number zero = Number{}; + auto const one = Number::one(); + if (f == one) return f; auto g = std::gcd(n, d); @@ -758,7 +1133,7 @@ power(Number const& f, unsigned n, unsigned d) if (f == -one) return one; if (abs(f) < one) - return Number{}; + return zero; // abs(f) > one throw std::overflow_error("Number::power infinity"); } @@ -766,7 +1141,7 @@ power(Number const& f, unsigned n, unsigned d) return one; n /= g; d /= g; - if ((n % 2) == 1 && (d % 2) == 0 && f < Number{}) + if ((n % 2) == 1 && (d % 2) == 0 && f < zero) throw std::overflow_error("Number::power nan"); return root(power(f, n), d); } diff --git a/src/libxrpl/protocol/IOUAmount.cpp b/src/libxrpl/protocol/IOUAmount.cpp index 5c9ab1febc..297c2bac12 100644 --- a/src/libxrpl/protocol/IOUAmount.cpp +++ b/src/libxrpl/protocol/IOUAmount.cpp @@ -1,8 +1,11 @@ +#include +// Do not remove. Forces IOUAmount.h to stay first, to verify it can compile +// without any hidden dependencies #include #include #include #include -#include +#include #include @@ -40,11 +43,24 @@ setSTNumberSwitchover(bool v) } /* The range for the mantissa when normalized */ -static std::int64_t constexpr minMantissa = 1000000000000000ull; -static std::int64_t constexpr maxMantissa = 9999999999999999ull; +// log(2^63,10) ~ 18.96 +// +static std::int64_t constexpr minMantissa = STAmount::cMinValue; +static std::int64_t constexpr maxMantissa = STAmount::cMaxValue; /* The range for the exponent when normalized */ -static int constexpr minExponent = -96; -static int constexpr maxExponent = 80; +static int constexpr minExponent = STAmount::cMinOffset; +static int constexpr maxExponent = STAmount::cMaxOffset; + +IOUAmount +IOUAmount::fromNumber(Number const& number) +{ + // Need to create a default IOUAmount and assign directly so it doesn't try + // to normalize, which calls fromNumber + IOUAmount result{}; + std::tie(result.mantissa_, result.exponent_) = + number.normalizeToRange(minMantissa, maxMantissa); + return result; +} IOUAmount IOUAmount::minPositiveAmount() @@ -64,8 +80,7 @@ IOUAmount::normalize() if (getSTNumberSwitchover()) { Number const v{mantissa_, exponent_}; - mantissa_ = v.mantissa(); - exponent_ = v.exponent(); + *this = fromNumber(v); if (exponent_ > maxExponent) Throw("value overflow"); if (exponent_ < minExponent) @@ -106,8 +121,7 @@ IOUAmount::normalize() mantissa_ = -mantissa_; } -IOUAmount::IOUAmount(Number const& other) - : mantissa_(other.mantissa()), exponent_(other.exponent()) +IOUAmount::IOUAmount(Number const& other) : IOUAmount(fromNumber(other)) { if (exponent_ > maxExponent) Throw("value overflow"); diff --git a/src/libxrpl/protocol/Issue.cpp b/src/libxrpl/protocol/Issue.cpp index b858a31e3e..ca5bf35e8b 100644 --- a/src/libxrpl/protocol/Issue.cpp +++ b/src/libxrpl/protocol/Issue.cpp @@ -49,6 +49,12 @@ Issue::native() const return *this == xrpIssue(); } +bool +Issue::integral() const +{ + return native(); +} + bool isConsistent(Issue const& ac) { diff --git a/src/libxrpl/protocol/Rules.cpp b/src/libxrpl/protocol/Rules.cpp index b1f2c2d631..3710322699 100644 --- a/src/libxrpl/protocol/Rules.cpp +++ b/src/libxrpl/protocol/Rules.cpp @@ -1,10 +1,13 @@ +#include +// Do not remove. Forces Rules.h to stay first, to verify it can compile +// without any hidden dependencies #include +#include #include #include #include #include #include -#include #include #include @@ -33,6 +36,15 @@ getCurrentTransactionRules() void setCurrentTransactionRules(std::optional r) { + // Make global changes associated with the rules before the value is moved. + // Push the appropriate setting, instead of having the class pull every time + // the value is needed. That could get expensive fast. + bool enableLargeNumbers = !r || + (r->enabled(featureSingleAssetVault) || + r->enabled(featureLendingProtocol)); + Number::setMantissaScale( + enableLargeNumbers ? MantissaRange::large : MantissaRange::small); + *getCurrentTransactionRulesRef() = std::move(r); } diff --git a/src/libxrpl/protocol/STAmount.cpp b/src/libxrpl/protocol/STAmount.cpp index ebccfb3e64..ec60971e63 100644 --- a/src/libxrpl/protocol/STAmount.cpp +++ b/src/libxrpl/protocol/STAmount.cpp @@ -11,11 +11,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -310,7 +312,8 @@ STAmount& STAmount::operator=(IOUAmount const& iou) { XRPL_ASSERT( - native() == false, "xrpl::STAmount::operator=(IOUAmount) : is not XRP"); + integral() == false, + "xrpl::STAmount::operator=(IOUAmount) : is not integral"); mOffset = iou.exponent(); mIsNegative = iou < beast::zero; if (mIsNegative) @@ -320,6 +323,26 @@ STAmount::operator=(IOUAmount const& iou) return *this; } +STAmount& +STAmount::operator=(Number const& number) +{ + if (!getCurrentTransactionRules() || + isFeatureEnabled(featureSingleAssetVault) || + isFeatureEnabled(featureLendingProtocol)) + { + *this = fromNumber(mAsset, number); + } + else + { + auto const originalMantissa = number.mantissa(); + mIsNegative = originalMantissa < 0; + mValue = mIsNegative ? -originalMantissa : originalMantissa; + mOffset = number.exponent(); + } + canonicalize(); + return *this; +} + //------------------------------------------------------------------------------ // // Operators @@ -849,11 +872,11 @@ STAmount::canonicalize() if (getSTNumberSwitchover()) { - Number num( - mIsNegative ? -mValue : mValue, mOffset, Number::unchecked{}); + Number num(mIsNegative, mValue, mOffset, Number::unchecked{}); auto set = [&](auto const& val) { - mIsNegative = val.value() < 0; - mValue = mIsNegative ? -val.value() : val.value(); + auto const value = val.value(); + mIsNegative = value < 0; + mValue = mIsNegative ? -value : value; }; if (native()) set(XRPAmount{num}); @@ -1323,7 +1346,7 @@ multiply(STAmount const& v1, STAmount const& v2, Asset const& asset) if (getSTNumberSwitchover()) { auto const r = Number{v1} * Number{v2}; - return STAmount{asset, r.mantissa(), r.exponent()}; + return STAmount{asset, r}; } std::uint64_t value1 = v1.mantissa(); @@ -1471,6 +1494,10 @@ roundToScale( if (value.integral()) return value; + // Nothing to do for zero. + if (value == beast::zero) + return value; + // If the value's exponent is greater than or equal to the scale, then // rounding will do nothing, and might even lose precision, so just return // the value. diff --git a/src/libxrpl/protocol/STNumber.cpp b/src/libxrpl/protocol/STNumber.cpp index f85bb48e0a..2f2dae7493 100644 --- a/src/libxrpl/protocol/STNumber.cpp +++ b/src/libxrpl/protocol/STNumber.cpp @@ -1,9 +1,13 @@ +#include +// Do not remove. Keep STNumber.h first #include #include #include +#include #include +#include #include -#include +#include #include #include @@ -17,11 +21,11 @@ namespace xrpl { STNumber::STNumber(SField const& field, Number const& value) - : STBase(field), value_(value) + : STTakesAsset(field), value_(value) { } -STNumber::STNumber(SerialIter& sit, SField const& field) : STBase(field) +STNumber::STNumber(SerialIter& sit, SField const& field) : STTakesAsset(field) { // We must call these methods in separate statements // to guarantee their order of execution. @@ -42,6 +46,19 @@ STNumber::getText() const return to_string(value_); } +void +STNumber::associateAsset(Asset const& a) +{ + STTakesAsset::associateAsset(a); + + XRPL_ASSERT_PARTS( + getFName().shouldMeta(SField::sMD_NeedsAsset), + "STNumber::associateAsset", + "field needs asset"); + + roundToAsset(a, value_); +} + void STNumber::add(Serializer& s) const { @@ -49,8 +66,49 @@ STNumber::add(Serializer& s) const XRPL_ASSERT( getFName().fieldType == getSType(), "xrpl::STNumber::add : field type match"); - s.add64(value_.mantissa()); - s.add32(value_.exponent()); + + auto value = value_; + auto const mantissa = value.mantissa(); + auto const exponent = value.exponent(); + + SField const& field = getFName(); + if (field.shouldMeta(SField::sMD_NeedsAsset)) + { + // asset is defined in the STTakesAsset base class + if (asset_) + { + // The number should be rounded to the asset's precision, but round + // it here if it has an asset assigned. + roundToAsset(*asset_, value); + XRPL_ASSERT_PARTS( + value_ == value, + "xrpl::STNumber::add", + "value is already rounded"); + } + else + { +#if !NDEBUG + // There are circumstances where an already-rounded Number is + // serialized without being touched by a transactor, and thus + // without an asset. We can't know if it's rounded, because it could + // represent _anything_, particularly when serializing user-provided + // Json. Regardless, the only time we should be serializing an + // STNumber is when the scale is large. + XRPL_ASSERT_PARTS( + Number::getMantissaScale() == MantissaRange::large, + "xrpl::STNumber::add", + "STNumber only used with large mantissa scale"); +#endif + } + } + + XRPL_ASSERT_PARTS( + mantissa <= std::numeric_limits::max() && + mantissa >= std::numeric_limits::min(), + "xrpl::STNumber::add", + "mantissa in valid range"); + s.add64(mantissa); + s.add32(exponent); } Number const& @@ -179,20 +237,30 @@ numberFromJson(SField const& field, Json::Value const& value) else if (value.isString()) { parts = partsFromString(value.asString()); - // Only strings can represent out-of-range values. - if (parts.mantissa > std::numeric_limits::max()) - Throw("too high"); + + XRPL_ASSERT_PARTS( + !getCurrentTransactionRules(), + "xrpld::numberFromJson", + "Not in a Transactor context"); + + // Number mantissas are much bigger than the allowable parsed values, so + // it can't be out of range. + static_assert( + std::numeric_limits::max() >= + std::numeric_limits::max()); } else { Throw("not a number"); } - std::int64_t mantissa = parts.mantissa; - if (parts.negative) - mantissa = -mantissa; - - return STNumber{field, Number{mantissa, parts.exponent}}; + return STNumber{ + field, + Number{ + parts.negative, + parts.mantissa, + parts.exponent, + Number::normalized{}}}; } } // namespace xrpl diff --git a/src/libxrpl/protocol/STTakesAsset.cpp b/src/libxrpl/protocol/STTakesAsset.cpp new file mode 100644 index 0000000000..d43e7b04a1 --- /dev/null +++ b/src/libxrpl/protocol/STTakesAsset.cpp @@ -0,0 +1,29 @@ +#include +// Do not remove. Force STTakesAsset.h first +#include + +namespace xrpl { + +void +associateAsset(SLE& sle, Asset const& asset) +{ + // Iterating by offset is the only way to get non-const references + for (int i = 0; i < sle.getCount(); ++i) + { + STBase& entry = sle.getIndex(i); + SField const& field = entry.getFName(); + if (field.shouldMeta(SField::sMD_NeedsAsset)) + { + auto const type = entry.getSType(); + // If the field is not set or present, skip it. + if (type == STI_NOTPRESENT) + continue; + // If the type doesn't downcast, then the flag shouldn't be on the + // SField + auto& ta = entry.downcast(); + ta.associateAsset(asset); + } + } +} + +} // namespace xrpl diff --git a/src/test/app/AMMClawback_test.cpp b/src/test/app/AMMClawback_test.cpp index 93fda8fe34..52f05f9ed5 100644 --- a/src/test/app/AMMClawback_test.cpp +++ b/src/test/app/AMMClawback_test.cpp @@ -2425,7 +2425,10 @@ class AMMClawback_test : public beast::unit_test::suite void run() override { - FeatureBitset const all = jtx::testable_amendments(); + // For now, just disable SAV entirely, which locks in the small Number + // mantissas + FeatureBitset const all = jtx::testable_amendments() - + featureSingleAssetVault - featureLendingProtocol; testInvalidRequest(); testFeatureDisabled(all - featureAMMClawback); diff --git a/src/test/app/AMMExtended_test.cpp b/src/test/app/AMMExtended_test.cpp index 317f6cb63d..d1816df51b 100644 --- a/src/test/app/AMMExtended_test.cpp +++ b/src/test/app/AMMExtended_test.cpp @@ -26,6 +26,9 @@ namespace test { */ struct AMMExtended_test : public jtx::AMMTest { + // Use small Number mantissas for the life of this test. + NumberMantissaScaleGuard const sg_{xrpl::MantissaRange::small}; + private: void testRmFundedOffer(FeatureBitset features) @@ -42,6 +45,7 @@ private: // funded and not used for the payment. using namespace jtx; + Env env{*this, features}; fund( @@ -1418,7 +1422,12 @@ private: testOffers() { using namespace jtx; - FeatureBitset const all{testable_amendments()}; + // For now, just disable SAV entirely, which locks in the small Number + // mantissas + FeatureBitset const all{ + testable_amendments() - featureSingleAssetVault - + featureLendingProtocol}; + testRmFundedOffer(all); testRmFundedOffer(all - fixAMMv1_1 - fixAMMv1_3); testEnforceNoRipple(all); @@ -3746,7 +3755,11 @@ private: testFlow() { using namespace jtx; - FeatureBitset const all{testable_amendments()}; + // For now, just disable SAV entirely, which locks in the small Number + // mantissas in the transaction engine + FeatureBitset const all{ + testable_amendments() - featureSingleAssetVault - + featureLendingProtocol}; testFalseDry(all); testBookStep(all); @@ -3760,7 +3773,11 @@ private: testCrossingLimits() { using namespace jtx; - FeatureBitset const all{testable_amendments()}; + // For now, just disable SAV entirely, which locks in the small Number + // mantissas in the transaction engine + FeatureBitset const all{ + testable_amendments() - featureSingleAssetVault - + featureLendingProtocol}; testStepLimit(all); testStepLimit(all - fixAMMv1_1 - fixAMMv1_3); } @@ -3769,7 +3786,11 @@ private: testDeliverMin() { using namespace jtx; - FeatureBitset const all{testable_amendments()}; + // For now, just disable SAV entirely, which locks in the small Number + // mantissas in the transaction engine + FeatureBitset const all{ + testable_amendments() - featureSingleAssetVault - + featureLendingProtocol}; test_convert_all_of_an_asset(all); test_convert_all_of_an_asset(all - fixAMMv1_1 - fixAMMv1_3); } @@ -3777,7 +3798,12 @@ private: void testDepositAuth() { - testPayment(jtx::testable_amendments()); + // For now, just disable SAV entirely, which locks in the small Number + // mantissas in the transaction engine + FeatureBitset const all{ + jtx::testable_amendments() - featureSingleAssetVault - + featureLendingProtocol}; + testPayment(all); testPayIOU(); } @@ -3785,7 +3811,11 @@ private: testFreeze() { using namespace test::jtx; - auto const sa = testable_amendments(); + // For now, just disable SAV entirely, which locks in the small Number + // mantissas in the transaction engine + FeatureBitset const sa{ + testable_amendments() - featureSingleAssetVault - + featureLendingProtocol}; testRippleState(sa); testGlobalFreeze(sa); testOffersWhenFrozen(sa); diff --git a/src/test/app/AMM_test.cpp b/src/test/app/AMM_test.cpp index 468d5b3ffd..55bf4aa0a3 100644 --- a/src/test/app/AMM_test.cpp +++ b/src/test/app/AMM_test.cpp @@ -30,7 +30,19 @@ namespace test { */ struct AMM_test : public jtx::AMMTest { + // Use small Number mantissas for the life of this test. + NumberMantissaScaleGuard const sg_{xrpl::MantissaRange::small}; + private: + static FeatureBitset + testable_amendments() + { + // For now, just disable SAV entirely, which locks in the small Number + // mantissas + return jtx::testable_amendments() - featureSingleAssetVault - + featureLendingProtocol; + } + void testInstanceCreate() { @@ -38,6 +50,7 @@ private: using namespace jtx; +#if NUMBERTODO // XRP to IOU, with featureSingleAssetVault testAMM( [&](AMM& ammAlice, Env&) { @@ -48,6 +61,7 @@ private: 0, {}, {testable_amendments() | featureSingleAssetVault}); +#endif // XRP to IOU, without featureSingleAssetVault testAMM( @@ -1365,8 +1379,8 @@ private: { testcase("Deposit"); - using namespace jtx; auto const all = testable_amendments(); + using namespace jtx; // Equal deposit: 1000000 tokens, 10% of the current pool testAMM([&](AMM& ammAlice, Env& env) { @@ -1384,15 +1398,14 @@ private: // equal asset deposit: unit test to exercise the rounding-down of // LPTokens in the AMMHelpers.cpp: adjustLPTokens calculations // The LPTokens need to have 16 significant digits and a fractional part - for (Number const deltaLPTokens : + for (Number const& deltaLPTokens : {Number{UINT64_C(100000'0000000009), -10}, Number{UINT64_C(100000'0000000001), -10}}) { testAMM([&](AMM& ammAlice, Env& env) { // initial LPToken balance IOUAmount const initLPToken = ammAlice.getLPTokensBalance(); - IOUAmount const newLPTokens{ - deltaLPTokens.mantissa(), deltaLPTokens.exponent()}; + IOUAmount const newLPTokens{deltaLPTokens}; // carol performs a two-asset deposit ammAlice.deposit( @@ -1417,11 +1430,9 @@ private: Number const deltaXRP = fr * 1e10; Number const deltaUSD = fr * 1e4; - STAmount const depositUSD = - STAmount{USD, deltaUSD.mantissa(), deltaUSD.exponent()}; + STAmount const depositUSD = STAmount{USD, deltaUSD}; - STAmount const depositXRP = - STAmount{XRP, deltaXRP.mantissa(), deltaXRP.exponent()}; + STAmount const depositXRP = STAmount{XRP, deltaXRP}; // initial LPTokens (1e7) + newLPTokens BEAST_EXPECT(ammAlice.expectBalances( @@ -1487,7 +1498,7 @@ private: }); // Single deposit: 100000 tokens worth of XRP - testAMM([&](AMM& ammAlice, Env&) { + testAMM([&](AMM& ammAlice, Env& env) { ammAlice.deposit(carol, 100'000, XRP(205)); BEAST_EXPECT(ammAlice.expectBalances( XRP(10'201), USD(10'000), IOUAmount{10'100'000, 0})); @@ -1668,8 +1679,8 @@ private: { testcase("Invalid Withdraw"); - using namespace jtx; auto const all = testable_amendments(); + using namespace jtx; testAMM( [&](AMM& ammAlice, Env& env) { @@ -2248,8 +2259,8 @@ private: { testcase("Withdraw"); - using namespace jtx; auto const all = testable_amendments(); + using namespace jtx; // Equal withdrawal by Carol: 1000000 of tokens, 10% of the current // pool @@ -2669,8 +2680,8 @@ private: testFeeVote() { testcase("Fee Vote"); - using namespace jtx; auto const all = testable_amendments(); + using namespace jtx; // One vote sets fee to 1%. testAMM([&](AMM& ammAlice, Env& env) { @@ -3014,6 +3025,10 @@ private: using namespace jtx; using namespace std::chrono; + // For now, just disable SAV entirely, which locks in the small Number + // mantissas + features = features - featureSingleAssetVault - featureLendingProtocol; + // Auction slot initially is owned by AMM creator, who pays 0 price. // Bid 110 tokens. Pay bidMin. @@ -3758,6 +3773,11 @@ private: testcase("Basic Payment"); using namespace jtx; + // For now, just disable SAV entirely, which locks in the small Number + // mantissas + features = features - featureSingleAssetVault - featureLendingProtocol - + featureLendingProtocol; + // Payment 100USD for 100XRP. // Force one path with tfNoRippleDirect. testAMM( @@ -4836,12 +4856,12 @@ private: testAmendment() { testcase("Amendment"); - using namespace jtx; FeatureBitset const all{testable_amendments()}; FeatureBitset const noAMM{all - featureAMM}; FeatureBitset const noNumber{all - fixUniversalNumber}; FeatureBitset const noAMMAndNumber{ all - featureAMM - fixUniversalNumber}; + using namespace jtx; for (auto const& feature : {noAMM, noNumber, noAMMAndNumber}) { @@ -6476,6 +6496,8 @@ private: Env env(*this, features, std::make_unique(&logs)); auto rules = env.current()->rules(); CurrentTransactionRulesGuard rg(rules); + NumberMantissaScaleGuard sg(MantissaRange::small); + for (auto const& t : tests) { auto getPool = [&](std::string const& v, bool isXRP) { @@ -7025,7 +7047,7 @@ private: {{xrpPool, iouPool}}, 889, std::nullopt, - {jtx::testable_amendments() | fixAMMv1_1}); + {testable_amendments() | fixAMMv1_1}); } void @@ -7566,6 +7588,7 @@ private: { auto const [amount, amount2, lptBalance] = amm.balances(GBP, EUR); + NumberMantissaScaleGuard sg(MantissaRange::small); NumberRoundModeGuard g( env.enabled(fixAMMv1_3) ? Number::upward : Number::getround()); auto const res = root2(amount * amount2); @@ -7880,7 +7903,7 @@ private: void run() override { - FeatureBitset const all{jtx::testable_amendments()}; + FeatureBitset const all{testable_amendments()}; testInvalidInstance(); testInstanceCreate(); testInvalidDeposit(all); diff --git a/src/test/app/EscrowToken_test.cpp b/src/test/app/EscrowToken_test.cpp index 955ca8f449..589a8b474e 100644 --- a/src/test/app/EscrowToken_test.cpp +++ b/src/test/app/EscrowToken_test.cpp @@ -559,12 +559,15 @@ struct EscrowToken_test : public beast::unit_test::suite env(pay(gw, bob, USD(1))); env.close(); + bool const largeMantissa = features[featureSingleAssetVault] || + features[featureLendingProtocol]; + // alice cannot create escrow for 1/10 iou - precision loss env(escrow::create(alice, bob, USD(1)), escrow::condition(escrow::cb1), escrow::finish_time(env.now() + 1s), fee(baseFee * 150), - ter(tecPRECISION_LOSS)); + ter(largeMantissa ? (TER)tesSUCCESS : (TER)tecPRECISION_LOSS)); env.close(); } } @@ -2076,12 +2079,15 @@ struct EscrowToken_test : public beast::unit_test::suite env(pay(gw, bob, USD(1))); env.close(); + bool const largeMantissa = features[featureSingleAssetVault] || + features[featureLendingProtocol]; + // alice cannot create escrow for 1/10 iou - precision loss env(escrow::create(alice, bob, USD(1)), escrow::condition(escrow::cb1), escrow::finish_time(env.now() + 1s), fee(baseFee * 150), - ter(tecPRECISION_LOSS)); + ter(largeMantissa ? (TER)tesSUCCESS : (TER)tecPRECISION_LOSS)); env.close(); auto const seq1 = env.seq(alice); @@ -3924,9 +3930,13 @@ public: { using namespace test::jtx; FeatureBitset const all{testable_amendments()}; - testIOUWithFeats(all); - testMPTWithFeats(all); - testMPTWithFeats(all - fixTokenEscrowV1); + for (FeatureBitset const& feats : + {all - featureSingleAssetVault - featureLendingProtocol, all}) + { + testIOUWithFeats(feats); + testMPTWithFeats(feats); + testMPTWithFeats(feats - fixTokenEscrowV1); + } } }; diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp index 50efe0ebe3..55fffad6b0 100644 --- a/src/test/app/LendingHelpers_test.cpp +++ b/src/test/app/LendingHelpers_test.cpp @@ -104,7 +104,7 @@ class LendingHelpers_test : public beast::unit_test::suite .name = "Multiple payments remaining", .periodicRate = Number{5, -2}, .paymentsRemaining = 3, - .expectedPaymentFactor = Number{367208564631245, -15}, + .expectedPaymentFactor = Number{3672085646312450436, -19}, }, // from calc { .name = "Zero payments remaining", @@ -172,7 +172,7 @@ class LendingHelpers_test : public beast::unit_test::suite loanPeriodicRate(TenthBips32(100'000), 30 * 24 * 60 * 60), .paymentsRemaining = 3, .expectedPeriodicPayment = - Number{3895690663961231, -13}, // from calc + Number{389569066396123265, -15}, // from calc }, }; @@ -229,7 +229,8 @@ class LendingHelpers_test : public beast::unit_test::suite }, { .name = "Standard case", - .periodicPayment = Number{3895690663961231, -13}, // from calc + .periodicPayment = + Number{389569066396123265, -15}, // from calc .periodicRate = loanPeriodicRate(TenthBips32(100'000), 30 * 24 * 60 * 60), .paymentsRemaining = 3, @@ -426,7 +427,7 @@ class LendingHelpers_test : public beast::unit_test::suite NetClock::time_point{NetClock::duration{3'000}}, .nextPaymentDueDate = 2'000, .expectedLateInterest = - Number{3170979198376459, -17}, // from calc + Number{317097919837645865, -19}, // from calc }, }; @@ -519,7 +520,7 @@ class LendingHelpers_test : public beast::unit_test::suite .prevPaymentDate = 2'000, .paymentInterval = 30 * 24 * 60 * 60, .expectedAccruedInterest = - Number{1929012345679012, -17}, // from calc + Number{1929012345679012346, -20}, // from calc }, }; @@ -587,7 +588,7 @@ class LendingHelpers_test : public beast::unit_test::suite .startDate = 1'000, .closeInterestRate = TenthBips32{0}, .expectedFullPaymentInterest = - Number{1929012345679012, -17}, // from calc + Number{1929012345679012346, -20}, // from calc }, { .name = "Standard case", @@ -600,7 +601,7 @@ class LendingHelpers_test : public beast::unit_test::suite .startDate = 1'000, .closeInterestRate = TenthBips32{10'000}, .expectedFullPaymentInterest = - Number{1000192901234568, -13}, // from calc + Number{1000192901234567901, -16}, // from calc }, }; diff --git a/src/test/app/LoanBroker_test.cpp b/src/test/app/LoanBroker_test.cpp index 93be28e9e9..769ed40321 100644 --- a/src/test/app/LoanBroker_test.cpp +++ b/src/test/app/LoanBroker_test.cpp @@ -752,30 +752,36 @@ class LoanBroker_test : public beast::unit_test::suite // LoanBrokerID env(set(alice, vault.vaultID), loanBrokerID(nextKeylet.key), - ter(tecNO_ENTRY)); + ter(tecNO_ENTRY), + THISLINE); // VaultID env(set(alice, nextKeylet.key), loanBrokerID(broker->key()), - ter(tecNO_PERMISSION)); + ter(tecNO_ENTRY), + THISLINE); // Owner env(set(evan, vault.vaultID), loanBrokerID(broker->key()), - ter(tecNO_PERMISSION)); + ter(tecNO_PERMISSION), + THISLINE); // ManagementFeeRate env(set(alice, vault.vaultID), loanBrokerID(broker->key()), managementFeeRate(maxManagementFeeRate), - ter(temINVALID)); + ter(temINVALID), + THISLINE); // CoverRateMinimum env(set(alice, vault.vaultID), loanBrokerID(broker->key()), coverRateMinimum(maxManagementFeeRate), - ter(temINVALID)); + ter(temINVALID), + THISLINE); // CoverRateLiquidation env(set(alice, vault.vaultID), loanBrokerID(broker->key()), coverRateLiquidation(maxManagementFeeRate), - ter(temINVALID)); + ter(temINVALID), + THISLINE); // fields that can be changed testData = "Test Data 1234"; @@ -783,23 +789,43 @@ class LoanBroker_test : public beast::unit_test::suite env(set(alice, vault.vaultID), loanBrokerID(broker->key()), data(std::string(maxDataPayloadLength + 1, 'W')), - ter(temINVALID)); + ter(temINVALID), + THISLINE); // Bad debt maximum env(set(alice, vault.vaultID), loanBrokerID(broker->key()), debtMaximum(Number(-175, -1)), - ter(temINVALID)); + ter(temINVALID), + THISLINE); + Number debtMax{175, -1}; + if (vault.asset.integral()) + { + env(set(alice, vault.vaultID), + loanBrokerID(broker->key()), + data(testData), + debtMaximum(debtMax), + ter(tecPRECISION_LOSS), + THISLINE); + roundToAsset(vault.asset, debtMax); + } // Data & Debt maximum env(set(alice, vault.vaultID), loanBrokerID(broker->key()), data(testData), - debtMaximum(Number(175, -1))); + debtMaximum(debtMax), + THISLINE); }, [&](SLE::const_ref broker) { // Check the updated fields BEAST_EXPECT(checkVL(broker->at(sfData), testData)); - BEAST_EXPECT(broker->at(sfDebtMaximum) == Number(175, -1)); + Number const expected = + STAmount{vault.asset, Number(175, -1)}; + auto const actual = broker->at(sfDebtMaximum); + BEAST_EXPECTS( + actual == expected, + "Expected: " + to_string(expected) + + ", Actual: " + to_string(actual)); }); lifecycle( @@ -1457,9 +1483,14 @@ class LoanBroker_test : public beast::unit_test::suite env.close(); PrettyAsset const asset = [&]() { - env(trust(alice, issuer["IOU"](1'000'000)), THISLINE); + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); env.close(); - return PrettyAsset(issuer["IOU"]); + PrettyAsset const mptAsset = mptt["MPT"]; + mptt.authorize({.account = alice}); + env.close(); + return mptAsset; }(); env(pay(issuer, alice, asset(100'000)), THISLINE); @@ -1512,6 +1543,40 @@ class LoanBroker_test : public beast::unit_test::suite tx2[sfDebtMaximum] = 0; env(tx2, ter(tesSUCCESS), THISLINE); + + tx2[sfDebtMaximum] = Json::Value::maxInt; + env(tx2, ter(tesSUCCESS), THISLINE); + + { + auto const dm = power(2, 64) - 1; + BEAST_EXPECT(dm > maxMPTokenAmount); + tx2[sfDebtMaximum] = dm; + env(tx2, ter(temINVALID), THISLINE); + } + + { + auto const dm = power(2, 63) - 1; + BEAST_EXPECTS(dm > maxMPTokenAmount, to_string(dm)); + tx2[sfDebtMaximum] = dm; + env(tx2, ter(temINVALID), THISLINE); + } + + { + auto const dm = power(2, 63) - 3; + BEAST_EXPECTS(dm == maxMPTokenAmount, to_string(dm)); + tx2[sfDebtMaximum] = dm; + env(tx2, ter(tesSUCCESS), THISLINE); + } + + { + auto const dm = 2 * (power(2, 62) - 1) + 1; + BEAST_EXPECTS(dm == maxMPTokenAmount, to_string(dm)); + tx2[sfDebtMaximum] = dm; + env(tx2, ter(tesSUCCESS), THISLINE); + } + + tx2[sfDebtMaximum] = Number{9223372036854775807, 0}; + env(tx2, ter(tesSUCCESS), THISLINE); } void diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index e4f5360043..e9780211de 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -352,8 +352,14 @@ protected: env.balance(account, broker.asset) - (balanceBefore - balanceChangeAmount), borrowerScale); - env.test.BEAST_EXPECT( - roundToScale(difference, loanScale) >= beast::zero); + env.test.expect( + roundToScale(difference, loanScale) >= beast::zero, + "Balance before: " + to_string(balanceBefore.value()) + + ", expected change: " + to_string(balanceChangeAmount) + + ", difference (balance after - expected): " + + to_string(difference), + __FILE__, + __LINE__); } } @@ -2396,7 +2402,7 @@ protected: interval * Number(12, -2) / secondsInYear; BEAST_EXPECT( periodicRate == - Number(2283105022831050, -21, Number::unchecked{})); + Number(2283105022831050228ULL, -24, Number::normalized{})); STAmount const principalOutstanding{ broker.asset, state.principalOutstanding}; STAmount const accruedInterest{ @@ -2449,6 +2455,10 @@ protected: getCurrentState(env, broker, loanKeylet, verifyLoanStatus); env.close(); + BEAST_EXPECT( + STAmount(broker.asset, state.periodicPayment) == + broker.asset(Number(8333457002039338267, -17))); + // Make all the payments in one transaction // service fee is 2 auto const startingPayments = state.paymentRemaining; @@ -2456,15 +2466,28 @@ protected: NumberRoundModeGuard mg(Number::upward); auto const rawPayoff = startingPayments * (state.periodicPayment + broker.asset(2).value()); - STAmount const payoffAmount{broker.asset, rawPayoff}; + STAmount payoffAmount{broker.asset, rawPayoff}; BEAST_EXPECTS( payoffAmount == - broker.asset(Number(1024014840139457, -12)), + broker.asset(Number(1024014840244721, -12)), to_string(payoffAmount)); BEAST_EXPECT(payoffAmount > state.principalOutstanding); + + payoffAmount = roundToScale(payoffAmount, state.loanScale); + return payoffAmount; }(); + auto const totalPayoffValue = state.totalValue + + startingPayments * broker.asset(2).value(); + STAmount const totalPayoffAmount{ + broker.asset, totalPayoffValue}; + + BEAST_EXPECTS( + totalPayoffAmount == payoffAmount, + "Payoff amount: " + to_string(payoffAmount) + + ". Total Value: " + to_string(totalPayoffAmount)); + singlePayment( loanKeylet, verifyLoanStatus, @@ -2633,7 +2656,7 @@ protected: interval * Number(12, -2) / secondsInYear; BEAST_EXPECT( periodicRate == - Number(2283105022831050, -21, Number::unchecked{})); + Number(2283105022831050228, -24, Number::normalized{})); STAmount const roundedPeriodicPayment{ broker.asset, roundPeriodicPayment( @@ -2651,7 +2674,7 @@ protected: roundedPeriodicPayment == roundToScale( broker.asset( - Number(8333457001162141, -14), Number::upward), + Number(8333457002039338267, -17), Number::upward), state.loanScale, Number::upward)); // 83334570.01162141 @@ -2666,7 +2689,7 @@ protected: totalDue == roundToScale( broker.asset( - Number(8533457001162141, -14), Number::upward), + Number(8533457002039338267, -17), Number::upward), state.loanScale, Number::upward)); @@ -2702,7 +2725,7 @@ protected: transactionAmount == roundToScale( broker.asset( - Number(9533457001162141, -14), Number::upward), + Number(9533457002039400, -14), Number::upward), state.loanScale, Number::upward)); @@ -2735,9 +2758,15 @@ protected: state.paymentRemaining, broker.params.managementFeeRate); - BEAST_EXPECT( - paymentComponents.trackedValueDelta <= - roundedPeriodicPayment); + BEAST_EXPECTS( + paymentComponents.specialCase == + detail::PaymentSpecialCase::final || + paymentComponents.trackedValueDelta <= + roundedPeriodicPayment, + "Delta: " + + to_string(paymentComponents.trackedValueDelta) + + ", periodic payment: " + + to_string(roundedPeriodicPayment)); xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( @@ -2792,8 +2821,10 @@ protected: paymentComponents.trackedInterestPart() + paymentComponents.trackedManagementFeeDelta); BEAST_EXPECT( + paymentComponents.specialCase == + detail::PaymentSpecialCase::final || paymentComponents.trackedValueDelta <= - roundedPeriodicPayment); + roundedPeriodicPayment); BEAST_EXPECT( state.paymentRemaining < 12 || @@ -2804,7 +2835,7 @@ protected: Number::upward) == roundToScale( broker.asset( - Number(8333228695260180, -14), + Number(8333228691531218890, -17), Number::upward), state.loanScale, Number::upward)); @@ -3689,7 +3720,7 @@ protected: env(pay(issuer, borrower, mptAsset(10'000))); env.close(); - std::array const assets{xrpAsset, mptAsset, iouAsset}; + std::array const assets{iouAsset, xrpAsset, mptAsset}; // Create vaults and loan brokers std::vector brokers; @@ -5041,7 +5072,6 @@ protected: auto const loanSetFee = fee(env.current()->fees().base * 2); Number const principalRequest{1, 3}; - auto const startDate = env.now() + 60s; auto createJson = env.json( set(borrower, broker.brokerID, principalRequest), @@ -5065,13 +5095,13 @@ protected: auto const keylet = keylet::loan(broker.brokerID, loanSequence); createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); - env(createJson, ter(tecPRECISION_LOSS), THISLINE); - env.close(startDate); + env(createJson, THISLINE); + env.close(); auto loanPayTx = env.json( pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); loanPayTx["Amount"]["value"] = "0.000281284125490196"; - env(loanPayTx, ter(tecNO_ENTRY)); + env(loanPayTx, ter(tecINSUFFICIENT_PAYMENT), THISLINE); env.close(); } @@ -5640,21 +5670,26 @@ protected: BEAST_EXPECT(beforeState.periodicPayment > 0); // pay all but the last payment - Number const payment = beforeState.periodicPayment * (total - 1); - XRPAmount const payFee{ - baseFee * ((total - 1) / loanPaymentsPerFeeIncrement + 1)}; - auto loanPayTx = env.json( - pay(borrower, keylet.key, STAmount{broker.asset, payment}), - fee(payFee)); - env(loanPayTx, ter(tesSUCCESS)); - env.close(); + { + NumberRoundModeGuard mg{Number::upward}; + Number const payment = + beforeState.periodicPayment * (total - 1); + XRPAmount const payFee{ + baseFee * ((total - 1) / loanPaymentsPerFeeIncrement + 1)}; + STAmount const paymentAmount = roundToScale( + STAmount{broker.asset, payment}, beforeState.loanScale); + auto loanPayTx = env.json( + pay(borrower, keylet.key, paymentAmount), fee(payFee)); + env(loanPayTx, ter(tesSUCCESS)); + env.close(); + } // The loan is on the last payment auto const afterState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(afterState.paymentRemaining == 1); BEAST_EXPECT(afterState.nextPaymentDate == maxTime - grace); BEAST_EXPECT( afterState.previousPaymentDate == maxTime - grace - interval); - BEAST_EXPECT(afterState.paymentRemaining == 1); } } diff --git a/src/test/app/MPToken_test.cpp b/src/test/app/MPToken_test.cpp index 747f78ef6b..65cb753755 100644 --- a/src/test/app/MPToken_test.cpp +++ b/src/test/app/MPToken_test.cpp @@ -1594,7 +1594,7 @@ class MPToken_test : public beast::unit_test::suite jv[jss::secret] = alice.name(); jv[jss::tx_json] = pay(alice, bob, mpt); jv[jss::tx_json][jss::Amount][jss::value] = - to_string(maxMPTokenAmount + 1); + std::to_string(maxMPTokenAmount + 1); auto const jrr = env.rpc("json", "submit", to_string(jv)); BEAST_EXPECT(jrr[jss::result][jss::error] == "invalidParams"); } @@ -2474,7 +2474,7 @@ class MPToken_test : public beast::unit_test::suite alice.name(), makeMptID(env.seq(alice), alice)); Json::Value jv = claw(alice, mpt(1), bob); - jv[jss::Amount][jss::value] = to_string(maxMPTokenAmount + 1); + jv[jss::Amount][jss::value] = std::to_string(maxMPTokenAmount + 1); Json::Value jv1; jv1[jss::secret] = alice.name(); jv1[jss::tx_json] = jv; diff --git a/src/test/basics/IOUAmount_test.cpp b/src/test/basics/IOUAmount_test.cpp index 305f2c83a1..d299f439d4 100644 --- a/src/test/basics/IOUAmount_test.cpp +++ b/src/test/basics/IOUAmount_test.cpp @@ -141,15 +141,28 @@ public: { testcase("IOU strings"); - BEAST_EXPECT(to_string(IOUAmount(-2, 0)) == "-2"); - BEAST_EXPECT(to_string(IOUAmount(0, 0)) == "0"); - BEAST_EXPECT(to_string(IOUAmount(2, 0)) == "2"); - BEAST_EXPECT(to_string(IOUAmount(25, -3)) == "0.025"); - BEAST_EXPECT(to_string(IOUAmount(-25, -3)) == "-0.025"); - BEAST_EXPECT(to_string(IOUAmount(25, 1)) == "250"); - BEAST_EXPECT(to_string(IOUAmount(-25, 1)) == "-250"); - BEAST_EXPECT(to_string(IOUAmount(2, 20)) == "2000000000000000e5"); - BEAST_EXPECT(to_string(IOUAmount(-2, -20)) == "-2000000000000000e-35"); + auto test = [this](IOUAmount const& n, std::string const& expected) { + auto const result = to_string(n); + std::stringstream ss; + ss << "to_string(" << result << "). Expected: " << expected; + BEAST_EXPECTS(result == expected, ss.str()); + }; + + for (auto const mantissaSize : + {MantissaRange::small, MantissaRange::large}) + { + NumberMantissaScaleGuard mg(mantissaSize); + + test(IOUAmount(-2, 0), "-2"); + test(IOUAmount(0, 0), "0"); + test(IOUAmount(2, 0), "2"); + test(IOUAmount(25, -3), "0.025"); + test(IOUAmount(-25, -3), "-0.025"); + test(IOUAmount(25, 1), "250"); + test(IOUAmount(-25, 1), "-250"); + test(IOUAmount(2, 20), "2e20"); + test(IOUAmount(-2, -20), "-2e-20"); + } } void diff --git a/src/test/basics/Number_test.cpp b/src/test/basics/Number_test.cpp index b7c5ee45b7..1fa5ae6e8f 100644 --- a/src/test/basics/Number_test.cpp +++ b/src/test/basics/Number_test.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -14,46 +15,84 @@ public: void testZero() { - testcase("zero"); + testcase << "zero " << to_string(Number::getMantissaScale()); - Number const z{0, 0}; + for (Number const& z : {Number{0, 0}, Number{0}}) + { + BEAST_EXPECT(z.mantissa() == 0); + BEAST_EXPECT(z.exponent() == Number{}.exponent()); - BEAST_EXPECT(z.mantissa() == 0); - BEAST_EXPECT(z.exponent() == Number{}.exponent()); - - BEAST_EXPECT((z + z) == z); - BEAST_EXPECT((z - z) == z); - BEAST_EXPECT(z == -z); + BEAST_EXPECT((z + z) == z); + BEAST_EXPECT((z - z) == z); + BEAST_EXPECT(z == -z); + } } void test_limits() { - testcase("test_limits"); + auto const scale = Number::getMantissaScale(); + testcase << "test_limits " << to_string(scale); bool caught = false; + auto const minMantissa = Number::minMantissa(); try { - Number x{10'000'000'000'000'000, 32768}; + Number x = + Number{false, minMantissa * 10, 32768, Number::normalized{}}; } catch (std::overflow_error const&) { caught = true; } BEAST_EXPECT(caught); - Number x{10'000'000'000'000'000, 32767}; - BEAST_EXPECT((x == Number{1'000'000'000'000'000, 32768})); - Number z{1'000'000'000'000'000, -32769}; - BEAST_EXPECT(z == Number{}); - Number y{1'000'000'000'000'001'500, 32000}; - BEAST_EXPECT((y == Number{1'000'000'000'000'002, 32003})); - Number m{std::numeric_limits::min()}; - BEAST_EXPECT((m == Number{-9'223'372'036'854'776, 3})); - Number M{std::numeric_limits::max()}; - BEAST_EXPECT((M == Number{9'223'372'036'854'776, 3})); + + auto test = [this](auto const& x, auto const& y, int line) { + auto const result = x == y; + std::stringstream ss; + ss << x << " == " << y << " -> " << (result ? "true" : "false"); + expect(result, ss.str(), __FILE__, line); + }; + + test( + Number{false, minMantissa * 10, 32767, Number::normalized{}}, + Number{false, minMantissa, 32768, Number::normalized{}}, + __LINE__); + test( + Number{false, minMantissa, -32769, Number::normalized{}}, + Number{}, + __LINE__); + test( + Number{false, minMantissa, 32000, Number::normalized{}} * 1'000 + + Number{false, 1'500, 32000, Number::normalized{}}, + Number{false, minMantissa + 2, 32003, Number::normalized{}}, + __LINE__); + // 9,223,372,036,854,775,808 + + test( + Number{std::numeric_limits::min()}, + scale == MantissaRange::small + ? Number{-9'223'372'036'854'776, 3} + : Number{true, 9'223'372'036'854'775'808ULL, 0, Number::normalized{}}, + __LINE__); + test( + Number{std::numeric_limits::min() + 1}, + scale == MantissaRange::small ? Number{-9'223'372'036'854'776, 3} + : Number{-9'223'372'036'854'775'807}, + __LINE__); + test( + Number{std::numeric_limits::max()}, + Number{ + scale == MantissaRange::small + ? 9'223'372'036'854'776 + : std::numeric_limits::max(), + 18 - Number::mantissaLog()}, + __LINE__); caught = false; try { - Number q{99'999'999'999'999'999, 32767}; + [[maybe_unused]] + Number q = + Number{false, minMantissa, 32767, Number::normalized{}} * 100; } catch (std::overflow_error const&) { @@ -65,76 +104,307 @@ public: void test_add() { - testcase("test_add"); + auto const scale = Number::getMantissaScale(); + testcase << "test_add " << to_string(scale); + using Case = std::tuple; - Case c[]{ - {Number{1'000'000'000'000'000, -15}, - Number{6'555'555'555'555'555, -29}, - Number{1'000'000'000'000'066, -15}}, - {Number{-1'000'000'000'000'000, -15}, - Number{-6'555'555'555'555'555, -29}, - Number{-1'000'000'000'000'066, -15}}, - {Number{-1'000'000'000'000'000, -15}, - Number{6'555'555'555'555'555, -29}, - Number{-9'999'999'999'999'344, -16}}, - {Number{-6'555'555'555'555'555, -29}, - Number{1'000'000'000'000'000, -15}, - Number{9'999'999'999'999'344, -16}}, - {Number{}, Number{5}, Number{5}}, - {Number{5'555'555'555'555'555, -32768}, - Number{-5'555'555'555'555'554, -32768}, - Number{0}}, - {Number{-9'999'999'999'999'999, -31}, - Number{1'000'000'000'000'000, -15}, - Number{9'999'999'999'999'990, -16}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x + y == z); - bool caught = false; - try + auto const cSmall = std::to_array( + {{Number{1'000'000'000'000'000, -15}, + Number{6'555'555'555'555'555, -29}, + Number{1'000'000'000'000'066, -15}}, + {Number{-1'000'000'000'000'000, -15}, + Number{-6'555'555'555'555'555, -29}, + Number{-1'000'000'000'000'066, -15}}, + {Number{-1'000'000'000'000'000, -15}, + Number{6'555'555'555'555'555, -29}, + Number{-9'999'999'999'999'344, -16}}, + {Number{-6'555'555'555'555'555, -29}, + Number{1'000'000'000'000'000, -15}, + Number{9'999'999'999'999'344, -16}}, + {Number{}, Number{5}, Number{5}}, + {Number{5}, Number{}, Number{5}}, + {Number{5'555'555'555'555'555, -32768}, + Number{-5'555'555'555'555'554, -32768}, + Number{0}}, + {Number{-9'999'999'999'999'999, -31}, + Number{1'000'000'000'000'000, -15}, + Number{9'999'999'999'999'990, -16}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items from C + // with larger mantissa + { + {Number{1'000'000'000'000'000, -15}, + Number{6'555'555'555'555'555, -29}, + Number{1'000'000'000'000'065'556, -18}}, + {Number{-1'000'000'000'000'000, -15}, + Number{-6'555'555'555'555'555, -29}, + Number{-1'000'000'000'000'065'556, -18}}, + {Number{-1'000'000'000'000'000, -15}, + Number{6'555'555'555'555'555, -29}, + Number{ + true, + 9'999'999'999'999'344'444ULL, + -19, + Number::normalized{}}}, + {Number{-6'555'555'555'555'555, -29}, + Number{1'000'000'000'000'000, -15}, + Number{ + false, + 9'999'999'999'999'344'444ULL, + -19, + Number::normalized{}}}, + {Number{}, Number{5}, Number{5}}, + {Number{5}, Number{}, Number{5}}, + {Number{5'555'555'555'555'555'000, -32768}, + Number{-5'555'555'555'555'554'000, -32768}, + Number{0}}, + {Number{-9'999'999'999'999'999, -31}, + Number{1'000'000'000'000'000, -15}, + Number{9'999'999'999'999'990, -16}}, + // Items from cSmall expanded for the larger mantissa + {Number{1'000'000'000'000'000'000, -18}, + Number{6'555'555'555'555'555'555, -35}, + Number{1'000'000'000'000'000'066, -18}}, + {Number{-1'000'000'000'000'000'000, -18}, + Number{-6'555'555'555'555'555'555, -35}, + Number{-1'000'000'000'000'000'066, -18}}, + {Number{-1'000'000'000'000'000'000, -18}, + Number{6'555'555'555'555'555'555, -35}, + Number{ + true, + 9'999'999'999'999'999'344ULL, + -19, + Number::normalized{}}}, + {Number{-6'555'555'555'555'555'555, -35}, + Number{1'000'000'000'000'000'000, -18}, + Number{ + false, + 9'999'999'999'999'999'344ULL, + -19, + Number::normalized{}}}, + {Number{}, Number{5}, Number{5}}, + {Number{5'555'555'555'555'555'555, -32768}, + Number{-5'555'555'555'555'555'554, -32768}, + Number{0}}, + {Number{ + true, + 9'999'999'999'999'999'999ULL, + -37, + Number::normalized{}}, + Number{1'000'000'000'000'000'000, -18}, + Number{ + false, + 9'999'999'999'999'999'990ULL, + -19, + Number::normalized{}}}, + {Number{Number::maxRep}, + Number{6, -1}, + Number{Number::maxRep / 10, 1}}, + {Number{Number::maxRep - 1}, + Number{1, 0}, + Number{Number::maxRep}}, + // Test extremes + { + // Each Number operand rounds up, so the actual mantissa is + // minMantissa + Number{ + false, + 9'999'999'999'999'999'999ULL, + 0, + Number::normalized{}}, + Number{ + false, + 9'999'999'999'999'999'999ULL, + 0, + Number::normalized{}}, + Number{2, 19}, + }, + { + // Does not round. Mantissas are going to be > maxRep, so if + // added together as uint64_t's, the result will overflow. + // With addition using uint128_t, there's no problem. After + // normalizing, the resulting mantissa ends up less than + // maxRep. + Number{ + false, + 9'999'999'999'999'999'990ULL, + 0, + Number::normalized{}}, + Number{ + false, + 9'999'999'999'999'999'990ULL, + 0, + Number::normalized{}}, + Number{ + false, + 1'999'999'999'999'999'998ULL, + 1, + Number::normalized{}}, + }, + }); + auto test = [this](auto const& c) { + for (auto const& [x, y, z] : c) + { + auto const result = x + y; + std::stringstream ss; + ss << x << " + " << y << " = " << result << ". Expected: " << z; + BEAST_EXPECTS(result == z, ss.str()); + } + }; + if (scale == MantissaRange::small) + test(cSmall); + else + test(cLarge); { - Number{9'999'999'999'999'999, 32768} + - Number{5'000'000'000'000'000, 32767}; + bool caught = false; + try + { + Number{ + false, Number::maxMantissa(), 32768, Number::normalized{}} + + Number{ + false, + Number::minMantissa(), + 32767, + Number::normalized{}} * + 5; + } + catch (std::overflow_error const&) + { + caught = true; + } + BEAST_EXPECT(caught); } - catch (std::overflow_error const&) - { - caught = true; - } - BEAST_EXPECT(caught); } void test_sub() { - testcase("test_sub"); + auto const scale = Number::getMantissaScale(); + testcase << "test_sub " << to_string(scale); + using Case = std::tuple; - Case c[]{ - {Number{1'000'000'000'000'000, -15}, - Number{6'555'555'555'555'555, -29}, - Number{9'999'999'999'999'344, -16}}, - {Number{6'555'555'555'555'555, -29}, - Number{1'000'000'000'000'000, -15}, - Number{-9'999'999'999'999'344, -16}}, - {Number{1'000'000'000'000'000, -15}, - Number{1'000'000'000'000'000, -15}, - Number{0}}, - {Number{1'000'000'000'000'000, -15}, - Number{1'000'000'000'000'001, -15}, - Number{-1'000'000'000'000'000, -30}}, - {Number{1'000'000'000'000'001, -15}, - Number{1'000'000'000'000'000, -15}, - Number{1'000'000'000'000'000, -30}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x - y == z); + auto const cSmall = std::to_array( + {{Number{1'000'000'000'000'000, -15}, + Number{6'555'555'555'555'555, -29}, + Number{9'999'999'999'999'344, -16}}, + {Number{6'555'555'555'555'555, -29}, + Number{1'000'000'000'000'000, -15}, + Number{-9'999'999'999'999'344, -16}}, + {Number{1'000'000'000'000'000, -15}, + Number{1'000'000'000'000'000, -15}, + Number{0}}, + {Number{1'000'000'000'000'000, -15}, + Number{1'000'000'000'000'001, -15}, + Number{-1'000'000'000'000'000, -30}}, + {Number{1'000'000'000'000'001, -15}, + Number{1'000'000'000'000'000, -15}, + Number{1'000'000'000'000'000, -30}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items from C + // with larger mantissa + { + {Number{1'000'000'000'000'000, -15}, + Number{6'555'555'555'555'555, -29}, + Number{ + false, + 9'999'999'999'999'344'444ULL, + -19, + Number::normalized{}}}, + {Number{6'555'555'555'555'555, -29}, + Number{1'000'000'000'000'000, -15}, + Number{ + true, + 9'999'999'999'999'344'444ULL, + -19, + Number::normalized{}}}, + {Number{1'000'000'000'000'000, -15}, + Number{1'000'000'000'000'000, -15}, + Number{0}}, + {Number{1'000'000'000'000'000, -15}, + Number{1'000'000'000'000'001, -15}, + Number{-1'000'000'000'000'000, -30}}, + {Number{1'000'000'000'000'001, -15}, + Number{1'000'000'000'000'000, -15}, + Number{1'000'000'000'000'000, -30}}, + // Items from cSmall expanded for the larger mantissa + {Number{1'000'000'000'000'000'000, -18}, + Number{6'555'555'555'555'555'555, -32}, + Number{ + false, + 9'999'999'999'999'344'444ULL, + -19, + Number::normalized{}}}, + {Number{6'555'555'555'555'555'555, -32}, + Number{1'000'000'000'000'000'000, -18}, + Number{ + true, + 9'999'999'999'999'344'444ULL, + -19, + Number::normalized{}}}, + {Number{1'000'000'000'000'000'000, -18}, + Number{1'000'000'000'000'000'000, -18}, + Number{0}}, + {Number{1'000'000'000'000'000'000, -18}, + Number{1'000'000'000'000'000'001, -18}, + Number{-1'000'000'000'000'000'000, -36}}, + {Number{1'000'000'000'000'000'001, -18}, + Number{1'000'000'000'000'000'000, -18}, + Number{1'000'000'000'000'000'000, -36}}, + {Number{Number::maxRep}, + Number{6, -1}, + Number{Number::maxRep - 1}}, + {Number{false, Number::maxRep + 1, 0, Number::normalized{}}, + Number{1, 0}, + Number{Number::maxRep / 10 + 1, 1}}, + {Number{false, Number::maxRep + 1, 0, Number::normalized{}}, + Number{3, 0}, + Number{Number::maxRep}}, + {power(2, 63), Number{3, 0}, Number{Number::maxRep}}, + }); + auto test = [this](auto const& c) { + for (auto const& [x, y, z] : c) + { + auto const result = x - y; + std::stringstream ss; + ss << x << " - " << y << " = " << result << ". Expected: " << z; + BEAST_EXPECTS(result == z, ss.str()); + } + }; + if (scale == MantissaRange::small) + test(cSmall); + else + test(cLarge); } void test_mul() { - testcase("test_mul"); + auto const scale = Number::getMantissaScale(); + testcase << "test_mul " << to_string(scale); + using Case = std::tuple; + auto test = [this](auto const& c) { + for (auto const& [x, y, z] : c) + { + auto const result = x * y; + std::stringstream ss; + ss << x << " * " << y << " = " << result << ". Expected: " << z; + BEAST_EXPECTS(result == z, ss.str()); + } + }; + auto tests = [&](auto const& cSmall, auto const& cLarge) { + if (scale == MantissaRange::small) + test(cSmall); + else + test(cLarge); + }; + auto const maxMantissa = Number::maxMantissa(); + saveNumberRoundMode save{Number::setround(Number::to_nearest)}; { - Case c[]{ + auto const cSmall = std::to_array({ {Number{7}, Number{8}, Number{56}}, {Number{1414213562373095, -15}, Number{1414213562373095, -15}, @@ -150,166 +420,520 @@ public: Number{1000000000000000, -14}}, {Number{1000000000000000, -32768}, Number{1000000000000000, -32768}, - Number{0}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x * y == z); + Number{0}}, + // Maximum mantissa range + {Number{9'999'999'999'999'999, 0}, + Number{9'999'999'999'999'999, 0}, + Number{9'999'999'999'999'998, 16}}, + }); + auto const cLarge = std::to_array({ + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items + // from C with larger mantissa + {Number{7}, Number{8}, Number{56}}, + {Number{1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{1999999999999999862, -18}}, + {Number{-1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{-1999999999999999862, -18}}, + {Number{-1414213562373095, -15}, + Number{-1414213562373095, -15}, + Number{1999999999999999862, -18}}, + {Number{3214285714285706, -15}, + Number{3111111111111119, -15}, + Number{ + false, + 9'999'999'999'999'999'579ULL, + -18, + Number::normalized{}}}, + {Number{1000000000000000000, -32768}, + Number{1000000000000000000, -32768}, + Number{0}}, + // Items from cSmall expanded for the larger mantissa, + // except duplicates. Sadly, it looks like sqrt(2)^2 != 2 + // with higher precision + {Number{1414213562373095049, -18}, + Number{1414213562373095049, -18}, + Number{2000000000000000001, -18}}, + {Number{-1414213562373095048, -18}, + Number{1414213562373095048, -18}, + Number{-1999999999999999998, -18}}, + {Number{-1414213562373095048, -18}, + Number{-1414213562373095049, -18}, + Number{1999999999999999999, -18}}, + {Number{3214285714285714278, -18}, + Number{3111111111111111119, -18}, + Number{10, 0}}, + // Maximum mantissa range - rounds up to 1e19 + {Number{false, maxMantissa, 0, Number::normalized{}}, + Number{false, maxMantissa, 0, Number::normalized{}}, + Number{1, 38}}, + // Maximum int64 range + {Number{Number::maxRep, 0}, + Number{Number::maxRep, 0}, + Number{85'070'591'730'234'615'85, 19}}, + }); + tests(cSmall, cLarge); } Number::setround(Number::towards_zero); + testcase << "test_mul " << to_string(Number::getMantissaScale()) + << " towards_zero"; { - Case c[]{ - {Number{7}, Number{8}, Number{56}}, - {Number{1414213562373095, -15}, - Number{1414213562373095, -15}, - Number{1999999999999999, -15}}, - {Number{-1414213562373095, -15}, - Number{1414213562373095, -15}, - Number{-1999999999999999, -15}}, - {Number{-1414213562373095, -15}, - Number{-1414213562373095, -15}, - Number{1999999999999999, -15}}, - {Number{3214285714285706, -15}, - Number{3111111111111119, -15}, - Number{9999999999999999, -15}}, - {Number{1000000000000000, -32768}, - Number{1000000000000000, -32768}, - Number{0}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x * y == z); + auto const cSmall = std::to_array( + {{Number{7}, Number{8}, Number{56}}, + {Number{1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{1999999999999999, -15}}, + {Number{-1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{-1999999999999999, -15}}, + {Number{-1414213562373095, -15}, + Number{-1414213562373095, -15}, + Number{1999999999999999, -15}}, + {Number{3214285714285706, -15}, + Number{3111111111111119, -15}, + Number{9999999999999999, -15}}, + {Number{1000000000000000, -32768}, + Number{1000000000000000, -32768}, + Number{0}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items + // from C with larger mantissa + { + {Number{7}, Number{8}, Number{56}}, + {Number{1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{1999999999999999861, -18}}, + {Number{-1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{-1999999999999999861, -18}}, + {Number{-1414213562373095, -15}, + Number{-1414213562373095, -15}, + Number{1999999999999999861, -18}}, + {Number{3214285714285706, -15}, + Number{3111111111111119, -15}, + Number{ + false, + 9999999999999999579ULL, + -18, + Number::normalized{}}}, + {Number{1000000000000000000, -32768}, + Number{1000000000000000000, -32768}, + Number{0}}, + // Items from cSmall expanded for the larger mantissa, + // except duplicates. Sadly, it looks like sqrt(2)^2 != 2 + // with higher precision + {Number{1414213562373095049, -18}, + Number{1414213562373095049, -18}, + Number{2, 0}}, + {Number{-1414213562373095048, -18}, + Number{1414213562373095048, -18}, + Number{-1999999999999999997, -18}}, + {Number{-1414213562373095048, -18}, + Number{-1414213562373095049, -18}, + Number{1999999999999999999, -18}}, + {Number{3214285714285714278, -18}, + Number{3111111111111111119, -18}, + Number{10, 0}}, + // Maximum mantissa range - rounds down to maxMantissa/10e1 + // 99'999'999'999'999'999'800'000'000'000'000'000'100 + {Number{false, maxMantissa, 0, Number::normalized{}}, + Number{false, maxMantissa, 0, Number::normalized{}}, + Number{ + false, + maxMantissa / 10 - 1, + 20, + Number::normalized{}}}, + // Maximum int64 range + // 85'070'591'730'234'615'847'396'907'784'232'501'249 + {Number{Number::maxRep, 0}, + Number{Number::maxRep, 0}, + Number{85'070'591'730'234'615'84, 19}}, + }); + tests(cSmall, cLarge); } Number::setround(Number::downward); + testcase << "test_mul " << to_string(Number::getMantissaScale()) + << " downward"; { - Case c[]{ - {Number{7}, Number{8}, Number{56}}, - {Number{1414213562373095, -15}, - Number{1414213562373095, -15}, - Number{1999999999999999, -15}}, - {Number{-1414213562373095, -15}, - Number{1414213562373095, -15}, - Number{-2000000000000000, -15}}, - {Number{-1414213562373095, -15}, - Number{-1414213562373095, -15}, - Number{1999999999999999, -15}}, - {Number{3214285714285706, -15}, - Number{3111111111111119, -15}, - Number{9999999999999999, -15}}, - {Number{1000000000000000, -32768}, - Number{1000000000000000, -32768}, - Number{0}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x * y == z); + auto const cSmall = std::to_array( + {{Number{7}, Number{8}, Number{56}}, + {Number{1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{1999999999999999, -15}}, + {Number{-1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{-2000000000000000, -15}}, + {Number{-1414213562373095, -15}, + Number{-1414213562373095, -15}, + Number{1999999999999999, -15}}, + {Number{3214285714285706, -15}, + Number{3111111111111119, -15}, + Number{9999999999999999, -15}}, + {Number{1000000000000000, -32768}, + Number{1000000000000000, -32768}, + Number{0}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items + // from C with larger mantissa + { + {Number{7}, Number{8}, Number{56}}, + {Number{1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{1999999999999999861, -18}}, + {Number{-1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{-1999999999999999862, -18}}, + {Number{-1414213562373095, -15}, + Number{-1414213562373095, -15}, + Number{1999999999999999861, -18}}, + {Number{3214285714285706, -15}, + Number{3111111111111119, -15}, + Number{ + false, + 9'999'999'999'999'999'579ULL, + -18, + Number::normalized{}}}, + {Number{1000000000000000000, -32768}, + Number{1000000000000000000, -32768}, + Number{0}}, + // Items from cSmall expanded for the larger mantissa, + // except duplicates. Sadly, it looks like sqrt(2)^2 != 2 + // with higher precision + {Number{1414213562373095049, -18}, + Number{1414213562373095049, -18}, + Number{2, 0}}, + {Number{-1414213562373095048, -18}, + Number{1414213562373095048, -18}, + Number{-1999999999999999998, -18}}, + {Number{-1414213562373095048, -18}, + Number{-1414213562373095049, -18}, + Number{1999999999999999999, -18}}, + {Number{3214285714285714278, -18}, + Number{3111111111111111119, -18}, + Number{10, 0}}, + // Maximum mantissa range - rounds down to maxMantissa/10e1 + // 99'999'999'999'999'999'800'000'000'000'000'000'100 + {Number{false, maxMantissa, 0, Number::normalized{}}, + Number{false, maxMantissa, 0, Number::normalized{}}, + Number{ + false, + maxMantissa / 10 - 1, + 20, + Number::normalized{}}}, + // Maximum int64 range + // 85'070'591'730'234'615'847'396'907'784'232'501'249 + {Number{Number::maxRep, 0}, + Number{Number::maxRep, 0}, + Number{85'070'591'730'234'615'84, 19}}, + }); + tests(cSmall, cLarge); } Number::setround(Number::upward); + testcase << "test_mul " << to_string(Number::getMantissaScale()) + << " upward"; { - Case c[]{ - {Number{7}, Number{8}, Number{56}}, - {Number{1414213562373095, -15}, - Number{1414213562373095, -15}, - Number{2000000000000000, -15}}, - {Number{-1414213562373095, -15}, - Number{1414213562373095, -15}, - Number{-1999999999999999, -15}}, - {Number{-1414213562373095, -15}, - Number{-1414213562373095, -15}, - Number{2000000000000000, -15}}, - {Number{3214285714285706, -15}, - Number{3111111111111119, -15}, - Number{1000000000000000, -14}}, - {Number{1000000000000000, -32768}, - Number{1000000000000000, -32768}, - Number{0}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x * y == z); + auto const cSmall = std::to_array( + {{Number{7}, Number{8}, Number{56}}, + {Number{1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{2000000000000000, -15}}, + {Number{-1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{-1999999999999999, -15}}, + {Number{-1414213562373095, -15}, + Number{-1414213562373095, -15}, + Number{2000000000000000, -15}}, + {Number{3214285714285706, -15}, + Number{3111111111111119, -15}, + Number{1000000000000000, -14}}, + {Number{1000000000000000, -32768}, + Number{1000000000000000, -32768}, + Number{0}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items + // from C with larger mantissa + { + {Number{7}, Number{8}, Number{56}}, + {Number{1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{1999999999999999862, -18}}, + {Number{-1414213562373095, -15}, + Number{1414213562373095, -15}, + Number{-1999999999999999861, -18}}, + {Number{-1414213562373095, -15}, + Number{-1414213562373095, -15}, + Number{1999999999999999862, -18}}, + {Number{3214285714285706, -15}, + Number{3111111111111119, -15}, + Number{999999999999999958, -17}}, + {Number{1000000000000000000, -32768}, + Number{1000000000000000000, -32768}, + Number{0}}, + // Items from cSmall expanded for the larger mantissa, + // except duplicates. Sadly, it looks like sqrt(2)^2 != 2 + // with higher precision + {Number{1414213562373095049, -18}, + Number{1414213562373095049, -18}, + Number{2000000000000000001, -18}}, + {Number{-1414213562373095048, -18}, + Number{1414213562373095048, -18}, + Number{-1999999999999999997, -18}}, + {Number{-1414213562373095048, -18}, + Number{-1414213562373095049, -18}, + Number{2, 0}}, + {Number{3214285714285714278, -18}, + Number{3111111111111111119, -18}, + Number{1000000000000000001, -17}}, + // Maximum mantissa range - rounds up to minMantissa*10 + // 1e19*1e19=1e38 + {Number{false, maxMantissa, 0, Number::normalized{}}, + Number{false, maxMantissa, 0, Number::normalized{}}, + Number{1, 38}}, + // Maximum int64 range + // 85'070'591'730'234'615'847'396'907'784'232'501'249 + {Number{Number::maxRep, 0}, + Number{Number::maxRep, 0}, + Number{85'070'591'730'234'615'85, 19}}, + }); + tests(cSmall, cLarge); } - bool caught = false; - try + testcase << "test_mul " << to_string(Number::getMantissaScale()) + << " overflow"; { - Number{9'999'999'999'999'999, 32768} * - Number{5'000'000'000'000'000, 32767}; + bool caught = false; + try + { + Number{false, maxMantissa, 32768, Number::normalized{}} * + Number{ + false, + Number::minMantissa() * 5, + 32767, + Number::normalized{}}; + } + catch (std::overflow_error const&) + { + caught = true; + } + BEAST_EXPECT(caught); } - catch (std::overflow_error const&) - { - caught = true; - } - BEAST_EXPECT(caught); } void test_div() { - testcase("test_div"); + auto const scale = Number::getMantissaScale(); + testcase << "test_div " << to_string(scale); + using Case = std::tuple; + auto test = [this](auto const& c) { + for (auto const& [x, y, z] : c) + { + auto const result = x / y; + std::stringstream ss; + ss << x << " / " << y << " = " << result << ". Expected: " << z; + BEAST_EXPECTS(result == z, ss.str()); + } + }; + auto const maxMantissa = Number::maxMantissa(); + auto tests = [&](auto const& cSmall, auto const& cLarge) { + if (scale == MantissaRange::small) + test(cSmall); + else + test(cLarge); + }; saveNumberRoundMode save{Number::setround(Number::to_nearest)}; { - Case c[]{ - {Number{1}, Number{2}, Number{5, -1}}, - {Number{1}, Number{10}, Number{1, -1}}, - {Number{1}, Number{-10}, Number{-1, -1}}, - {Number{0}, Number{100}, Number{0}}, - {Number{1414213562373095, -10}, - Number{1414213562373095, -10}, - Number{1}}, - {Number{9'999'999'999'999'999}, - Number{1'000'000'000'000'000}, - Number{9'999'999'999'999'999, -15}}, - {Number{2}, Number{3}, Number{6'666'666'666'666'667, -16}}, - {Number{-2}, Number{3}, Number{-6'666'666'666'666'667, -16}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x / y == z); + auto const cSmall = std::to_array( + {{Number{1}, Number{2}, Number{5, -1}}, + {Number{1}, Number{10}, Number{1, -1}}, + {Number{1}, Number{-10}, Number{-1, -1}}, + {Number{0}, Number{100}, Number{0}}, + {Number{1414213562373095, -10}, + Number{1414213562373095, -10}, + Number{1}}, + {Number{9'999'999'999'999'999}, + Number{1'000'000'000'000'000}, + Number{9'999'999'999'999'999, -15}}, + {Number{2}, Number{3}, Number{6'666'666'666'666'667, -16}}, + {Number{-2}, Number{3}, Number{-6'666'666'666'666'667, -16}}, + {Number{1}, Number{7}, Number{1'428'571'428'571'428, -16}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items + // from C with larger mantissa + {{Number{1}, Number{2}, Number{5, -1}}, + {Number{1}, Number{10}, Number{1, -1}}, + {Number{1}, Number{-10}, Number{-1, -1}}, + {Number{0}, Number{100}, Number{0}}, + {Number{1414213562373095, -10}, + Number{1414213562373095, -10}, + Number{1}}, + {Number{9'999'999'999'999'999}, + Number{1'000'000'000'000'000}, + Number{9'999'999'999'999'999, -15}}, + {Number{2}, Number{3}, Number{6'666'666'666'666'666'667, -19}}, + {Number{-2}, + Number{3}, + Number{-6'666'666'666'666'666'667, -19}}, + {Number{1}, Number{7}, Number{1'428'571'428'571'428'571, -19}}, + // Items from cSmall expanded for the larger mantissa, except + // duplicates. + {Number{1414213562373095049, -13}, + Number{1414213562373095049, -13}, + Number{1}}, + {Number{false, maxMantissa, 0, Number::normalized{}}, + Number{1'000'000'000'000'000'000}, + Number{false, maxMantissa, -18, Number::normalized{}}}}); + tests(cSmall, cLarge); } + testcase << "test_div " << to_string(Number::getMantissaScale()) + << " towards_zero"; Number::setround(Number::towards_zero); { - Case c[]{ - {Number{1}, Number{2}, Number{5, -1}}, - {Number{1}, Number{10}, Number{1, -1}}, - {Number{1}, Number{-10}, Number{-1, -1}}, - {Number{0}, Number{100}, Number{0}}, - {Number{1414213562373095, -10}, - Number{1414213562373095, -10}, - Number{1}}, - {Number{9'999'999'999'999'999}, - Number{1'000'000'000'000'000}, - Number{9'999'999'999'999'999, -15}}, - {Number{2}, Number{3}, Number{6'666'666'666'666'666, -16}}, - {Number{-2}, Number{3}, Number{-6'666'666'666'666'666, -16}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x / y == z); + auto const cSmall = std::to_array( + {{Number{1}, Number{2}, Number{5, -1}}, + {Number{1}, Number{10}, Number{1, -1}}, + {Number{1}, Number{-10}, Number{-1, -1}}, + {Number{0}, Number{100}, Number{0}}, + {Number{1414213562373095, -10}, + Number{1414213562373095, -10}, + Number{1}}, + {Number{9'999'999'999'999'999}, + Number{1'000'000'000'000'000}, + Number{9'999'999'999'999'999, -15}}, + {Number{2}, Number{3}, Number{6'666'666'666'666'666, -16}}, + {Number{-2}, Number{3}, Number{-6'666'666'666'666'666, -16}}, + {Number{1}, Number{7}, Number{1'428'571'428'571'428, -16}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items + // from C with larger mantissa + {{Number{1}, Number{2}, Number{5, -1}}, + {Number{1}, Number{10}, Number{1, -1}}, + {Number{1}, Number{-10}, Number{-1, -1}}, + {Number{0}, Number{100}, Number{0}}, + {Number{1414213562373095, -10}, + Number{1414213562373095, -10}, + Number{1}}, + {Number{9'999'999'999'999'999}, + Number{1'000'000'000'000'000}, + Number{9'999'999'999'999'999, -15}}, + {Number{2}, Number{3}, Number{6'666'666'666'666'666'666, -19}}, + {Number{-2}, + Number{3}, + Number{-6'666'666'666'666'666'666, -19}}, + {Number{1}, Number{7}, Number{1'428'571'428'571'428'571, -19}}, + // Items from cSmall expanded for the larger mantissa, except + // duplicates. + {Number{1414213562373095049, -13}, + Number{1414213562373095049, -13}, + Number{1}}, + {Number{false, maxMantissa, 0, Number::normalized{}}, + Number{1'000'000'000'000'000'000}, + Number{false, maxMantissa, -18, Number::normalized{}}}}); + tests(cSmall, cLarge); } + testcase << "test_div " << to_string(Number::getMantissaScale()) + << " downward"; Number::setround(Number::downward); { - Case c[]{ - {Number{1}, Number{2}, Number{5, -1}}, - {Number{1}, Number{10}, Number{1, -1}}, - {Number{1}, Number{-10}, Number{-1, -1}}, - {Number{0}, Number{100}, Number{0}}, - {Number{1414213562373095, -10}, - Number{1414213562373095, -10}, - Number{1}}, - {Number{9'999'999'999'999'999}, - Number{1'000'000'000'000'000}, - Number{9'999'999'999'999'999, -15}}, - {Number{2}, Number{3}, Number{6'666'666'666'666'666, -16}}, - {Number{-2}, Number{3}, Number{-6'666'666'666'666'667, -16}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x / y == z); + auto const cSmall = std::to_array( + {{Number{1}, Number{2}, Number{5, -1}}, + {Number{1}, Number{10}, Number{1, -1}}, + {Number{1}, Number{-10}, Number{-1, -1}}, + {Number{0}, Number{100}, Number{0}}, + {Number{1414213562373095, -10}, + Number{1414213562373095, -10}, + Number{1}}, + {Number{9'999'999'999'999'999}, + Number{1'000'000'000'000'000}, + Number{9'999'999'999'999'999, -15}}, + {Number{2}, Number{3}, Number{6'666'666'666'666'666, -16}}, + {Number{-2}, Number{3}, Number{-6'666'666'666'666'667, -16}}, + {Number{1}, Number{7}, Number{1'428'571'428'571'428, -16}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items + // from C with larger mantissa + {{Number{1}, Number{2}, Number{5, -1}}, + {Number{1}, Number{10}, Number{1, -1}}, + {Number{1}, Number{-10}, Number{-1, -1}}, + {Number{0}, Number{100}, Number{0}}, + {Number{1414213562373095, -10}, + Number{1414213562373095, -10}, + Number{1}}, + {Number{9'999'999'999'999'999}, + Number{1'000'000'000'000'000}, + Number{9'999'999'999'999'999, -15}}, + {Number{2}, Number{3}, Number{6'666'666'666'666'666'666, -19}}, + {Number{-2}, + Number{3}, + Number{-6'666'666'666'666'666'667, -19}}, + {Number{1}, Number{7}, Number{1'428'571'428'571'428'571, -19}}, + // Items from cSmall expanded for the larger mantissa, except + // duplicates. + {Number{1414213562373095049, -13}, + Number{1414213562373095049, -13}, + Number{1}}, + {Number{false, maxMantissa, 0, Number::normalized{}}, + Number{1'000'000'000'000'000'000}, + Number{false, maxMantissa, -18, Number::normalized{}}}}); + tests(cSmall, cLarge); } + testcase << "test_div " << to_string(Number::getMantissaScale()) + << " upward"; Number::setround(Number::upward); { - Case c[]{ - {Number{1}, Number{2}, Number{5, -1}}, - {Number{1}, Number{10}, Number{1, -1}}, - {Number{1}, Number{-10}, Number{-1, -1}}, - {Number{0}, Number{100}, Number{0}}, - {Number{1414213562373095, -10}, - Number{1414213562373095, -10}, - Number{1}}, - {Number{9'999'999'999'999'999}, - Number{1'000'000'000'000'000}, - Number{9'999'999'999'999'999, -15}}, - {Number{2}, Number{3}, Number{6'666'666'666'666'667, -16}}, - {Number{-2}, Number{3}, Number{-6'666'666'666'666'666, -16}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT(x / y == z); + auto const cSmall = std::to_array( + {{Number{1}, Number{2}, Number{5, -1}}, + {Number{1}, Number{10}, Number{1, -1}}, + {Number{1}, Number{-10}, Number{-1, -1}}, + {Number{0}, Number{100}, Number{0}}, + {Number{1414213562373095, -10}, + Number{1414213562373095, -10}, + Number{1}}, + {Number{9'999'999'999'999'999}, + Number{1'000'000'000'000'000}, + Number{9'999'999'999'999'999, -15}}, + {Number{2}, Number{3}, Number{6'666'666'666'666'667, -16}}, + {Number{-2}, Number{3}, Number{-6'666'666'666'666'666, -16}}, + {Number{1}, Number{7}, Number{1'428'571'428'571'429, -16}}}); + auto const cLarge = std::to_array( + // Note that items with extremely large mantissas need to be + // calculated, because otherwise they overflow uint64. Items + // from C with larger mantissa + {{Number{1}, Number{2}, Number{5, -1}}, + {Number{1}, Number{10}, Number{1, -1}}, + {Number{1}, Number{-10}, Number{-1, -1}}, + {Number{0}, Number{100}, Number{0}}, + {Number{1414213562373095, -10}, + Number{1414213562373095, -10}, + Number{1}}, + {Number{9'999'999'999'999'999}, + Number{1'000'000'000'000'000}, + Number{9'999'999'999'999'999, -15}}, + {Number{2}, Number{3}, Number{6'666'666'666'666'666'667, -19}}, + {Number{-2}, + Number{3}, + Number{-6'666'666'666'666'666'666, -19}}, + {Number{1}, Number{7}, Number{1'428'571'428'571'428'572, -19}}, + // Items from cSmall expanded for the larger mantissa, except + // duplicates. + {Number{1414213562373095049, -13}, + Number{1414213562373095049, -13}, + Number{1}}, + {Number{false, maxMantissa, 0, Number::normalized{}}, + Number{1'000'000'000'000'000'000}, + Number{false, maxMantissa, -18, Number::normalized{}}}}); + tests(cSmall, cLarge); } + testcase << "test_div " << to_string(Number::getMantissaScale()) + << " overflow"; bool caught = false; try { @@ -325,20 +949,59 @@ public: void test_root() { - testcase("test_root"); + auto const scale = Number::getMantissaScale(); + testcase << "test_root " << to_string(scale); + using Case = std::tuple; - Case c[]{ - {Number{2}, 2, Number{1414213562373095, -15}}, - {Number{2'000'000}, 2, Number{1414213562373095, -12}}, - {Number{2, -30}, 2, Number{1414213562373095, -30}}, - {Number{-27}, 3, Number{-3}}, - {Number{1}, 5, Number{1}}, - {Number{-1}, 0, Number{1}}, - {Number{5, -1}, 0, Number{0}}, - {Number{0}, 5, Number{0}}, - {Number{5625, -4}, 2, Number{75, -2}}}; - for (auto const& [x, y, z] : c) - BEAST_EXPECT((root(x, y) == z)); + auto test = [this](auto const& c) { + for (auto const& [x, y, z] : c) + { + auto const result = root(x, y); + std::stringstream ss; + ss << "root(" << x << ", " << y << ") = " << result + << ". Expected: " << z; + BEAST_EXPECTS(result == z, ss.str()); + } + }; + /* + auto tests = [&](auto const& cSmall, auto const& cLarge) { + test(cSmall); + if (scale != MantissaRange::small) + test(cLarge); + }; + */ + + auto const cSmall = std::to_array( + {{Number{2}, 2, Number{1414213562373095049, -18}}, + {Number{2'000'000}, 2, Number{1414213562373095049, -15}}, + {Number{2, -30}, 2, Number{1414213562373095049, -33}}, + {Number{-27}, 3, Number{-3}}, + {Number{1}, 5, Number{1}}, + {Number{-1}, 0, Number{1}}, + {Number{5, -1}, 0, Number{0}}, + {Number{0}, 5, Number{0}}, + {Number{5625, -4}, 2, Number{75, -2}}}); + auto const cLarge = std::to_array({ + {Number{false, Number::maxMantissa() - 9, -1, Number::normalized{}}, + 2, + Number{false, 999'999'999'999'999'999, -9, Number::normalized{}}}, + {Number{false, Number::maxMantissa() - 9, 0, Number::normalized{}}, + 2, + Number{ + false, 3'162'277'660'168'379'330, -9, Number::normalized{}}}, + {Number{Number::maxRep}, + 2, + Number{false, 3'037'000'499'976049692, -9, Number::normalized{}}}, + {Number{Number::maxRep}, + 4, + Number{false, 55'108'98747006743627, -14, Number::normalized{}}}, + }); + test(cSmall); + if (Number::getMantissaScale() != MantissaRange::small) + { + NumberRoundModeGuard mg(Number::towards_zero); + test(cLarge); + } bool caught = false; try { @@ -361,10 +1024,52 @@ public: BEAST_EXPECT(caught); } + void + test_root2() + { + auto const scale = Number::getMantissaScale(); + testcase << "test_root2 " << to_string(scale); + + auto test = [this](auto const& c) { + for (auto const& x : c) + { + auto const expected = root(x, 2); + auto const result = root2(x); + std::stringstream ss; + ss << "root2(" << x << ") = " << result + << ". Expected: " << expected; + BEAST_EXPECTS(result == expected, ss.str()); + } + }; + + auto const cSmall = std::to_array({ + Number{2}, + Number{2'000'000}, + Number{2, -30}, + Number{27}, + Number{1}, + Number{5, -1}, + Number{0}, + Number{5625, -4}, + Number{Number::maxRep}, + }); + test(cSmall); + bool caught = false; + try + { + (void)root2(Number{-2}); + } + catch (std::overflow_error const&) + { + caught = true; + } + BEAST_EXPECT(caught); + } + void test_power1() { - testcase("test_power1"); + testcase << "test_power1 " << to_string(Number::getMantissaScale()); using Case = std::tuple; Case c[]{ {Number{64}, 0, Number{1}}, @@ -372,7 +1077,13 @@ public: {Number{64}, 2, Number{4096}}, {Number{-64}, 2, Number{4096}}, {Number{64}, 3, Number{262144}}, - {Number{-64}, 3, Number{-262144}}}; + {Number{-64}, 3, Number{-262144}}, + {Number{64}, + 11, + Number{false, 7378697629483820646ULL, 1, Number::normalized{}}}, + {Number{-64}, + 11, + Number{true, 7378697629483820646ULL, 1, Number::normalized{}}}}; for (auto const& [x, y, z] : c) BEAST_EXPECT((power(x, y) == z)); } @@ -380,7 +1091,7 @@ public: void test_power2() { - testcase("test_power2"); + testcase << "test_power2 " << to_string(Number::getMantissaScale()); using Case = std::tuple; Case c[]{ {Number{1}, 3, 7, Number{1}}, @@ -426,7 +1137,7 @@ public: void testConversions() { - testcase("testConversions"); + testcase << "testConversions " << to_string(Number::getMantissaScale()); IOUAmount x{5, 6}; Number y = x; @@ -452,7 +1163,7 @@ public: void test_to_integer() { - testcase("test_to_integer"); + testcase << "test_to_integer " << to_string(Number::getMantissaScale()); using Case = std::tuple; saveNumberRoundMode save{Number::setround(Number::to_nearest)}; { @@ -620,7 +1331,7 @@ public: void test_squelch() { - testcase("test_squelch"); + testcase << "test_squelch " << to_string(Number::getMantissaScale()); Number limit{1, -6}; BEAST_EXPECT((squelch(Number{2, -6}, limit) == Number{2, -6})); BEAST_EXPECT((squelch(Number{1, -6}, limit) == Number{1, -6})); @@ -633,22 +1344,129 @@ public: void testToString() { - testcase("testToString"); - BEAST_EXPECT(to_string(Number(-2, 0)) == "-2"); - BEAST_EXPECT(to_string(Number(0, 0)) == "0"); - BEAST_EXPECT(to_string(Number(2, 0)) == "2"); - BEAST_EXPECT(to_string(Number(25, -3)) == "0.025"); - BEAST_EXPECT(to_string(Number(-25, -3)) == "-0.025"); - BEAST_EXPECT(to_string(Number(25, 1)) == "250"); - BEAST_EXPECT(to_string(Number(-25, 1)) == "-250"); - BEAST_EXPECT(to_string(Number(2, 20)) == "2000000000000000e5"); - BEAST_EXPECT(to_string(Number(-2, -20)) == "-2000000000000000e-35"); + auto const scale = Number::getMantissaScale(); + testcase << "testToString " << to_string(scale); + + auto test = [this](Number const& n, std::string const& expected) { + auto const result = to_string(n); + std::stringstream ss; + ss << "to_string(" << result << "). Expected: " << expected; + BEAST_EXPECTS(result == expected, ss.str()); + }; + + test(Number(-2, 0), "-2"); + test(Number(0, 0), "0"); + test(Number(2, 0), "2"); + test(Number(25, -3), "0.025"); + test(Number(-25, -3), "-0.025"); + test(Number(25, 1), "250"); + test(Number(-25, 1), "-250"); + test(Number(2, 20), "2e20"); + test(Number(-2, -20), "-2e-20"); + // Test the edges + // ((exponent < -(25)) || (exponent > -(5))))) + // or ((exponent < -(28)) || (exponent > -(8))))) + test(Number(2, -10), "0.0000000002"); + test(Number(2, -11), "2e-11"); + + test(Number(-2, 10), "-20000000000"); + test(Number(-2, 11), "-2e11"); + + switch (scale) + { + case MantissaRange::small: + + test(Number::min(), "1e-32753"); + test(Number::max(), "9999999999999999e32768"); + test(Number::lowest(), "-9999999999999999e32768"); + { + NumberRoundModeGuard mg(Number::towards_zero); + + auto const maxMantissa = Number::maxMantissa(); + BEAST_EXPECT(maxMantissa == 9'999'999'999'999'999); + test( + Number{ + false, + maxMantissa * 1000 + 999, + -3, + Number::normalized()}, + "9999999999999999"); + test( + Number{ + true, + maxMantissa * 1000 + 999, + -3, + Number::normalized()}, + "-9999999999999999"); + + test( + Number{std::numeric_limits::max(), -3}, + "9223372036854775"); + test( + -(Number{std::numeric_limits::max(), -3}), + "-9223372036854775"); + + test( + Number{std::numeric_limits::min(), 0}, + "-9223372036854775e3"); + test( + -(Number{std::numeric_limits::min(), 0}), + "9223372036854775e3"); + } + break; + case MantissaRange::large: + // Test the edges + // ((exponent < -(28)) || (exponent > -(8))))) + test(Number::min(), "1e-32750"); + test(Number::max(), "9223372036854775807e32768"); + test(Number::lowest(), "-9223372036854775807e32768"); + { + NumberRoundModeGuard mg(Number::towards_zero); + + auto const maxMantissa = Number::maxMantissa(); + BEAST_EXPECT(maxMantissa == 9'999'999'999'999'999'999ULL); + test( + Number{false, maxMantissa, 0, Number::normalized{}}, + "9999999999999999990"); + test( + Number{true, maxMantissa, 0, Number::normalized{}}, + "-9999999999999999990"); + + test( + Number{std::numeric_limits::max(), 0}, + "9223372036854775807"); + test( + -(Number{std::numeric_limits::max(), 0}), + "-9223372036854775807"); + + // Because the absolute value of min is larger than max, it + // will be scaled down to fit under max. Since we're + // rounding towards zero, the 8 at the end is dropped. + test( + Number{std::numeric_limits::min(), 0}, + "-9223372036854775800"); + test( + -(Number{std::numeric_limits::min(), 0}), + "9223372036854775800"); + } + + test( + Number{std::numeric_limits::max(), 0} + 1, + "9223372036854775810"); + test( + -(Number{std::numeric_limits::max(), 0} + 1), + "-9223372036854775810"); + break; + default: + BEAST_EXPECT(false); + } } void test_relationals() { - testcase("test_relationals"); + testcase << "test_relationals " + << to_string(Number::getMantissaScale()); BEAST_EXPECT(!(Number{100} < Number{10})); BEAST_EXPECT(Number{100} > Number{10}); BEAST_EXPECT(Number{100} >= Number{10}); @@ -658,7 +1476,7 @@ public: void test_stream() { - testcase("test_stream"); + testcase << "test_stream " << to_string(Number::getMantissaScale()); Number x{100}; std::ostringstream os; os << x; @@ -668,7 +1486,7 @@ public: void test_inc_dec() { - testcase("test_inc_dec"); + testcase << "test_inc_dec " << to_string(Number::getMantissaScale()); Number x{100}; Number y = +x; BEAST_EXPECT(x == y); @@ -685,19 +1503,19 @@ public: Issue const issue; Number const n{7'518'783'80596, -5}; saveNumberRoundMode const save{Number::setround(Number::to_nearest)}; - auto res2 = STAmount{issue, n.mantissa(), n.exponent()}; + auto res2 = STAmount{issue, n}; BEAST_EXPECT(res2 == STAmount{7518784}); Number::setround(Number::towards_zero); - res2 = STAmount{issue, n.mantissa(), n.exponent()}; + res2 = STAmount{issue, n}; BEAST_EXPECT(res2 == STAmount{7518783}); Number::setround(Number::downward); - res2 = STAmount{issue, n.mantissa(), n.exponent()}; + res2 = STAmount{issue, n}; BEAST_EXPECT(res2 == STAmount{7518783}); Number::setround(Number::upward); - res2 = STAmount{issue, n.mantissa(), n.exponent()}; + res2 = STAmount{issue, n}; BEAST_EXPECT(res2 == STAmount{7518784}); } @@ -834,28 +1652,94 @@ public: } } + void + testInt64() + { + auto const scale = Number::getMantissaScale(); + testcase << "std::int64_t " << to_string(scale); + + // Control case + BEAST_EXPECT(Number::maxMantissa() > 10); + Number ten{10}; + BEAST_EXPECT(ten.exponent() <= 0); + + if (scale == MantissaRange::small) + { + BEAST_EXPECT( + std::numeric_limits::max() > INITIAL_XRP.drops()); + BEAST_EXPECT(Number::maxMantissa() < INITIAL_XRP.drops()); + Number const initalXrp{INITIAL_XRP}; + BEAST_EXPECT(initalXrp.exponent() > 0); + + Number const maxInt64{Number::maxRep}; + BEAST_EXPECT(maxInt64.exponent() > 0); + // 85'070'591'730'234'615'865'843'651'857'942'052'864 - 38 digits + BEAST_EXPECT( + (power(maxInt64, 2) == Number{85'070'591'730'234'62, 22})); + + Number const max = + Number{false, Number::maxMantissa(), 0, Number::normalized{}}; + BEAST_EXPECT(max.exponent() <= 0); + // 99'999'999'999'999'980'000'000'000'000'001 - 32 digits + BEAST_EXPECT((power(max, 2) == Number{99'999'999'999'999'98, 16})); + } + else + { + BEAST_EXPECT( + std::numeric_limits::max() > INITIAL_XRP.drops()); + BEAST_EXPECT(Number::maxMantissa() > INITIAL_XRP.drops()); + Number const initalXrp{INITIAL_XRP}; + BEAST_EXPECT(initalXrp.exponent() <= 0); + + Number const maxInt64{Number::maxRep}; + BEAST_EXPECT(maxInt64.exponent() <= 0); + // 85'070'591'730'234'615'847'396'907'784'232'501'249 - 38 digits + BEAST_EXPECT( + (power(maxInt64, 2) == Number{85'070'591'730'234'615'85, 19})); + + NumberRoundModeGuard mg(Number::towards_zero); + + auto const maxMantissa = Number::maxMantissa(); + Number const max = + Number{false, maxMantissa, 0, Number::normalized{}}; + BEAST_EXPECT(max.mantissa() == maxMantissa / 10); + BEAST_EXPECT(max.exponent() == 1); + // 99'999'999'999'999'999'800'000'000'000'000'000'100 - also 38 + // digits + BEAST_EXPECT(( + power(max, 2) == + Number{false, maxMantissa / 10 - 1, 20, Number::normalized{}})); + } + } + void run() override { - testZero(); - test_limits(); - test_add(); - test_sub(); - test_mul(); - test_div(); - test_root(); - test_power1(); - test_power2(); - testConversions(); - test_to_integer(); - test_squelch(); - testToString(); - test_relationals(); - test_stream(); - test_inc_dec(); - test_toSTAmount(); - test_truncate(); - testRounding(); + for (auto const scale : {MantissaRange::small, MantissaRange::large}) + { + NumberMantissaScaleGuard sg(scale); + testZero(); + test_limits(); + testToString(); + test_add(); + test_sub(); + test_mul(); + test_div(); + test_root(); + test_root2(); + test_power1(); + test_power2(); + testConversions(); + test_to_integer(); + test_squelch(); + test_relationals(); + test_stream(); + test_inc_dec(); + test_toSTAmount(); + test_truncate(); + testRounding(); + testInt64(); + } } }; diff --git a/src/test/jtx/AMMTest.h b/src/test/jtx/AMMTest.h index 83366d61e2..208e3c4e5f 100644 --- a/src/test/jtx/AMMTest.h +++ b/src/test/jtx/AMMTest.h @@ -21,7 +21,12 @@ struct TestAMMArg std::optional> pool = std::nullopt; std::uint16_t tfee = 0; std::optional ter = std::nullopt; - std::vector features = {testable_amendments()}; + std::vector features = { + // For now, just disable SAV entirely, which locks in the small Number + // mantissas + jtx::testable_amendments() - featureSingleAssetVault - + featureLendingProtocol}; + bool noLog = false; }; @@ -66,6 +71,15 @@ protected: public: AMMTestBase(); + static FeatureBitset + testable_amendments() + { + // For now, just disable SAV entirely, which locks in the small Number + // mantissas + return jtx::testable_amendments() - featureSingleAssetVault - + featureLendingProtocol; + } + protected: /** testAMM() funds 30,000XRP and 30,000IOU * for each non-XRP asset to Alice and Carol diff --git a/src/test/jtx/amount.h b/src/test/jtx/amount.h index a310fc5b44..147307f7b7 100644 --- a/src/test/jtx/amount.h +++ b/src/test/jtx/amount.h @@ -261,6 +261,12 @@ struct XRP_t return xrpIssue(); } + bool + integral() const + { + return true; + } + /** Returns an amount of XRP as PrettyAmount, which is trivially convertible to STAmount @@ -400,6 +406,11 @@ public: { return issue(); } + bool + integral() const + { + return issue().integral(); + } /** Implicit conversion to Issue or Asset. @@ -490,6 +501,11 @@ public: { return mptIssue(); } + bool + integral() const + { + return true; + } /** Implicit conversion to MPTIssue or asset. diff --git a/src/test/jtx/impl/AMMTest.cpp b/src/test/jtx/impl/AMMTest.cpp index de7ce5504b..139b9113b9 100644 --- a/src/test/jtx/impl/AMMTest.cpp +++ b/src/test/jtx/impl/AMMTest.cpp @@ -105,9 +105,14 @@ AMMTestBase::testAMM( for (auto const& features : arg.features) { + // Use small Number mantissas for the life of this test. + NumberMantissaScaleGuard const sg{xrpl::MantissaRange::small}; + + // For now, just disable SAV entirely, which locks in the small Number + // mantissas Env env{ *this, - features, + features - featureSingleAssetVault - featureLendingProtocol, arg.noLog ? std::make_unique(&logs) : nullptr}; auto const [asset1, asset2] = diff --git a/src/test/protocol/STNumber_test.cpp b/src/test/protocol/STNumber_test.cpp index 1275c756cf..4e7a8388ee 100644 --- a/src/test/protocol/STNumber_test.cpp +++ b/src/test/protocol/STNumber_test.cpp @@ -29,10 +29,8 @@ struct STNumber_test : public beast::unit_test::suite } void - run() override + doRun() { - static_assert(!std::is_convertible_v); - { STNumber const stnum{sfNumber}; BEAST_EXPECT(stnum.getSType() == STI_NUMBER); @@ -127,6 +125,41 @@ struct STNumber_test : public beast::unit_test::suite BEAST_EXPECT( numberFromJson(sfNumber, "-0.000e6") == STNumber(sfNumber, 0)); + { + NumberRoundModeGuard mg(Number::towards_zero); + // maxint64 9,223,372,036,854,775,807 + auto const maxInt = + std::to_string(std::numeric_limits::max()); + // minint64 -9,223,372,036,854,775,808 + auto const minInt = + std::to_string(std::numeric_limits::min()); + if (Number::getMantissaScale() == MantissaRange::small) + { + BEAST_EXPECT( + numberFromJson(sfNumber, maxInt) == + STNumber(sfNumber, Number{9'223'372'036'854'775, 3})); + BEAST_EXPECT( + numberFromJson(sfNumber, minInt) == + STNumber(sfNumber, Number{-9'223'372'036'854'775, 3})); + } + else + { + BEAST_EXPECT( + numberFromJson(sfNumber, maxInt) == + STNumber( + sfNumber, Number{9'223'372'036'854'775'807, 0})); + BEAST_EXPECT( + numberFromJson(sfNumber, minInt) == + STNumber( + sfNumber, + Number{ + true, + 9'223'372'036'854'775'808ULL, + 0, + Number::normalized{}})); + } + } + constexpr auto imin = std::numeric_limits::min(); BEAST_EXPECT( numberFromJson(sfNumber, imin) == @@ -279,15 +312,21 @@ struct STNumber_test : public beast::unit_test::suite } } } + + void + run() override + { + static_assert(!std::is_convertible_v); + + for (auto const scale : {MantissaRange::small, MantissaRange::large}) + { + NumberMantissaScaleGuard sg(scale); + testcase << to_string(Number::getMantissaScale()); + doRun(); + } + } }; BEAST_DEFINE_TESTSUITE(STNumber, protocol, xrpl); -void -testCompile(std::ostream& out) -{ - STNumber number{sfNumber, 42}; - out << number; -} - } // namespace xrpl diff --git a/src/test/rpc/GetAggregatePrice_test.cpp b/src/test/rpc/GetAggregatePrice_test.cpp index 52f82ffc6c..0ffefc6cb6 100644 --- a/src/test/rpc/GetAggregatePrice_test.cpp +++ b/src/test/rpc/GetAggregatePrice_test.cpp @@ -191,18 +191,38 @@ public: // Aggregate data set includes all price oracle instances, no trimming // or time threshold { - Env env(*this); - OraclesData oracles; - prep(env, oracles); - // entire and trimmed stats - auto ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles); - BEAST_EXPECT(ret[jss::entire_set][jss::mean] == "74.45"); - BEAST_EXPECT(ret[jss::entire_set][jss::size].asUInt() == 10); - BEAST_EXPECT( - ret[jss::entire_set][jss::standard_deviation] == - "0.3027650354097492"); - BEAST_EXPECT(ret[jss::median] == "74.45"); - BEAST_EXPECT(ret[jss::time] == 946694900); + auto const all = testable_amendments(); + for (auto const& feats : + {all - featureSingleAssetVault - featureLendingProtocol, all}) + { + for (auto const mantissaSize : + {MantissaRange::small, MantissaRange::large}) + { + // Regardless of the features enabled, RPC is controlled by + // the global mantissa size. And since it's a thread-local, + // overriding it locally won't make a difference either. + // This will mean all RPC will use the default of "large". + NumberMantissaScaleGuard mg(mantissaSize); + + Env env(*this, feats); + OraclesData oracles; + prep(env, oracles); + // entire and trimmed stats + auto ret = + Oracle::aggregatePrice(env, "XRP", "USD", oracles); + BEAST_EXPECT(ret[jss::entire_set][jss::mean] == "74.45"); + BEAST_EXPECT( + ret[jss::entire_set][jss::size].asUInt() == 10); + // Short: 0.3027650354097492 + BEAST_EXPECTS( + ret[jss::entire_set][jss::standard_deviation] == + "0.3027650354097491666", + ret[jss::entire_set][jss::standard_deviation] + .asString()); + BEAST_EXPECT(ret[jss::median] == "74.45"); + BEAST_EXPECT(ret[jss::time] == 946694900); + } + } } // Aggregate data set includes all price oracle instances @@ -215,15 +235,19 @@ public: Oracle::aggregatePrice(env, "XRP", "USD", oracles, 20, 100); BEAST_EXPECT(ret[jss::entire_set][jss::mean] == "74.45"); BEAST_EXPECT(ret[jss::entire_set][jss::size].asUInt() == 10); - BEAST_EXPECT( + // Short: "0.3027650354097492", + BEAST_EXPECTS( ret[jss::entire_set][jss::standard_deviation] == - "0.3027650354097492"); + "0.3027650354097491666", + ret[jss::entire_set][jss::standard_deviation].asString()); BEAST_EXPECT(ret[jss::median] == "74.45"); BEAST_EXPECT(ret[jss::trimmed_set][jss::mean] == "74.45"); BEAST_EXPECT(ret[jss::trimmed_set][jss::size].asUInt() == 6); - BEAST_EXPECT( + // Short: "0.187082869338697", + BEAST_EXPECTS( ret[jss::trimmed_set][jss::standard_deviation] == - "0.187082869338697"); + "0.1870828693386970693", + ret[jss::trimmed_set][jss::standard_deviation].asString()); BEAST_EXPECT(ret[jss::time] == 946694900); } @@ -274,15 +298,19 @@ public: Oracle::aggregatePrice(env, "XRP", "USD", oracles, 20, "200"); BEAST_EXPECT(ret[jss::entire_set][jss::mean] == "74.6"); BEAST_EXPECT(ret[jss::entire_set][jss::size].asUInt() == 7); - BEAST_EXPECT( + // Short: 0.2160246899469287 + BEAST_EXPECTS( ret[jss::entire_set][jss::standard_deviation] == - "0.2160246899469287"); + "0.2160246899469286744", + ret[jss::entire_set][jss::standard_deviation].asString()); BEAST_EXPECT(ret[jss::median] == "74.6"); BEAST_EXPECT(ret[jss::trimmed_set][jss::mean] == "74.6"); BEAST_EXPECT(ret[jss::trimmed_set][jss::size].asUInt() == 5); - BEAST_EXPECT( + // Short: 0.158113883008419 + BEAST_EXPECTS( ret[jss::trimmed_set][jss::standard_deviation] == - "0.158113883008419"); + "0.1581138830084189666", + ret[jss::trimmed_set][jss::standard_deviation].asString()); BEAST_EXPECT(ret[jss::time] == 946694900); } diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp index 1cc6b1f5ae..c61145dda1 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp @@ -2,6 +2,8 @@ // #include +#include + namespace xrpl { bool @@ -338,6 +340,8 @@ LoanBrokerCoverClawback::doApply() sleBroker->at(sfCoverAvailable) -= clawAmount; view().update(sleBroker); + associateAsset(*sleBroker, vaultAsset); + // Transfer assets from pseudo-account to depositor. return accountSend( view(), brokerPseudoID, account, clawAmount, j_, WaiveTransferFee::Yes); diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp index b68cf46a00..ed47d40631 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp @@ -2,6 +2,8 @@ // #include +#include + namespace xrpl { bool @@ -100,6 +102,12 @@ LoanBrokerCoverDeposit::doApply() if (!broker) return tecINTERNAL; // LCOV_EXCL_LINE + auto const vault = view().read(keylet::vault(broker->at(sfVaultID))); + if (!vault) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const vaultAsset = vault->at(sfAsset); + auto const brokerPseudoID = broker->at(sfAccount); // Transfer assets from depositor to pseudo-account. @@ -116,6 +124,8 @@ LoanBrokerCoverDeposit::doApply() broker->at(sfCoverAvailable) += amount; view().update(broker); + associateAsset(*broker, vaultAsset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp index 830f9e26c1..5d4d2053ed 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp @@ -4,6 +4,7 @@ #include #include +#include namespace xrpl { @@ -156,12 +157,20 @@ LoanBrokerCoverWithdraw::doApply() if (!broker) return tecINTERNAL; // LCOV_EXCL_LINE + auto const vault = view().read(keylet::vault(broker->at(sfVaultID))); + if (!vault) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const vaultAsset = vault->at(sfAsset); + auto const brokerPseudoID = *broker->at(sfAccount); // Decrease the LoanBroker's CoverAvailable by Amount broker->at(sfCoverAvailable) -= amount; view().update(broker); + associateAsset(*broker, vaultAsset); + return doWithdraw( view(), tx, diff --git a/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp index 227bad10a9..f3f57f8659 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp @@ -2,6 +2,8 @@ // #include +#include + namespace xrpl { bool @@ -185,6 +187,8 @@ LoanBrokerDelete::doApply() adjustOwnerCount(view(), owner, -2, j_); } + associateAsset(*broker, vaultAsset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/LoanBrokerSet.cpp b/src/xrpld/app/tx/detail/LoanBrokerSet.cpp index 0cbae4d779..b7e9e4c79f 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerSet.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerSet.cpp @@ -2,6 +2,8 @@ // #include +#include + namespace xrpl { bool @@ -62,6 +64,15 @@ LoanBrokerSet::preflight(PreflightContext const& ctx) return tesSUCCESS; } +std::vector> const& +LoanBrokerSet::getValueFields() +{ + static std::vector> const valueFields{ + ~sfDebtMaximum}; + + return valueFields; +} + TER LoanBrokerSet::preclaim(PreclaimContext const& ctx) { @@ -70,8 +81,24 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx) auto const account = tx[sfAccount]; auto const vaultID = tx[sfVaultID]; + auto const sleVault = ctx.view.read(keylet::vault(vaultID)); + if (!sleVault) + { + JLOG(ctx.j.warn()) << "Vault does not exist."; + return tecNO_ENTRY; + } + Asset const asset = sleVault->at(sfAsset); + + if (account != sleVault->at(sfOwner)) + { + JLOG(ctx.j.warn()) << "Account is not the owner of the Vault."; + return tecNO_PERMISSION; + } + if (auto const brokerID = tx[~sfLoanBrokerID]) { + // Updating an existing Broker + auto const sleBroker = ctx.view.read(keylet::loanbroker(*brokerID)); if (!sleBroker) { @@ -104,18 +131,7 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx) } else { - auto const sleVault = ctx.view.read(keylet::vault(vaultID)); - if (!sleVault) - { - JLOG(ctx.j.warn()) << "Vault does not exist."; - return tecNO_ENTRY; - } - if (account != sleVault->at(sfOwner)) - { - JLOG(ctx.j.warn()) << "Account is not the owner of the Vault."; - return tecNO_PERMISSION; - } - if (auto const ter = canAddHolding(ctx.view, sleVault->at(sfAsset))) + if (auto const ter = canAddHolding(ctx.view, asset)) return ter; if (auto const ter = checkFrozen( @@ -125,6 +141,21 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx) return ter; } } + + // Check that relevant values can be represented as the vault asset + // type. This is mostly only relevant for integral (non-IOU) types + for (auto const& field : getValueFields()) + { + if (auto const value = tx[field]; + value && STAmount{asset, *value} != *value) + { + JLOG(ctx.j.warn()) << field.f->getName() << " (" << *value + << ") can not be represented as a(n) " + << to_string(asset) << "."; + return tecPRECISION_LOSS; + } + } + return tesSUCCESS; } @@ -147,12 +178,20 @@ LoanBrokerSet::doApply() // LCOV_EXCL_STOP } + auto const vault = view.read(keylet::vault(broker->at(sfVaultID))); + if (!vault) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const vaultAsset = vault->at(sfAsset); + if (auto const data = tx[~sfData]) broker->at(sfData) = *data; if (auto const debtMax = tx[~sfDebtMaximum]) broker->at(sfDebtMaximum) = *debtMax; view.update(broker); + + associateAsset(*broker, vaultAsset); } else { @@ -168,6 +207,7 @@ LoanBrokerSet::doApply() // LCOV_EXCL_STOP } auto const vaultPseudoID = sleVault->at(sfAccount); + auto const vaultAsset = sleVault->at(sfAsset); auto const sequence = tx.getSeqValue(); auto owner = view.peek(keylet::account(account_)); @@ -224,6 +264,8 @@ LoanBrokerSet::doApply() broker->at(sfCoverRateLiquidation) = *coverLiq; view.insert(broker); + + associateAsset(*broker, vaultAsset); } return tesSUCCESS; diff --git a/src/xrpld/app/tx/detail/LoanBrokerSet.h b/src/xrpld/app/tx/detail/LoanBrokerSet.h index 625c0adeb2..57170b9cb9 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerSet.h +++ b/src/xrpld/app/tx/detail/LoanBrokerSet.h @@ -20,6 +20,9 @@ public: static NotTEC preflight(PreflightContext const& ctx); + static std::vector> const& + getValueFields(); + static TER preclaim(PreclaimContext const& ctx); diff --git a/src/xrpld/app/tx/detail/LoanDelete.cpp b/src/xrpld/app/tx/detail/LoanDelete.cpp index 3643e6331b..ddb286db12 100644 --- a/src/xrpld/app/tx/detail/LoanDelete.cpp +++ b/src/xrpld/app/tx/detail/LoanDelete.cpp @@ -2,6 +2,8 @@ // #include +#include + namespace xrpl { bool @@ -78,9 +80,10 @@ LoanDelete::doApply() return tefBAD_LEDGER; // LCOV_EXCL_LINE auto const brokerPseudoAccount = brokerSle->at(sfAccount); - auto const vaultSle = view.peek(keylet ::vault(brokerSle->at(sfVaultID))); + auto const vaultSle = view.peek(keylet::vault(brokerSle->at(sfVaultID))); if (!vaultSle) return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const vaultAsset = vaultSle->at(sfAsset); // Remove LoanID from Directory of the LoanBroker pseudo-account. if (!view.dirRemove( @@ -125,6 +128,11 @@ LoanDelete::doApply() // Decrement the borrower's owner count adjustOwnerCount(view, borrowerSle, -1, j_); + // These associations shouldn't do anything, but do them just to be safe + associateAsset(*loanSle, vaultAsset); + associateAsset(*brokerSle, vaultAsset); + associateAsset(*vaultSle, vaultAsset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/LoanManage.cpp b/src/xrpld/app/tx/detail/LoanManage.cpp index bd0539ae4e..17dee8ffe8 100644 --- a/src/xrpld/app/tx/detail/LoanManage.cpp +++ b/src/xrpld/app/tx/detail/LoanManage.cpp @@ -2,6 +2,7 @@ // #include +#include #include namespace xrpl { @@ -412,7 +413,7 @@ LoanManage::doApply() if (!brokerSle) return tefBAD_LEDGER; // LCOV_EXCL_LINE - auto const vaultSle = view.peek(keylet ::vault(brokerSle->at(sfVaultID))); + auto const vaultSle = view.peek(keylet::vault(brokerSle->at(sfVaultID))); if (!vaultSle) return tefBAD_LEDGER; // LCOV_EXCL_LINE auto const vaultAsset = vaultSle->at(sfAsset); @@ -426,6 +427,11 @@ LoanManage::doApply() if (tx.isFlag(tfLoanUnimpair)) return unimpairLoan(view, loanSle, vaultSle, vaultAsset, j_); // Noop, as described above. + + associateAsset(*loanSle, vaultAsset); + associateAsset(*brokerSle, vaultAsset); + associateAsset(*vaultSle, vaultAsset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/LoanPay.cpp b/src/xrpld/app/tx/detail/LoanPay.cpp index f3973d9488..13f62d4c0d 100644 --- a/src/xrpld/app/tx/detail/LoanPay.cpp +++ b/src/xrpld/app/tx/detail/LoanPay.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -485,6 +486,16 @@ LoanPay::doApply() coverAvailableProxy += totalPaidToBroker; } + associateAsset(*loanSle, asset); + associateAsset(*brokerSle, asset); + associateAsset(*vaultSle, asset); + + // Duplicate some checks after rounding + XRPL_ASSERT_PARTS( + *assetsAvailableProxy <= *assetsTotalProxy, + "xrpl::LoanPay::doApply", + "assets available must not be greater than assets outstanding"); + #if !NDEBUG auto const accountBalanceBefore = accountHolds( view, diff --git a/src/xrpld/app/tx/detail/LoanSet.cpp b/src/xrpld/app/tx/detail/LoanSet.cpp index 4c14cde421..0b83d3009f 100644 --- a/src/xrpld/app/tx/detail/LoanSet.cpp +++ b/src/xrpld/app/tx/detail/LoanSet.cpp @@ -2,6 +2,7 @@ // #include +#include #include namespace xrpl { @@ -301,17 +302,15 @@ LoanSet::preclaim(PreclaimContext const& ctx) // This check is almost duplicated in doApply, but that check is done after // the overall loan scale is known. This is mostly only relevant for // integral (non-IOU) types + for (auto const& field : getValueFields()) { - for (auto const& field : getValueFields()) + if (auto const value = tx[field]; + value && STAmount{asset, *value} != *value) { - if (auto const value = tx[field]; - value && STAmount{asset, *value} != *value) - { - JLOG(ctx.j.warn()) << field.f->getName() << " (" << *value - << ") can not be represented as a(n) " - << to_string(asset) << "."; - return tecPRECISION_LOSS; - } + JLOG(ctx.j.warn()) << field.f->getName() << " (" << *value + << ") can not be represented as a(n) " + << to_string(asset) << "."; + return tecPRECISION_LOSS; } } @@ -434,19 +433,16 @@ LoanSet::doApply() } // Check that relevant values won't lose precision. This is mostly only // relevant for IOU assets. + for (auto const& field : getValueFields()) { - for (auto const& field : getValueFields()) + if (auto const value = tx[field]; + value && !isRounded(vaultAsset, *value, properties.loanScale)) { - if (auto const value = tx[field]; - value && !isRounded(vaultAsset, *value, properties.loanScale)) - { - JLOG(j_.warn()) - << field.f->getName() << " (" << *value - << ") has too much precision. Total loan value is " - << properties.loanState.valueOutstanding - << " with a scale of " << properties.loanScale; - return tecPRECISION_LOSS; - } + JLOG(j_.warn()) << field.f->getName() << " (" << *value + << ") has too much precision. Total loan value is " + << properties.loanState.valueOutstanding + << " with a scale of " << properties.loanScale; + return tecPRECISION_LOSS; } } @@ -649,6 +645,10 @@ LoanSet::doApply() if (auto const ter = dirLink(view, borrower, loan, sfOwnerNode)) return ter; + associateAsset(*vaultSle, vaultAsset); + associateAsset(*brokerSle, vaultAsset); + associateAsset(*loan, vaultAsset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/Transactor.cpp b/src/xrpld/app/tx/detail/Transactor.cpp index a834f7c6c3..691443ed93 100644 --- a/src/xrpld/app/tx/detail/Transactor.cpp +++ b/src/xrpld/app/tx/detail/Transactor.cpp @@ -1136,6 +1136,10 @@ Transactor::operator()() { JLOG(j_.trace()) << "apply: " << ctx_.tx.getTransactionID(); + // These global updates really should have been for every Transaction + // step: preflight, preclaim, and doApply. And even calculateBaseFee. See + // with_txn_type(). + // // raii classes for the current ledger rules. // fixUniversalNumber predate the rulesGuard and should be replaced. NumberSO stNumberSO{view().rules().enabled(fixUniversalNumber)}; @@ -1152,7 +1156,7 @@ Transactor::operator()() { // LCOV_EXCL_START JLOG(j_.fatal()) << "Transaction serdes mismatch"; - JLOG(j_.info()) << to_string(ctx_.tx.getJson(JsonOptions::none)); + JLOG(j_.fatal()) << ctx_.tx.getJson(JsonOptions::none); JLOG(j_.fatal()) << s2.getJson(JsonOptions::none); UNREACHABLE( "xrpl::Transactor::operator() : transaction serdes mismatch"); diff --git a/src/xrpld/app/tx/detail/VaultClawback.cpp b/src/xrpld/app/tx/detail/VaultClawback.cpp index 2552e8c1ff..dbdf4440ec 100644 --- a/src/xrpld/app/tx/detail/VaultClawback.cpp +++ b/src/xrpld/app/tx/detail/VaultClawback.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -457,6 +458,8 @@ VaultClawback::doApply() } } + associateAsset(*vault, vaultAsset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/VaultCreate.cpp b/src/xrpld/app/tx/detail/VaultCreate.cpp index 893a1108fa..402d877a00 100644 --- a/src/xrpld/app/tx/detail/VaultCreate.cpp +++ b/src/xrpld/app/tx/detail/VaultCreate.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -230,6 +231,8 @@ VaultCreate::doApply() return err; } + associateAsset(*vault, asset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/VaultDelete.cpp b/src/xrpld/app/tx/detail/VaultDelete.cpp index 756e7b94e6..9b63c7766b 100644 --- a/src/xrpld/app/tx/detail/VaultDelete.cpp +++ b/src/xrpld/app/tx/detail/VaultDelete.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -85,6 +86,7 @@ VaultDelete::doApply() // Destroy the asset holding. auto asset = vault->at(sfAsset); + if (auto ter = removeEmptyHolding(view(), vault->at(sfAccount), asset, j_); !isTesSuccess(ter)) return ter; @@ -205,6 +207,8 @@ VaultDelete::doApply() // Destroy the vault. view().erase(vault); + associateAsset(*vault, asset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/VaultDeposit.cpp b/src/xrpld/app/tx/detail/VaultDeposit.cpp index 51b38afc36..02ef4afad1 100644 --- a/src/xrpld/app/tx/detail/VaultDeposit.cpp +++ b/src/xrpld/app/tx/detail/VaultDeposit.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -134,6 +135,7 @@ VaultDeposit::doApply() auto const vault = view().peek(keylet::vault(ctx_.tx[sfVaultID])); if (!vault) return tefINTERNAL; // LCOV_EXCL_LINE + auto const vaultAsset = vault->at(sfAsset); auto const amount = ctx_.tx[sfAmount]; // Make sure the depositor can hold shares. @@ -282,6 +284,8 @@ VaultDeposit::doApply() !isTesSuccess(ter)) return ter; + associateAsset(*vault, vaultAsset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/VaultSet.cpp b/src/xrpld/app/tx/detail/VaultSet.cpp index 648ac12c3d..13c8ad5db8 100644 --- a/src/xrpld/app/tx/detail/VaultSet.cpp +++ b/src/xrpld/app/tx/detail/VaultSet.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -128,6 +129,8 @@ VaultSet::doApply() if (!vault) return tefINTERNAL; // LCOV_EXCL_LINE + auto const vaultAsset = vault->at(sfAsset); + auto const mptIssuanceID = (*vault)[sfShareMPTID]; auto const sleIssuance = view().peek(keylet::mptIssuance(mptIssuanceID)); if (!sleIssuance) @@ -172,6 +175,8 @@ VaultSet::doApply() // to verify the operation. view().update(vault); + associateAsset(*vault, vaultAsset); + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/VaultWithdraw.cpp b/src/xrpld/app/tx/detail/VaultWithdraw.cpp index f8b7a1a739..9a4334e435 100644 --- a/src/xrpld/app/tx/detail/VaultWithdraw.cpp +++ b/src/xrpld/app/tx/detail/VaultWithdraw.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -115,6 +116,7 @@ VaultWithdraw::doApply() auto const amount = ctx_.tx[sfAmount]; Asset const vaultAsset = vault->at(sfAsset); + MPTIssue const share{mptIssuanceID}; STAmount sharesRedeemed = {share}; STAmount assetsWithdrawn; @@ -239,6 +241,8 @@ VaultWithdraw::doApply() auto const dstAcct = ctx_.tx[~sfDestination].value_or(account_); + associateAsset(*vault, vaultAsset); + return doWithdraw( view(), ctx_.tx, diff --git a/src/xrpld/app/tx/detail/applySteps.cpp b/src/xrpld/app/tx/detail/applySteps.cpp index e0bd9d0d2d..0fae1a15e0 100644 --- a/src/xrpld/app/tx/detail/applySteps.cpp +++ b/src/xrpld/app/tx/detail/applySteps.cpp @@ -34,8 +34,38 @@ struct UnknownTxnType : std::exception // throw an "UnknownTxnType" exception on error template auto -with_txn_type(TxType txnType, F&& f) +with_txn_type(Rules const& rules, TxType txnType, F&& f) { + // These global updates really should have been for every Transaction + // step: preflight, preclaim, calculateBaseFee, and doApply. Unfortunately, + // they were only included in doApply (via Transactor::operator()). That may + // have been sufficient when the changes were only related to operations + // that mutated data, but some features will now change how they read data, + // so these need to be more global. + // + // To prevent unintentional side effects on existing checks, they will be + // set for every operation only once SingleAssetVault (or later + // LendingProtocol) are enabled. + // + // See also Transactor::operator(). + // + std::optional stNumberSO; + std::optional rulesGuard; + std::optional mantissaScaleGuard; + if (rules.enabled(featureSingleAssetVault) || + rules.enabled(featureLendingProtocol)) + { + // raii classes for the current ledger rules. + // fixUniversalNumber predates the rulesGuard and should be replaced. + stNumberSO.emplace(rules.enabled(fixUniversalNumber)); + rulesGuard.emplace(rules); + } + else + { + // Without those features enabled, always use the old number rules. + mantissaScaleGuard.emplace(MantissaRange::small); + } + switch (txnType) { #pragma push_macro("TRANSACTION") @@ -99,7 +129,7 @@ invoke_preflight(PreflightContext const& ctx) { try { - return with_txn_type(ctx.tx.getTxnType(), [&]() { + return with_txn_type(ctx.rules, ctx.tx.getTxnType(), [&]() { auto const tec = Transactor::invokePreflight(ctx); return std::make_pair( tec, @@ -126,50 +156,51 @@ invoke_preclaim(PreclaimContext const& ctx) { // use name hiding to accomplish compile-time polymorphism of static // class functions for Transactor and derived classes. - return with_txn_type(ctx.tx.getTxnType(), [&]() -> TER { - // preclaim functionality is divided into two sections: - // 1. Up to and including the signature check: returns NotTEC. - // All transaction checks before and including checkSign - // MUST return NotTEC, or something more restrictive. - // Allowing tec results in these steps risks theft or - // destruction of funds, as a fee will be charged before the - // signature is checked. - // 2. After the signature check: returns TER. + return with_txn_type( + ctx.view.rules(), ctx.tx.getTxnType(), [&]() -> TER { + // preclaim functionality is divided into two sections: + // 1. Up to and including the signature check: returns NotTEC. + // All transaction checks before and including checkSign + // MUST return NotTEC, or something more restrictive. + // Allowing tec results in these steps risks theft or + // destruction of funds, as a fee will be charged before the + // signature is checked. + // 2. After the signature check: returns TER. - // If the transactor requires a valid account and the - // transaction doesn't list one, preflight will have already - // a flagged a failure. - auto const id = ctx.tx.getAccountID(sfAccount); + // If the transactor requires a valid account and the + // transaction doesn't list one, preflight will have already + // a flagged a failure. + auto const id = ctx.tx.getAccountID(sfAccount); - if (id != beast::zero) - { - if (NotTEC const preSigResult = [&]() -> NotTEC { - if (NotTEC const result = - T::checkSeqProxy(ctx.view, ctx.tx, ctx.j)) - return result; + if (id != beast::zero) + { + if (NotTEC const preSigResult = [&]() -> NotTEC { + if (NotTEC const result = + T::checkSeqProxy(ctx.view, ctx.tx, ctx.j)) + return result; - if (NotTEC const result = - T::checkPriorTxAndLastLedger(ctx)) - return result; + if (NotTEC const result = + T::checkPriorTxAndLastLedger(ctx)) + return result; - if (NotTEC const result = - T::checkPermission(ctx.view, ctx.tx)) - return result; + if (NotTEC const result = + T::checkPermission(ctx.view, ctx.tx)) + return result; - if (NotTEC const result = T::checkSign(ctx)) - return result; + if (NotTEC const result = T::checkSign(ctx)) + return result; - return tesSUCCESS; - }()) - return preSigResult; + return tesSUCCESS; + }()) + return preSigResult; - if (TER const result = - T::checkFee(ctx, calculateBaseFee(ctx.view, ctx.tx))) - return result; - } + if (TER const result = T::checkFee( + ctx, calculateBaseFee(ctx.view, ctx.tx))) + return result; + } - return T::preclaim(ctx); - }); + return T::preclaim(ctx); + }); } catch (UnknownTxnType const& e) { @@ -204,7 +235,7 @@ invoke_calculateBaseFee(ReadView const& view, STTx const& tx) { try { - return with_txn_type(tx.getTxnType(), [&]() { + return with_txn_type(view.rules(), tx.getTxnType(), [&]() { return T::calculateBaseFee(view, tx); }); } @@ -263,10 +294,11 @@ invoke_apply(ApplyContext& ctx) { try { - return with_txn_type(ctx.tx.getTxnType(), [&]() { - T p(ctx); - return p(); - }); + return with_txn_type( + ctx.view().rules(), ctx.tx.getTxnType(), [&]() { + T p(ctx); + return p(); + }); } catch (UnknownTxnType const& e) { From efa57e872badfacc82e4d95c65b64d0b95dc6bf3 Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Tue, 13 Jan 2026 17:53:40 -0400 Subject: [PATCH 12/14] Change LendingProtocol feature and dependencies to supported (#6146) --- include/xrpl/protocol/detail/features.macro | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index 932668c16f..0c952bf59b 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -17,7 +17,7 @@ // Keep it sorted in reverse chronological order. XRPL_FIX (BatchInnerSigs, Supported::yes, VoteBehavior::DefaultNo) -XRPL_FEATURE(LendingProtocol, Supported::no, VoteBehavior::DefaultNo) +XRPL_FEATURE(LendingProtocol, Supported::yes, VoteBehavior::DefaultNo) XRPL_FEATURE(PermissionDelegationV1_1, Supported::no, VoteBehavior::DefaultNo) XRPL_FIX (DirectoryLimit, Supported::yes, VoteBehavior::DefaultNo) XRPL_FIX (IncludeKeyletFields, Supported::yes, VoteBehavior::DefaultNo) @@ -31,7 +31,7 @@ XRPL_FIX (EnforceNFTokenTrustlineV2, Supported::yes, VoteBehavior::DefaultNo XRPL_FIX (AMMv1_3, Supported::yes, VoteBehavior::DefaultNo) XRPL_FEATURE(PermissionedDEX, Supported::yes, VoteBehavior::DefaultNo) XRPL_FEATURE(Batch, Supported::yes, VoteBehavior::DefaultNo) -XRPL_FEATURE(SingleAssetVault, Supported::no, VoteBehavior::DefaultNo) +XRPL_FEATURE(SingleAssetVault, Supported::yes, VoteBehavior::DefaultNo) XRPL_FIX (PayChanCancelAfter, Supported::yes, VoteBehavior::DefaultNo) // Check flags in Credential transactions XRPL_FIX (InvalidTxFlags, Supported::yes, VoteBehavior::DefaultNo) From ebcfd6645db82e0485fdb380ecdf9f413a4c1044 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Wed, 14 Jan 2026 14:40:07 -0500 Subject: [PATCH 13/14] test: Replace `failed` string in Vault test case (#6214) The word `failed` in the test case makes it hard to search through the test logs when an actual test failure occurs, so this change renames the word to just `fail` instead. --- src/test/app/Vault_test.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index a6d08b6531..41a4fc2b3b 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -2076,7 +2076,7 @@ class Vault_test : public beast::unit_test::suite PrettyAsset const& asset, Vault& vault, MPTTester& mptt) { - testcase("MPT failed reserve to re-create MPToken"); + testcase("MPT fail reserve to re-create MPToken"); auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); From c9458b72cab68d3cbbf533cc87d14309c2eb93b7 Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Wed, 14 Jan 2026 19:45:00 -0400 Subject: [PATCH 14/14] test: Suppress "parse failed" message in Batch tests (#6207) --- src/test/app/Batch_test.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/app/Batch_test.cpp b/src/test/app/Batch_test.cpp index 68bf7e833b..67b0933ae2 100644 --- a/src/test/app/Batch_test.cpp +++ b/src/test/app/Batch_test.cpp @@ -427,6 +427,7 @@ class Batch_test : public beast::unit_test::suite auto const batchFee = batch::calcBatchFee(env, 0, 2); auto tx1 = batch::inner(pay(alice, bob, XRP(1)), seq + 1); tx1[jss::Fee] = "1.5"; + env.set_parse_failure_expected(true); try { env(batch::outer(alice, seq, batchFee, tfAllOrNothing), @@ -438,6 +439,7 @@ class Batch_test : public beast::unit_test::suite { BEAST_EXPECT(true); } + env.set_parse_failure_expected(false); } // temSEQ_AND_TICKET: Batch: inner txn cannot have both Sequence