Compare commits

...

3 Commits

Author SHA1 Message Date
Denis Angell
6519117097 test: Check every Batch's BatchResult against its mode and ledger 2026-10-08 00:47:45 -04:00
Denis Angell
1d9e0def40 docs: Add BatchResult to the API changelog 2026-10-07 20:27:47 +00:00
Denis Angell
ff7411078f feat: Record Batch inner results in a BatchResult pseudo-transaction (BatchV2) 2026-10-07 13:00:39 -04:00
15 changed files with 862 additions and 4 deletions

View File

@@ -30,6 +30,7 @@ Version 3.5.0 is not yet released.
- `subscribe`, `unsubscribe`: Added an optional `mpt_issuances` request field, an array of MPT issuance IDs (hex strings). Subscribers receive a message with `type` `mptTransaction` for each validated transaction whose metadata affects a subscribed issuance; the message has the same fields as the `transactions` stream. MPT issuance subscriptions count toward the per-connection subscription limit. An empty array, a non-array value, or an invalid ID returns `invalidParams`. ([#5671](https://github.com/XRPLF/rippled/pull/5671))
- `ledger_entry`: Add full support for checks, NFT offers, payment channels, and signer lists. ([#6319](https://github.com/XRPLF/rippled/pull/6319))
- `tx`, `ledger`, `subscribe` (`transactions` stream): With the `BatchV2` amendment enabled, a `Batch` transaction that applies with `tesSUCCESS` is followed in the ledger by a `BatchResult` pseudo-transaction, placed after the batch's inner transactions. Its `Account` is `rrrrrrrrrrrrrrrrrrrrrhoLvTp`, `ParentBatchID` is the hash of the `Batch`, and `BatchResults` lists every inner transaction the batch attempted, in order, each as a `BatchResult` object with `TransactionHash` and `EngineResultCode`. `EngineResultCode` is a signed integer, so `tem`, `tef` and `ter` codes are negative. An inner transaction missing from `BatchResults` was never attempted; one with a `tes` or `tec` code is in the ledger unless the whole batch was rolled back. Its metadata carries `TransactionResult` `tesSUCCESS` and an empty `AffectedNodes`, and like other pseudo-transactions it cannot be submitted. `server_definitions`: `BatchResult` is added to `TRANSACTION_TYPES` and `TRANSACTION_FORMATS`, and `BatchResults`, `BatchResult` and `EngineResultCode` to `FIELDS`. ([#8532](https://github.com/XRPLF/rippled/pull/8532))
### Bugfixes in 3.5.0

View File

@@ -15,6 +15,7 @@
// Add new amendments to the top of this list.
// Keep it sorted in reverse chronological order.
XRPL_FEATURE(BatchV2, Supported::No, VoteBehavior::DefaultNo)
XRPL_FEATURE(Cosign, Supported::No, VoteBehavior::DefaultNo)
XRPL_FEATURE(SmartEscrow, Supported::No, VoteBehavior::DefaultNo)
XRPL_FEATURE(LendingProtocolV1_2, Supported::No, VoteBehavior::DefaultNo)

View File

@@ -245,6 +245,7 @@ TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17, SField::kSmdNeedsAsset
TYPED_SFIELD(sfLoanScale, INT32, 1)
TYPED_SFIELD(sfRemainingOwnerCountDelta, INT32, 2)
TYPED_SFIELD(sfVMReturnCode, INT32, 3)
TYPED_SFIELD(sfEngineResultCode, INT32, 4)
// currency amount (common)
TYPED_SFIELD(sfAmount, AMOUNT, 1)
@@ -425,6 +426,7 @@ UNTYPED_SFIELD(sfBook, OBJECT, 36)
UNTYPED_SFIELD(sfCounterpartySignature, OBJECT, 37, SField::kSmdDefault, SField::kNotSigning)
UNTYPED_SFIELD(sfSponsorSignature, OBJECT, 38, SField::kSmdDefault, SField::kNotSigning)
UNTYPED_SFIELD(sfProposedTransaction, OBJECT, 39)
UNTYPED_SFIELD(sfBatchResult, OBJECT, 40)
// array of objects (common)
// ARRAY/1 is reserved for end of array
@@ -457,3 +459,4 @@ UNTYPED_SFIELD(sfAcceptedCredentials, ARRAY, 28)
UNTYPED_SFIELD(sfPermissions, ARRAY, 29)
UNTYPED_SFIELD(sfRawTransactions, ARRAY, 30)
UNTYPED_SFIELD(sfBatchSigners, ARRAY, 31, SField::kSmdDefault, SField::kNotSigning)
UNTYPED_SFIELD(sfBatchResults, ARRAY, 32)

View File

@@ -1221,3 +1221,16 @@ TRANSACTION(ttUNL_MODIFY, 102, UNLModify,
{sfLedgerSequence, SoeRequired},
{sfUNLModifyValidator, SoeRequired},
}))
/** This system-generated transaction type records the outcome of every inner
transaction a Batch attempted. The ledger build inserts it after the Batch's
inner transactions; it is never submitted or proposed.
*/
TRANSACTION(ttBATCH_RESULT, 103, BatchResult,
({
.amendment = featureBatchV2,
}),
({
{sfParentBatchID, SoeRequired},
{sfBatchResults, SoeRequired},
}))

View File

@@ -0,0 +1,155 @@
// This file is auto-generated. Do not edit.
#pragma once
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/STParsedJSON.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/protocol_autogen/TransactionBase.h>
#include <xrpl/protocol_autogen/TransactionBuilderBase.h>
#include <xrpl/json/json_value.h>
#include <stdexcept>
#include <optional>
namespace xrpl::transactions {
class BatchResultBuilder;
/**
* @brief Transaction: BatchResult
*
* Type: ttBATCH_RESULT (103)
* Delegable: Delegation::NotDelegable
* Amendment: featureBatchV2
* Privileges: Privilege::NoPriv
*
* Immutable wrapper around STTx providing type-safe field access.
* Use BatchResultBuilder to construct new transactions.
*/
class BatchResult : public TransactionBase
{
public:
static constexpr xrpl::TxType txType = ttBATCH_RESULT;
/**
* @brief Construct a BatchResult transaction wrapper from an existing STTx object.
* @throws std::runtime_error if the transaction type doesn't match.
*/
explicit BatchResult(std::shared_ptr<STTx const> tx)
: TransactionBase(std::move(tx))
{
// Verify transaction type
if (tx_->getTxnType() != txType)
{
throw std::runtime_error("Invalid transaction type for BatchResult");
}
}
// Transaction-specific field getters
/**
* @brief Get sfParentBatchID (SoeRequired)
* @return The field value.
*/
[[nodiscard]]
SF_UINT256::type::value_type
getParentBatchID() const
{
return this->tx_->at(sfParentBatchID);
}
/**
* @brief Get sfBatchResults (SoeRequired)
* @note This is an untyped field.
* @return The field value.
*/
[[nodiscard]]
STArray const&
getBatchResults() const
{
return this->tx_->getFieldArray(sfBatchResults);
}
};
/**
* @brief Builder for BatchResult transactions.
*
* Provides a fluent interface for constructing transactions with method chaining.
* Uses STObject internally for flexible transaction construction.
* Inherits common field setters from TransactionBuilderBase.
*/
class BatchResultBuilder : public TransactionBuilderBase<BatchResultBuilder>
{
public:
/**
* @brief Construct a new BatchResultBuilder with required fields.
* @param account The account initiating the transaction.
* @param parentBatchID The sfParentBatchID field value.
* @param batchResults The sfBatchResults field value.
* @param sequence Optional sequence number for the transaction.
* @param fee Optional fee for the transaction.
*/
BatchResultBuilder(SF_ACCOUNT::type::value_type account,
std::decay_t<typename SF_UINT256::type::value_type> const& parentBatchID, STArray const& batchResults, std::optional<SF_UINT32::type::value_type> sequence = std::nullopt,
std::optional<SF_AMOUNT::type::value_type> fee = std::nullopt
)
: TransactionBuilderBase<BatchResultBuilder>(ttBATCH_RESULT, account, sequence, fee)
{
setParentBatchID(parentBatchID);
setBatchResults(batchResults);
}
/**
* @brief Construct a BatchResultBuilder from an existing STTx object.
* @param tx The existing transaction to copy from.
* @throws std::runtime_error if the transaction type doesn't match.
*/
BatchResultBuilder(std::shared_ptr<STTx const> tx)
{
if (tx->getTxnType() != ttBATCH_RESULT)
{
throw std::runtime_error("Invalid transaction type for BatchResultBuilder");
}
object_ = *tx;
}
/**
* @brief Transaction-specific field setters
*/
/**
* @brief Set sfParentBatchID (SoeRequired)
* @return Reference to this builder for method chaining.
*/
BatchResultBuilder&
setParentBatchID(std::decay_t<typename SF_UINT256::type::value_type> const& value)
{
object_[sfParentBatchID] = value;
return *this;
}
/**
* @brief Set sfBatchResults (SoeRequired)
* @return Reference to this builder for method chaining.
*/
BatchResultBuilder&
setBatchResults(STArray const& value)
{
object_.setFieldArray(sfBatchResults, value);
return *this;
}
/**
* @brief Build and return the BatchResult wrapper.
* @param publicKey The public key for signing.
* @param secretKey The secret key for signing.
* @return The constructed transaction wrapper.
*/
BatchResult
build(PublicKey const& publicKey, SecretKey const& secretKey)
{
sign(publicKey, secretKey);
return BatchResult{std::make_shared<STTx>(std::move(object_))};
}
};
} // namespace xrpl::transactions

View File

@@ -59,5 +59,6 @@ private:
using EnableAmendment = Change;
using SetFee = Change;
using UNLModify = Change;
using BatchResult = Change;
} // namespace xrpl

View File

@@ -146,6 +146,10 @@ InnerObjectFormats::InnerObjectFormats()
{sfTxnSignature, SoeOptional},
{sfSigners, SoeOptional}});
add(sfBatchResult.jsonName,
sfBatchResult.getCode(),
{{sfTransactionHash, SoeRequired}, {sfEngineResultCode, SoeRequired}});
add(sfBook.jsonName,
sfBook.getCode(),
{

View File

@@ -895,7 +895,7 @@ isPseudoTx(STObject const& tx)
auto const tt = safeCast<TxType>(*t);
return tt == ttAMENDMENT || tt == ttFEE || tt == ttUNL_MODIFY;
return tt == ttAMENDMENT || tt == ttFEE || tt == ttUNL_MODIFY || tt == ttBATCH_RESULT;
}
} // namespace xrpl

