From 30f8a227b157cd1df8a6b30efa61f8e0894bc205 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S <21219765+ckeshava@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:31:21 -0700 Subject: [PATCH] fix: Serialize MPToken UInt64 amounts as base-10 strings (#3139) --- src/rpc/handlers/AccountMPTokenIssuances.cpp | 19 ++- src/rpc/handlers/AccountMPTokens.cpp | 14 +- .../handlers/AccountMPTokenIssuancesTests.cpp | 138 +++++++++++++++--- .../rpc/handlers/AccountMPTokensTests.cpp | 103 +++++++++++-- 4 files changed, 233 insertions(+), 41 deletions(-) diff --git a/src/rpc/handlers/AccountMPTokenIssuances.cpp b/src/rpc/handlers/AccountMPTokenIssuances.cpp index 52698f25f..14413dc48 100644 --- a/src/rpc/handlers/AccountMPTokenIssuances.cpp +++ b/src/rpc/handlers/AccountMPTokenIssuances.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -236,11 +237,23 @@ tag_invoke( } }; + // UInt64 amount fields must be serialized as base-10 strings (matching rippled's + // STUInt64::getJson) so that JSON parsers using IEEE-754 doubles do not silently lose + // precision for values greater than 2^53. + auto const setUint64IfPresent = + [&](boost::json::string_view field, xrpl::SField const& sField, auto const& value) { + if (value.has_value()) { + obj[field] = toBoostJson( + xrpl::STUInt64{sField, *value}.getJson(xrpl::JsonOptions::Values::None) + ); + } + }; + setIfPresent("transfer_fee", issuance.transferFee); setIfPresent("asset_scale", issuance.assetScale); - setIfPresent("maximum_amount", issuance.maximumAmount); - setIfPresent("outstanding_amount", issuance.outstandingAmount); - setIfPresent("locked_amount", issuance.lockedAmount); + setUint64IfPresent("maximum_amount", xrpl::sfMaximumAmount, issuance.maximumAmount); + setUint64IfPresent("outstanding_amount", xrpl::sfOutstandingAmount, issuance.outstandingAmount); + setUint64IfPresent("locked_amount", xrpl::sfLockedAmount, issuance.lockedAmount); setIfPresent("mptoken_metadata", issuance.mptokenMetadata); setIfPresent("domain_id", issuance.domainID); diff --git a/src/rpc/handlers/AccountMPTokens.cpp b/src/rpc/handlers/AccountMPTokens.cpp index d46cf565c..6978ea389 100644 --- a/src/rpc/handlers/AccountMPTokens.cpp +++ b/src/rpc/handlers/AccountMPTokens.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -176,20 +177,29 @@ tag_invoke( AccountMPTokensHandler::MPTokenResponse const& mptoken ) { + // UInt64 amount fields must be serialized as base-10 strings (matching rippled's + // STUInt64::getJson) so that JSON parsers using IEEE-754 doubles do not silently lose + // precision for values greater than 2^53. + auto const uint64ToString = [](xrpl::SField const& field, std::uint64_t value) { + return toBoostJson(xrpl::STUInt64{field, value}.getJson(xrpl::JsonOptions::Values::None)); + }; + auto obj = boost::json::object{ {"mpt_id", mptoken.mpTokenId}, {JS(account), mptoken.account}, {JS(mpt_issuance_id), mptoken.mpTokenIssuanceId}, - {JS(mpt_amount), mptoken.mptAmount}, + {JS(mpt_amount), uint64ToString(xrpl::sfMPTAmount, mptoken.mptAmount)}, }; + if (mptoken.lockedAmount.has_value()) + obj["locked_amount"] = uint64ToString(xrpl::sfLockedAmount, *mptoken.lockedAmount); + auto const setIfPresent = [&](boost::json::string_view field, auto const& value) { if (value.has_value()) { obj[field] = *value; } }; - setIfPresent("locked_amount", mptoken.lockedAmount); setIfPresent("mpt_locked", mptoken.mptLocked); setIfPresent("mpt_authorized", mptoken.mptAuthorized); diff --git a/tests/unit/rpc/handlers/AccountMPTokenIssuancesTests.cpp b/tests/unit/rpc/handlers/AccountMPTokenIssuancesTests.cpp index 199be11d5..4ec2575ff 100644 --- a/tests/unit/rpc/handlers/AccountMPTokenIssuancesTests.cpp +++ b/tests/unit/rpc/handlers/AccountMPTokenIssuancesTests.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -60,8 +61,8 @@ auto const kIssuanceOuT1 = fmt::format( "mpt_issuance_id": "{}", "issuer": "{}", "sequence": 1, - "maximum_amount": {}, - "outstanding_amount": {}, + "maximum_amount": "{}", + "outstanding_amount": "{}", "asset_scale": {}, "mpt_can_escrow": true, "mpt_can_trade": true, @@ -80,9 +81,9 @@ auto const kIssuanceOuT2 = fmt::format( "mpt_issuance_id": "{}", "issuer": "{}", "sequence": 2, - "maximum_amount": {}, - "outstanding_amount": {}, - "locked_amount": {}, + "maximum_amount": "{}", + "outstanding_amount": "{}", + "locked_amount": "{}", "transfer_fee": {}, "mptoken_metadata": "{}", "domain_id": "{}", @@ -370,7 +371,7 @@ TEST_F(RPCAccountMPTokenIssuancesHandlerTest, DefaultParameters) // return non-empty account auto const account = getAccountIdWithString(kAccount); auto const accountKk = xrpl::keylet::account(account).key; - auto const owneDirKk = xrpl::keylet::ownerDir(account).key; + auto const ownerDirKk = xrpl::keylet::ownerDir(account).key; ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)) .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); @@ -378,7 +379,7 @@ TEST_F(RPCAccountMPTokenIssuancesHandlerTest, DefaultParameters) xrpl::STObject const ownerDir = createOwnerDirLedgerObject( {xrpl::uint256{kIssuanceIndeX1}, xrpl::uint256{kIssuanceIndeX2}}, kIssuanceIndeX1 ); - ON_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)) + ON_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) .WillByDefault(Return(ownerDir.getSerializer().peekData())); // mocking mptoken issuance ledger objects @@ -452,7 +453,7 @@ TEST_F(RPCAccountMPTokenIssuancesHandlerTest, UseLimit) auto const account = getAccountIdWithString(kAccount); auto const accountKk = xrpl::keylet::account(account).key; - auto const owneDirKk = xrpl::keylet::ownerDir(account).key; + auto const ownerDirKk = xrpl::keylet::ownerDir(account).key; ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)) .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); @@ -468,7 +469,7 @@ TEST_F(RPCAccountMPTokenIssuancesHandlerTest, UseLimit) xrpl::STObject ownerDir = createOwnerDirLedgerObject(indexes, kIssuanceIndeX1); ownerDir.setFieldU64(xrpl::sfIndexNext, 99); - ON_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)) + ON_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) .WillByDefault(Return(ownerDir.getSerializer().peekData())); EXPECT_CALL(*backend_, doFetchLedgerObject).Times(7); @@ -662,14 +663,14 @@ TEST_F(RPCAccountMPTokenIssuancesHandlerTest, LimitLessThanMin) auto const account = getAccountIdWithString(kAccount); auto const accountKk = xrpl::keylet::account(account).key; - auto const owneDirKk = xrpl::keylet::ownerDir(account).key; + auto const ownerDirKk = xrpl::keylet::ownerDir(account).key; EXPECT_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)) .WillOnce(Return(Blob{'f', 'a', 'k', 'e'})); xrpl::STObject const ownerDir = createOwnerDirLedgerObject( {xrpl::uint256{kIssuanceIndeX1}, xrpl::uint256{kIssuanceIndeX2}}, kIssuanceIndeX1 ); - EXPECT_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)) + EXPECT_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) .WillOnce(Return(ownerDir.getSerializer().peekData())); auto const bbs = std::vector{ @@ -750,14 +751,14 @@ TEST_F(RPCAccountMPTokenIssuancesHandlerTest, LimitMoreThanMax) auto const account = getAccountIdWithString(kAccount); auto const accountKk = xrpl::keylet::account(account).key; - auto const owneDirKk = xrpl::keylet::ownerDir(account).key; + auto const ownerDirKk = xrpl::keylet::ownerDir(account).key; EXPECT_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)) .WillOnce(Return(Blob{'f', 'a', 'k', 'e'})); xrpl::STObject const ownerDir = createOwnerDirLedgerObject( {xrpl::uint256{kIssuanceIndeX1}, xrpl::uint256{kIssuanceIndeX2}}, kIssuanceIndeX1 ); - EXPECT_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)) + EXPECT_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) .WillOnce(Return(ownerDir.getSerializer().peekData())); auto const bbs = std::vector{ @@ -838,12 +839,12 @@ TEST_F(RPCAccountMPTokenIssuancesHandlerTest, EmptyResult) auto const account = getAccountIdWithString(kAccount); auto const accountKk = xrpl::keylet::account(account).key; - auto const owneDirKk = xrpl::keylet::ownerDir(account).key; + auto const ownerDirKk = xrpl::keylet::ownerDir(account).key; EXPECT_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)) .WillOnce(Return(Blob{'f', 'a', 'k', 'e'})); xrpl::STObject const ownerDir = createOwnerDirLedgerObject({}, kIssuanceIndeX1); - EXPECT_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)) + EXPECT_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) .WillOnce(Return(ownerDir.getSerializer().peekData())); runSpawn([this](auto yield) { @@ -862,6 +863,97 @@ TEST_F(RPCAccountMPTokenIssuancesHandlerTest, EmptyResult) }); } +// Regression test: UInt64 amount fields must be serialized as base-10 JSON strings (as xrpld +// does) so that values greater than 2^53 are not silently rounded by JSON parsers backed by +// IEEE-754 doubles. 2^53 itself is still exactly representable as a double, but it must be emitted +// as a string like every other amount so the wire format stays consistent regardless of magnitude. +struct AccountMPTokenIssuancesAmountSerializationTestCaseBundle { + std::string testName; + uint64_t maxAmount; + uint64_t outstandingAmount; + uint64_t lockedAmount; +}; + +struct AccountMPTokenIssuancesAmountSerializationTest + : RPCAccountMPTokenIssuancesHandlerTest, + WithParamInterface {}; + +INSTANTIATE_TEST_SUITE_P( + RPCAccountMPTokenIssuancesAmountSerializationGroup, + AccountMPTokenIssuancesAmountSerializationTest, + ValuesIn( + std::vector{ + {.testName = "LargeAmounts", + .maxAmount = static_cast(std::pow(2, 63)) - 1, // max MPT amount + .outstandingAmount = static_cast(std::pow(2, 53)) + 1, + .lockedAmount = static_cast(std::pow(2, 53)) + + 12345}, // odd value above 2^53 + {.testName = "ExactDoubleBoundary", + .maxAmount = static_cast(std::pow(2, 53)), + .outstandingAmount = static_cast(std::pow(2, 53)), + .lockedAmount = static_cast(std::pow(2, 53))} + } + ), + tests::util::kNameGenerator +); + +TEST_P(AccountMPTokenIssuancesAmountSerializationTest, SerializedAsStrings) +{ + auto const testBundle = GetParam(); + + auto const ledgerHeader = createLedgerHeader(kLedgerHash, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerHeader)); + + auto const account = getAccountIdWithString(kAccount); + auto const accountKk = xrpl::keylet::account(account).key; + auto const ownerDirKk = xrpl::keylet::ownerDir(account).key; + EXPECT_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)) + .WillOnce(Return(Blob{'f', 'a', 'k', 'e'})); + + xrpl::STObject const ownerDir = + createOwnerDirLedgerObject({xrpl::uint256{kIssuanceIndeX1}}, kIssuanceIndeX1); + EXPECT_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) + .WillOnce(Return(ownerDir.getSerializer().peekData())); + + xrpl::STObject const mptIssuance = createMptIssuanceObject( + kAccount, + 1, + std::nullopt, + xrpl::lsfMPTCanLock, + testBundle.outstandingAmount, + std::nullopt, + std::nullopt, + testBundle.maxAmount, + testBundle.lockedAmount + ); + auto const bbs = std::vector{mptIssuance.getSerializer().peekData()}; + EXPECT_CALL(*backend_, doFetchLedgerObjects).WillOnce(Return(bbs)); + + runSpawn([&, this](auto yield) { + auto const input = + boost::json::parse(fmt::format(R"JSON({{"account": "{}"}})JSON", kAccount)); + auto const handler = AnyHandler{AccountMPTokenIssuancesHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + + auto const& issuances = output.result->as_object().at("mpt_issuances").as_array(); + ASSERT_EQ(issuances.size(), 1); + auto const& issuance = issuances[0].as_object(); + + ASSERT_TRUE(issuance.at("maximum_amount").is_string()); + EXPECT_EQ(issuance.at("maximum_amount").as_string(), std::to_string(testBundle.maxAmount)); + ASSERT_TRUE(issuance.at("outstanding_amount").is_string()); + EXPECT_EQ( + issuance.at("outstanding_amount").as_string(), + std::to_string(testBundle.outstandingAmount) + ); + ASSERT_TRUE(issuance.at("locked_amount").is_string()); + EXPECT_EQ( + issuance.at("locked_amount").as_string(), std::to_string(testBundle.lockedAmount) + ); + }); +} + TEST_F(RPCAccountMPTokenIssuancesHandlerTest, MutableFlags) { uint32_t const mutableFlags1 = xrpl::lsmfMPTCanEnableCanLock | @@ -877,14 +969,14 @@ TEST_F(RPCAccountMPTokenIssuancesHandlerTest, MutableFlags) auto const account = getAccountIdWithString(kAccount); auto const accountKk = xrpl::keylet::account(account).key; - auto const owneDirKk = xrpl::keylet::ownerDir(account).key; + auto const ownerDirKk = xrpl::keylet::ownerDir(account).key; EXPECT_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)) .WillOnce(Return(Blob{'f', 'a', 'k', 'e'})); xrpl::STObject const ownerDir = createOwnerDirLedgerObject( {xrpl::uint256{kIssuanceIndeX1}, xrpl::uint256{kIssuanceIndeX2}}, kIssuanceIndeX1 ); - EXPECT_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)) + EXPECT_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) .WillOnce(Return(ownerDir.getSerializer().peekData())); auto const bbs = std::vector{ @@ -945,7 +1037,7 @@ TEST_F(RPCAccountMPTokenIssuancesHandlerTest, MutableFlags) "mpt_issuance_id": "{}", "issuer": "{}", "sequence": 3, - "outstanding_amount": {}, + "outstanding_amount": "{}", "transfer_fee": {}, "mpt_can_transfer": true, "mpt_can_mutate_can_lock": true, @@ -957,7 +1049,7 @@ TEST_F(RPCAccountMPTokenIssuancesHandlerTest, MutableFlags) "mpt_issuance_id": "{}", "issuer": "{}", "sequence": 5, - "outstanding_amount": {}, + "outstanding_amount": "{}", "transfer_fee": {}, "mptoken_metadata": "{}", "mpt_can_transfer": true, @@ -1035,13 +1127,13 @@ TEST_P(AccountMPTokenIssuancesImmutableFlagsTest, SingleFlag) auto const account = getAccountIdWithString(kAccount); auto const accountKk = xrpl::keylet::account(account).key; - auto const owneDirKk = xrpl::keylet::ownerDir(account).key; + auto const ownerDirKk = xrpl::keylet::ownerDir(account).key; EXPECT_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)) .WillOnce(Return(Blob{'f', 'a', 'k', 'e'})); xrpl::STObject const ownerDir = createOwnerDirLedgerObject({xrpl::uint256{kIssuanceIndeX1}}, kIssuanceIndeX1); - EXPECT_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)) + EXPECT_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) .WillOnce(Return(ownerDir.getSerializer().peekData())); auto const bbs = @@ -1130,13 +1222,13 @@ TEST_P(AccountMPTokenIssuancesMutableFlagsTest, SingleMutableFlag) auto const account = getAccountIdWithString(kAccount); auto const accountKk = xrpl::keylet::account(account).key; - auto const owneDirKk = xrpl::keylet::ownerDir(account).key; + auto const ownerDirKk = xrpl::keylet::ownerDir(account).key; EXPECT_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)) .WillOnce(Return(Blob{'f', 'a', 'k', 'e'})); xrpl::STObject const ownerDir = createOwnerDirLedgerObject({xrpl::uint256{kIssuanceIndeX1}}, kIssuanceIndeX1); - EXPECT_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)) + EXPECT_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) .WillOnce(Return(ownerDir.getSerializer().peekData())); auto const bbs = std::vector{createMptIssuanceObject( diff --git a/tests/unit/rpc/handlers/AccountMPTokensTests.cpp b/tests/unit/rpc/handlers/AccountMPTokensTests.cpp index 61eed2f0e..8c56dbd8a 100644 --- a/tests/unit/rpc/handlers/AccountMPTokensTests.cpp +++ b/tests/unit/rpc/handlers/AccountMPTokensTests.cpp @@ -20,6 +20,7 @@ #include #include +#include #include #include #include @@ -47,8 +48,8 @@ auto const kTokenOuT1 = fmt::format( "mpt_id": "{}", "account": "{}", "mpt_issuance_id": "{}", - "mpt_amount": {}, - "locked_amount": {}, + "mpt_amount": "{}", + "locked_amount": "{}", "mpt_locked": true }})JSON", kTokenIndeX1, @@ -63,7 +64,7 @@ auto const kTokenOuT2 = fmt::format( "mpt_id": "{}", "account": "{}", "mpt_issuance_id": "{}", - "mpt_amount": {}, + "mpt_amount": "{}", "mpt_authorized": true }})JSON", kTokenIndeX2, @@ -337,14 +338,14 @@ TEST_F(RPCAccountMPTokensHandlerTest, DefaultParameters) auto const account = getAccountIdWithString(kAccount); auto const accountKk = xrpl::keylet::account(account).key; - auto const owneDirKk = xrpl::keylet::ownerDir(account).key; + auto const ownerDirKk = xrpl::keylet::ownerDir(account).key; ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)) .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); xrpl::STObject const ownerDir = createOwnerDirLedgerObject( {xrpl::uint256{kTokenIndeX1}, xrpl::uint256{kTokenIndeX2}}, kTokenIndeX1 ); - ON_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)) + ON_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) .WillByDefault(Return(ownerDir.getSerializer().peekData())); auto const bbs = std::vector{ @@ -407,7 +408,7 @@ TEST_F(RPCAccountMPTokensHandlerTest, UseLimit) auto const account = getAccountIdWithString(kAccount); auto const accountKk = xrpl::keylet::account(account).key; - auto const owneDirKk = xrpl::keylet::ownerDir(account).key; + auto const ownerDirKk = xrpl::keylet::ownerDir(account).key; ON_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)) .WillByDefault(Return(Blob{'f', 'a', 'k', 'e'})); @@ -427,7 +428,7 @@ TEST_F(RPCAccountMPTokensHandlerTest, UseLimit) xrpl::STObject ownerDir = createOwnerDirLedgerObject(indexes, kTokenIndeX1); ownerDir.setFieldU64(xrpl::sfIndexNext, 99); - ON_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)) + ON_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) .WillByDefault(Return(ownerDir.getSerializer().peekData())); EXPECT_CALL(*backend_, doFetchLedgerObject).Times(7); @@ -630,14 +631,14 @@ TEST_F(RPCAccountMPTokensHandlerTest, LimitLessThanMin) auto const account = getAccountIdWithString(kAccount); auto const accountKk = xrpl::keylet::account(account).key; - auto const owneDirKk = xrpl::keylet::ownerDir(account).key; + auto const ownerDirKk = xrpl::keylet::ownerDir(account).key; EXPECT_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)) .WillOnce(Return(Blob{'f', 'a', 'k', 'e'})); xrpl::STObject const ownerDir = createOwnerDirLedgerObject( {xrpl::uint256{kTokenIndeX1}, xrpl::uint256{kTokenIndeX2}}, kTokenIndeX1 ); - EXPECT_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)) + EXPECT_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) .WillOnce(Return(ownerDir.getSerializer().peekData())); auto const bbs = std::vector{ @@ -709,14 +710,14 @@ TEST_F(RPCAccountMPTokensHandlerTest, LimitMoreThanMax) auto const account = getAccountIdWithString(kAccount); auto const accountKk = xrpl::keylet::account(account).key; - auto const owneDirKk = xrpl::keylet::ownerDir(account).key; + auto const ownerDirKk = xrpl::keylet::ownerDir(account).key; EXPECT_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)) .WillOnce(Return(Blob{'f', 'a', 'k', 'e'})); xrpl::STObject const ownerDir = createOwnerDirLedgerObject( {xrpl::uint256{kTokenIndeX1}, xrpl::uint256{kTokenIndeX2}}, kTokenIndeX1 ); - EXPECT_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)) + EXPECT_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) .WillOnce(Return(ownerDir.getSerializer().peekData())); auto const bbs = std::vector{ @@ -788,12 +789,12 @@ TEST_F(RPCAccountMPTokensHandlerTest, EmptyResult) auto const account = getAccountIdWithString(kAccount); auto const accountKk = xrpl::keylet::account(account).key; - auto const owneDirKk = xrpl::keylet::ownerDir(account).key; + auto const ownerDirKk = xrpl::keylet::ownerDir(account).key; EXPECT_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)) .WillOnce(Return(Blob{'f', 'a', 'k', 'e'})); xrpl::STObject const ownerDir = createOwnerDirLedgerObject({}, kTokenIndeX1); - EXPECT_CALL(*backend_, doFetchLedgerObject(owneDirKk, _, _)) + EXPECT_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) .WillOnce(Return(ownerDir.getSerializer().peekData())); runSpawn([this](auto yield) { @@ -811,3 +812,79 @@ TEST_F(RPCAccountMPTokensHandlerTest, EmptyResult) EXPECT_EQ(output.result->as_object().at("mptokens").as_array().size(), 0); }); } + +// Regression test: UInt64 amount fields must be serialized as base-10 JSON strings (as xrpld +// does) so that values greater than 2^53 are not silently rounded by JSON parsers backed by +// IEEE-754 doubles. 2^53 itself is still exactly representable as a double, but it must be emitted +// as a string like every other amount so the wire format stays consistent regardless of magnitude. +struct AccountMPTokensAmountSerializationTestCaseBundle { + std::string testName; + uint64_t mptAmount; + uint64_t lockedAmount; +}; + +struct AccountMPTokensAmountSerializationTest + : RPCAccountMPTokensHandlerTest, + WithParamInterface {}; + +INSTANTIATE_TEST_SUITE_P( + RPCAccountMPTokensAmountSerializationGroup, + AccountMPTokensAmountSerializationTest, + ValuesIn( + std::vector{ + {.testName = "LargeAmounts", + .mptAmount = static_cast(std::pow(2, 63)) - 1, // max MPT amount + .lockedAmount = static_cast(std::pow(2, 53)) + 1}, + {.testName = "ExactDoubleBoundary", + .mptAmount = static_cast(std::pow(2, 53)), + .lockedAmount = static_cast(std::pow(2, 53))} + } + ), + tests::util::kNameGenerator +); + +TEST_P(AccountMPTokensAmountSerializationTest, SerializedAsStrings) +{ + auto const testBundle = GetParam(); + + auto const ledgerHeader = createLedgerHeader(kLedgerHash, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerHeader)); + + auto const account = getAccountIdWithString(kAccount); + auto const accountKk = xrpl::keylet::account(account).key; + auto const ownerDirKk = xrpl::keylet::ownerDir(account).key; + EXPECT_CALL(*backend_, doFetchLedgerObject(accountKk, _, _)) + .WillOnce(Return(Blob{'f', 'a', 'k', 'e'})); + + xrpl::STObject const ownerDir = + createOwnerDirLedgerObject({xrpl::uint256{kTokenIndeX1}}, kTokenIndeX1); + EXPECT_CALL(*backend_, doFetchLedgerObject(ownerDirKk, _, _)) + .WillOnce(Return(ownerDir.getSerializer().peekData())); + + xrpl::STObject const mptoken = createMpTokenObject( + kAccount, + xrpl::uint192(kIssuanceIdHex), + testBundle.mptAmount, + xrpl::lsfMPTLocked, + testBundle.lockedAmount + ); + auto const bbs = std::vector{mptoken.getSerializer().peekData()}; + EXPECT_CALL(*backend_, doFetchLedgerObjects).WillOnce(Return(bbs)); + + runSpawn([&, this](auto yield) { + auto const input = + boost::json::parse(fmt::format(R"JSON({{"account": "{}"}})JSON", kAccount)); + auto const handler = AnyHandler{AccountMPTokensHandler{this->backend_}}; + auto const output = handler.process(input, Context{yield}); + ASSERT_TRUE(output); + + auto const& mptokens = output.result->as_object().at("mptokens").as_array(); + ASSERT_EQ(mptokens.size(), 1); + auto const& token = mptokens[0].as_object(); + + ASSERT_TRUE(token.at("mpt_amount").is_string()); + EXPECT_EQ(token.at("mpt_amount").as_string(), std::to_string(testBundle.mptAmount)); + ASSERT_TRUE(token.at("locked_amount").is_string()); + EXPECT_EQ(token.at("locked_amount").as_string(), std::to_string(testBundle.lockedAmount)); + }); +}