Compare commits

...

5 Commits

Author SHA1 Message Date
Denis Angell
f403bddd86 docs: Batch weight in fee escalation 2026-10-20 15:14:12 -04:00
Denis Angell
e87f58f9bd fix: Count Batch inner transactions in TxQ fee escalation 2026-10-20 15:05:55 -04:00
Denis Angell
90ed4442d3 test: Batch weight in TxQ escalation 2026-10-20 15:03:00 -04:00
Denis Angell
a775aa5fcf feat: Count Batch inner transactions in OpenView 2026-10-20 15:01:23 -04:00
Denis Angell
57e053dad0 test: Count Batch inner transactions in OpenView 2026-10-20 14:52:12 -04:00
8 changed files with 277 additions and 18 deletions

View File

@@ -40,6 +40,7 @@ Version 3.5.0 is not yet released.
- `account_info`, `account_lines`, `account_channels`: A request with more than one problem may now report a different one of them. An invalid `ledger_hash` or `ledger_index` is reported before a missing or invalid `account`, or `ident` for `account_info`. For `account_lines`, a `peer` that is not a string, or an invalid `limit` or `marker`, is reported before `lgrNotFound`, `actNotFound` or a malformed `account`, and an invalid `limit` or `marker` also before a malformed `peer`. For `account_channels`, a malformed `account` is reported before `lgrNotFound`, and an invalid `destination_account`, `limit` or `marker` before `lgrNotFound` or `actNotFound`. A request with a single problem returns the same error as before. [#8573](https://github.com/XRPLF/rippled/pull/8573)
- `deposit_authorized`, `nft_buy_offers`, `nft_sell_offers`: A request with more than one problem may now report a different one of them. An invalid `ledger_hash` or `ledger_index` is reported before a missing or invalid `source_account`, `destination_account` or `nft_id`, and for `nft_buy_offers` / `nft_sell_offers` also before an invalid `limit`. For `deposit_authorized`, an invalid `credentials` array is reported before `lgrNotFound`, `srcActNotFound`, `dstActNotFound` or `badCredentials`. For `nft_buy_offers` and `nft_sell_offers`, an invalid `marker` is reported before `lgrNotFound` or `objectNotFound`. A request with a single problem returns the same error as before. [#8613](https://github.com/XRPLF/rippled/pull/8613)
- `nft_buy_offers`, `nft_sell_offers`: An `nft_id` that is an array or an object now returns an `invalidParams` error instead of an `internal` error. [#8613](https://github.com/XRPLF/rippled/pull/8613)
- `fee`: `current_ledger_size` and `expected_ledger_size` count each Batch transaction as 1 plus its inner transactions, so the open-ledger fee escalates on Batches the way it does on the same number of separate transactions. `open_ledger_fee`, and `load_factor_fee_escalation` in `server_info`, follow from the same counts.
### Bugfixes in 3.5.0

View File

@@ -103,6 +103,13 @@ private:
*/
std::size_t baseTxCount_ = 0;
/**
* Inner transactions declared by the Batch transactions inserted into
* this view. The open ledger never applies them, so TxQ adds this to
* txCount().
*/
std::size_t batchInnerCount_ = 0;
bool open_ = true;
public:
@@ -198,12 +205,31 @@ public:
std::size_t
txCount() const;
/**
* Return the number of inner transactions declared by the Batch
* transactions inserted into this view.
*/
std::size_t
batchInnerCount() const;
/**
* Record that a Batch declaring `count` inner transactions was inserted.
*/
void
rawBatchInnerAdd(std::size_t count);
/**
* Apply changes.
*/
void
apply(TxsRawView& to) const;
/**
* Apply changes, including the Batch inner count.
*/
void
apply(OpenView& to) const;
// ReadView
LedgerHeader const&

View File

@@ -20,6 +20,7 @@
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/TxMeta.h>
#include <xrpl/protocol/XRPAmount.h>
@@ -275,6 +276,8 @@ ApplyStateTable::apply(
if (!isDryRun)
{
to.rawTxInsert(tx.getTransactionID(), sTx, sMeta);
if (tx.getTxnType() == ttBATCH)
to.rawBatchInnerAdd(tx.getFieldArray(sfRawTransactions).size());
apply(to);
}
return metadata;

View File

@@ -87,6 +87,7 @@ OpenView::OpenView(OpenView const& rhs)
, base_{rhs.base_}
, items_{rhs.items_}
, hold_{rhs.hold_}
, batchInnerCount_{rhs.batchInnerCount_}
, open_{rhs.open_} {};
OpenView::OpenView(OpenLedgerT, ReadView const* base, Rules rules, std::shared_ptr<void const> hold)
@@ -125,6 +126,18 @@ OpenView::txCount() const
return baseTxCount_ + txs_.size();
}
std::size_t
OpenView::batchInnerCount() const
{
return batchInnerCount_;
}
void
OpenView::rawBatchInnerAdd(std::size_t count)
{
batchInnerCount_ += count;
}
void
OpenView::apply(TxsRawView& to) const
{
@@ -133,6 +146,13 @@ OpenView::apply(TxsRawView& to) const
to.rawTxInsert(item.first, item.second.txn, item.second.meta);
}
void
OpenView::apply(OpenView& to) const
{
apply(static_cast<TxsRawView&>(to));
to.rawBatchInnerAdd(batchInnerCount_);
}
//---
LedgerHeader const&

View File

@@ -4896,7 +4896,7 @@ class Batch_test : public beast::unit_test::Suite
Ter(tesSUCCESS));
}
// inner batch transactions are counter towards the ledger tx count
// inner batch transactions are counted towards the ledger tx count
{
Env env{
*this,
@@ -4933,10 +4933,10 @@ class Batch_test : public beast::unit_test::Suite
Ter(tesSUCCESS));
}
checkMetrics(*this, env, 0, std::nullopt, 3, 2);
checkMetrics(*this, env, 0, std::nullopt, 5, 2);
env(noop(carol), Ter(terQUEUED));
checkMetrics(*this, env, 1, std::nullopt, 3, 2);
checkMetrics(*this, env, 1, std::nullopt, 5, 2);
}
// A Batch is never queued, so it also cannot sit behind the account's
@@ -4984,6 +4984,186 @@ class Batch_test : public beast::unit_test::Suite
}
}
void
testOpenViewBatchInnerCount(FeatureBitset features)
{
testcase("open view batch inner count");
using namespace test::jtx;
Env env{*this, features};
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const carol = Account("carol");
env.fund(XRP(10000), alice, bob, carol);
env.close();
// The open ledger holds the Batch but never its inner transactions.
auto const aliceSeq = env.seq(alice);
env(batch::outer(alice, aliceSeq, batch::calcBatchFee(env, 0, 2), tfAllOrNothing),
batch::Inner(pay(alice, bob, XRP(1)), aliceSeq + 1),
batch::Inner(pay(alice, bob, XRP(1)), aliceSeq + 2),
Ter(tesSUCCESS));
BEAST_EXPECT(env.current()->txCount() == 1);
BEAST_EXPECT(env.current()->batchInnerCount() == 2);
env(noop(bob));
BEAST_EXPECT(env.current()->txCount() == 2);
BEAST_EXPECT(env.current()->batchInnerCount() == 2);
// A copy keeps the count.
OpenView copy(*env.current());
BEAST_EXPECT(copy.batchInnerCount() == 2);
// A view applied into another OpenView adds its count to it.
auto const carolSeq = env.seq(carol);
auto const jt = env.jt(
batch::outer(carol, carolSeq, batch::calcBatchFee(env, 0, 3), tfAllOrNothing),
batch::Inner(pay(carol, bob, XRP(1)), carolSeq + 1),
batch::Inner(pay(carol, bob, XRP(1)), carolSeq + 2),
batch::Inner(pay(carol, bob, XRP(1)), carolSeq + 3));
OpenView sandbox(kOpenLedger, &copy, copy.rules());
auto const result = xrpl::apply(env.app(), sandbox, *jt.stx, TapNone, env.journal);
BEAST_EXPECT(result.applied && result.ter == tesSUCCESS);
BEAST_EXPECT(sandbox.txCount() == 1);
BEAST_EXPECT(sandbox.batchInnerCount() == 3);
sandbox.apply(copy);
BEAST_EXPECT(copy.txCount() == 3);
BEAST_EXPECT(copy.batchInnerCount() == 5);
// The next open ledger starts from zero.
env.close();
BEAST_EXPECT(env.current()->batchInnerCount() == 0);
}
void
testOpenLedgerCountsBatchInners(FeatureBitset features)
{
testcase("open ledger counts batch inners");
using namespace test::jtx;
using namespace std::literals;
Env env{
*this,
makeSmallQueueConfig({{Keys::kMinimumTxnInLedgerStandalone, "3"}}),
features,
nullptr,
beast::Severity::Error};
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const carol = Account("carol");
// Fund across several ledgers so the TxQ metrics stay restricted.
env.fund(XRP(10000), noripple(alice, bob));
env.close(env.now() + 5s, 10000ms);
env.fund(XRP(10000), noripple(carol));
env.close(env.now() + 5s, 10000ms);
checkMetrics(*this, env, 0, std::nullopt, 0, 3);
// A Batch with two inner transactions reaches the target on its own.
auto const aliceSeq = env.seq(alice);
env(batch::outer(alice, aliceSeq, batch::calcBatchFee(env, 0, 2), tfAllOrNothing),
batch::Inner(pay(alice, bob, XRP(1)), aliceSeq + 1),
batch::Inner(pay(alice, bob, XRP(1)), aliceSeq + 2),
Ter(tesSUCCESS));
checkMetrics(*this, env, 0, std::nullopt, 3, 3);
env(noop(bob));
checkMetrics(*this, env, 0, std::nullopt, 4, 3);
// Past the target the fee escalates, so a base-fee transaction queues.
env(noop(carol), Ter(terQUEUED));
checkMetrics(*this, env, 1, std::nullopt, 4, 3);
}
void
testClosedLedgerCountsBatchInners(FeatureBitset features)
{
testcase("closed ledger counts batch inners");
using namespace test::jtx;
using namespace std::literals;
Env env{
*this,
makeSmallQueueConfig({{Keys::kMinimumTxnInLedgerStandalone, "3"}}),
features,
nullptr,
beast::Severity::Error};
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const carol = Account("carol");
// Fund across several ledgers so the TxQ metrics stay restricted.
env.fund(XRP(10000), noripple(alice, bob));
env.close(env.now() + 5s, 10000ms);
env.fund(XRP(10000), noripple(carol));
env.close(env.now() + 5s, 10000ms);
// Both inner transactions apply.
auto const aliceSeq = env.seq(alice);
env(batch::outer(alice, aliceSeq, batch::calcBatchFee(env, 0, 2), tfAllOrNothing),
batch::Inner(pay(alice, bob, XRP(1)), aliceSeq + 1),
batch::Inner(pay(alice, bob, XRP(1)), aliceSeq + 2),
Ter(tesSUCCESS));
// The second inner transaction is unfunded, so neither applies.
auto const carolSeq = env.seq(carol);
env(batch::outer(carol, carolSeq, batch::calcBatchFee(env, 0, 2), tfAllOrNothing),
batch::Inner(pay(carol, bob, XRP(1)), carolSeq + 1),
batch::Inner(pay(carol, bob, XRP(100000)), carolSeq + 2),
Ter(tesSUCCESS));
checkMetrics(*this, env, 0, std::nullopt, 6, 3);
// The closed ledger holds 4 entries: alice's Batch, its two inner
// transactions and carol's Batch. Each Batch counts 1 + 2.
env.close();
checkMetrics(*this, env, 0, 12, 0, 6);
}
void
testClosedLedgerMedianSkipsInners(FeatureBitset features)
{
testcase("closed ledger median skips batch inners");
using namespace test::jtx;
using namespace std::literals;
Env env{
*this,
makeSmallQueueConfig({{Keys::kMinimumTxnInLedgerStandalone, "3"}}),
features,
nullptr,
beast::Severity::Error};
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const carol = Account("carol");
// Fund across several ledgers so the TxQ metrics stay restricted.
env.fund(XRP(10000), noripple(alice, bob));
env.close(env.now() + 5s, 10000ms);
env.fund(XRP(10000), noripple(carol));
env.close(env.now() + 5s, 10000ms);
// Both pay 1000 times their base fee: fee level 256,000.
auto const aliceSeq = env.seq(alice);
env(batch::outer(alice, aliceSeq, batch::calcBatchFee(env, 0, 2) * 1000, tfAllOrNothing),
batch::Inner(pay(alice, bob, XRP(1)), aliceSeq + 1),
batch::Inner(pay(alice, bob, XRP(1)), aliceSeq + 2),
Ter(tesSUCCESS));
env(noop(bob), Fee(env.current()->fees().base * 1000));
// The inner transactions pay no fee and stay out of the median.
env.close();
checkMetrics(*this, env, 0, 8, 0, 4, 256, 256000);
}
void
testBatchNetworkOps(FeatureBitset features)
{
@@ -6390,6 +6570,10 @@ class Batch_test : public beast::unit_test::Suite
testPseudoTxn(features);
testOpenLedger(features);
testBatchTxQueue(features);
testOpenViewBatchInnerCount(features);
testOpenLedgerCountsBatchInners(features);
testClosedLedgerCountsBatchInners(features);
testClosedLedgerMedianSkipsInners(features);
testBatchNetworkOps(features);
testBatchDelegate(features);
testBatchDelegateConsent(features);

View File

@@ -52,6 +52,11 @@ How fees escalate:
- The formula is `( lastLedgerMedianFeeLevel *
TransactionsInOpenLedger^2 / limit^2 )`,
and returns a [fee level](#fee-level).
- In `TransactionsInOpenLedger` and in the limit, a Batch transaction
counts as 1 plus its inner transactions, which every server applies
when it builds the ledger. The inner transactions in a closed ledger
are counted with their Batch, and are left out of the median fee
level because they pay no fee.
4. That may still be pretty small, but as more transactions get
into the ledger, the fee level increases exponentially.

View File

@@ -198,11 +198,13 @@ public:
*/
std::optional<std::size_t> txQMaxSize;
/**
* Number of transactions currently in the open ledger
* Number of transactions currently in the open ledger, counting
* each Batch as 1 plus its inner transactions
*/
std::size_t txInLedger{};
/**
* Number of transactions expected per ledger
* Number of transactions expected per ledger, counting each Batch
* as 1 plus its inner transactions
*/
std::size_t txPerLedger{};
/**

View File

@@ -25,9 +25,11 @@
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/RippleLedgerHash.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STArray.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/Units.h>
#include <xrpl/protocol/XRPAmount.h>
@@ -109,6 +111,16 @@ increase(FeeLevel64 level, std::uint32_t increasePercent)
.value_or(static_cast<FeeLevel64>(xrpl::kMuldivMax));
}
/**
* Open-ledger transaction count with each Batch weighted by its inner
* transactions, which only the ledger build applies.
*/
static std::size_t
openLedgerWeight(OpenView const& view)
{
return view.txCount() + view.batchInnerCount();
}
//////////////////////////////////////////////////////////////////////////
struct MultiTxn
@@ -670,7 +682,7 @@ TxQApplyImpl::tryClearAccountQueueUpThruTx()
prevTxs_->first,
feeLevelPaid_,
preflightRes_,
view_.txCount(),
openLedgerWeight(view_),
flags_,
*metricsSnapshot_,
jExt_);
@@ -822,11 +834,15 @@ TxQ::FeeMetrics::update(
TxQ::Setup const& setup)
{
std::vector<FeeLevel64> feeLevels;
auto const txBegin = view.txs.begin();
auto const txEnd = view.txs.end();
auto const size = std::distance(txBegin, txEnd);
feeLevels.reserve(size);
std::for_each(txBegin, txEnd, [&](auto const& tx) {
std::size_t size = 0;
for (auto const& tx : view.txs)
{
// An inner transaction is counted with its Batch, and pays no fee.
if (tx.first->isFlag(tfInnerBatchTxn))
continue;
++size;
if (tx.first->getTxnType() == ttBATCH)
size += tx.first->getFieldArray(sfRawTransactions).size();
auto const maybeFeeLevel = getFeeLevelPaid(view, *tx.first);
if (maybeFeeLevel.has_value())
{
@@ -839,11 +855,12 @@ TxQ::FeeMetrics::update(
<< tx.first->getTransactionID() << " in ledger " << view.header().seq
<< ": " << transToken(maybeFeeLevel.error());
}
});
}
std::ranges::sort(feeLevels);
JLOG((timeLeap ? j_.warn() : j_.debug()))
<< "Ledger " << view.header().seq << " has " << size << " transactions. "
<< "Ledger " << view.header().seq << " has " << size
<< " transactions, counting Batch inner transactions. "
<< "Ledgers are processing " << (timeLeap ? "slowly" : "as expected")
<< ". Expected transactions is currently " << txnsExpected_ << " and multiplier is "
<< escalationMultiplier_;
@@ -886,7 +903,8 @@ TxQ::FeeMetrics::update(
// The median is taken over the transactions whose fee level could be
// computed, while txnsExpected_ above deliberately uses the full
// transaction count.
// transaction count. Both skip inner Batch transactions: each Batch
// counts 1 plus its inner transactions, and inner transactions pay no fee.
if (feeLevels.empty())
{
escalationMultiplier_ = setup.minimumEscalationMultiplier;
@@ -912,7 +930,7 @@ FeeLevel64
TxQ::FeeMetrics::scaleFeeLevel(Snapshot const& snapshot, OpenView const& view)
{
// Transactions in the open ledger so far
auto const current = view.txCount();
auto const current = openLedgerWeight(view);
auto const target = snapshot.txnsExpected;
auto const multiplier = snapshot.escalationMultiplier;
@@ -977,7 +995,7 @@ TxQ::FeeMetrics::escalatedSeriesFeeLevel(
AKA Transactions that will be in the open ledger when
the first tx in the series is attempted.
*/
auto const current = view.txCount() + extraCount;
auto const current = openLedgerWeight(view) + extraCount;
/* Transactions that will be in the open ledger when
the last tx in the series is attempted.
*/
@@ -1611,7 +1629,7 @@ TxQApplyImpl::applyImpl()
JLOG(j_.trace()) << "Transaction " << txID_ << " from account " << accID_
<< " has fee level of " << feeLevelPaid_ << " needs at least "
<< requiredFeeLevel_ << " to get in the open ledger, which has "
<< view_.txCount() << " entries.";
<< openLedgerWeight(view_) << " entries.";
// Quick heuristic check to see if it's worth checking that this tx has a high enough fee to
// clear all the txs in front of it in the queue.
@@ -2077,7 +2095,7 @@ TxQ::getMetrics(OpenView const& view) const
result.txCount = byFee_.size();
result.txQMaxSize = maxSize_;
result.txInLedger = view.txCount();
result.txInLedger = openLedgerWeight(view);
result.txPerLedger = snapshot.txnsExpected;
result.referenceFeeLevel = kBaseLevel;
result.minProcessingFeeLevel =