fix: Increase manifest protocol message size cap and fix manifests relay

Signed-off-by: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com>
This commit is contained in:
Pratik Mankawde
2026-08-04 22:08:43 +01:00
committed by Ayaz Salikhov
parent 15d08770bf
commit 54cfdda00b
13 changed files with 347 additions and 59 deletions

View File

@@ -549,6 +549,45 @@
# only be used for local testing and debugging. Do not disable
# on mainnet.
#
# max_untrusted_count = <number>
#
# The number of manifests the server keeps for validators it does not
# list, and the number it sends and processes in a single peer protocol
# message. Once the server holds this many, a manifest for a new
# unlisted validator is rejected, so peer gossip cannot grow the cache
# without end.
#
# This option can take any value between 50 and 1000, inclusive. If
# the option is not present the server uses its built-in value.
#
# The current default (which is subject to change) is 300.
#
# max_trusted_count = <number>
#
# The number of manifests for listed validators to allow for when
# sizing peer protocol messages. Manifests for listed validators are
# never dropped, whether sending or receiving, because doing so would
# delay a validator key change reaching this server. Set this above the
# number of validators the server lists.
#
# Together the two counts above set the largest manifest message the
# server accepts: bigger messages are discarded without reading them,
# and without penalising the sender. Raising either means the server
# accepts and sends bigger messages than a peer using the defaults, and
# those peers will discard what this server sends. Lowering either below
# what peers send makes this server discard their manifest messages,
# which it does without recording anything.
#
# This option can take any value between 50 and 1000, inclusive. If
# the option is not present the server uses its built-in value.
#
# The current default (which is subject to change) is 300.
#
# NOTE: These two options (max_untrusted_count and max_trusted_count)
# are transitional. They exist to bound manifest-message size and cache
# growth during the network upgrade. They may be removed in a future
# release once the fleet has upgraded, and should not be relied upon as
# stable configuration.
#
# [transaction_queue] EXPERIMENTAL
#

View File

@@ -119,7 +119,9 @@ struct Keys
static constexpr auto kLogInterval = "log_interval";
static constexpr auto kMaxDivergedTime = "max_diverged_time";
static constexpr auto kMaxLedgerCountsToStore = "max_ledger_counts_to_store";
static constexpr auto kMaxTrustedCount = "max_trusted_count";
static constexpr auto kMaxUnknownTime = "max_unknown_time";
static constexpr auto kMaxUntrustedCount = "max_untrusted_count";
static constexpr auto kMaximumTxnInLedger = "maximum_txn_in_ledger";
static constexpr auto kMaximumTxnPerAccount = "maximum_txn_per_account";
static constexpr auto kMemoryLevel = "memory_level";

View File

