fix: float_from_stnumber and float_from_stamount deserialization not returning canonicalized floats

This commit is contained in:
TimothyBanks
2026-09-24 13:05:23 -04:00
parent f04386dcf7
commit 5d41eb2201
3 changed files with 181 additions and 0 deletions

View File

@@ -1,6 +1,7 @@
#include <xrpl/tx/wasm/HostContext.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/strHex.h>
@@ -1128,6 +1129,10 @@ HostContext::floatFromSTAmount(
{
return hfErrorToInt(parsed.error());
}
if (!isLegalMPT(*parsed) || !isLegalNet(*parsed))
{
return hfErrorToInt(HostFunctionError::InvalidParams);
}
return invoke<false>(out, [&] { return hostFunctions_.floatFromSTAmount(*parsed, mode); });
});
}
@@ -1139,6 +1144,14 @@ HostContext::floatFromSTNumber(
rust::Slice<std::uint8_t> out) const noexcept
{
return guarded(hostFunctions_.getJournal(), kHostInternal, [&] {
// The rounding mode has to be installed *before* the bytes are decoded, not
// only around the encode the host does afterwards.
auto rounding = std::optional<NumberRoundModeGuard>{};
if (auto const rm = Number::checkedRoundingMode(mode))
{
rounding.emplace(*rm);
}
auto const parsed = parseST<STNumber>(number);
if (!parsed)
{

View File

@@ -1,3 +1,4 @@
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/tx/wasm/WasmCommon.h>
@@ -99,4 +100,106 @@ TEST_F(FloatFromSTAmountCall, ShortOutRegionWritesNothingAndReturnsTrueLength)
EXPECT_FALSE(out.wasWritten());
}
// Bytes that parse are not yet bytes that are safe to use: both branches of
// `STAmount(SerialIter&, SField const&)` return before `canonicalize`, where the bounds are.
// `STAmount`'s own constructors canonicalize, so these are laid out by hand.
struct FloatFromSTAmountBounds : HostContextTest
{
std::int32_t const mode = 1;
// The MPT wire form: eight bytes carrying the flags, one more byte of magnitude, then the
// 192-bit issuance id. The magnitude is reassembled as `(first << 8) | second`, so the
// flag bits shift out and the low 56 bits of the first word hold all but the last byte.
static Bytes
serializedMpt(std::uint64_t magnitude, bool positive)
{
auto header = (magnitude >> 8) | STAmount::kMpToken;
if (positive)
{
header |= STAmount::kPositive;
}
auto s = Serializer{};
s.add64(header);
s.add8(static_cast<unsigned char>(magnitude & 0xffU));
s.addBitString(MPTID{42});
return s.getData();
}
// The XRP wire form is the eight bytes alone; `kValueMask` clears the two flag bits, so
// everything below them is magnitude.
static Bytes
serializedXrp(std::uint64_t drops)
{
auto s = Serializer{};
s.add64(drops | STAmount::kPositive);
return s.getData();
}
};
TEST_F(FloatFromSTAmountBounds, AnMptMagnitudeAtTheSignBitIsRefused)
{
EXPECT_CALL(host, floatFromSTAmount).Times(0);
auto const wire = serializedMpt(std::uint64_t{1} << 63U, /*positive*/ false);
auto out = OutRegion{32};
EXPECT_EQ(
hostContext.floatFromSTAmount(bytesOf(wire), mode, out.slice()),
hfErrorToInt(HostFunctionError::InvalidParams));
}
TEST_F(FloatFromSTAmountBounds, AnMptMagnitudePastTheProtocolMaximumIsRefused)
{
EXPECT_CALL(host, floatFromSTAmount).Times(0);
auto const wire = serializedMpt(kMaxMpTokenAmount + 1, /*positive*/ true);
auto out = OutRegion{32};
EXPECT_EQ(
hostContext.floatFromSTAmount(bytesOf(wire), mode, out.slice()),
hfErrorToInt(HostFunctionError::InvalidParams));
}
// MPT amounts are non-negative by protocol invariant, which is why `isLegalMPT` asks.
TEST_F(FloatFromSTAmountBounds, ANegativeMptIsRefused)
{
EXPECT_CALL(host, floatFromSTAmount).Times(0);
auto const wire = serializedMpt(1000, /*positive*/ false);
auto out = OutRegion{32};
EXPECT_EQ(
hostContext.floatFromSTAmount(bytesOf(wire), mode, out.slice()),
hfErrorToInt(HostFunctionError::InvalidParams));
}
// `isLegalNet`'s half: the XRP branch skips `canonicalize` too, so drops past the network
// maximum reach the host unchallenged without this.
TEST_F(FloatFromSTAmountBounds, XrpDropsPastTheNetworkMaximumAreRefused)
{
EXPECT_CALL(host, floatFromSTAmount).Times(0);
auto const wire = serializedXrp(STAmount::kMaxNativeN + 1);
auto out = OutRegion{32};
EXPECT_EQ(
hostContext.floatFromSTAmount(bytesOf(wire), mode, out.slice()),
hfErrorToInt(HostFunctionError::InvalidParams));
}
TEST_F(FloatFromSTAmountBounds, TheMaximaThemselvesStillReachTheHost)
{
auto const result = Bytes{1, 2, 3};
EXPECT_CALL(host, floatFromSTAmount(testing::_, mode))
.Times(2)
.WillRepeatedly(testing::Return(result));
for (auto const& wire :
{serializedMpt(kMaxMpTokenAmount, /*positive*/ true),
serializedXrp(STAmount::kMaxNativeN)})
{
auto out = OutRegion{32};
EXPECT_EQ(
hostContext.floatFromSTAmount(bytesOf(wire), mode, out.slice()),
static_cast<std::int32_t>(result.size()));
}
}
} // namespace xrpl::test

View File

@@ -92,6 +92,71 @@ TEST_F(FloatFromSTNumberCall, MalformedBytesAreRefusedWithoutAskingHost)
hfErrorToInt(HostFunctionError::InvalidParams));
}
// Decoding is where the rounding happens, so the guest's mode has to be installed before it.
struct FloatFromSTNumberRounding : HostContextTest
{
// Declared first so the range is in force while the expectations below are built.
NumberMantissaScaleGuard const scale{MantissaRange::MantissaScale::Small};
// Seventeen digits against a sixteen-digit range: normalizing drops the last one and the
// mode decides its fate. A dropped `7` rounds up under `ToNearest`, a dropped `3` down, so
// each case below disagrees with `ToNearest` and fails if the decode does not honour the
// mode it was given.
std::int32_t const exponent = 0;
Bytes const dropsSeven = serialized(12'345'678'901'234'567, exponent);
Bytes const dropsThree = serialized(12'345'678'901'234'563, exponent);
STNumber const truncated{sfGeneric, Number{1'234'567'890'123'456, 1}};
STNumber const raised{sfGeneric, Number{1'234'567'890'123'457, 1}};
void
expectDecodedAs(Bytes const& wire, Number::RoundingMode mode, STNumber const& expected)
{
auto const asInt = static_cast<std::int32_t>(mode);
auto const result = Bytes{1, 2, 3};
EXPECT_CALL(host, floatFromSTNumber(testing::Eq(expected), asInt))
.WillOnce(testing::Return(result));
auto out = OutRegion{32};
EXPECT_EQ(
hostContext.floatFromSTNumber(bytesOf(wire), asInt, out.slice()),
static_cast<std::int32_t>(result.size()));
}
};
TEST_F(FloatFromSTNumberRounding, TowardsZeroTruncatesInsteadOfRoundingToNearest)
{
expectDecodedAs(dropsSeven, Number::RoundingMode::TowardsZero, truncated);
}
TEST_F(FloatFromSTNumberRounding, UpwardRoundsAwayInsteadOfRoundingToNearest)
{
expectDecodedAs(dropsThree, Number::RoundingMode::Upward, raised);
}
TEST_F(FloatFromSTNumberRounding, DownwardTruncatesInsteadOfRoundingToNearest)
{
expectDecodedAs(dropsSeven, Number::RoundingMode::Downward, truncated);
}
TEST_F(FloatFromSTNumberRounding, ToNearestIsUnchanged)
{
expectDecodedAs(dropsSeven, Number::RoundingMode::ToNearest, raised);
expectDecodedAs(dropsThree, Number::RoundingMode::ToNearest, truncated);
}
TEST_F(FloatFromSTNumberRounding, AnInvalidModeStillReachesTheHost)
{
constexpr auto kNotAMode = std::int32_t{99};
EXPECT_CALL(host, floatFromSTNumber(testing::_, kNotAMode))
.WillOnce(testing::Return(std::unexpected(HostFunctionError::FloatInputMalformed)));
auto out = OutRegion{32};
EXPECT_EQ(
hostContext.floatFromSTNumber(bytesOf(dropsSeven), kNotAMode, out.slice()),
hfErrorToInt(HostFunctionError::FloatInputMalformed));
}
// The out-region contract: write only if the whole value fits, and return the true length
// either way.
TEST_F(FloatFromSTNumberCall, ShortOutRegionWritesNothingAndReturnsTrueLength)