fix: Fix IOU precision issues in LoanBrokerCover transactions (#7274)

This commit is contained in:
Vito Tumas
2026-05-21 16:51:58 +02:00
committed by GitHub
parent 795dc5e364
commit 7fdaa0a5ef
10 changed files with 546 additions and 11 deletions

View File

@@ -4,8 +4,38 @@
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/st.h>
#include <string_view>
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 Read 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 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);

View File

@@ -184,6 +184,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 Number::RoundingMode::ToNearest 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

View File

@@ -8,6 +8,7 @@
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/View.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
@@ -24,10 +25,42 @@
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <string_view>
#include <utility>
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(), "xrpl::canApplyToBrokerCover : valid asset");
if (!view.rules().enabled(fixCleanup3_2_0))
return tesSUCCESS;
if (amount == beast::kZero)
return tecPRECISION_LOSS;
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)
{

View File

@@ -1738,4 +1738,9 @@ divRoundStrict(STAmount const& num, STAmount const& den, Asset const& asset, boo
return divRoundImpl<NumberRoundModeGuard>(num, den, asset, roundUp);
}
[[nodiscard]] bool
STAmount::isZeroAtScale(int scale) const
{
return roundToScale(*this, scale, Number::RoundingMode::ToNearest).signum() == 0;
}
} // namespace xrpl

View File

@@ -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.

View File

@@ -1,9 +1,11 @@
#include <xrpl/tx/transactors/lending/LoanBrokerCoverDeposit.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
@@ -87,6 +89,29 @@ LoanBrokerCoverDeposit::preclaim(PreclaimContext const& ctx)
if (auto const ret = requireAuth(ctx.view, vaultAsset, account, AuthType::StrongAuth))
return ret;
// Deposit must round the amount Downward to cover scale and then reuse that rounded
// value for the actual transfer in doApply — otherwise implicit round-to-nearest during
// `sfCoverAvailable +=` could credit the broker more than the depositor paid Computing it
// here in preclaim lets us reject sub-cover-scale dust early with tecPRECISION_LOSS instead of
// failing only in doApply.
bool const fix320Enabled = ctx.view.rules().enabled(fixCleanup3_2_0);
auto const roundedAmount = [&]() -> STAmount {
if (!fix320Enabled)
return tx[sfAmount];
return roundToScale(
tx[sfAmount],
scale(sleBroker->at(sfCoverAvailable), vaultAsset),
Number::RoundingMode::Downward);
}();
if (fix320Enabled && roundedAmount == beast::kZero)
{
JLOG(ctx.j.warn()) << "LoanBrokerCoverDeposit: deposit amount: " << tx[sfAmount]
<< " is zero at loan broker scale";
return tecPRECISION_LOSS;
}
if (accountHolds(
ctx.view,
account,
@@ -94,7 +119,7 @@ LoanBrokerCoverDeposit::preclaim(PreclaimContext const& ctx)
FreezeHandling::ZeroIfFrozen,
AuthHandling::ZeroIfUnauthorized,
ctx.j,
SpendableHandling::FullBalance) < amount)
SpendableHandling::FullBalance) < roundedAmount)
return tecINSUFFICIENT_FUNDS;
return tesSUCCESS;
@@ -106,8 +131,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 +140,32 @@ LoanBrokerCoverDeposit::doApply()
return tecINTERNAL; // LCOV_EXCL_LINE
auto const vaultAsset = vault->at(sfAsset);
auto const brokerPseudoID = broker->at(sfAccount);
// Re-round here (matches preclaim) so the same cover-scale-quantized
// value drives both the trustline transfer and the cover increment;
// see the rationale comment in preclaim.
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);
}();
// We validated zero-amount in preclaim, if we ended up with zero now, fail hard.
if (amount == beast::kZero)
{
// LCOV_EXCL_START
JLOG(j_.error()) << "LoanBrokerCoverDeposit: deposit amount: " << tx[sfAmount]
<< " is zero";
return tecINTERNAL;
// LCOV_EXCL_STOP
}
// Transfer assets from depositor to pseudo-account.
if (auto ter =
accountSend(view(), accountID_, brokerPseudoID, amount, j_, WaiveTransferFee::Yes))

View File

@@ -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

View File