@@ -201,21 +201,67 @@ constexpr std::size_t kMaxManifestBytes = 358;
constexpr std::size_t kMaxManifestBase64 = base64::encodedSize(kMaxManifestBytes);
/**
* Maximum number of manifests carried in a single TMManifests message.
* Default number of untrusted manifests to store in cache and allowed
* in one Manifest message.
*
* Outbound, the TMManifests message sent to a peer includes every trusted
* manifest and fills the rest of this budget with untrusted gossip, so it
* never exceeds this size. Inbound, trusted manifests are always processed
* and untrusted ones are processed up to this many, so a peer sending its
* whole cache cannot force unbounded work.
* Bounds unlisted validators two ways. In the cache, a manifest for a
* brand-new unlisted key is rejected once this many are held, so peer gossip
* cannot grow the cache without end. In a TMManifests message, this many are
* sent and processed, so a peer sending its whole cache cannot force unbounded
* work.
*
* The trusted set is tiny relative to this bound, so trusted manifests are
* not dropped in practice. This is a transitional per-message cap; the cache
* already bounds untrusted manifests (see kMaxUntrustedCount), so it is no
* longer needed once the network has upgraded past nodes that send their
* whole cache in one message.
* Operators can override this with `[overlay] max_untrusted_count`. Both users
* read the configured value and fall back to this default.
*/
constexpr std::size_t kMaxManifestsPerMessage = 200;
constexpr std::size_t kMaxUntrustedCount = 300;
/**
* Default number of trusted manifests allowed in a Manifest message.
* Not used atm while creating the message, but used to calculate the higher limit on
* received message size. Introduced to maintain consistency. Future implementation
* will use this limit.
*
* Trusted manifests are never dropped: every one this node holds is sent, and
* every one received is processed, since dropping one would delay a validator
* key rotation. This count only sizes the largest message accepted, so it must
* stay above any realistic validator list. Cap can be increased in the config
* file if messages get rejected with actual trusted manifest count crossing
* configured(or else default) value.
* Operators can override this with `[overlay] max_trusted_count`.
*/
constexpr std::size_t kMaxTrustedCount = 300;
/**
* Number of untrusted manifests to store in cache and allowed
* in one Manifest message..
*
* Returns the operator's override when one is configured, otherwise
* @ref kMaxUntrustedCount. Config stores an override rather than the default
* itself because the core module cannot depend on this module.
*
* @param configured The value from `[overlay] max_untrusted_count`, or
* `std::nullopt` when the operator did not set it.
*/
constexpr std::size_t
untrustedManifestCount(std::optional<std::size_t> const& configured)
{
return configured.value_or(kMaxUntrustedCount);
}
/**
* Number of trusted manifests allowed in a Manifest message.
*
* Not a cap on how many are sent or processed; see @ref kMaxTrustedCount.
* but used to calculate the higher limit on received message size.
*
* @param configured The value from `[overlay] max_trusted_count`, or
* `std::nullopt` when the operator did not set it.
*/
constexpr std::size_t
trustedManifestCount(std::optional<std::size_t> const& configured)
{
return configured.value_or(kMaxTrustedCount);
}
/**
* Constructs Manifest from serialized string
@@ -361,9 +407,10 @@ private:
/**
* Maximum number of untrusted master keys kept in the cache.
*
* Once reached, a manifest for a brand-new unlisted key is rejected.
* Once reached, a manifest for a brand-new unlisted key is rejected. Set
* from the config, defaulting to @ref kMaxUntrustedCount.
*/
static constexpr std::size_t kMaxUntrustedCount = 100;
std::size_t const maxUntrustedCount_;
/**
* Running count of manifests rejected because the untrusted cap was full.
@@ -381,7 +428,17 @@ private:
static constexpr std::uint64_t kUntrustedRejectCount = 10000;
public:
explicit ManifestCache(beast::Journal j = beast::Journal(beast::Journal::getNullSink())) : j_(j)
/**
* @param j Journal for logging.
*
* @param maxUntrustedCount Untrusted master keys to keep. Pass the
* configured value; defaults to @ref kMaxUntrustedCount. Taken as a
* parameter because this module cannot depend on the config.
*/
explicit ManifestCache(
beast::Journal j = beast::Journal(beast::Journal::getNullSink()),
std::size_t maxUntrustedCount = kMaxUntrustedCount)
: j_(j), maxUntrustedCount_(maxUntrustedCount)
{
}

View File

