Compare commits

...

2 Commits

Author SHA1 Message Date
Bart
c2478f509c refactor: Open the acquisition seams its tests need (#8081)
Co-authored-by: Bart <11445373+bthomee@users.noreply.github.com>
2026-10-13 03:51:12 +00:00
Mayukha Vadari
c908ed7839 fix: Reject malformed conditions in EscrowFinish preflight (#8523) 2026-10-12 20:48:55 +00:00
9 changed files with 173 additions and 48 deletions

View File

@@ -95,20 +95,40 @@ EscrowFinish::preflightSigValidated(PreflightContext const& ctx)
auto& router = ctx.registry.get().getHashRouter();
auto const id = ctx.tx.getTransactionID();
auto const flags = router.getFlags(id);
auto flags = router.getFlags(id);
// If we haven't checked the condition, check it
// now. Whether it passes or not isn't important
// in preflight.
// now. Other than a malformed condition, whether
// it passes or not isn't important in preflight.
if (!any(flags & (kSfCfInvalid | kSfCfValid)))
{
if (checkCondition(*fb, *cb))
{
router.setFlags(id, kSfCfValid);
flags = kSfCfValid;
}
else
{
router.setFlags(id, kSfCfInvalid);
flags = kSfCfInvalid;
}
}
// The invalid flag also covers a bad fulfillment, so
// check the condition itself. This runs whether or not
// the flag was cached, so the result is the same on
// every node.
if (any(flags & kSfCfInvalid) && ctx.rules.enabled(fixCleanup3_5_0))
{
using namespace xrpl::cryptoconditions;
std::error_code ec;
auto const condition = Condition::deserialize(*cb, ec);
if (!condition)
{
JLOG(ctx.j.debug()) << "Malformed condition during escrow finish: " << ec.message();
return temMALFORMED;
}
}
}

View File

@@ -941,41 +941,43 @@ struct Escrow_test : public beast::unit_test::Suite
// Now, try to fulfill using the same sequence of
// malformed conditions.
TER const malformedCondTer =
features[fixCleanup3_5_0] ? TER{temMALFORMED} : TER{tecCRYPTOCONDITION_ERROR};
env(escrow::finish("bob", "alice", seq),
escrow::kCondition(Slice{cp, cs}),
escrow::kFulfillment(Slice{fp, fs}),
Fee(150 * baseFee),
Ter(tecCRYPTOCONDITION_ERROR));
Ter(malformedCondTer));
env(escrow::finish("bob", "alice", seq),
escrow::kCondition(Slice{cp, cs - 1}),
escrow::kFulfillment(Slice{fp, fs}),
Fee(150 * baseFee),
Ter(tecCRYPTOCONDITION_ERROR));
Ter(malformedCondTer));
env(escrow::finish("bob", "alice", seq),
escrow::kCondition(Slice{cp, cs - 2}),
escrow::kFulfillment(Slice{fp, fs}),
Fee(150 * baseFee),
Ter(tecCRYPTOCONDITION_ERROR));
Ter(malformedCondTer));
env(escrow::finish("bob", "alice", seq),
escrow::kCondition(Slice{cp + 1, cs - 1}),
escrow::kFulfillment(Slice{fp, fs}),
Fee(150 * baseFee),
Ter(tecCRYPTOCONDITION_ERROR));
Ter(malformedCondTer));
env(escrow::finish("bob", "alice", seq),
escrow::kCondition(Slice{cp + 1, cs - 3}),
escrow::kFulfillment(Slice{fp, fs}),
Fee(150 * baseFee),
Ter(tecCRYPTOCONDITION_ERROR));
Ter(malformedCondTer));
env(escrow::finish("bob", "alice", seq),
escrow::kCondition(Slice{cp + 2, cs - 2}),
escrow::kFulfillment(Slice{fp, fs}),
Fee(150 * baseFee),
Ter(tecCRYPTOCONDITION_ERROR));
Ter(malformedCondTer));
env(escrow::finish("bob", "alice", seq),
escrow::kCondition(Slice{cp + 2, cs - 3}),
escrow::kFulfillment(Slice{fp, fs}),
Fee(150 * baseFee),
Ter(tecCRYPTOCONDITION_ERROR));
Ter(malformedCondTer));
// Now, using the correct kCondition, try malformed fulfillments:
env(escrow::finish("bob", "alice", seq),
@@ -1043,11 +1045,13 @@ struct Escrow_test : public beast::unit_test::Suite
escrow::kCondition(escrow::kCb3),
escrow::kCancelTime(env.now() + 1s));
TER const emptyCondTer =
features[fixCleanup3_5_0] ? TER{temMALFORMED} : TER{tecCRYPTOCONDITION_ERROR};
env(escrow::finish("bob", "alice", seq),
escrow::kCondition(Slice{}),
escrow::kFulfillment(Slice{}),
Fee(150 * baseFee),
Ter(tecCRYPTOCONDITION_ERROR));
Ter(emptyCondTer));
env(escrow::finish("bob", "alice", seq),
escrow::kCondition(escrow::kCb3),
escrow::kFulfillment(Slice{}),
@@ -1057,7 +1061,7 @@ struct Escrow_test : public beast::unit_test::Suite
escrow::kCondition(Slice{}),
escrow::kFulfillment(escrow::kFb3),
Fee(150 * baseFee),
Ter(tecCRYPTOCONDITION_ERROR));
Ter(emptyCondTer));
// Assemble finish that is missing the Condition or the Fulfillment
// since either both must be present, or neither can:
@@ -1097,6 +1101,41 @@ struct Escrow_test : public beast::unit_test::Suite
escrow::kCancelTime(env.now() + 1s),
Ter(temMALFORMED));
}
{ // Test finish with a malformed condition
Env env(*this, features);
env.fund(XRP(5000), "alice", "bob", "carol");
env.close();
auto const seq = env.seq("alice");
env(escrow::create("alice", "carol", XRP(1000)),
escrow::kCondition(escrow::kCb1),
escrow::kCancelTime(env.now() + 100s));
env.close();
// Does not deserialize as a condition.
std::array<std::uint8_t, 3> const malformed = {{0x78, 0x78, 0x78}};
auto const baseFee = env.current()->fees().base;
auto const bobBalance = env.balance("bob");
bool const fixEnabled = features[fixCleanup3_5_0];
auto const jt = env.jt(
escrow::finish("bob", "alice", seq),
escrow::kCondition(malformed),
escrow::kFulfillment(escrow::kFb1),
Fee(150 * baseFee));
env(jt, Ter(fixEnabled ? TER{temMALFORMED} : TER{tecCRYPTOCONDITION_ERROR}));
// Run preflight on the same transaction again, now with the
// HashRouter flag cached. The result must not change.
auto const pf =
preflight(env.app(), env.current()->rules(), *jt.stx, TapNone, env.journal);
BEAST_EXPECT(pf.ter == (fixEnabled ? TER{temMALFORMED} : TER{tesSUCCESS}));
env.close();
// Before the fix, the transaction is included and charges a fee.
env.require(
Balance("bob", fixEnabled ? bobBalance : bobBalance - drops(150 * baseFee)));
BEAST_EXPECT(env.le(keylet::escrow(Account("alice").id(), SeqProxy::rawSequence(seq))));
}
}
void
@@ -1642,6 +1681,7 @@ public:
FeatureBitset const all{testableAmendments()};
testWithFeats(all);
testWithFeats(all - featureTokenEscrow);
testWithFeats(all - fixCleanup3_5_0);
testTags(all - fixIncludeKeyletFields);
}
};