@@ -8,9 +8,15 @@
#include <xrpl/basics/chrono.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/Units.h>
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
@@ -287,9 +293,9 @@ class LendingHelpers_test : public beast::unit_test::Suite
std::uint32_t n;
};
auto const cases = std::vector<AboveThreshold>{
{"r=5%, n=3", Number{5, -2}, 3},
{"r=0.1%, n=1000", Number{1, -3}, 1'000},
{"r=1e-7, n=100 (above threshold by 10x)", Number{1, -7}, 100},
{.name = "r=5%, n=3", .r = Number{5, -2}, .n = 3},
{.name = "r=0.1%, n=1000", .r = Number{1, -3}, .n = 1'000},
{.name = "r=1e-7, n=100 (above threshold by 10x)", .r = Number{1, -7}, .n = 100},
};
for (auto const& tc : cases)
{
@@ -318,8 +324,10 @@ class LendingHelpers_test : public beast::unit_test::Suite
auto const cases = std::vector<BelowThreshold>{
// bug regime: r = 1 TenthBips32 over 600s payment interval
// → r ≈ 1.9e-10, r*n ≈ 3.8e-10 < 1e-9.
{"bug regime: r~1.9e-10, n=2", loanPeriodicRate(TenthBips32{1}, 600), 2},
{"r=1e-12, n=100", Number{1, -12}, 100},
{.name = "bug regime: r~1.9e-10, n=2",
.r = loanPeriodicRate(TenthBips32{1}, 600),
.n = 2},
{.name = "r=1e-12, n=100", .r = Number{1, -12}, .n = 100},
};
for (auto const& tc : cases)
{
@@ -356,8 +364,8 @@ class LendingHelpers_test : public beast::unit_test::Suite
std::uint32_t n;
};
auto const cases = std::vector<Boundary>{
{"r=1e-9, n=1", Number{1, -9}, 1},
{"r=1e-12, n=1000", Number{1, -12}, 1'000},
{.name = "r=1e-9, n=1", .r = Number{1, -9}, .n = 1},
{.name = "r=1e-12, n=1000", .r = Number{1, -12}, .n = 1'000},
};
for (auto const& tc : cases)
@@ -1439,6 +1447,84 @@ class LendingHelpers_test : public beast::unit_test::Suite
}
public:
void
testCanApplyToBrokerCover()
{
using namespace jtx;
Account const issuer{"issuer"};
PrettyAsset const iou = issuer["IOU"];
// sfCoverAvailable = Number{10} on an IOU → STAmount exponent = -14,
// so coverScale = -14. The ULP boundary is 5e-15; anything below
// that rounds to zero at cover scale. Number{1,-16} = 1e-16 is our
// representative sub-ULP probe.
struct TestCase
{
std::string name;
Number coverAvailable;
STAmount amount;
TER expected;
};
auto const testCases = std::vector<TestCase>{
{
.name = "Zero amount",
.coverAvailable = Number{10},
.amount = STAmount{iou, Number{0}},
.expected = tecPRECISION_LOSS,
},
{
.name = "Rounds to zero at cover scale",
.coverAvailable = Number{10},
.amount = STAmount{iou, Number{1, -16}},
.expected = tecPRECISION_LOSS,
},
{
.name = "Zero coverAvailable, whole-unit amount",
// coverScale = 0 (zero STAmount exponent); 1 IOU is not
// zero at integer scale → tesSUCCESS.
.coverAvailable = Number{0},
.amount = STAmount{iou, Number{1}},
.expected = tesSUCCESS,
},
{
.name = "Supra-ULP amount",
.coverAvailable = Number{10},
.amount = STAmount{iou, Number{1, -13}},
.expected = tesSUCCESS,
},
};
Env const env{*this};
for (auto const& tc : testCases)
{
testcase("canApplyToBrokerCover: " + tc.name);
auto sle = std::make_shared<SLE>(ltLOAN_BROKER, uint256{1u});
sle->at(sfCoverAvailable) = tc.coverAvailable;
BEAST_EXPECT(
canApplyToBrokerCover(*env.current(), sle, iou, tc.amount, env.journal, "test") ==
tc.expected);
}
// Amendment off → guard is bypassed regardless of amount.
{
testcase("canApplyToBrokerCover: amendment disabled");
Env const envOff{*this, testableAmendments() - fixCleanup3_2_0};
auto sle = std::make_shared<SLE>(ltLOAN_BROKER, uint256{1u});
sle->at(sfCoverAvailable) = Number{10};
BEAST_EXPECT(
canApplyToBrokerCover(
*envOff.current(),
sle,
iou,
STAmount{iou, Number{0}},
envOff.journal,
"test") == tesSUCCESS);
}
}
void
run() override
{
@@ -1462,6 +1548,7 @@ public:
testComputePaymentFactorNearZeroRate();
testComputeOverpaymentComponents();
testComputeInterestAndFeeParts();
testCanApplyToBrokerCover();
}
};

View File

@@ -55,6 +55,7 @@
#include <optional>
#include <string_view>
#include <tuple>
#include <utility>
#include <vector>
namespace xrpl::test {
@@ -1823,10 +1824,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<Keylet, PrettyAsset> {
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();

View File

@@ -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();
}
};