Skip to content

Commit a59ad9e

Browse files
Merge #7347: fix: punish invalid dstx messages
4329a4f refactor: use named result type and score enum for ValidateDSTX (PastaClaw) 7d073b9 fix: penalize unknown-masternode dstx relays with a small score (PastaClaw) 9c68646 fix: avoid punishing unverifiable dstx relays (PastaClaw) c25cf3d fix: punish invalid dstx messages (PastaClaw) Pull request description: # fix: punish invalid dstx messages ## Issue being fixed or feature implemented Invalid CoinJoin broadcast transaction (`dstx`) messages can return early from validation without applying the same peer-accounting consequence used by other invalid P2P messages. This hardens DSTX handling by making invalid validation results contribute a low misbehavior score while preserving benign early returns for duplicate or otherwise non-error DSTX cases. ## What was done? - Apply a low misbehavior score when DSTX validation returns an invalid/error result. - Preserve existing behavior for successful validation and benign early-return cases. - Add P2P test framework serialization for `dstx` messages. - Add a focused functional test covering invalid DSTX peer accounting. ## How Has This Been Tested? Tested on macOS arm64. - `git diff --check` - `python3 -m py_compile test/functional/p2p_dstx.py test/functional/test_framework/messages.py` - Configured with depends `config.site`, `--without-gui`, `--disable-bench`, and `--disable-fuzz-binary` - `make -j8 src/dashd` - `test/functional/p2p_dstx.py --tmpdir=/tmp/dash_func_dstx` - `test/functional/p2p_dstx.py --tmpdir=/tmp/dash_func_dstx_2` ## Breaking Changes None. ## Checklist - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have added or updated relevant unit/integration/functional/e2e tests - [ ] I have made corresponding changes to the documentation - [ ] I have assigned this pull request to a milestone ACKs for top commit: UdjinM6: utACK 4329a4f Tree-SHA512: 79ac5b9bf9d359d68abf9b8a890fd09719c642635f199568efe4cb61c9979aa8cf89df48e6229c5c807b12b4ae127be701aef95ec04f4518fcabcdde305ce9b0
2 parents 20bc677 + 4329a4f commit a59ad9e

4 files changed

Lines changed: 195 additions & 14 deletions

File tree

src/net_processing.cpp

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3490,18 +3490,31 @@ void PeerManagerImpl::ProcessGetCFCheckPt(CNode& node, Peer& peer, CDataStream&
34903490
m_connman.PushMessage(&node, std::move(msg));
34913491
}
34923492