View File

@@ -42,7 +42,7 @@ ConsensusTransSetSF::gotNode(
nodeCache_.insert(nodeHash, nodeData);
if ((type == SHAMapNodeType::TnTransactionNm) && (nodeData.size() > 16))
if ((type == SHAMapNodeType::TnTransactionNm) && (nodeData.size() >= kMinTxNodeBytesToParse))
{
// this is a transaction, and we didn't have it
JLOG(j_.debug()) << "Node on our acquiring TX set is TXN we may not have";

View File

@@ -9,6 +9,7 @@
#include <xrpl/shamap/SHAMapSyncFilter.h>
#include <xrpl/shamap/SHAMapTreeNode.h>
#include <cstddef>
#include <cstdint>
#include <optional>
@@ -24,6 +25,15 @@ class ConsensusTransSetSF : public SHAMapSyncFilter
public:
using NodeCache = TaggedCache<SHAMapHash, Blob>;
/**
* The size a node's hash-prefixed wire data must reach before gotNode()
* tries to parse and resubmit it as a transaction. One byte past the
* smallest a hash-prefixed SHAMap leaf can be, which a signed transaction
* clears.
*/
static constexpr std::size_t kMinTxNodeBytesToParse =
sizeof(std::uint32_t) + kMinShaMapItemBytes + 1;
ConsensusTransSetSF(Application& app, NodeCache& nodeCache);
// Note that the nodeData is overwritten by this call

View File

@@ -30,9 +30,9 @@
namespace xrpl {
// A ledger we are trying to acquire
class InboundLedger final : public TimeoutCounter,
public std::enable_shared_from_this<InboundLedger>,
public CountedObject<InboundLedger>
class InboundLedger : public TimeoutCounter,
public std::enable_shared_from_this<InboundLedger>,
public CountedObject<InboundLedger>
{
public:
using ClockType = beast::AbstractClock<std::chrono::steady_clock>;
@@ -44,13 +44,29 @@ public:
CONSENSUS, // We believe the consensus round requires this ledger
};
/**
* How long to wait between retries, and so how long one timeout takes.
*/
static constexpr std::chrono::milliseconds kRetryInterval{3000};
/**
* @param app The application to run in.
* @param hash The ledger to acquire.
* @param seq Its sequence, or zero if not known yet.
* @param reason Why it is being acquired.
* @param clock The clock touch() records against.
* @param peerSet Which peers to ask, and how to reach them.
* @param retryInterval How long to wait between retries. TimeoutCounter
* requires more than 10ms and less than 30s.
*/
InboundLedger(
Application& app,
UInt256 const& hash,
std::uint32_t seq,
Reason reason,
ClockType&,
std::unique_ptr<PeerSet> peerSet);
ClockType& clock,
std::unique_ptr<PeerSet> peerSet,
std::chrono::milliseconds retryInterval = kRetryInterval);
~InboundLedger() override;
@@ -119,15 +135,33 @@ public:
return lastAction_;
}
private:
protected:
// Protected so a test subclass can drive an acquisition through trigger() and done().
// Why trigger() is being run, which decides how deep a request goes and whether an
// aggressive retry applies.
enum class TriggerReason { Added, Reply, Timeout };
/**
* Ask for more nodes, or judge what has been collected.
*
* @param peer The peer to ask, or nullptr to ask everyone being tracked.
* @param reason Why the acquisition is being triggered.
*/
void
trigger(std::shared_ptr<Peer> const& peer, TriggerReason reason);
/**
* Settle the acquisition and signal whatever is waiting on it. Runs at most
* once. Callers hold mtx_, except trigger(), which releases it first.
*/
void
done();
private:
void
filterNodes(std::vector<std::pair<SHAMapNodeID, UInt256>>& nodes, TriggerReason reason);
void
trigger(std::shared_ptr<Peer> const&, TriggerReason);
std::vector<NeededHashT>
getNeededHashes();
@@ -138,10 +172,7 @@ private:
tryDB(node_store::Database& srcDB);
void
done();
void
onTimer(bool progress, ScopedLockType& peerSetLock) override;
onTimer(bool progress, ScopedLockType& sl) override;
std::size_t
getPeerCount() const;

View File

@@ -55,8 +55,6 @@
namespace xrpl {
using namespace std::chrono_literals;
static constexpr auto kPeerCountStart = 5; // Number of peers to start with
static constexpr auto kPeerCountAdd = 3; // Number of peers to add on a timeout
static constexpr auto kLedgerTimeoutRetriesMax = 6; // how many timeouts before we give up
@@ -66,20 +64,18 @@ static constexpr auto kMissingNodesFind = 256; // Number of nodes to find initi
static constexpr auto kReqNodesReply = 128; // Number of nodes to request for a reply
static constexpr auto kReqNodes = 12; // Number of nodes to request blindly
// millisecond for each ledger timeout
constexpr auto kLedgerAcquireTimeout = 3000ms;
InboundLedger::InboundLedger(
Application& app,
UInt256 const& hash,
std::uint32_t seq,
Reason reason,
ClockType& clock,
std::unique_ptr<PeerSet> peerSet)
std::unique_ptr<PeerSet> peerSet,
std::chrono::milliseconds retryInterval)
: TimeoutCounter(
app,
hash,
kLedgerAcquireTimeout,
retryInterval,
{.jobType = JtLedgerData, .jobName = "InboundLedger", .jobLimit = 5},
app.getJournal("InboundLedger"))
, clock_(clock)

View File

@@ -98,9 +98,14 @@ protected:
/**
* Hook called from invokeOnTimer().
*
* @param progress Whether the subtype recorded progress since the
* last call.
* @param sl Proof mtx_ is held. mtx_ is recursive, so a nested lock taken
* inside the call does not release it.
*/
virtual void
onTimer(bool progress, ScopedLockType&) = 0;
onTimer(bool progress, ScopedLockType& sl) = 0;
/**
* Return a weak pointer to this.

View File

@@ -18,6 +18,7 @@
#include <xrpl.pb.h>
#include <algorithm>
#include <chrono>
#include <cstddef>
#include <exception>
#include <memory>
@@ -26,22 +27,18 @@
namespace xrpl {
using namespace std::chrono_literals;
// Timeout interval in milliseconds
constexpr auto kTxAcquireTimeout = 250ms;
static constexpr auto kNormTimeouts = 4;
static constexpr auto kMaxTimeouts = 20;
TransactionAcquire::TransactionAcquire(
Application& app,
UInt256 const& hash,
std::unique_ptr<PeerSet> peerSet)
std::unique_ptr<PeerSet> peerSet,
std::chrono::milliseconds retryInterval)
: TimeoutCounter(
app,
hash,
kTxAcquireTimeout,
retryInterval,
{.jobType = JtTxnData, .jobName = "TxAcq", .jobLimit = {}},
app.getJournal("TransactionAcquire"))
, peerSet_(std::move(peerSet))
@@ -79,7 +76,7 @@ TransactionAcquire::done()
}
void
TransactionAcquire::onTimer(bool progress, ScopedLockType& psl)
TransactionAcquire::onTimer(bool progress, ScopedLockType&)
{
if (timeouts_ > kMaxTimeouts)
{

View File

@@ -12,6 +12,7 @@
#include <xrpl/shamap/SHAMapNodeID.h>
#include <xrpl/shamap/SHAMapTreeNode.h>
#include <chrono>
#include <cstddef>
#include <memory>
#include <utility>
@@ -21,14 +22,30 @@ namespace xrpl {
// VFALCO TODO rename to PeerTxRequest
// A transaction set we are trying to acquire
class TransactionAcquire final : public TimeoutCounter,
public std::enable_shared_from_this<TransactionAcquire>,
public CountedObject<TransactionAcquire>
class TransactionAcquire : public TimeoutCounter,
public std::enable_shared_from_this<TransactionAcquire>,
public CountedObject<TransactionAcquire>
{
public:
using pointer = std::shared_ptr<TransactionAcquire>;
TransactionAcquire(Application& app, UInt256 const& hash, std::unique_ptr<PeerSet> peerSet);
/**
* How long to wait between retries, and so how long one timeout takes.
*/
static constexpr std::chrono::milliseconds kRetryInterval{250};
/**
* @param app The application to run in.
* @param hash The set to acquire.
* @param peerSet Which peers to ask, and how to reach them.
* @param retryInterval How long to wait between retries. TimeoutCounter
* requires more than 10ms and less than 30s.
*/
TransactionAcquire(
Application& app,
UInt256 const& hash,
std::unique_ptr<PeerSet> peerSet,
std::chrono::milliseconds retryInterval = kRetryInterval);
~TransactionAcquire() override = default;
SHAMapAddNode
@@ -42,14 +59,23 @@ public:
void
stillNeed();
private:
protected:
// Kept protected so a test subclass can read the map's state.
// Production callers reach a set through InboundTransactions.
std::shared_ptr<SHAMap> map_;
private:
bool haveRoot_{false};
std::unique_ptr<PeerSet> peerSet_;
void
onTimer(bool progress, ScopedLockType& peerSetLock) override;
onTimer(bool progress, ScopedLockType& sl) override;
/**
* Settle the acquired set and hand it on, or report the failure. Call under
* mtx_. Runs once per outcome, and a stillNeed() revival gives one object a
* second outcome.
*/
void
done();