@@ -487,7 +487,7 @@ ManifestCache::applyManifest(Manifest m, ManifestRateLimitCapPolicy const cap)
lock.owns_lock(), "xrpl::ManifestCache::applyManifest::atUntrustedCap : locked");
(void)lock; // not used. parameter is present to ensure the mutex is
// locked when the lambda is called.
if (iter == map_.end() && !uncapped && untrustedKeys_.size() >= kMaxUntrustedCount)
if (iter == map_.end() && !uncapped && untrustedKeys_.size() >= maxUntrustedCount_)
{
// Log each rejection at debug, but warn only once per interval so a
// flood does not fill the log.

View File

@@ -1575,6 +1575,87 @@ r.ripple.com:51235
// Above upper bound
BEAST_EXPECT(!testDiverged("901"));
testcase("overlay: manifest counts");
// Both keys share one range and one parse path, so exercise each
// through the same helper.
auto testCount = [](std::string const& key,
std::string const& value) -> std::optional<std::size_t> {
try
{
Config c;
c.loadFromString("[overlay]\n" + key + "=" + value);
return key == "max_trusted_count" ? c.maxTrustedCount : c.maxUntrustedCount;
}
catch (std::runtime_error const&)
{
return {};
}
};
for (auto const* key : {"max_untrusted_count", "max_trusted_count"})
{
// Failures. A bad value must surface as std::runtime_error, not
// the std::bad_cast that the underlying parse throws.
BEAST_EXPECT(!testCount(key, "none"));
BEAST_EXPECT(!testCount(key, "0.5"));
BEAST_EXPECT(!testCount(key, "400 manifests"));
BEAST_EXPECT(!testCount(key, "-1"));
// Below lower bound
BEAST_EXPECT(!testCount(key, "0"));
BEAST_EXPECT(!testCount(key, "49"));
// In bounds
BEAST_EXPECT(testCount(key, "50") == 50);
BEAST_EXPECT(testCount(key, "51") == 51);
BEAST_EXPECT(testCount(key, "300") == 300);
BEAST_EXPECT(testCount(key, "400") == 400);
BEAST_EXPECT(testCount(key, "999") == 999);
BEAST_EXPECT(testCount(key, "1000") == 1000);
// Above upper bound
BEAST_EXPECT(!testCount(key, "1001"));
}
// Each key is independent: setting one leaves the other unset.
{
Config c;
c.loadFromString("[overlay]\nmax_untrusted_count=500");
BEAST_EXPECT(c.maxUntrustedCount == 500);
BEAST_EXPECT(!c.maxTrustedCount);
}
{
Config c;
c.loadFromString("[overlay]\nmax_trusted_count=500");
BEAST_EXPECT(c.maxTrustedCount == 500);
BEAST_EXPECT(!c.maxUntrustedCount);
}
// Both can be set together.
{
Config c;
c.loadFromString("[overlay]\nmax_untrusted_count=250\nmax_trusted_count=750");
BEAST_EXPECT(c.maxUntrustedCount == 250);
BEAST_EXPECT(c.maxTrustedCount == 750);
}
// Unset leaves no override, so the use sites fall back to the defaults.
{
Config c;
c.loadFromString("[overlay]\nip_limit=64");
BEAST_EXPECT(!c.maxUntrustedCount);
BEAST_EXPECT(!c.maxTrustedCount);
}
// No [overlay] section at all leaves both unset too.
{
Config c;
c.loadFromString("");
BEAST_EXPECT(!c.maxUntrustedCount);
BEAST_EXPECT(!c.maxTrustedCount);
}
}
void

View File

