Skip to content

Commit 45ee9af

Browse files
committed
fix: separate CoinJoin entry wire and semantic limits
Deserialize CoinJoin entry input and output vectors through the pool-wide 180-element wire cap while retaining the 9-element per-entry policy in AddEntry. Apply the shared pool cap to DSSIGNFINALTX and only consume an oversized entry's collateral when it was accepted into the current session.
1 parent d81ff3c commit 45ee9af

4 files changed

Lines changed: 48 additions & 82 deletions

File tree

src/coinjoin/coinjoin.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ bool CCoinJoinBroadcastTx::IsValidStructure() const
9595
if (tx->vin.size() < size_t(CoinJoin::GetMinPoolParticipants())) {
9696
return false;
9797
}
98-
if (tx->vin.size() > CoinJoin::GetMaxPoolParticipants() * COINJOIN_ENTRY_MAX_SIZE) {
98+
if (tx->vin.size() > CoinJoin::GetMaxPoolInputOutputCount()) {
9999
return false;
100100
}
101101
return std::ranges::all_of(tx->vout, [](const auto& txOut) {

src/coinjoin/coinjoin.h

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ static constexpr int COINJOIN_SIGNING_TIMEOUT = 15;
5050

5151
static constexpr size_t COINJOIN_ENTRY_MAX_SIZE = 9;
5252

53+
namespace CoinJoin {
54+
/// Get the minimum/maximum number of participants for the pool
55+
int GetMinPoolParticipants();
56+
int GetMaxPoolParticipants();
57+
58+
/// Maximum number of inputs or outputs across a full pool
59+
inline size_t GetMaxPoolInputOutputCount() { return size_t(GetMaxPoolParticipants()) * COINJOIN_ENTRY_MAX_SIZE; }
60+
} // namespace CoinJoin
61+
5362
// pool responses
5463
enum PoolMessage : int32_t {
5564
ERR_ALREADY_HAVE,
@@ -155,7 +164,6 @@ class CCoinJoinEntry
155164
CTransactionRef txCollateral;
156165
// memory only
157166
CService addr;
158-
bool fHasOversizedTxOut{false};
159167

160168
CCoinJoinEntry() :
161169
txCollateral(MakeTransactionRef(CMutableTransaction{}))
@@ -178,24 +186,15 @@ class CCoinJoinEntry
178186
template <typename Stream>
179187
void Unserialize(Stream& s)
180188
{
181-
fHasOversizedTxOut = false;
182-
183-
// Accept one input over the cap so AddEntry can apply the collateral
184-
// penalty for an entry that is merely too large; anything bigger is
185-
// rejected before any element is decoded.
186-
if (!UnserializeVectorWithMaxSize(s, vecTxDSIn, COINJOIN_ENTRY_MAX_SIZE + 1)) {
189+
const size_t max_count{CoinJoin::GetMaxPoolInputOutputCount()};
190+
if (!UnserializeVectorWithMaxSize(s, vecTxDSIn, max_count)) {
187191
throw std::ios_base::failure("CCoinJoinEntry::vecTxDSIn size too large");
188192
}
189193

190194
s >> txCollateral;
191195

192-
if (!UnserializeVectorWithMaxSize(s, vecTxOut, COINJOIN_ENTRY_MAX_SIZE)) {
193-
// Outputs follow txCollateral on the wire, so an output count over the
194-
// cap must not throw mid-entry: flag it and leave txCollateral intact
195-
// so AddEntry can reject the entry with ERR_MAXIMUM and consume its
196-
// collateral. A count above the generic MAX_SIZE cap is malformed and
197-
// still throws from ReadCompactSize.
198-
fHasOversizedTxOut = true;
196+
if (!UnserializeVectorWithMaxSize(s, vecTxOut, max_count)) {
197+
throw std::ios_base::failure("CCoinJoinEntry::vecTxOut size too large");
199198
}
200199
}
201200

@@ -407,10 +406,6 @@ namespace CoinJoin
407406
{
408407
bilingual_str GetMessageByID(PoolMessage nMessageID);
409408

410-
/// Get the minimum/maximum number of participants for the pool
411-
int GetMinPoolParticipants();
412-
int GetMaxPoolParticipants();
413-
414409
constexpr CAmount GetMaxPoolAmount() { return COINJOIN_ENTRY_MAX_SIZE * vecStandardDenominations.front(); }
415410

416411
/// If the collateral is valid given by a client

src/coinjoin/server.cpp

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ void CCoinJoinServer::ProcessDSSIGNFINALTX(CNode& peer, CDataStream& vRecv)
246246
}
247247
}
248248

249-
const size_t max_txins{size_t(CoinJoin::GetMaxPoolParticipants()) * COINJOIN_ENTRY_MAX_SIZE};
249+
const size_t max_txins{CoinJoin::GetMaxPoolInputOutputCount()};
250250
std::vector<CTxIn> vecTxIn;
251251
// Reject an over-cap count through this peer-local ERR_MAXIMUM path before a
252252
// single CTxIn is decoded or allocated. A count above the generic MAX_SIZE cap
@@ -610,11 +610,12 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag
610610
return false;
611611
}
612612

613-
if (entry.fHasOversizedTxOut || entry.vecTxOut.size() > COINJOIN_ENTRY_MAX_SIZE) {
614-
const size_t txout_size{entry.fHasOversizedTxOut ? COINJOIN_ENTRY_MAX_SIZE + 1 : entry.vecTxOut.size()};
615-
LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: too many outputs! %d/%d\n", __func__,
616-
static_cast<int>(txout_size), static_cast<int>(COINJOIN_ENTRY_MAX_SIZE));
613+
if (entry.vecTxDSIn.size() > COINJOIN_ENTRY_MAX_SIZE || entry.vecTxOut.size() > COINJOIN_ENTRY_MAX_SIZE) {
614+
LogPrint(BCLog::COINJOIN, /* Continued */
615+
"CCoinJoinServer::%s -- ERROR: too many inputs or outputs! inputs=%s/%s, outputs=%s/%s\n", __func__,
616+
entry.vecTxDSIn.size(), COINJOIN_ENTRY_MAX_SIZE, entry.vecTxOut.size(), COINJOIN_ENTRY_MAX_SIZE);
617617
nMessageIDRet = ERR_MAXIMUM;
618+
618619
CTransactionRef txCollateralToConsume;
619620
{
620621
LOCK(cs_coinjoin);
@@ -637,13 +638,6 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag
637638
return false;
638639
}
639640

640-
if (entry.vecTxDSIn.size() > COINJOIN_ENTRY_MAX_SIZE) {
641-
LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: too many inputs! %d/%d\n", __func__, entry.vecTxDSIn.size(), COINJOIN_ENTRY_MAX_SIZE);
642-
nMessageIDRet = ERR_MAXIMUM;
643-
ConsumeCollateral(entry.txCollateral);
644-
return false;
645-
}
646-
647641
std::vector<CTxIn> vin;
648642
for (const auto& txin : entry.vecTxDSIn) {
649643
LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- txin=%s\n", __func__, txin.ToString());

src/test/coinjoin_inouts_tests.cpp

Lines changed: 28 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ BOOST_AUTO_TEST_CASE(server_signfinaltx_nonparticipant_cannot_abort_session)
232232
auto nonparticipant = MakePeer(/*id=*/42, /*ipv4=*/0x01020304);
233233
BOOST_REQUIRE(!(nonparticipant->addr == participant->addr));
234234

235-
const size_t max_txins{size_t(CoinJoin::GetMaxPoolParticipants()) * COINJOIN_ENTRY_MAX_SIZE};
235+
const size_t max_txins{CoinJoin::GetMaxPoolInputOutputCount()};
236236
CDataStream stream{SER_NETWORK, PROTOCOL_VERSION};
237237
WriteCompactSize(stream, max_txins + 1);
238238

@@ -265,7 +265,7 @@ BOOST_AUTO_TEST_CASE(server_signfinaltx_participant_oversized_count_is_rejected_
265265
server.SeedParticipant(participant->addr);
266266
server.EnterSigningState();
267267

268-
const size_t max_txins{size_t(CoinJoin::GetMaxPoolParticipants()) * COINJOIN_ENTRY_MAX_SIZE};
268+
const size_t max_txins{CoinJoin::GetMaxPoolInputOutputCount()};
269269
CDataStream stream{SER_NETWORK, PROTOCOL_VERSION};
270270
WriteCompactSize(stream, max_txins + 1);
271271

@@ -287,76 +287,53 @@ BOOST_AUTO_TEST_CASE(server_signfinaltx_participant_oversized_count_is_rejected_
287287
BOOST_CHECK_EQUAL(server.GetEntriesCount(), 1);
288288
}
289289

290-
BOOST_AUTO_TEST_CASE(entry_allows_one_oversized_input_for_collateral_penalty)
290+
BOOST_AUTO_TEST_CASE(entry_deserializes_vectors_through_wire_cap)
291291
{
292-
CCoinJoinEntry oversized_entry;
293-
oversized_entry.vecTxDSIn.resize(COINJOIN_ENTRY_MAX_SIZE + 1);
294-
295-
CDataStream stream{SER_NETWORK, PROTOCOL_VERSION};
296-
stream << oversized_entry;
297-
298-
CCoinJoinEntry entry;
299-
BOOST_CHECK_NO_THROW(stream >> entry);
300-
BOOST_CHECK_EQUAL(entry.vecTxDSIn.size(), COINJOIN_ENTRY_MAX_SIZE + 1);
301-
BOOST_CHECK(entry.vecTxOut.empty());
292+
const size_t wire_cap{CoinJoin::GetMaxPoolInputOutputCount()};
293+
BOOST_REQUIRE_GT(wire_cap, COINJOIN_ENTRY_MAX_SIZE);
294+
295+
for (const size_t count : {size_t{0}, COINJOIN_ENTRY_MAX_SIZE, COINJOIN_ENTRY_MAX_SIZE + 1, wire_cap}) {
296+
BOOST_TEST_CONTEXT("count=" << count)
297+
{
298+
CCoinJoinEntry entry;
299+
entry.vecTxDSIn.resize(count);
300+
entry.vecTxOut.resize(count);
301+
302+
CDataStream stream{SER_NETWORK, PROTOCOL_VERSION};
303+
stream << entry;
304+
305+
CCoinJoinEntry roundtripped;
306+
BOOST_CHECK_NO_THROW(stream >> roundtripped);
307+
BOOST_CHECK_EQUAL(roundtripped.vecTxDSIn.size(), count);
308+
BOOST_CHECK_EQUAL(roundtripped.vecTxOut.size(), count);
309+
}
310+
}
302311
}
303312

304-
BOOST_AUTO_TEST_CASE(entry_rejects_huge_inputs_before_materializing)
313+
BOOST_AUTO_TEST_CASE(entry_rejects_inputs_above_wire_cap_before_materializing)
305314
{
315+
const size_t wire_cap{CoinJoin::GetMaxPoolInputOutputCount()};
306316
CDataStream stream{SER_NETWORK, PROTOCOL_VERSION};
307-
WriteCompactSize(stream, COINJOIN_ENTRY_MAX_SIZE + 2);
317+
WriteCompactSize(stream, wire_cap + 1);
308318

309319
CCoinJoinEntry entry;
310320
BOOST_CHECK_THROW(stream >> entry, std::ios_base::failure);
311321
BOOST_CHECK(entry.vecTxDSIn.empty());
312322
}
313323

314-
BOOST_AUTO_TEST_CASE(entry_flags_oversized_outputs_before_materializing)
324+
BOOST_AUTO_TEST_CASE(entry_rejects_outputs_above_wire_cap_before_materializing)
315325
{
326+
const size_t wire_cap{CoinJoin::GetMaxPoolInputOutputCount()};
316327
CDataStream stream{SER_NETWORK, PROTOCOL_VERSION};
317328
WriteCompactSize(stream, 0);
318329
stream << MakeTransactionRef(CMutableTransaction{});
319-
WriteCompactSize(stream, COINJOIN_ENTRY_MAX_SIZE + 1);
330+
WriteCompactSize(stream, wire_cap + 1);
320331

321-
CCoinJoinEntry entry;
322-
BOOST_CHECK_NO_THROW(stream >> entry);
323-
BOOST_CHECK(entry.fHasOversizedTxOut);
324-
BOOST_CHECK(entry.vecTxOut.empty());
325-
}
326-
327-
BOOST_AUTO_TEST_CASE(entry_throws_on_huge_outputs_before_materializing)
328-
{
329-
CDataStream stream{SER_NETWORK, PROTOCOL_VERSION};
330-
WriteCompactSize(stream, 0);
331-
stream << MakeTransactionRef(CMutableTransaction{});
332-
WriteCompactSize(stream, MAX_SIZE + 1);
333-
334-
// Above the generic CompactSize cap the message is simply malformed, so the
335-
// standard deserialization error is raised. No output is materialized, and the
336-
// half-read entry is not left looking like an oversized-but-chargeable one:
337-
// fHasOversizedTxOut stays clear, so it cannot reach AddEntry's collateral path.
338332
CCoinJoinEntry entry;
339333
BOOST_CHECK_THROW(stream >> entry, std::ios_base::failure);
340-
BOOST_CHECK(!entry.fHasOversizedTxOut);
341334
BOOST_CHECK(entry.vecTxOut.empty());
342335
}
343336

344-
BOOST_AUTO_TEST_CASE(entry_accepts_max_sized_vectors)
345-
{
346-
CCoinJoinEntry entry;
347-
entry.vecTxDSIn.resize(COINJOIN_ENTRY_MAX_SIZE); // exactly the cap must be accepted
348-
entry.vecTxOut.resize(COINJOIN_ENTRY_MAX_SIZE);
349-
350-
CDataStream stream{SER_NETWORK, PROTOCOL_VERSION};
351-
stream << entry;
352-
353-
CCoinJoinEntry roundtripped;
354-
BOOST_CHECK_NO_THROW(stream >> roundtripped);
355-
BOOST_CHECK_EQUAL(roundtripped.vecTxDSIn.size(), COINJOIN_ENTRY_MAX_SIZE);
356-
BOOST_CHECK_EQUAL(roundtripped.vecTxOut.size(), COINJOIN_ENTRY_MAX_SIZE);
357-
BOOST_CHECK(!roundtripped.fHasOversizedTxOut);
358-
}
359-
360337
BOOST_AUTO_TEST_CASE(queue_timeout_bounds)
361338
{
362339
CCoinJoinQueue dsq;

0 commit comments

Comments
 (0)