diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index a6ab42254b..b5d582dee4 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -4,8 +4,39 @@ #include #include +#include + namespace xrpl { +/** + * Broker cover preclaim precision guard (fixCleanup3_2_0). + * + * Prevents a "silent sub-ULP no-op" where a deposit, withdrawal, or clawback + * amount is so small that it rounds to zero at `sfCoverAvailable`'s scale. + * Without this guard, both the pseudo trust-line and `sfCoverAvailable` would + * identically absorb the rounded zero, resulting in a successful transaction + * (tesSUCCESS) where no funds actually moved. + * + * @param view Apply view (rules used for amendment gating). + * @param sleBroker The loan broker SLE (read-only). + * @param vaultAsset The underlying vault asset (the broker's cover asset). + * @param amount The effective subtraction/addition amount. + * @param j Journal for logging. + * @param logPrefix Transactor name for log diagnostics. + * + * @return `tecPRECISION_LOSS` if the request rounds to zero at cover scale. + * `tesSUCCESS` if the amendment is disabled, the amount is true zero, + * or the request is safely supra-ULP. + */ +[[nodiscard]] TER +canApplyToBrokerCover( + ReadView const& view, + SLE::const_ref sleBroker, + Asset const& vaultAsset, + STAmount const& amount, + beast::Journal j, + std::string_view logPrefix); + // Lending protocol has dependencies, so capture them here. bool checkLendingProtocolDependencies(Rules const& rules, STTx const& tx); diff --git a/include/xrpl/protocol/STAmount.h b/include/xrpl/protocol/STAmount.h index c7d00bc98c..30477c148e 100644 --- a/include/xrpl/protocol/STAmount.h +++ b/include/xrpl/protocol/STAmount.h @@ -187,6 +187,24 @@ public: [[nodiscard]] STAmount const& value() const noexcept; + /** + * Checks if this amount evaluates to zero when constrained to a specific + * accounting scale. + * + * For XRP and MPT `roundToScale` is a no-op, returns true only when the amount itself is zero. + * The `scale` argument is ignored in that case. + * For IOU, the amount is rounded to the given scale (using the current rounding mode) + * and the result is checked for zero; if `scale <= exponent()`, `roundToScale` short-circuits + * and returns the value unchanged, so this returns false for any non-zero amount. + * + * @param scale The target accounting scale to evaluate against. + * @return `true` if this amount rounds to zero at the given scale, `false` otherwise. + * + * @see roundToScale + */ + [[nodiscard]] bool + isZeroAtScale(int scale) const; + //-------------------------------------------------------------------------- // // Operators diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 26e320c15d..aa5b3cd539 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -24,10 +25,41 @@ #include #include #include +#include #include namespace xrpl { +[[nodiscard]] TER +canApplyToBrokerCover( + ReadView const& view, + SLE::const_ref sleBroker, + Asset const& vaultAsset, + STAmount const& amount, + beast::Journal j, + std::string_view logPrefix) +{ + XRPL_ASSERT( + sleBroker && sleBroker->getType() == ltLOAN_BROKER, + "xrpl::canApplyToBrokerCover : valid LoanBroker sle"); + XRPL_ASSERT( + vaultAsset == amount.asset() && amount > beast::kZero, + "xrpl::canApplyToBrokerCover : valid amount for asset"); + + if (!view.rules().enabled(fixCleanup3_2_0)) + return tesSUCCESS; + + int const coverScale = scale(sleBroker->at(sfCoverAvailable), vaultAsset); + if (amount.isZeroAtScale(coverScale)) + { + JLOG(j.warn()) << logPrefix << ": amount " << amount.getFullText() + << " rounds to zero at cover scale " << coverScale; + return tecPRECISION_LOSS; + } + + return tesSUCCESS; +} + bool checkLendingProtocolDependencies(Rules const& rules, STTx const& tx) { diff --git a/src/libxrpl/protocol/STAmount.cpp b/src/libxrpl/protocol/STAmount.cpp index 3cbf03575b..787f8f2f9b 100644 --- a/src/libxrpl/protocol/STAmount.cpp +++ b/src/libxrpl/protocol/STAmount.cpp @@ -1738,4 +1738,9 @@ divRoundStrict(STAmount const& num, STAmount const& den, Asset const& asset, boo return divRoundImpl(num, den, asset, roundUp); } +[[nodiscard]] bool +STAmount::isZeroAtScale(int scale) const +{ + return roundToScale(*this, scale).signum() == 0; +} } // namespace xrpl diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverClawback.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverClawback.cpp index 553404911c..56fc068786 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverClawback.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverClawback.cpp @@ -291,6 +291,10 @@ LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx) } STAmount const& clawAmount = *findClawAmount; + if (auto const ret = canApplyToBrokerCover( + ctx.view, sleBroker, vaultAsset, clawAmount, ctx.j, "LoanBrokerCoverClawback")) + return ret; + // Explicitly check the balance of the trust line / MPT to make sure the // balance is actually there. It should always match `sfCoverAvailable`, so // if there isn't, this is an internal error. diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverDeposit.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverDeposit.cpp index 62f1be8f81..27b3ebc7e5 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverDeposit.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverDeposit.cpp @@ -1,9 +1,11 @@ #include #include +#include #include #include #include +#include #include #include #include @@ -106,8 +108,6 @@ LoanBrokerCoverDeposit::doApply() auto const& tx = ctx_.tx; auto const brokerID = tx[sfLoanBrokerID]; - auto const amount = tx[sfAmount]; - auto broker = view().peek(keylet::loanbroker(brokerID)); if (!broker) return tecINTERNAL; // LCOV_EXCL_LINE @@ -117,9 +117,25 @@ LoanBrokerCoverDeposit::doApply() return tecINTERNAL; // LCOV_EXCL_LINE auto const vaultAsset = vault->at(sfAsset); - auto const brokerPseudoID = broker->at(sfAccount); + bool const fix320Enabled = view().rules().enabled(fixCleanup3_2_0); + auto const amount = [&]() -> STAmount { + if (!fix320Enabled) + return tx[sfAmount]; + + return roundToScale( + tx[sfAmount], + scale(broker->at(sfCoverAvailable), vaultAsset), + Number::RoundingMode::Downward); + }(); + + if (fix320Enabled && amount == beast::kZero) + { + JLOG(ctx_.journal.warn()) << "LoanBrokerCoverDeposit: deposit amount: " << tx[sfAmount] + << " is zero at loan broker scale"; + return tecPRECISION_LOSS; + } // Transfer assets from depositor to pseudo-account. if (auto ter = accountSend(view(), accountID_, brokerPseudoID, amount, j_, WaiveTransferFee::Yes)) diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp index 52838b2119..209014d61c 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp @@ -94,6 +94,11 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx) if (amount.asset() != vaultAsset) return tecWRONG_ASSET; + // Helper handles both IOU and MPT correctly without explicit branching. + if (auto const ret = canApplyToBrokerCover( + ctx.view, sleBroker, vaultAsset, amount, ctx.j, "LoanBrokerCoverWithdraw")) + return ret; + // The broker's pseudo-account is the source of funds. auto const pseudoAccountID = sleBroker->at(sfAccount); // Post-fixCleanup3_2_0: cover withdraw is a recovery path that bypasses diff --git a/src/test/app/LoanBroker_test.cpp b/src/test/app/LoanBroker_test.cpp index a216cc1c7a..92949256fd 100644 --- a/src/test/app/LoanBroker_test.cpp +++ b/src/test/app/LoanBroker_test.cpp @@ -55,6 +55,7 @@ #include #include #include +#include #include namespace xrpl::test { @@ -2027,10 +2028,221 @@ class LoanBroker_test : public beast::unit_test::Suite testRIPD4274MPT(); } + // Exercises canApplyToBrokerCover (fixCleanup3_2_0): a deposit, withdraw, + // or clawback whose amount rounds to zero at sfCoverAvailable's precision + // scale must be rejected with tecPRECISION_LOSS once the amendment is on, + // and must silently succeed without changing sfCoverAvailable when off. + void + testCoverPrecisionGuard() + { + using namespace jtx; + using namespace loanBroker; + + Account const issuer{"issuer"}; + Account const alice{"alice"}; + + // sfCoverAvailable = 10 IOU → STAmount exponent = -14. + // Anything < 5e-15 rounds to zero at that scale. + // 1e-16 is the representative sub-ULP probe amount. + + // Shared setup: funds accounts, creates a vault + broker with 10 IOU + // cover, and returns {brokerKeylet, iou}. + auto const setup = [&](Env& env) -> std::pair { + Vault const vault{env}; + + env.fund(XRP(100'000), issuer, alice); + env.close(); + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const iou = issuer["IOU"]; + env(trust(alice, iou(1'000'000))); + env.close(); + env(pay(issuer, alice, iou(1'000))); + env.close(); + + auto [createTx, vaultKeylet] = vault.create({.owner = alice, .asset = iou}); + env(createTx); + env.close(); + + auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice)); + env(set(alice, vaultKeylet.key)); + env.close(); + + env(coverDeposit(alice, brokerKeylet.key, iou(10))); + env.close(); + + return {brokerKeylet, iou}; + }; + + auto runTestCases = [&](FeatureBitset features) { + TER const expected = + features[fixCleanup3_2_0] ? TER{tecPRECISION_LOSS} : TER{tesSUCCESS}; + + { + testcase("Cover precision guard: Deposit zero-at-scale"); + Env env{*this, features}; + auto const [brokerKeylet, iou] = setup(env); + PrettyAmount const subUlpAmt = iou(Number{1, -16}); + auto const coverBefore = env.le(brokerKeylet)->at(sfCoverAvailable); + env(coverDeposit(alice, brokerKeylet.key, subUlpAmt), Ter(expected)); + env.close(); + if (expected == tesSUCCESS) + { + if (auto const broker = env.le(brokerKeylet); BEAST_EXPECT(broker)) + BEAST_EXPECT(broker->at(sfCoverAvailable) == coverBefore); + } + } + + { + testcase("Cover precision guard: Deposit rounds down"); + // Both cases succeed; post-fix the amount is rounded DOWN to + // cover scale first, so the delta differs from pre-fix + // Input: 1.8e-14 IOU (sub-scale at cover scale -14) + // Pre-fix: 10 + 1.8e-14 → round-to-nearest → + // 10.00000000000002 → delta 2e-14 + // Post-fix: roundToScale(1.8e-14, -14, Downward) = 1e-14; + // 10 + 1e-14 = 10.00000000000001 → delta 1e-14 + Env env{*this, features}; + auto const [brokerKeylet, iou] = setup(env); + PrettyAmount const subUlpAmt = iou(Number{18, -15}); + auto const coverBefore = env.le(brokerKeylet)->at(sfCoverAvailable); + env(coverDeposit(alice, brokerKeylet.key, subUlpAmt), Ter(tesSUCCESS)); + env.close(); + auto const brokerAfter = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerAfter)) + return; + + Number const delta = features[fixCleanup3_2_0] ? Number{1, -14} : Number{2, -14}; + BEAST_EXPECT(brokerAfter->at(sfCoverAvailable) - coverBefore == delta); + } + + // Property: post-fix, when the user deposits `x` and cover + // gains `x'`, we always have 0 <= x - x' < 1 ULP at cover + // scale (cover holds 10 IOU → ULP = 1e-14). Pre-fix uses + // STAmount's default round-to-nearest during `+=`, which can + // over-deposit (x' > x), so the property only holds with + // fixCleanup3_2_0 enabled. + if (features[fixCleanup3_2_0]) + { + testcase("Cover precision guard: Deposit rounding bound"); + Env env{*this, features}; + auto const [brokerKeylet, iou] = setup(env); + Number const oneUlp{1, -14}; + // Each requested amount lies strictly between 1·ULP and + // 2·ULP at cover scale; post-fix `roundDown` credits + // exactly `oneUlp` and leaves a strictly-positive, + // strictly-sub-ULP residual. + for (Number const requested : {Number{11, -15}, Number{15, -15}, Number{19, -15}}) + { + auto const broker = env.le(brokerKeylet); + if (!BEAST_EXPECT(broker)) + return; + Number const coverBefore = broker->at(sfCoverAvailable); + env(coverDeposit(alice, brokerKeylet.key, iou(requested)), Ter(tesSUCCESS)); + env.close(); + auto const brokerAfter = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerAfter)) + return; + Number const coverAfter = brokerAfter->at(sfCoverAvailable); + Number const actual = coverAfter - coverBefore; + Number const lost = requested - actual; + BEAST_EXPECT(lost >= Number{0}); + BEAST_EXPECT(lost < oneUlp); + } + } + + { + testcase("Cover precision guard: Withdraw"); + Env env{*this, features}; + auto const [brokerKeylet, iou] = setup(env); + PrettyAmount const subUlpAmt = iou(Number{1, -16}); + auto const coverBefore = env.le(brokerKeylet)->at(sfCoverAvailable); + auto const aliceBalanceBefore = env.balance(alice, iou); + env(coverWithdraw(alice, brokerKeylet.key, subUlpAmt), Ter(expected)); + env.close(); + if (expected == tesSUCCESS) + { + if (auto const broker = env.le(brokerKeylet); BEAST_EXPECT(broker)) + BEAST_EXPECT(broker->at(sfCoverAvailable) == coverBefore); + BEAST_EXPECT(env.balance(alice, iou) == aliceBalanceBefore); + } + } + + { + testcase("Cover precision guard: Clawback"); + Env env{*this, features}; + auto const [brokerKeylet, iou] = setup(env); + PrettyAmount const subUlpAmt = iou(Number{1, -16}); + auto const coverBefore = env.le(brokerKeylet)->at(sfCoverAvailable); + env(coverClawback(issuer), + kLoanBrokerId(brokerKeylet.key), + kAmount(subUlpAmt), + Ter(expected)); + env.close(); + if (expected == tesSUCCESS) + { + if (auto const broker = env.le(brokerKeylet); BEAST_EXPECT(broker)) + BEAST_EXPECT(broker->at(sfCoverAvailable) == coverBefore); + } + } + + // MPT amounts are integers; scale is 0; the guard never rejects a + // positive integer amount. Verify all three callsites pass with amendment on. + { + testcase("Cover precision guard: MPT min amount passes"); + Env env{*this, all_}; + + env.fund(XRP(100'000), issuer, alice); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + env.close(); + + PrettyAsset const mptAsset = mptt["MPT"]; + mptt.authorize({.account = alice}); + env.close(); + + env(pay(issuer, alice, mptAsset(100))); + env.close(); + + Vault const vault{env}; + auto [createTx, vaultKeylet] = vault.create({.owner = alice, .asset = mptAsset}); + env(createTx); + env.close(); + + auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice)); + env(set(alice, vaultKeylet.key)); + env.close(); + + env(coverDeposit(alice, brokerKeylet.key, mptAsset(10))); + env.close(); + + env(coverDeposit(alice, brokerKeylet.key, mptAsset(1)), Ter(tesSUCCESS)); + env.close(); + + env(coverWithdraw(alice, brokerKeylet.key, mptAsset(1)), Ter(tesSUCCESS)); + env.close(); + + env(coverClawback(issuer), + kLoanBrokerId(brokerKeylet.key), + kAmount(mptAsset(1)), + Ter(tesSUCCESS)); + env.close(); + } + }; + + runTestCases(all_); + runTestCases(all_ - fixCleanup3_2_0); + } + public: void run() override { + testCoverPrecisionGuard(); + testLoanBrokerSetDebtMaximum(); testLoanBrokerCoverDepositNullVault(); diff --git a/src/test/protocol/STAmount_test.cpp b/src/test/protocol/STAmount_test.cpp index b387c361cb..5d4b68adde 100644 --- a/src/test/protocol/STAmount_test.cpp +++ b/src/test/protocol/STAmount_test.cpp @@ -1205,6 +1205,100 @@ public: //-------------------------------------------------------------------------- + void + testIsZeroAtScale() + { + testcase("isZeroAtScale"); + + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + + // IOU: 10 IOU — mantissa = kMinValue (10^15), exponent = -14. + // One ULP at this scale is 10^-14; half-ULP is 5*10^-15. + { + STAmount const ref{usd, STAmount::kMinValue, -14}; + int const refScale = ref.exponent(); // -14 + BEAST_EXPECT(refScale == -14); + + // Zero rounds to zero at any scale. + STAmount const iouZero{usd, 0}; + BEAST_EXPECT(iouZero.isZeroAtScale(refScale)); + + // Sub-ULP: 1e-16 IOU (mantissa = kMinValue, exponent = -31). + // Far below half-ULP → rounds to zero. + STAmount const subUlp{usd, STAmount::kMinValue, -31}; + BEAST_EXPECT(subUlp.isZeroAtScale(refScale)); + + // One ULP: 1e-14 IOU (mantissa = kMinValue, exponent = -29). + // Exactly the smallest representable unit at refScale → not zero. + STAmount const oneUlp{usd, STAmount::kMinValue, -29}; + BEAST_EXPECT(!oneUlp.isZeroAtScale(refScale)); + + // The reference value itself: exponent == scale → returned + // unchanged → not zero. + BEAST_EXPECT(!ref.isZeroAtScale(refScale)); + + // A much larger value: certainly not zero at this scale. + STAmount const large{usd, STAmount::kMinValue, 0}; // 1e15 IOU + BEAST_EXPECT(!large.isZeroAtScale(refScale)); + + // When scale equals the value's own exponent, roundToScale + // short-circuits and returns the value unchanged. + BEAST_EXPECT(!subUlp.isZeroAtScale(subUlp.exponent())); + BEAST_EXPECT(!oneUlp.isZeroAtScale(oneUlp.exponent())); + + // Half-ULP boundary. roundToScale forms (value + ref) - ref + // where ref = 10 IOU has mantissa 1e15 (LSB 0, even). + // Number's default rounding is to-nearest-even, so an exact + // half-ULP tie rounds toward the even-LSB neighbour — the + // reference itself — and the round-trip result is zero. + // Just below half-ULP rounds the same way; just above + // clears half-ULP and bumps the mantissa to 1e15 + 1. + STAmount const justBelowHalf{usd, STAmount::kMinValue * 4, -30}; + BEAST_EXPECT(justBelowHalf.isZeroAtScale(refScale)); + + STAmount const halfUlp{usd, STAmount::kMinValue * 5, -30}; + BEAST_EXPECT(halfUlp.isZeroAtScale(refScale)); + + STAmount const justAboveHalf{usd, STAmount::kMinValue * 6, -30}; + BEAST_EXPECT(!justAboveHalf.isZeroAtScale(refScale)); + + // Large magnitude gap: dust value far below an enormous scale. + // 1e-80 with scale +15 — the value vanishes utterly. + STAmount const dust{usd, STAmount::kMinValue, -95}; + BEAST_EXPECT(dust.isZeroAtScale(15)); + + // Negative values mirror positive behaviour. + STAmount const negSubUlp{usd, STAmount::kMinValue, -31, true}; + BEAST_EXPECT(negSubUlp.isZeroAtScale(refScale)); + + STAmount const negOneUlp{usd, STAmount::kMinValue, -29, true}; + BEAST_EXPECT(!negOneUlp.isZeroAtScale(refScale)); + } + + // XRP is integral — roundToScale short-circuits, value is preserved. + { + STAmount const xrp{XRPAmount{1}}; + BEAST_EXPECT(!xrp.isZeroAtScale(-14)); + BEAST_EXPECT(!xrp.isZeroAtScale(0)); + + STAmount const xrpZero{XRPAmount{0}}; + BEAST_EXPECT(xrpZero.isZeroAtScale(-14)); + } + + // MPT is integral — same short-circuit behaviour as XRP. + { + MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; + STAmount const mptAmt{mpt, 1}; + BEAST_EXPECT(!mptAmt.isZeroAtScale(0)); + BEAST_EXPECT(!mptAmt.isZeroAtScale(-14)); + + STAmount const mptZero{mpt, 0}; + BEAST_EXPECT(mptZero.isZeroAtScale(0)); + } + } + + //-------------------------------------------------------------------------- + void run() override { @@ -1223,6 +1317,7 @@ public: testCanSubtractXRP(); testCanSubtractIOU(); testCanSubtractMPT(); + testIsZeroAtScale(); } };