@@ -91,6 +91,7 @@
#include <xrpl/resource/Fees.h>
#include <xrpl/resource/ResourceManager.h>
#include <xrpl/server/LoadFeeTrack.h>
#include <xrpl/server/Manifest.h>
#include <xrpl/server/NetworkOPs.h>
#include <xrpl/server/Wallet.h>
#include <xrpl/server/detail/ServerImpl.h>
@@ -428,8 +429,14 @@ public:
, cluster_(std::make_unique<Cluster>(logs_->journal("Overlay")))
, peerReservations_(
std::make_unique<PeerReservationTable>(logs_->journal("PeerReservationTable")))
, validatorManifests_(std::make_unique<ManifestCache>(logs_->journal("ManifestCache")))
, publisherManifests_(std::make_unique<ManifestCache>(logs_->journal("ManifestCache")))
, validatorManifests_(
std::make_unique<ManifestCache>(
logs_->journal("ManifestCache"),
untrustedManifestCount(config_->maxUntrustedCount)))
, publisherManifests_(
std::make_unique<ManifestCache>(
logs_->journal("ManifestCache"),
untrustedManifestCount(config_->maxUntrustedCount)))
, validators_(
std::make_unique<ValidatorList>(
*validatorManifests_,
@@ -1190,6 +1197,15 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
JLOG(journal_.info()) << "Process starting: " << BuildInfo::getFullVersionString()
<< ", Instance Cookie: " << instanceCookie_;
// Log the resolved manifest counts, whether configured or defaulted, so a
// shared log shows what the server is running without needing its config.
JLOG(journal_.warn()) << "Manifest counts: max_untrusted_count "
<< untrustedManifestCount(config_->maxUntrustedCount)
<< (config_->maxUntrustedCount ? " (configured)" : " (default)")
<< ", max_trusted_count "
<< trustedManifestCount(config_->maxTrustedCount)
<< (config_->maxTrustedCount ? " (configured)" : " (default)");
if (numberOfThreads(*config_) < 2)
{
JLOG(journal_.warn()) << "Limited to a single I/O service thread by "

View File

@@ -295,6 +295,23 @@ public:
// How long can a peer remain in the "diverged" state
std::chrono::seconds maxDivergedTime{300};
// Optional overrides for how many manifests are kept in the cache and
// carried in one TMManifests message, split by whether this node lists the
// validator. Unset means use the built-in defaults (kMaxUntrustedCount and
// kMaxTrustedCount in Manifest.h). Kept as overrides here, rather than the
// defaults themselves, so the core module need not depend on the server
// module that owns the constants.
std::optional<std::size_t> maxUntrustedCount;
std::optional<std::size_t> maxTrustedCount;
// Bounds for both counts above. The lower bound leaves room for a small
// network or a deliberately tight limit; note that setting a count below
// what peers actually send means their manifest messages are dropped for
// being oversized. The upper bound keeps the implied message size well
// under the overall protocol message limit.
static constexpr std::size_t kMinManifestCount = 50;
static constexpr std::size_t kMaxManifestCount = 1000;
// Enable the beta API version
bool betaRpcApi = false;

View File

@@ -923,6 +923,38 @@ Config::loadFromString(std::string const& fileContents)
std::string("Invalid value 'max_diverged_time' in ") + Sections::kOverlay +
": the time must be between 60 and 900 seconds, inclusive.");
}
// Both manifest counts parse and validate identically, so read them
// the same way. Returns nullopt when the key is absent, leaving the
// built-in default in effect at the use site.
auto manifestCount = [&sec](char const* key) -> std::optional<std::size_t> {
std::optional<std::size_t> count;
try
{
if (auto val = sec.get(key))
count = beast::lexicalCastThrow<std::size_t>(*val);
}
catch (...)
{
Throw<std::runtime_error>(
std::string("Invalid value '") + key + "' in " + Sections::kOverlay +
": must be of the form '<number>' representing a count of manifests.");
}
if (count && (*count < kMinManifestCount || *count > kMaxManifestCount))
{
Throw<std::runtime_error>(
std::string("Invalid value '") + key + "' in " + Sections::kOverlay +
": the count must be between " + std::to_string(kMinManifestCount) + " and " +
std::to_string(kMaxManifestCount) + ", inclusive.");
}
return count;
};
maxUntrustedCount = manifestCount(Keys::kMaxUntrustedCount);
maxTrustedCount = manifestCount(Keys::kMaxTrustedCount);
}
if (getSingleSection(secConfig, Sections::kAmendmentMajorityTime, strTemp, j_))

View File

