fix: Reject sub-ULP cover amounts with tecPRECISION_LOSS (fixCleanup3_2_0)

Add STAmount::isZeroAtScale() and canApplyToBrokerCover() to detect
amounts that round to zero at sfCoverAvailable's precision scale, then
call the guard in LoanBrokerCoverDeposit, LoanBrokerCoverWithdraw, and
LoanBrokerCoverClawback preclaim. Without the guard a sub-ULP deposit,
withdrawal, or clawback would silently succeed while moving no funds.
This commit is contained in:
Vito
2026-05-14 14:44:16 +02:00
parent 1297f0bc52
commit c327fc1ee2
7 changed files with 87 additions and 0 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 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);

View File

@@ -184,6 +184,19 @@ public:
[[nodiscard]] STAmount const&
value() const noexcept;
/**
* Checks if this amount evaluates to zero when constrained to a specific
* accounting scale.
*
* @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

@@ -28,6 +28,31 @@
namespace xrpl {
[[nodiscard]] TER
canApplyToBrokerCover(
ReadView const& view,
std::shared_ptr<SLE const> const& sleBroker,
Asset const& vaultAsset,
STAmount const& amount,
beast::Journal j,
std::string_view logPrefix)
{
if (!view.rules().enabled(fixCleanup3_2_0))
return tesSUCCESS;
if (amount == beast::kZERO)
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)
{

View File

@@ -1739,4 +1739,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).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

@@ -73,6 +73,11 @@ LoanBrokerCoverDeposit::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, "LoanBrokerCoverDeposit"))
return ret;
auto const pseudoAccountID = sleBroker->at(sfAccount);
// Cannot transfer a non-transferable Asset
if (auto const ret = canTransfer(ctx.view, vaultAsset, account, pseudoAccountID))

View File

@@ -93,6 +93,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);
// Cannot transfer a non-transferable Asset