View File

@@ -8,19 +8,25 @@
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STArray.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/TxMeta.h>
#include <xrpl/tx/applySteps.h>
#include <exception>
#include <memory>
#include <string>
#include <utility>
#include <vector>
namespace xrpl {
@@ -198,11 +204,22 @@ apply(
});
}
namespace {
struct BatchInnerResult
{
UInt256 id;
TER ter;
};
} // namespace
static bool
applyBatchTransactions(
ServiceRegistry& registry,
OpenView& batchView,
STTx const& batchTxn,
std::vector<BatchInnerResult>& results,
beast::Journal j)
{
XRPL_ASSERT(
@@ -239,6 +256,7 @@ applyBatchTransactions(
for (auto const& stx : batchTxn.getBatchTransactions())
{
auto const result = applyOneTransaction(*stx);
results.push_back({.id = stx->getTransactionID(), .ter = result.ter});
XRPL_ASSERT(
result.applied == (isTesSuccess(result.ter) || isTecClaim(result.ter)),
"Outer Batch failure, inner transaction should not be applied");
@@ -263,6 +281,46 @@ applyBatchTransactions(
return applied != 0;
}
/**
* Inserts the BatchResult record listing every inner the Batch attempted.
*
* It goes into the view the outer Batch applied to, after the batch view was
* committed or dropped, so it survives a rollback and takes the index after
* the last applied inner.
*/
static void
insertBatchResult(
OpenView& view,
UInt256 const& parentBatchId,
std::vector<BatchInnerResult> const& results)
{
XRPL_ASSERT(!view.open(), "xrpl::insertBatchResult : closed ledger view");
STArray entries(sfBatchResults, results.size());
for (auto const& result : results)
{
auto entry = STObject::makeInnerObject(sfBatchResult);
entry.setFieldH256(sfTransactionHash, result.id);
entry.setFieldI32(sfEngineResultCode, TERtoInt(result.ter));
entries.push_back(std::move(entry));
}
STTx const record(ttBATCH_RESULT, [&](STObject& obj) {
obj.setAccountID(sfAccount, AccountID());
obj.setFieldH256(sfParentBatchID, parentBatchId);
obj.setFieldArray(sfBatchResults, entries);
});
auto const txID = record.getTransactionID();
auto const sTx = std::make_shared<Serializer>();
record.add(*sTx);
auto const sMeta = std::make_shared<Serializer>();
TxMeta(txID, view.seq()).addRaw(*sMeta, tesSUCCESS, view.txCount());
view.rawTxInsert(txID, sTx, sMeta);
}
ApplyTransactionResult
applyTransaction(
ServiceRegistry& registry,
@@ -291,9 +349,13 @@ applyTransaction(
if (isTesSuccess(result.ter) && txn.getTxnType() == ttBATCH)
{
OpenView wholeBatchView(kBatchView, view);
std::vector<BatchInnerResult> innerResults;
if (applyBatchTransactions(registry, wholeBatchView, txn, j))
if (applyBatchTransactions(registry, wholeBatchView, txn, innerResults, j))
wholeBatchView.apply(view);
if (view.rules().enabled(featureBatchV2))
insertBatchResult(view, txn.getTransactionID(), innerResults);
}
return ApplyTransactionResult::Success;

View File

@@ -42,6 +42,13 @@ Transactor::invokePreflight<Change>(PreflightContext const& ctx)
preflight0(ctx, ctx.rules.enabled(featureLendingProtocol) ? tfEnableAmendmentMask : 0))
return ret;
// The ledger build inserts BatchResult directly; it is never applied as a transaction.
if (ctx.tx.getTxnType() == ttBATCH_RESULT)
{
JLOG(ctx.j.warn()) << "Change: BatchResult cannot be applied";
return temINVALID;
}
auto account = ctx.tx.getAccountID(sfAccount);
if (account != beast::kZero)
{

View File

@@ -68,9 +68,12 @@
#include <xrpl/tx/transactors/payment/Payment.h>
#include <xrpl/tx/transactors/system/Batch.h>
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <exception>
#include <functional>
#include <iterator>
#include <map>
#include <memory>
#include <optional>
@@ -97,12 +100,40 @@ class Batch_test : public beast::unit_test::Suite
std::string txHash;
};
static bool
isBatchResult(json::Value const& txn)
{
return txn[sfTransactionType.jsonName] == "BatchResult";
}
static std::uint32_t
txIndex(json::Value const& txn)
{
return txn[jss::metaData][sfTransactionIndex.jsonName].asUInt();
}
// Index counts only the ledger's non-BatchResult transactions, as in a ledger without BatchV2.
static json::Value
getTxByIndex(json::Value const& jrr, int const index)
{
std::vector<json::Value> txns;
for (auto const& txn : jrr[jss::result][jss::ledger][jss::transactions])
{
if (txn[jss::metaData][sfTransactionIndex.jsonName] == index)
if (!isBatchResult(txn))
txns.push_back(txn);
}
std::ranges::sort(txns, {}, &txIndex);
if (index < 0 || std::cmp_greater_equal(index, txns.size()))
return {};
return txns[index];
}
static json::Value
getBatchResult(json::Value const& jrr, std::string const& batchID)
{
for (auto const& txn : jrr[jss::result][jss::ledger][jss::transactions])
{
if (isBatchResult(txn) && txn[sfParentBatchID.jsonName] == batchID)
return txn;
}
return {};
@@ -118,12 +149,118 @@ class Batch_test : public beast::unit_test::Suite
return env.rpc("json", "ledger", to_string(params));
}
// Checks batchID's BatchResult against the Batch's mode and the inner leaves in the ledger.
void
validateBatchResult(jtx::Env& env, json::Value const& jrr, std::string const& batchID)
{
auto const record = getBatchResult(jrr, batchID);
if (!BEAST_EXPECT(record.isObject()))
return;
// The record follows the Batch's last leaf, changes nothing and has no account.
std::uint32_t last = 0;
std::size_t leaves = 0;
for (auto const& txn : jrr[jss::result][jss::ledger][jss::transactions])
{
bool const isLeaf = txn[jss::metaData][sfParentBatchID.jsonName] == batchID;
if (isLeaf)
++leaves;
if (isLeaf || txn[jss::hash] == batchID)
last = std::max(last, txIndex(txn));
}
BEAST_EXPECT(txIndex(record) == last + 1);
BEAST_EXPECT(record[jss::metaData][sfTransactionResult.jsonName] == "tesSUCCESS");
BEAST_EXPECT(record[jss::metaData][sfAffectedNodes.jsonName].size() == 0);
BEAST_EXPECT(record[sfAccount.jsonName] == toBase58(AccountID()));
UInt256 outerID;
UInt256 recordID;
if (!BEAST_EXPECT(
outerID.parseHex(batchID) && recordID.parseHex(record[jss::hash].asString())))
return;
auto const& ledger = *env.closed();
auto const outer = ledger.txRead(outerID).first;
auto const recordTx = ledger.txRead(recordID).first;
if (!BEAST_EXPECT(outer && recordTx))
return;
// Entries are the first inners in sfRawTransactions order.
auto const innerIDs = outer->getBatchTransactionIDs();
std::vector<TER> results;
for (auto const& entry : recordTx->getFieldArray(sfBatchResults))
{
if (!BEAST_EXPECT(results.size() < innerIDs.size()))
return;
BEAST_EXPECT(entry.getFieldH256(sfTransactionHash) == innerIDs[results.size()]);
results.push_back(TER::fromInt(entry.getFieldI32(sfEngineResultCode)));
}
// The Batch stops after the first non-tes under tfAllOrNothing and tfUntilFailure,
// after the first tes under tfOnlyOne, and never under tfIndependent.
auto const mode = outer->getFlags();
auto const attempted = [&](auto stopsOn) {
auto const stop = std::ranges::find_if(results, stopsOn);
return stop == results.end() ? innerIDs.size()
: std::ranges::distance(results.begin(), stop) + 1;
};
auto const isTes = [](TER ter) { return isTesSuccess(ter); };
auto expected = innerIDs.size();
if ((mode & (tfAllOrNothing | tfUntilFailure)) != 0u)
{
expected = attempted(std::not_fn(isTes));
}
else if ((mode & tfOnlyOne) != 0u)
{
expected = attempted(isTes);
}
BEAST_EXPECT(std::cmp_equal(results.size(), expected));
// An entry has a leaf iff it is tes or tec and the Batch was not rolled back.
bool const rolledBack =
(mode & tfAllOrNothing) != 0u && !std::ranges::all_of(results, isTes);
std::size_t applied = 0;
for (std::size_t i = 0; i < results.size(); ++i)
{
auto const [tx, meta] = ledger.txRead(innerIDs[i]);
bool const isApplied =
!rolledBack && (isTesSuccess(results[i]) || isTecClaim(results[i]));
if (!BEAST_EXPECT((tx != nullptr) == isApplied) || !isApplied)
continue;
++applied;
BEAST_EXPECT(meta->getFieldH256(sfParentBatchID) == outerID);
BEAST_EXPECT(
std::cmp_equal(meta->getFieldU8(sfTransactionResult), TERtoInt(results[i])));
}
BEAST_EXPECT(leaves == applied);
}
void
validateClosedLedger(jtx::Env& env, std::vector<TestLedgerData> const& ledgerResults)
{
auto const jrr = getLastLedger(env);
auto const transactions = jrr[jss::result][jss::ledger][jss::transactions];
BEAST_EXPECT(transactions.size() == ledgerResults.size());
auto const records = std::ranges::count_if(transactions, isBatchResult);
BEAST_EXPECT(transactions.size() - records == ledgerResults.size());
// Each applied Batch has one BatchResult under BatchV2 and none without it.
bool const recordsEnabled = env.closed()->rules().enabled(featureBatchV2);
std::size_t outers = 0;
for (TestLedgerData const& ledgerResult : ledgerResults)
{
if (ledgerResult.txType != "Batch" || ledgerResult.result != "tesSUCCESS")
continue;
++outers;
if (recordsEnabled)
{
validateBatchResult(env, jrr, ledgerResult.txHash);
}
else
{
BEAST_EXPECT(getBatchResult(jrr, ledgerResult.txHash).isNull());
}
}
BEAST_EXPECT(std::cmp_equal(records, recordsEnabled ? outers : 0));
for (TestLedgerData const& ledgerResult : ledgerResults)
{
auto const txn = getTxByIndex(jrr, ledgerResult.index);
@@ -5940,6 +6077,249 @@ class Batch_test : public beast::unit_test::Suite
}
}
void
testBatchResult(FeatureBitset features)
{
testcase("batch result");
using namespace test::jtx;
Env env{*this, features};
bool const enabled = features[featureBatchV2];
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const gw = Account("gw");
auto const usd = gw["USD"];
env.fund(XRP(10000), alice, bob, gw);
env.close();
// The closed ledger's BatchResult for batchID lists exactly these inners and results.
auto const expectRecord = [&](std::string const& batchID,
std::vector<std::pair<std::string, TER>> const& expected) {
auto const record = getBatchResult(getLastLedger(env), batchID);
if (!enabled)
{
BEAST_EXPECT(record.isNull());
return;
}
if (!BEAST_EXPECT(record.isObject()))
return;
auto const& entries = record[sfBatchResults.jsonName];
if (!BEAST_EXPECT(entries.size() == expected.size()))
return;
for (std::size_t i = 0; i < expected.size(); ++i)
{
auto const& entry = entries[i][sfBatchResult.jsonName];
BEAST_EXPECT(entry[sfTransactionHash.jsonName] == expected[i].first);
BEAST_EXPECT(
entry[sfEngineResultCode.jsonName].asInt() == TERtoInt(expected[i].second));
}
};
// tfAllOrNothing rolled back: both inners recorded, neither in the ledger.
{
auto const batchFee = batch::calcBatchFee(env, 0, 2);
auto const seq = env.seq(alice);
auto const [txIDs, batchID] = submitBatch(
env,
tesSUCCESS,
batch::outer(alice, seq, batchFee, tfAllOrNothing),
batch::Inner(pay(alice, bob, XRP(1)), seq + 1),
// tefNO_AUTH_REQUIRED: trustline auth is not required
batch::Inner(trust(alice, usd(1000), tfSetfAuth), seq + 2));
env.close();
validateClosedLedger(
env,
{{.index = 0,
.txType = "Batch",
.result = "tesSUCCESS",
.txHash = batchID,
.batchID = std::nullopt}});
expectRecord(batchID, {{txIDs[0], tesSUCCESS}, {txIDs[1], tefNO_AUTH_REQUIRED}});
// The record is retrievable by hash like any ledger transaction.
if (enabled)
{
auto const record = getBatchResult(getLastLedger(env), batchID);
auto const jrr = env.rpc("tx", record[jss::hash].asString());
BEAST_EXPECT(jrr[jss::result][sfTransactionType.jsonName] == "BatchResult");
BEAST_EXPECT(jrr[jss::result][sfParentBatchID.jsonName] == batchID);
}
}
// tfIndependent, all succeed: the record follows both inners.
{
auto const batchFee = batch::calcBatchFee(env, 0, 2);
auto const seq = env.seq(alice);
auto const [txIDs, batchID] = submitBatch(
env,
tesSUCCESS,
batch::outer(alice, seq, batchFee, tfIndependent),
batch::Inner(pay(alice, bob, XRP(1)), seq + 1),
batch::Inner(pay(alice, bob, XRP(2)), seq + 2));
env.close();
validateClosedLedger(
env,
{{.index = 0,
.txType = "Batch",
.result = "tesSUCCESS",
.txHash = batchID,
.batchID = std::nullopt},
{.index = 1,
.txType = "Payment",
.result = "tesSUCCESS",
.txHash = txIDs[0],
.batchID = batchID},
{.index = 2,
.txType = "Payment",
.result = "tesSUCCESS",
.txHash = txIDs[1],
.batchID = batchID}});
expectRecord(batchID, {{txIDs[0], tesSUCCESS}, {txIDs[1], tesSUCCESS}});
}
// tfIndependent with a tec inner: the tec is recorded and its leaf is in the ledger.
{
auto const batchFee = batch::calcBatchFee(env, 0, 2);
auto const seq = env.seq(alice);
auto const [txIDs, batchID] = submitBatch(
env,
tesSUCCESS,
batch::outer(alice, seq, batchFee, tfIndependent),
// tecUNFUNDED_PAYMENT: alice does not have enough XRP
batch::Inner(pay(alice, bob, XRP(9999)), seq + 1),
batch::Inner(pay(alice, bob, XRP(1)), seq + 2));
env.close();
validateClosedLedger(
env,
{{.index = 0,
.txType = "Batch",
.result = "tesSUCCESS",
.txHash = batchID,
.batchID = std::nullopt},
{.index = 1,
.txType = "Payment",
.result = "tecUNFUNDED_PAYMENT",
.txHash = txIDs[0],
.batchID = batchID},
{.index = 2,
.txType = "Payment",
.result = "tesSUCCESS",
.txHash = txIDs[1],
.batchID = batchID}});
expectRecord(batchID, {{txIDs[0], tecUNFUNDED_PAYMENT}, {txIDs[1], tesSUCCESS}});
}
// tfUntilFailure stops at the failing second inner; the third is never attempted.
{
auto const batchFee = batch::calcBatchFee(env, 0, 3);
auto const seq = env.seq(alice);
auto const [txIDs, batchID] = submitBatch(
env,
tesSUCCESS,
batch::outer(alice, seq, batchFee, tfUntilFailure),
batch::Inner(pay(alice, bob, XRP(1)), seq + 1),
// tefNO_AUTH_REQUIRED: trustline auth is not required
batch::Inner(trust(alice, usd(1000), tfSetfAuth), seq + 2),
batch::Inner(pay(alice, bob, XRP(2)), seq + 3));
env.close();
validateClosedLedger(
env,
{{.index = 0,
.txType = "Batch",
.result = "tesSUCCESS",
.txHash = batchID,
.batchID = std::nullopt},
{.index = 1,
.txType = "Payment",
.result = "tesSUCCESS",
.txHash = txIDs[0],
.batchID = batchID}});
expectRecord(batchID, {{txIDs[0], tesSUCCESS}, {txIDs[1], tefNO_AUTH_REQUIRED}});
}
// tfOnlyOne stops at the first success; the third is never attempted.
{
auto const batchFee = batch::calcBatchFee(env, 0, 3);
auto const seq = env.seq(alice);
auto const [txIDs, batchID] = submitBatch(
env,
tesSUCCESS,
batch::outer(alice, seq, batchFee, tfOnlyOne),
// tecUNFUNDED_PAYMENT: alice does not have enough XRP
batch::Inner(pay(alice, bob, XRP(9999)), seq + 1),
batch::Inner(pay(alice, bob, XRP(1)), seq + 2),
batch::Inner(pay(alice, bob, XRP(2)), seq + 3));
env.close();
validateClosedLedger(
env,
{{.index = 0,
.txType = "Batch",
.result = "tesSUCCESS",
.txHash = batchID,
.batchID = std::nullopt},
{.index = 1,
.txType = "Payment",
.result = "tecUNFUNDED_PAYMENT",
.txHash = txIDs[0],
.batchID = batchID},
{.index = 2,
.txType = "Payment",
.result = "tesSUCCESS",
.txHash = txIDs[1],
.batchID = batchID}});
expectRecord(batchID, {{txIDs[0], tecUNFUNDED_PAYMENT}, {txIDs[1], tesSUCCESS}});
}
// One result of each class an applied inner can return.
{
auto const batchFee = batch::calcBatchFee(env, 0, 4);
auto const seq = env.seq(alice);
auto const [txIDs, batchID] = submitBatch(
env,
tesSUCCESS,
batch::outer(alice, seq, batchFee, tfIndependent),
batch::Inner(pay(alice, bob, XRP(1)), seq + 1),
// tecUNFUNDED_PAYMENT: alice does not have enough XRP
batch::Inner(pay(alice, bob, XRP(9999)), seq + 2),
// temDST_IS_SRC: TrustSet::preclaim rejects a trust line to itself
batch::Inner(trust(alice, alice["USD"](100)), seq + 3),
// terPRE_SEQ: the account is at seq + 3
batch::Inner(pay(alice, bob, XRP(1)), seq + 5));
env.close();
validateClosedLedger(
env,
{{.index = 0,
.txType = "Batch",
.result = "tesSUCCESS",
.txHash = batchID,
.batchID = std::nullopt},
{.index = 1,
.txType = "Payment",
.result = "tesSUCCESS",
.txHash = txIDs[0],
.batchID = batchID},
{.index = 2,
.txType = "Payment",
.result = "tecUNFUNDED_PAYMENT",
.txHash = txIDs[1],
.batchID = batchID}});
expectRecord(
batchID,
{{txIDs[0], tesSUCCESS},
{txIDs[1], tecUNFUNDED_PAYMENT},
{txIDs[2], temDST_IS_SRC},
{txIDs[3], terPRE_SEQ}});
}
}
void
testWithFeats(FeatureBitset features)
{
@@ -5980,6 +6360,7 @@ class Batch_test : public beast::unit_test::Suite
testOuterBinding(features);
testUnsortedBatchSigners(features);
testBatchSigCache(features);
testBatchResult(features);
}
public:
@@ -5990,6 +6371,7 @@ public:
auto const sa = testableAmendments();
testWithFeats(sa);
testWithFeats(sa - featureBatchV2);
}
};

View File

@@ -37,6 +37,7 @@
#include <xrpl/protocol/RippleLedgerHash.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/server/Handoff.h>
#include <xrpl/shamap/SHAMapItem.h>
@@ -116,7 +117,37 @@ struct LedgerReplay_test : public beast::unit_test::Suite
batch::Inner(pay(alice, bob, XRP(2)), seq + 2),
Ter(tesSUCCESS));
env.close();
expectReplayMatches(env);
// A rolled-back tfAllOrNothing batch and a tfIndependent batch in one ledger.
auto const seq2 = env.seq(alice);
env(batch::outer(alice, seq2, batchFee, tfAllOrNothing),
batch::Inner(pay(alice, bob, XRP(1)), seq2 + 1),
// tecUNFUNDED_PAYMENT rolls the whole batch back
batch::Inner(pay(alice, bob, XRP(1000000)), seq2 + 2),
Ter(tesSUCCESS));
auto const seq3 = env.seq(alice);
env(batch::outer(alice, seq3, batchFee, tfIndependent),
batch::Inner(pay(alice, bob, XRP(1)), seq3 + 1),
batch::Inner(pay(alice, bob, XRP(2)), seq3 + 2),
Ter(tesSUCCESS));
env.close();
// Both batches left a BatchResult record, so the replay has to regenerate them.
int records = 0;
for (auto const& item : env.app().getLedgerMaster().getClosedLedger()->txs)
{
if (item.first->getTxnType() == ttBATCH_RESULT)
++records;
}
BEAST_EXPECT(records == 2);
expectReplayMatches(env);
}
// Replaying the last closed ledger from its parent reproduces its hash.
void
expectReplayMatches(jtx::Env& env)
{
LedgerMaster& ledgerMaster = env.app().getLedgerMaster();
auto const lastClosed = ledgerMaster.getClosedLedger();
auto const lastClosedParent = ledgerMaster.getLedgerByHash(lastClosed->header().parentHash);

View File

@@ -6,14 +6,18 @@
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STArray.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/tx/apply.h>
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
@@ -51,6 +55,12 @@ struct PseudoTx_test : public beast::unit_test::Suite
obj.setFieldU32(sfLedgerSequence, seq);
});
res.emplace_back(ttBATCH_RESULT, [&](auto& obj) {
obj.setAccountID(sfAccount, AccountID());
obj.setFieldH256(sfParentBatchID, UInt256(3));
obj.setFieldArray(sfBatchResults, STArray(sfBatchResults));
});
return res;
}
@@ -89,6 +99,26 @@ struct PseudoTx_test : public beast::unit_test::Suite
}
}
void
testBatchResultRejectedOnClosedView(FeatureBitset features)
{
using namespace jtx;
Env env(*this, features);
// A BatchResult placed in a proposed transaction set is applied to a closed view.
auto const pseudoTxs = getPseudoTxs(env.closed()->rules(), env.closed()->seq() + 1);
auto const record = std::ranges::find_if(
pseudoTxs, [](STTx const& stx) { return stx.getTxnType() == ttBATCH_RESULT; });
if (!BEAST_EXPECT(record != pseudoTxs.end()))
return;
OpenView closedView(&*env.closed());
BEAST_EXPECT(!closedView.open());
auto const result = xrpl::apply(env.app(), closedView, *record, TapNone, env.journal);
BEAST_EXPECT(!result.applied && result.ter == temINVALID);
BEAST_EXPECT(closedView.txCount() == 0);
}
void
testAllowed()
{
@@ -109,6 +139,8 @@ struct PseudoTx_test : public beast::unit_test::Suite
testPrevented(all - featureXRPFees);
testPrevented(all);
testBatchResultRejectedOnClosedView(all);
testBatchResultRejectedOnClosedView(all - featureBatchV2);
testAllowed();
}
};