@@ -24,12 +24,37 @@ constexpr std::size_t kMaximumMessageSize = megabytes(64);
// so we define a separate limit for them.
constexpr std::size_t kMaximumPingMessageSize = kilobytes(1);
// Upper bound on the wire size of a TMManifests message: kMaxManifestsPerMessage entries
// of at most kMaxManifestBytes each, plus a small allowance for protobuf
// framing per entry.
// Allowance for protobuf framing around each manifest in a TMManifests message.
constexpr std::size_t kManifestFramingBytes = 8;
constexpr std::size_t kMaximumManifestsMessageSize =
kMaxManifestsPerMessage * (kMaxManifestBytes + kManifestFramingBytes);
/**
* Upper bound on the wire size of a TMManifests message.
*
* Allows both counts' worth of entries at @ref kMaxManifestBytes each, plus
* framing per entry. Messages larger than this are dropped before parsing,
* which bounds the work an oversized message can cause.
*
* @param trustedCount Trusted manifests per message.
*
* @param untrustedCount Untrusted manifests per message.
*
* @note A node that raises either count accepts larger messages than a peer
* running the defaults, and the messages it sends may be dropped by such a
* peer. Lowering either count below what peers send drops their manifest
* messages, including any trusted key rotations they carry, and the drop
* is not recorded on either side.
*/
constexpr std::size_t
maximumManifestsMessageSize(std::size_t const trustedCount, std::size_t const untrustedCount)
{
return (trustedCount + untrustedCount) * (kMaxManifestBytes + kManifestFramingBytes);
}
// The message size the defaults imply must stay within the overall protocol
// message limit. The same check for the largest configurable counts lives in
// OverlayImpl.h, where the configured bound is visible.
static_assert(
maximumManifestsMessageSize(kMaxTrustedCount, kMaxUntrustedCount) < kMaximumMessageSize);
// VFALCO NOTE If we forward declare Message and write out shared_ptr
// instead of using the in-class type alias, we can remove the

View File

@@ -667,7 +667,10 @@ OverlayImpl::onManifests(
auto const& journal = from->pJournal();
// Process every trusted manifest, but stop processing untrusted ones once
// kMaxManifestsPerMessage of them have been handled, so the work stays bounded.
// the configured untrusted count has been handled, so the work stays
// bounded. Trusted manifests are always processed: dropping one would delay
// a validator key rotation reaching this node.
auto const maxUntrusted = untrustedManifestCount(app_.config().maxUntrustedCount);
auto const total = static_cast<std::size_t>(m->list_size());
std::size_t untrusted = 0;
bool skippedUntrusted = false;
@@ -686,23 +689,18 @@ OverlayImpl::onManifests(
// first avoids holding the two locks in opposite orders.
bool const isTrusted = app_.getValidators().listed(mo->masterKey);
// Bound untrusted work: process at most kMaxManifestsPerMessage
// untrusted manifests, but never skip a trusted one. Trusted
// manifests are not counted against the cap.
// Bound untrusted work: process at most maxUntrusted untrusted
// manifests, but never skip a trusted one. Trusted manifests are
// not counted against the cap.
if (!isTrusted)
{
if (untrusted >= kMaxManifestsPerMessage)
if (untrusted >= maxUntrusted)
{
skippedUntrusted = true;
continue;
}
++untrusted;
}
// Updates to a known key are relayed even when untrusted. Use
// getSequence, not getManifest, to avoid copying the cached payload
// on this hot path.
bool const isKnown =
app_.getValidatorManifests().getSequence(mo->masterKey).has_value();
auto const result = app_.getValidatorManifests().applyManifest(
std::move(*mo),
@@ -721,22 +719,17 @@ OverlayImpl::onManifests(
"deserialization succeeded");
// NOLINTBEGIN(bugprone-unchecked-optional-access) assert above
app_.getOPs().pubManifest(*mo);
// NOLINTEND(bugprone-unchecked-optional-access)
relay.add_list()->set_stobject(s);
// Relay only trusted manifests or updates to known keys, so
// untrusted gossip for a brand-new key cannot be amplified.
// Persist to the wallet DB only for trusted keys, so untrusted
// gossip never survives a restart.
if (isTrusted || isKnown)
if (isTrusted)
{
relay.add_list()->set_stobject(s);
if (isTrusted)
{
auto db = app_.getWalletDB().checkoutDb();
addValidatorManifest(*db, serialized);
}
auto db = app_.getWalletDB().checkoutDb();
addValidatorManifest(*db, serialized);
}
// NOLINTEND(bugprone-unchecked-optional-access)
}
}
else
@@ -749,13 +742,12 @@ OverlayImpl::onManifests(
if (skippedUntrusted)
{
// The sender exceeded the untrusted per-message cap. Charge it (once,
// here) so a flood of untrusted manifests is penalized, while an honest
// message of trusted manifests never is.
// here) so a flood of untrusted manifests is penalized.
from->charge(Resource::kFeeMalformedRequest, "too many untrusted manifests");
JLOG(journal.warn()) << "Manifests: message had " << total
<< " entries; processed all trusted plus the first "
<< kMaxManifestsPerMessage << " untrusted";
<< " entries; processed all trusted plus the first " << maxUntrusted
<< " untrusted";
}
if (!relay.list().empty())
@@ -1283,10 +1275,9 @@ OverlayImpl::getManifestsMessage()
});
// Phase 2: no cache lock held, so trust checks are safe. Include every
// trusted manifest, then fill any remaining headroom up to
// kMaxManifestsPerMessage with untrusted gossip, so the whole message
// stays within the per-message cap the receiver enforces (trusted
// count is tiny in practice, so this effectively never drops trusted).
// trusted manifest, then fill any remaining headroom up to the
// configured untrusted count with gossip. Trusted manifests are never
// dropped; the trusted count only sizes the accepted message.
std::vector<CachedManifest const*> selected;
std::vector<CachedManifest const*> untrusted;
for (auto const& e : cached)
@@ -1302,7 +1293,8 @@ OverlayImpl::getManifestsMessage()
}
// Cap untrusted only; trusted manifests are all included above.
auto const take = std::min(kMaxManifestsPerMessage, untrusted.size());
auto const take =
std::min(untrustedManifestCount(app_.config().maxUntrustedCount), untrusted.size());
selected.insert(selected.end(), untrusted.begin(), untrusted.begin() + take);
// Shuffle the order. Cryptographic randomness is not needed here.

