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
31 changes: 27 additions & 4 deletions src/llmq/quorums_instantsend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,11 @@ void CInstantSendManager::ProcessMessage(CNode* pfrom, const std::string& strCom
if (strCommand == NetMsgType::ISLOCK) {
CInstantSendLock islock;
vRecv >> islock;
if (islock.inputs.size() > CInstantSendLock::MAX_INPUTS) {
LOCK(cs_main);
Misbehaving(pfrom->id, 100);
return;
Comment on lines +707 to +710

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enforce the input limit during deserialization

When an unauthenticated peer sends an ISLOCK whose CompactSize input count exceeds MAX_INPUTS, vRecv >> islock has already allocated and parsed the entire vector before this check runs. The generic vector deserializer accepts counts up to MAX_SIZE, so a maximum-sized network message can force allocation and parsing of hundreds of thousands of COutPoints before the peer is banned, leaving the intended intake bound ineffective; read and validate the count before resizing the vector.

AGENTS.md reference: AGENTS.md:L238-L245

Useful? React with 👍 / 👎.

}
Comment on lines +707 to +711

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The limit is checked only after deserialization, so a peer can still make the parser allocate and process a network-sized input vector before rejection. [resource leak]

Assessment: 🟠 Major · 🔁 Occurrence: Sometimes

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/llmq/quorums_instantsend.cpp
**Line:** 707:711
**Comment:**
	*Resource Leak: The limit is checked only after deserialization, so a peer can still make the parser allocate and process a network-sized input vector before rejection.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

ProcessMessageInstantSendLock(pfrom, islock, connman);
}
}
Expand All @@ -728,6 +733,11 @@ void CInstantSendManager::ProcessMessageInstantSendLock(CNode* pfrom, const llmq
if (pendingInstantSendLocks.count(hash)) {
return;
}
if (pendingInstantSendLocks.size() >= MAX_PENDING_INSTANTSEND_LOCKS) {
LogPrint("instantsend", "CInstantSendManager::%s -- pending islock queue full (%d), dropping islock=%s, peer=%d\n",
__func__, pendingInstantSendLocks.size(), hash.ToString(), pfrom->id);
return;
Comment on lines +736 to +739

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reserve pending capacity across peers

A connected peer can fill this global first-come queue with distinct one-input locks because PreVerifyInstantSendLock does not authenticate the BLS signature; once it reaches 1024, valid locks from every other peer are dropped before verification. By continuously submitting BLS-valid but quorum-invalid signatures faster than the 32-item worker batches, an attacker can keep the queue full and suppress InstantSend lock intake, so the bound needs per-peer admission limits, fair eviction, or reserved capacity rather than an unconditional global drop.

AGENTS.md reference: AGENTS.md:L238-L245

Useful? React with 👍 / 👎.

}

LogPrint("instantsend", "CInstantSendManager::%s -- txid=%s, islock=%s: received islock, peer=%d\n", __func__,
islock.txid.ToString(), hash.ToString(), pfrom->id);
Expand All @@ -739,7 +749,7 @@ bool CInstantSendManager::PreVerifyInstantSendLock(NodeId nodeId, const llmq::CI
{
retBan = false;

if (islock.txid.IsNull() || islock.inputs.empty()) {
if (islock.txid.IsNull() || islock.inputs.empty() || islock.inputs.size() > CInstantSendLock::MAX_INPUTS) {
retBan = true;
return false;
}
Expand All @@ -761,7 +771,18 @@ bool CInstantSendManager::ProcessPendingInstantSendLocks()

{
LOCK(cs);
pend = std::move(pendingInstantSendLocks);
// Only process 32 locks at a time to avoid duplicate verification of recovered signatures which have been
// verified by CSigningManager in parallel.
const size_t maxCount = 32;
Comment on lines +774 to +776

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid scoring the same peer once per 32-lock batch

When one peer has more than 128 locks that fail verification because they are old enough not to match either active quorum set, this cap splits them across at least five invocations. The existing verification path applies Misbehaving(nodeId, 20) once per bad source per invocation, so the default score reaches 100 and disconnects the peer; before this change the entire pending set produced only one 20-point penalty. This contradicts the nearby intent to be lenient toward peers relaying old locks, so penalties need to be deduplicated across a queue drain or entries need to be batched by source.

AGENTS.md reference: AGENTS.md:L238-L245

Useful? React with 👍 / 👎.

if (pendingInstantSendLocks.size() <= maxCount) {
pend = std::move(pendingInstantSendLocks);
} else {
while (pend.size() < maxCount) {
auto it = pendingInstantSendLocks.begin();
pend.emplace(it->first, std::move(it->second));
pendingInstantSendLocks.erase(it);
}
Comment on lines +780 to +784

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: Extracted locks are lost when InstantSend is disabled or quorum selection fails, because this partial drain never reinserts pend into pendingInstantSendLocks. [incomplete implementation]

Assessment: 🟠 Major · 🔁 Occurrence: Sometimes

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/llmq/quorums_instantsend.cpp
**Line:** 780:784
**Comment:**
	*Incomplete Implementation: Extracted locks are lost when InstantSend is disabled or quorum selection fails, because this partial drain never reinserts `pend` into `pendingInstantSendLocks`.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}
}

if (pend.empty()) {
Expand Down Expand Up @@ -834,8 +855,10 @@ std::unordered_set<uint256> CInstantSendManager::ProcessPendingInstantSendLocks(

auto id = islock.GetRequestId();

// no need to verify an ISLOCK if we already have verified the recovered sig that belongs to it
if (quorumSigningManager->HasRecoveredSig(llmqType, id, islock.txid)) {
// no need to verify an ISLOCK if we already have verified the exact recovered sig that belongs to it
CRecoveredSig recoveredSig;
if (quorumSigningManager->GetRecoveredSigForId(llmqType, id, recoveredSig) &&
recoveredSig.msgHash == islock.txid && recoveredSig.sig == islock.sig) {
continue;
}

Expand Down
12 changes: 11 additions & 1 deletion src/llmq/quorums_instantsend.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,24 @@
#include "quorums_signing.h"

#include "coins.h"
#include "unordered_lru_cache.h"
#include "consensus/consensus.h"
#include "primitives/transaction.h"
#include "unordered_lru_cache.h"

#include <unordered_map>
#include <unordered_set>

namespace llmq
{

struct CInstantSendManagerTestAccess;

class CInstantSendLock
{
public:
// A valid transaction must fit in a block, and each input serializes to at least 41 bytes.
static constexpr size_t MAX_INPUTS{MAX_BLOCK_BASE_SIZE / 41};

std::vector<COutPoint> inputs;
uint256 txid;
CBLSLazySignature sig;
Expand Down Expand Up @@ -74,7 +80,11 @@ class CInstantSendDb

class CInstantSendManager : public CRecoveredSigsListener
{
friend struct CInstantSendManagerTestAccess;

private:
static constexpr size_t MAX_PENDING_INSTANTSEND_LOCKS{1024};

CCriticalSection cs;
CInstantSendDb db;

Expand Down
1 change: 1 addition & 0 deletions src/test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ add_executable(test_firo
${CMAKE_CURRENT_SOURCE_DIR}/evospork_tests.cpp
${CMAKE_CURRENT_SOURCE_DIR}/evo_deterministicmns_tests.cpp
${CMAKE_CURRENT_SOURCE_DIR}/evo_simplifiedmns_tests.cpp
${CMAKE_CURRENT_SOURCE_DIR}/quorums_instantsend_tests.cpp
${CMAKE_CURRENT_SOURCE_DIR}/progpow_tests.cpp
${CMAKE_CURRENT_SOURCE_DIR}/bls_tests.cpp
${CMAKE_CURRENT_SOURCE_DIR}/sparkmessage_tests.cpp
Expand Down
190 changes: 190 additions & 0 deletions src/test/quorums_instantsend_tests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Copyright (c) 2026 The Firo developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.

#include "dbwrapper.h"
#include "llmq/quorums_instantsend.h"
#include "net.h"
#include "test/test_bitcoin.h"
#include "validation.h"

#include <boost/test/unit_test.hpp>

namespace llmq
{

struct CInstantSendManagerTestAccess {
static bool PreVerify(CInstantSendManager& manager, const CInstantSendLock& islock, bool& ban)
{
return manager.PreVerifyInstantSendLock(1, islock, ban);
}

static void ProcessMessage(CInstantSendManager& manager, CNode& peer, const CInstantSendLock& islock, CConnman& connman)
{
manager.ProcessMessageInstantSendLock(&peer, islock, connman);
}

static size_t PendingCount(CInstantSendManager& manager)
{
LOCK(manager.cs);
return manager.pendingInstantSendLocks.size();
}

static void ProcessPending(CInstantSendManager& manager)
{
manager.ProcessPendingInstantSendLocks();
}

static void ProcessPending(CInstantSendManager& manager, NodeId nodeId, const CInstantSendLock& islock)
{
std::unordered_map<uint256, std::pair<NodeId, CInstantSendLock>, StaticSaltedHasher> pending;
pending.emplace(::SerializeHash(islock), std::make_pair(nodeId, islock));
manager.ProcessPendingInstantSendLocks(0, pending, false);
}
};

} // namespace llmq

namespace
{

uint256 HashFromNonce(size_t nonce)
{
return uint256S(strprintf("%064x", static_cast<unsigned int>(nonce)));
}

llmq::CInstantSendLock MakeInstantSendLock(size_t nonce)
{
llmq::CInstantSendLock islock;
islock.txid = HashFromNonce(nonce + 1);
islock.inputs.emplace_back(HashFromNonce(1), static_cast<uint32_t>(nonce));
return islock;
}

struct InstantSendSetup : BasicTestingSetup {
CDBWrapper db;
llmq::CSigningManager signingManager;
llmq::CInstantSendManager manager;
CNode peer;
llmq::CSigningManager* previousSigningManager;
bool previousTxIndex;

InstantSendSetup() : db(boost::filesystem::temp_directory_path() / boost::filesystem::unique_path(), 1 << 20, true, false),
signingManager(db, true),
manager(db),
peer(1, NODE_NETWORK, 0, INVALID_SOCKET, CAddress(), 0, 0, "", true),
previousSigningManager(llmq::quorumSigningManager),
previousTxIndex(fTxIndex)
{
llmq::quorumSigningManager = &signingManager;
fTxIndex = false;
g_connman = std::make_unique<CConnman>(0x1337, 0x1337);
}

~InstantSendSetup()
{
llmq::quorumSigningManager = previousSigningManager;
fTxIndex = previousTxIndex;
}
};

} // namespace

BOOST_FIXTURE_TEST_SUITE(quorums_instantsend_tests, InstantSendSetup)

BOOST_AUTO_TEST_CASE(input_limit_is_protocol_derived)
{
llmq::CInstantSendLock islock;
islock.txid = HashFromNonce(1);
islock.inputs.reserve(llmq::CInstantSendLock::MAX_INPUTS + 1);

BOOST_CHECK_EQUAL(llmq::CInstantSendLock::MAX_INPUTS, MAX_BLOCK_BASE_SIZE / 41);
for (size_t i = 0; i < llmq::CInstantSendLock::MAX_INPUTS - 1; ++i) {
islock.inputs.emplace_back(HashFromNonce(i + 1), 0);
}

bool ban = false;
BOOST_CHECK(llmq::CInstantSendManagerTestAccess::PreVerify(manager, islock, ban));
BOOST_CHECK(!ban);

islock.inputs.emplace_back(HashFromNonce(llmq::CInstantSendLock::MAX_INPUTS), 0);
BOOST_CHECK(llmq::CInstantSendManagerTestAccess::PreVerify(manager, islock, ban));
BOOST_CHECK(!ban);

islock.inputs.emplace_back(HashFromNonce(llmq::CInstantSendLock::MAX_INPUTS + 1), 0);
BOOST_CHECK(!llmq::CInstantSendManagerTestAccess::PreVerify(manager, islock, ban));
BOOST_CHECK(ban);
}

BOOST_AUTO_TEST_CASE(pending_queue_and_processing_are_bounded)
{
constexpr size_t maxPending{1024};
constexpr size_t maxPerPass{32};

for (size_t i = 0; i < maxPending - 1; ++i) {
const auto islock = MakeInstantSendLock(i);
llmq::CInstantSendManagerTestAccess::ProcessMessage(manager, peer, islock, *g_connman);
}
BOOST_CHECK_EQUAL(llmq::CInstantSendManagerTestAccess::PendingCount(manager), maxPending - 1);

auto islock = MakeInstantSendLock(maxPending - 1);
llmq::CInstantSendManagerTestAccess::ProcessMessage(manager, peer, islock, *g_connman);
BOOST_CHECK_EQUAL(llmq::CInstantSendManagerTestAccess::PendingCount(manager), maxPending);

islock = MakeInstantSendLock(maxPending);
llmq::CInstantSendManagerTestAccess::ProcessMessage(manager, peer, islock, *g_connman);
BOOST_CHECK_EQUAL(llmq::CInstantSendManagerTestAccess::PendingCount(manager), maxPending);

llmq::CInstantSendManagerTestAccess::ProcessPending(manager);
BOOST_CHECK_EQUAL(llmq::CInstantSendManagerTestAccess::PendingCount(manager), maxPending - maxPerPass);

for (size_t i = maxPending; i < maxPending + maxPerPass; ++i) {
islock = MakeInstantSendLock(i);
llmq::CInstantSendManagerTestAccess::ProcessMessage(manager, peer, islock, *g_connman);
}
BOOST_CHECK_EQUAL(llmq::CInstantSendManagerTestAccess::PendingCount(manager), maxPending);

islock = MakeInstantSendLock(maxPending + maxPerPass);
llmq::CInstantSendManagerTestAccess::ProcessMessage(manager, peer, islock, *g_connman);
BOOST_CHECK_EQUAL(llmq::CInstantSendManagerTestAccess::PendingCount(manager), maxPending);
}

BOOST_AUTO_TEST_CASE(recovered_signature_shortcut_requires_exact_signature)
{
auto islock = MakeInstantSendLock(1);
CBLSSecretKey recoveredKey;
recoveredKey.MakeNewKey();
islock.sig.Set(recoveredKey.Sign(HashFromNonce(100)));

const auto llmqType = Params().GetConsensus().llmqForInstantSend;
llmq::CRecoveredSig recoveredSig;
recoveredSig.llmqType = llmqType;
recoveredSig.quorumHash = HashFromNonce(101);
recoveredSig.id = islock.GetRequestId();
recoveredSig.msgHash = islock.txid;
recoveredSig.sig = islock.sig;
recoveredSig.UpdateHash();

llmq::CRecoveredSigsDb recoveredSigsDb(db);
recoveredSigsDb.WriteRecoveredSig(recoveredSig);

const auto islockHash = ::SerializeHash(islock);
llmq::CInstantSendManagerTestAccess::ProcessPending(manager, peer.GetId(), islock);
BOOST_CHECK_EQUAL(manager.GetInstantSendLockCount(), 1);

llmq::CInstantSendLock storedLock;
BOOST_CHECK(db.Read(std::make_tuple(std::string("is_i"), islockHash), storedLock));

auto alteredIslock = islock;
CBLSSecretKey alteredKey;
alteredKey.MakeNewKey();
alteredIslock.sig.Set(alteredKey.Sign(HashFromNonce(102)));
const auto alteredIslockHash = ::SerializeHash(alteredIslock);
BOOST_REQUIRE(islockHash != alteredIslockHash);

llmq::CInstantSendManagerTestAccess::ProcessPending(manager, peer.GetId(), alteredIslock);
BOOST_CHECK_EQUAL(manager.GetInstantSendLockCount(), 1);
BOOST_CHECK(!db.Read(std::make_tuple(std::string("is_i"), alteredIslockHash), storedLock));
}

BOOST_AUTO_TEST_SUITE_END()
Loading