diff --git a/src/libxrpl/tx/wasm/HostContext.cpp b/src/libxrpl/tx/wasm/HostContext.cpp index decfc2a0a8..ea06356ba5 100644 --- a/src/libxrpl/tx/wasm/HostContext.cpp +++ b/src/libxrpl/tx/wasm/HostContext.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include #include @@ -1128,6 +1129,10 @@ HostContext::floatFromSTAmount( { return hfErrorToInt(parsed.error()); } + if (!isLegalMPT(*parsed) || !isLegalNet(*parsed)) + { + return hfErrorToInt(HostFunctionError::InvalidParams); + } return invoke(out, [&] { return hostFunctions_.floatFromSTAmount(*parsed, mode); }); }); } @@ -1139,6 +1144,14 @@ HostContext::floatFromSTNumber( rust::Slice 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{}; + if (auto const rm = Number::checkedRoundingMode(mode)) + { + rounding.emplace(*rm); + } + auto const parsed = parseST(number); if (!parsed) { diff --git a/src/tests/libxrpl/tx/wasm/host_context/FloatFromSTAmount.cpp b/src/tests/libxrpl/tx/wasm/host_context/FloatFromSTAmount.cpp index ce2b9f070e..ba2b7fc704 100644 --- a/src/tests/libxrpl/tx/wasm/host_context/FloatFromSTAmount.cpp +++ b/src/tests/libxrpl/tx/wasm/host_context/FloatFromSTAmount.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -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(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(result.size())); + } +} + } // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/host_context/FloatFromSTNumber.cpp b/src/tests/libxrpl/tx/wasm/host_context/FloatFromSTNumber.cpp index 58497bd914..da7231f925 100644 --- a/src/tests/libxrpl/tx/wasm/host_context/FloatFromSTNumber.cpp +++ b/src/tests/libxrpl/tx/wasm/host_context/FloatFromSTNumber.cpp @@ -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(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(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)