View File

@@ -1,6 +1,7 @@
#pragma once
#include <xrpld/app/main/Application.h>
#include <xrpld/core/Config.h>
#include <xrpld/overlay/Message.h>
#include <xrpld/overlay/Overlay.h>
#include <xrpld/overlay/Peer.h>
@@ -54,6 +55,13 @@
namespace xrpl {
// The largest counts an operator can configure must still imply a message size
// within the overall protocol message limit. The same check for the defaults
// lives in Message.h.
static_assert(
maximumManifestsMessageSize(Config::kMaxManifestCount, Config::kMaxManifestCount) <
kMaximumMessageSize);
class PeerImp;
class BasicConfig;

View File

@@ -32,6 +32,7 @@
#include <xrpl/resource/Consumer.h>
#include <xrpl/resource/Fees.h>
#include <xrpl/server/Handoff.h>
#include <xrpl/server/Manifest.h>
#include <boost/circular_buffer.hpp>
#include <boost/endian/conversion.hpp>
@@ -464,6 +465,21 @@ public:
return compressionEnabled_ == Compressed::On;
}
/**
* Largest TMManifests message this node accepts, in bytes.
*
* Read by invokeProtocolMessage to drop oversized messages before
* parsing. Not part of the Peer interface: the message handler is a
* template parameter, so only PeerImp needs to provide this.
*/
[[nodiscard]] std::size_t
maxManifestsMessageSize() const
{
return maximumManifestsMessageSize(
trustedManifestCount(app_.config().maxTrustedCount),
untrustedManifestCount(app_.config().maxUntrustedCount));
}
bool
txReduceRelayEnabled() const override
{

View File

@@ -375,13 +375,16 @@ invokeProtocolMessage(Buffers const& buffers, Handler& handler, std::size_t& hin
}
// Drop an oversized TMManifests without penalty: consume the bytes and
// return no error, so the connection is preserved.
if (header->messageType == protocol::mtMANIFESTS &&
(header->payloadWireSize > kMaximumManifestsMessageSize ||
header->uncompressedSize > kMaximumManifestsMessageSize))
// return no error, so the connection is preserved. The limit follows this
// node's configured manifests-per-message count.
if (header->messageType == protocol::mtMANIFESTS)
{
result.first = header->totalWireSize;
return result;
auto const maxSize = handler.maxManifestsMessageSize();
if (header->payloadWireSize > maxSize || header->uncompressedSize > maxSize)
{
result.first = header->totalWireSize;
return result;
}
}
bool success = false;