Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/rpc/handlers/AccountMPTokenIssuances.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/LedgerHeader.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STInteger.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/jss.h>

Expand Down Expand Up @@ -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);

Expand Down
14 changes: 12 additions & 2 deletions src/rpc/handlers/AccountMPTokens.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/LedgerHeader.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STInteger.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/jss.h>

Expand Down Expand Up @@ -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) {

@PeterChen13579 PeterChen13579 Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need to do this this PR, but I think this function along with setUint64IfPresent and in MPTHolders all do the same thing more or less. If this mpt_amount ever changes, there will be 3 places that needs to change. I think worth adding a helper to RPChelpers.cpp: something like:

boost::json::string
toBoostJsonString(xrpl::SField const& field, std::uint64_t value);

And all these 3 places can use it instead

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, that makes sense. its good to have one common utility method.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on my search, I found only two similar utility methods: setUint64IfPresent (introduced in this PR) and the verbose code in MPTHolders RPC, both of which do identical tasks. I wanted to keep the diff of this PR as limited as possible, but it is a good idea to refactor them if there are more such usages.

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);

Expand Down
138 changes: 115 additions & 23 deletions tests/unit/rpc/handlers/AccountMPTokenIssuancesTests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STObject.h>

#include <cmath>
#include <cstdint>
#include <functional>
#include <optional>
Expand Down Expand Up @@ -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,
Expand All @@ -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": "{}",
Expand Down Expand Up @@ -370,15 +371,15 @@ 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'}));

// return two mptoken issuance objects
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
Expand Down Expand Up @@ -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'}));

Expand All @@ -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);

Expand Down Expand Up @@ -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<Blob>{
Expand Down Expand Up @@ -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<Blob>{
Expand Down Expand Up @@ -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) {
Expand All @@ -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<AccountMPTokenIssuancesAmountSerializationTestCaseBundle> {};

INSTANTIATE_TEST_SUITE_P(
RPCAccountMPTokenIssuancesAmountSerializationGroup,
AccountMPTokenIssuancesAmountSerializationTest,
ValuesIn(
std::vector<AccountMPTokenIssuancesAmountSerializationTestCaseBundle>{
{.testName = "LargeAmounts",
.maxAmount = static_cast<uint64_t>(std::pow(2, 63)) - 1, // max MPT amount
.outstandingAmount = static_cast<uint64_t>(std::pow(2, 53)) + 1,
.lockedAmount = static_cast<uint64_t>(std::pow(2, 53)) +
12345}, // odd value above 2^53
{.testName = "ExactDoubleBoundary",
.maxAmount = static_cast<uint64_t>(std::pow(2, 53)),
.outstandingAmount = static_cast<uint64_t>(std::pow(2, 53)),
.lockedAmount = static_cast<uint64_t>(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<Blob>{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::lsmfMPTCanMutateCanLock |
Expand All @@ -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<Blob>{
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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<Blob>{createMptIssuanceObject(
Expand Down
Loading
Loading