3493-
std::pair<bool /*ret*/, bool /*do_return*/> static ValidateDSTX(CDeterministicMNManager& dmnman, CDSTXManager& dstxman, ChainstateManager& chainman,
3494-
CMasternodeMetaMan& mn_metaman, CTxMemPool& mempool, CCoinJoinBroadcastTx& dstx, uint256 hashTx)
3493+
// Misbehavior penalty to apply to the relaying peer; NONE means no penalty.
3494+
enum class DSTXValidationScore : int {
3495+
NONE = 0,
3496+
UNKNOWN_MASTERNODE = 1,
3497+
INVALID = 10,
3498+
};
3499+
3500+
// do_return signals the caller to stop further processing of the DSTX.
3501+
struct DSTXValidationResult {
3502+
DSTXValidationScore score;
3503+
bool do_return;
3504+
};
3505+
3506+
static DSTXValidationResult ValidateDSTX(CDeterministicMNManager& dmnman, CDSTXManager& dstxman, ChainstateManager& chainman,
3507+
CMasternodeMetaMan& mn_metaman, CTxMemPool& mempool, CCoinJoinBroadcastTx& dstx, uint256 hashTx)
34953508
{
34963509
assert(mn_metaman.IsValid());
34973510

34983511
if (!dstx.IsValidStructure()) {
34993512
LogPrint(BCLog::COINJOIN, "DSTX -- Invalid DSTX structure: %s\n", hashTx.ToString());
3500-
return {false, true};
3513+
return {DSTXValidationScore::INVALID, true};
35013514
}
35023515
if (dstxman.GetDSTX(hashTx)) {
35033516
LogPrint(BCLog::COINJOIN, "DSTX -- Already have %s, skipping...\n", hashTx.ToString());
3504-
return {true, true}; // not an error
3517+
return {DSTXValidationScore::NONE, true}; // not an error
35053518
}
35063519

35073520
const CBlockIndex* pindex{nullptr};
@@ -3534,26 +3547,29 @@ std::pair<bool /*ret*/, bool /*do_return*/> static ValidateDSTX(CDeterministicMN
35343547

35353548
if (!dmn) {
35363549
LogPrint(BCLog::COINJOIN, "DSTX -- Can't find masternode %s to verify %s\n", dstx.masternodeOutpoint.ToStringShort(), hashTx.ToString());
3537-
return {false, true};
3550+
// We can't verify the signature here, so apply only a small penalty.
3551+
// The MN may have been removed very recently, but a peer flooding us with
3552+
// unverifiable DSTX-es should still eventually be discouraged.
3553+
return {DSTXValidationScore::UNKNOWN_MASTERNODE, true};
35383554
}
35393555

35403556
if (!mn_metaman.IsValidForMixingTxes(dmn->proTxHash)) {
35413557
LogPrint(BCLog::COINJOIN, "DSTX -- Masternode %s is sending too many transactions %s\n", dstx.masternodeOutpoint.ToStringShort(), hashTx.ToString());
3542-
return {true, true};
3558+
return {DSTXValidationScore::NONE, true};
35433559
// TODO: Not an error? Could it be that someone is relaying old DSTXes
35443560
// we have no idea about (e.g we were offline)? How to handle them?
35453561
}
35463562

35473563
if (!dstx.CheckSignature(dmn->pdmnState->pubKeyOperator.Get())) {
35483564
LogPrint(BCLog::COINJOIN, "DSTX -- CheckSignature() failed for %s\n", hashTx.ToString());
3549-
return {false, true};
3565+
return {DSTXValidationScore::INVALID, true};
35503566
}
35513567

35523568
LogPrint(BCLog::COINJOIN, "DSTX -- Got Masternode transaction %s\n", hashTx.ToString());
35533569
mempool.PrioritiseTransaction(hashTx, 0.1*COIN);
35543570
mn_metaman.DisallowMixing(dmn->proTxHash);
35553571

3556-
return {true, false};
3572+
return {DSTXValidationScore::NONE, false};
35573573
}
35583574

35593575
void PeerManagerImpl::ProcessBlock(CNode& node, const std::shared_ptr<const CBlock>& block, bool force_processing)
@@ -4617,12 +4633,14 @@ void PeerManagerImpl::ProcessMessage(
46174633

46184634
// Process custom logic, no matter if tx will be accepted to mempool later or not
46194635
if (nInvType == MSG_DSTX) {
4620-
// Validate DSTX and return bRet if we need to return from here
4621-
uint256 hashTx = tx.GetHash();
4622-
const auto& [bRet, bDoReturn] = ValidateDSTX(*m_dmnman, m_dstxman, m_chainman, m_mn_metaman, m_mempool, dstx, hashTx);
4623-
if (bDoReturn) {
4624-
return;
4625-
}
4636+
uint256 hashTx = tx.GetHash();
4637+
const auto result = ValidateDSTX(*m_dmnman, m_dstxman, m_chainman, m_mn_metaman, m_mempool, dstx, hashTx);
4638+
if (result.do_return) {
4639+
if (result.score != DSTXValidationScore::NONE) {
4640+
Misbehaving(pfrom.GetId(), static_cast<int>(result.score), "invalid dstx");
4641+
}
4642+
return;
4643+
}
46264644
}
46274645

46284646
LOCK(cs_main);

test/functional/p2p_dstx.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2026 The Dash Core developers
3+
# Distributed under the MIT software license, see the accompanying
4+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
5+
"""Test P2P CoinJoin broadcast transaction handling.
6+
7+
Verifies that DSTX messages with an unverifiable (unknown) masternode incur
8+
only a small misbehavior penalty, while clearly malformed DSTXes get the
9+
existing stronger penalty. Also exercises the cumulative behavior so that
10+
a peer flooding unknown-MN DSTXes is eventually discouraged.
11+
"""
12+
13+
import time
14+
15+
from test_framework.messages import (
16+
CCoinJoinBroadcastTx,
17+
COIN,
18+
COutPoint,
19+
CTransaction,
20+
CTxIn,
21+
CTxOut,
22+
msg_dstx,
23+
)
24+
from test_framework.p2p import P2PInterface
25+
from test_framework.script import (
26+
CScript,
27+
OP_CHECKSIG,
28+
OP_DUP,
29+
OP_EQUALVERIFY,
30+
OP_HASH160,
31+
)
32+
from test_framework.test_framework import BitcoinTestFramework
33+
34+
# Default DISCOURAGEMENT_THRESHOLD in net_processing.h.
35+
DISCOURAGEMENT_THRESHOLD = 100
36+
# Penalty applied when the relaying peer sends a DSTX whose masternode we
37+
# can't find in the deterministic MN list (and therefore can't verify).
38+
UNKNOWN_MN_SCORE = 1
39+
# Penalty applied when the DSTX itself is structurally bad / bad signature.
40+
INVALID_DSTX_SCORE = 10
41+
42+
43+
class P2PDSTXTest(BitcoinTestFramework):
44+
def set_test_params(self):
45+
self.num_nodes = 1
46+
self.setup_clean_chain = True
47+
self.extra_args = [["-debug=net", "-debug=coinjoin"]]
48+
49+
def make_dstx(self, nonce=0):
50+
tx = CTransaction()
51+
# The nonce flows into one of the prevouts so each DSTX has a distinct
52+
# txid (and therefore is not deduped by dstxman.GetDSTX).
53+
tx.vin = [CTxIn(COutPoint(hash=(nonce << 8) | (i + 1), n=0)) for i in range(3)]
54+
p2pkh = CScript([
55+
OP_DUP,
56+
OP_HASH160,
57+
b"\x01" * 20,
58+
OP_EQUALVERIFY,
59+
OP_CHECKSIG,
60+
])
61+
# CoinJoin::IsDenominatedAmount requires a recognised denom; the
62+
# smallest denom is 0.001 DASH + 0.0000001 fee == COIN//1000 + 1.
63+
tx.vout = [CTxOut(nValue=COIN // 1000 + 1, scriptPubKey=p2pkh) for _ in tx.vin]
64+
return CCoinJoinBroadcastTx(
65+
tx=tx,
66+
m_protxHash=1,
67+
vchSig=b"\x01" * 96,
68+
sigTime=int(time.time()),
69+
)
70+
71+
def run_test(self):
72+
node = self.nodes[0]
73+
self.log.info("Leave IBD so unsolicited DSTX is processed")
74+
self.generate(node, 1)
75+
76+
self.log.info("Unknown-MN DSTX => small (+%d) misbehavior penalty", UNKNOWN_MN_SCORE)
77+
peer = node.add_p2p_connection(P2PInterface())
78+
# Match the substring of the Misbehaving log that identifies the score
79+
# jump of a fresh peer (0 -> 1) along with our message tag.
80+
with node.assert_debug_log([
81+
"Can't find masternode",
82+
"Misbehaving",
83+
"(0 -> {})".format(UNKNOWN_MN_SCORE),
84+
"invalid dstx",
85+
]):
86+
peer.send_and_ping(msg_dstx(self.make_dstx(nonce=1)))
87+
88+
self.log.info("Structurally invalid DSTX => stronger (+%d) misbehavior penalty", INVALID_DSTX_SCORE)
89+
peer_invalid = node.add_p2p_connection(P2PInterface())
90+
bad = self.make_dstx(nonce=2)
91+
bad.tx.vout.pop() # vin.size() != vout.size() trips IsValidStructure
92+
with node.assert_debug_log([
93+
"Invalid DSTX structure",
94+
"Misbehaving",
95+
"(0 -> {})".format(INVALID_DSTX_SCORE),
96+
"invalid dstx",
97+
]):
98+
peer_invalid.send_and_ping(msg_dstx(bad))
99+
100+
self.log.info("A peer flooding unknown-MN DSTXes is eventually discouraged")
101+
peer_flood = node.add_p2p_connection(P2PInterface())
102+
# +1 per unknown-MN DSTX, so DISCOURAGEMENT_THRESHOLD distinct DSTXes
103+
# are enough to cross the threshold exactly once on this peer.
104+
# send_message is fire-and-forget; the node disconnects once
105+
# discouraged, so we wait on the threshold log line directly and then
106+
# confirm the peer was dropped.
107+
with node.assert_debug_log(["DISCOURAGE THRESHOLD EXCEEDED"], timeout=10):
108+
for nonce in range(DISCOURAGEMENT_THRESHOLD):
109+
# offset nonces to avoid txid clashes with earlier sends
110+
peer_flood.send_message(msg_dstx(self.make_dstx(nonce=1000 + nonce)))
111+
peer_flood.wait_for_disconnect()
112+
113+
114+
if __name__ == '__main__':
115+
P2PDSTXTest().main()

test/functional/test_framework/messages.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1835,6 +1835,53 @@ def __repr__(self):
18351835
return "msg_tx(tx=%s)" % (repr(self.tx))
18361836

18371837

1838+
class CCoinJoinBroadcastTx:
1839+
__slots__ = ("tx", "m_protxHash", "vchSig", "sigTime")
1840+
1841+
def __init__(self, tx=None, m_protxHash=0, vchSig=b"", sigTime=0):
1842+
self.tx = tx or CTransaction()
1843+
self.m_protxHash = m_protxHash
1844+
self.vchSig = vchSig
1845+
self.sigTime = sigTime
1846+
1847+
def deserialize(self, f):
1848+
self.tx = CTransaction()
1849+
self.tx.deserialize(f)
1850+
self.m_protxHash = deser_uint256(f)
1851+
self.vchSig = deser_string(f)
1852+
self.sigTime = struct.unpack("<q", f.read(8))[0]
1853+
1854+
def serialize(self):
1855+
r = b""
1856+
r += self.tx.serialize()
1857+
r += ser_uint256(self.m_protxHash)
1858+
r += ser_string(self.vchSig)
1859+
r += struct.pack("<q", self.sigTime)
1860+
return r
1861+
1862+
def __repr__(self):
1863+
return "CCoinJoinBroadcastTx(tx=%s m_protxHash=%064x)" \
1864+
% (repr(self.tx), self.m_protxHash)
1865+
1866+
1867+
class msg_dstx:
1868+
__slots__ = ("dstx",)
1869+
msgtype = b"dstx"
1870+
1871+
def __init__(self, dstx=None):
1872+
self.dstx = dstx or CCoinJoinBroadcastTx()
1873+
1874+
def deserialize(self, f):
1875+
self.dstx = CCoinJoinBroadcastTx()
1876+
self.dstx.deserialize(f)
1877+
1878+
def serialize(self):
1879+
return self.dstx.serialize()
1880+
1881+
def __repr__(self):
1882+
return "msg_dstx(dstx=%s)" % (repr(self.dstx))
1883+
1884+
18381885
class msg_block:
18391886
__slots__ = ("block",)
18401887
msgtype = b"block"

test/functional/test_runner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@
258258
'p2p_nobloomfilter_messages.py',
259259
'p2p_filter.py',
260260
'p2p_blocksonly.py',
261+
'p2p_dstx.py',
261262
'rpc_setban.py --v1transport',
262263
'rpc_setban.py --v2transport',
263264
'mining_prioritisetransaction.py',

0 commit comments

Comments
 (0)