View File

@@ -0,0 +1,162 @@
// Auto-generated unit tests for transaction BatchResult
#include <gtest/gtest.h>
#include <protocol_autogen/TestHelpers.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Seed.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol_autogen/transactions/BatchResult.h>
#include <xrpl/protocol_autogen/transactions/AccountSet.h>
#include <string>
namespace xrpl::transactions {
// 1 & 4) Set fields via builder setters, build, then read them back via
// wrapper getters. After build(), validate() should succeed.
TEST(TransactionsBatchResultTests, BuilderSettersRoundTrip)
{
// Generate a deterministic keypair for signing
auto const [publicKey, secretKey] =
generateKeyPair(KeyType::Secp256k1, generateSeed("testBatchResult"));
// Common transaction fields
auto const accountValue = calcAccountID(publicKey);
std::uint32_t const sequenceValue = 1;
auto const feeValue = canonical_AMOUNT();
// Transaction-specific field values
auto const parentBatchIDValue = canonical_UINT256();
auto const batchResultsValue = canonical_ARRAY();
BatchResultBuilder builder{
accountValue,
parentBatchIDValue,
batchResultsValue,
sequenceValue,
feeValue
};
// Set optional fields
auto tx = builder.build(publicKey, secretKey);
std::string reason;
EXPECT_TRUE(tx.validate(reason)) << reason;
// Verify signing was applied
EXPECT_FALSE(tx.getSigningPubKey().empty());
EXPECT_TRUE(tx.hasTxnSignature());
// Verify common fields
EXPECT_EQ(tx.getAccount(), accountValue);
EXPECT_EQ(tx.getSequence(), sequenceValue);
EXPECT_EQ(tx.getFee(), feeValue);
// Verify required fields
{
auto const& expected = parentBatchIDValue;
auto const actual = tx.getParentBatchID();
expectEqualField(expected, actual, "sfParentBatchID");
}
{
auto const& expected = batchResultsValue;
auto const actual = tx.getBatchResults();
expectEqualField(expected, actual, "sfBatchResults");
}
// Verify optional fields
}
// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
// and verify all fields match.
TEST(TransactionsBatchResultTests, BuilderFromStTxRoundTrip)
{
// Generate a deterministic keypair for signing
auto const [publicKey, secretKey] =
generateKeyPair(KeyType::Secp256k1, generateSeed("testBatchResultFromTx"));
// Common transaction fields
auto const accountValue = calcAccountID(publicKey);
std::uint32_t const sequenceValue = 2;
auto const feeValue = canonical_AMOUNT();
// Transaction-specific field values
auto const parentBatchIDValue = canonical_UINT256();
auto const batchResultsValue = canonical_ARRAY();
// Build an initial transaction
BatchResultBuilder initialBuilder{
accountValue,
parentBatchIDValue,
batchResultsValue,
sequenceValue,
feeValue
};
auto initialTx = initialBuilder.build(publicKey, secretKey);
// Create builder from existing STTx
BatchResultBuilder builderFromTx{initialTx.getSTTx()};
auto rebuiltTx = builderFromTx.build(publicKey, secretKey);
std::string reason;
EXPECT_TRUE(rebuiltTx.validate(reason)) << reason;
// Verify common fields
EXPECT_EQ(rebuiltTx.getAccount(), accountValue);
EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue);
EXPECT_EQ(rebuiltTx.getFee(), feeValue);
// Verify required fields
{
auto const& expected = parentBatchIDValue;
auto const actual = rebuiltTx.getParentBatchID();
expectEqualField(expected, actual, "sfParentBatchID");
}
{
auto const& expected = batchResultsValue;
auto const actual = rebuiltTx.getBatchResults();
expectEqualField(expected, actual, "sfBatchResults");
}
// Verify optional fields
}
// 3) Verify wrapper throws when constructed from wrong transaction type.
TEST(TransactionsBatchResultTests, WrapperThrowsOnWrongTxType)
{
// Build a valid transaction of a different type
auto const [pk, sk] =
generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType"));
auto const account = calcAccountID(pk);
AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()};
auto wrongTx = wrongBuilder.build(pk, sk);
EXPECT_THROW(BatchResult{wrongTx.getSTTx()}, std::runtime_error);
}
// 4) Verify builder throws when constructed from wrong transaction type.
TEST(TransactionsBatchResultTests, BuilderThrowsOnWrongTxType)
{
// Build a valid transaction of a different type
auto const [pk, sk] =
generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder"));
auto const account = calcAccountID(pk);
AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()};
auto wrongTx = wrongBuilder.build(pk, sk);
EXPECT_THROW(BatchResultBuilder{wrongTx.getSTTx()}, std::runtime_error);
}
}

View File

@@ -19,6 +19,7 @@
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SystemParameters.h> // IWYU pragma: keep
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/tx/apply.h>
#include <cstddef>
@@ -243,6 +244,9 @@ buildLedger(
// replay.
if (tx.second->isFlag(tfInnerBatchTxn))
continue;
// Replaying the outer Batch regenerates its BatchResult record.
if (tx.second->getTxnType() == ttBATCH_RESULT)
continue;
applyTransaction(app, accum, *tx.second, false, applyFlags, j);
}
});