Skip to content

Commit 45c7a4b

Browse files
glozowinstagibbs
andcommitted
[functional test] orphan resolution works in the presence of DoSy peers
Co-authored-by: Greg Sanders <[email protected]>
1 parent 835f5c7 commit 45c7a4b

File tree

3 files changed

+271
-2
lines changed

3 files changed

+271
-2
lines changed

test/functional/p2p_opportunistic_1p1c.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,20 @@
77
"""
88

99
from decimal import Decimal
10+
import random
1011
import time
12+
13+
from test_framework.blocktools import MAX_STANDARD_TX_WEIGHT
1114
from test_framework.mempool_util import (
15+
create_large_orphan,
1216
fill_mempool,
1317
)
1418
from test_framework.messages import (
1519
CInv,
20+
COutPoint,
21+
CTransaction,
22+
CTxIn,
23+
CTxOut,
1624
CTxInWitness,
1725
MAX_BIP125_RBF_SEQUENCE,
1826
MSG_WTX,
@@ -21,12 +29,20 @@
2129
tx_from_hex,
2230
)
2331
from test_framework.p2p import (
32+
NONPREF_PEER_TX_DELAY,
2433
P2PInterface,
34+
TXID_RELAY_DELAY,
35+
)
36+
from test_framework.script import (
37+
CScript,
38+
OP_NOP,
39+
OP_RETURN,
2540
)
2641
from test_framework.test_framework import BitcoinTestFramework
2742
from test_framework.util import (
2843
assert_equal,
2944
assert_greater_than,
45+
assert_greater_than_or_equal,
3046
)
3147
from test_framework.wallet import (
3248
MiniWallet,
@@ -373,6 +389,164 @@ def test_other_parent_in_mempool(self):
373389
result_missing_parent = node.submitpackage(package_hex_missing_parent)
374390
assert_equal(result_missing_parent["package_msg"], "package-not-child-with-unconfirmed-parents")
375391

392+
def create_small_orphan(self):
393+
"""Create small orphan transaction"""
394+
tx = CTransaction()
395+
# Nonexistent UTXO
396+
tx.vin = [CTxIn(COutPoint(random.randrange(1 << 256), random.randrange(1, 100)))]
397+
tx.wit.vtxinwit = [CTxInWitness()]
398+
tx.wit.vtxinwit[0].scriptWitness.stack = [CScript([OP_NOP] * 5)]
399+
tx.vout = [CTxOut(100, CScript([OP_RETURN, b'a' * 3]))]
400+
return tx
401+
402+
@cleanup
403+
def test_orphanage_dos_large(self):
404+
self.log.info("Test that the node can still resolve orphans when peers use lots of orphanage space")
405+
node = self.nodes[0]
406+
node.setmocktime(int(time.time()))
407+
408+
peer_normal = node.add_p2p_connection(P2PInterface())
409+
peer_doser = node.add_p2p_connection(P2PInterface())
410+
411+
self.log.info("Create very large orphans to be sent by DoSy peers (may take a while)")
412+
large_orphans = [create_large_orphan() for _ in range(100)]
413+
# Check to make sure these are orphans, within max standard size (to be accepted into the orphanage)
414+
for large_orphan in large_orphans:
415+
assert_greater_than_or_equal(100000, large_orphan.get_vsize())
416+
assert_greater_than(MAX_STANDARD_TX_WEIGHT, large_orphan.get_weight())
417+
assert_greater_than_or_equal(3 * large_orphan.get_vsize(), 2 * 100000)
418+
testres = node.testmempoolaccept([large_orphan.serialize().hex()])
419+
assert not testres[0]["allowed"]
420+
assert_equal(testres[0]["reject-reason"], "missing-inputs")
421+
422+
num_individual_dosers = 30
423+
self.log.info(f"Connect {num_individual_dosers} peers and send a very large orphan from each one")
424+
# This test assumes that unrequested transactions are processed (skipping inv and
425+
# getdata steps because they require going through request delays)
426+
# Connect 20 peers and have each of them send a large orphan.
427+
for large_orphan in large_orphans[:num_individual_dosers]:
428+
peer_doser_individual = node.add_p2p_connection(P2PInterface())
429+
peer_doser_individual.send_and_ping(msg_tx(large_orphan))
430+
node.bumpmocktime(NONPREF_PEER_TX_DELAY + TXID_RELAY_DELAY)
431+
peer_doser_individual.wait_for_getdata([large_orphan.vin[0].prevout.hash])
432+
433+
# Make sure that these transactions are going through the orphan handling codepaths.
434+
# Subsequent rounds will not wait for getdata because the time mocking will cause the
435+
# normal package request to time out.
436+
self.wait_until(lambda: len(node.getorphantxs()) == num_individual_dosers)
437+
438+
self.log.info("Send an orphan from a non-DoSy peer. Its orphan should not be evicted.")
439+
low_fee_parent = self.create_tx_below_mempoolminfee(self.wallet)
440+
high_fee_child = self.wallet.create_self_transfer(
441+
utxo_to_spend=low_fee_parent["new_utxo"],
442+
fee_rate=200*FEERATE_1SAT_VB,
443+
target_vsize=100000
444+
)
445+
446+
# Announce
447+
orphan_tx = high_fee_child["tx"]
448+
orphan_inv = CInv(t=MSG_WTX, h=orphan_tx.wtxid_int)
449+
450+
# Wait for getdata
451+
peer_normal.send_and_ping(msg_inv([orphan_inv]))
452+
node.bumpmocktime(NONPREF_PEER_TX_DELAY)
453+
peer_normal.wait_for_getdata([orphan_tx.wtxid_int])
454+
peer_normal.send_and_ping(msg_tx(orphan_tx))
455+
456+
# Wait for parent request
457+
parent_txid_int = int(low_fee_parent["txid"], 16)
458+
node.bumpmocktime(NONPREF_PEER_TX_DELAY + TXID_RELAY_DELAY)
459+
peer_normal.wait_for_getdata([parent_txid_int])
460+
461+
self.log.info("Send another round of very large orphans from a DoSy peer")
462+
for large_orphan in large_orphans[30:]:
463+
peer_doser.send_and_ping(msg_tx(large_orphan))
464+
465+
# Something was evicted; the orphanage does not contain all large orphans + the 1p1c child
466+
self.wait_until(lambda: len(node.getorphantxs()) < len(large_orphans) + 1)
467+
468+
self.log.info("Provide the orphan's parent. This 1p1c package should be successfully accepted.")
469+
peer_normal.send_and_ping(msg_tx(low_fee_parent["tx"]))
470+
assert_equal(node.getmempoolentry(orphan_tx.txid_hex)["ancestorcount"], 2)
471+
472+
@cleanup
473+
def test_orphanage_dos_many(self):
474+
self.log.info("Test that the node can still resolve orphans when peers are sending tons of orphans")
475+
node = self.nodes[0]
476+
node.setmocktime(int(time.time()))
477+
478+
peer_normal = node.add_p2p_connection(P2PInterface())
479+
480+
# 2 sets of peers: the first set all send the same batch_size orphans. The second set each
481+
# sends batch_size distinct orphans.
482+
batch_size = 51
483+
num_peers_shared = 60
484+
num_peers_unique = 40
485+
486+
# 60 peers * 51 orphans = 3060 announcements
487+
shared_orphans = [self.create_small_orphan() for _ in range(batch_size)]
488+
self.log.info(f"Send the same {batch_size} orphans from {num_peers_shared} DoSy peers (may take a while)")
489+
peer_doser_shared = [node.add_p2p_connection(P2PInterface()) for _ in range(num_peers_shared)]
490+
for i in range(num_peers_shared):
491+
for orphan in shared_orphans:
492+
peer_doser_shared[i].send_without_ping(msg_tx(orphan))
493+
494+
# We sync peers to make sure we have processed as many orphans as possible. Ensure at least
495+
# one of the orphans was processed.
496+
for peer_doser in peer_doser_shared:
497+
peer_doser.sync_with_ping()
498+
self.wait_until(lambda: any([tx.txid_hex in node.getorphantxs() for tx in shared_orphans]))
499+
500+
self.log.info("Send an orphan from a non-DoSy peer. Its orphan should not be evicted.")
501+
low_fee_parent = self.create_tx_below_mempoolminfee(self.wallet)
502+
high_fee_child = self.wallet.create_self_transfer(
503+
utxo_to_spend=low_fee_parent["new_utxo"],
504+
fee_rate=200*FEERATE_1SAT_VB,
505+
)
506+
507+
# Announce
508+
orphan_tx = high_fee_child["tx"]
509+
orphan_inv = CInv(t=MSG_WTX, h=orphan_tx.wtxid_int)
510+
511+
# Wait for getdata
512+
peer_normal.send_and_ping(msg_inv([orphan_inv]))
513+
node.bumpmocktime(NONPREF_PEER_TX_DELAY)
514+
peer_normal.wait_for_getdata([orphan_tx.wtxid_int])
515+
peer_normal.send_and_ping(msg_tx(orphan_tx))
516+
517+
# Orphan has been entered and evicted something else
518+
self.wait_until(lambda: high_fee_child["txid"] in node.getorphantxs())
519+
520+
# Wait for parent request
521+
parent_txid_int = low_fee_parent["tx"].txid_int
522+
node.bumpmocktime(NONPREF_PEER_TX_DELAY + TXID_RELAY_DELAY)
523+
peer_normal.wait_for_getdata([parent_txid_int])
524+
525+
# Each of the num_peers_unique peers creates a distinct set of orphans
526+
many_orphans = [self.create_small_orphan() for _ in range(batch_size * num_peers_unique)]
527+
528+
self.log.info(f"Send sets of {batch_size} orphans from {num_peers_unique} DoSy peers (may take a while)")
529+
for peernum in range(num_peers_unique):
530+
peer_doser_batch = node.add_p2p_connection(P2PInterface())
531+
this_batch_orphans = many_orphans[batch_size*peernum : batch_size*(peernum+1)]
532+
for tx in this_batch_orphans:
533+
# Don't wait for responses, because it dramatically increases the runtime of this test.
534+
peer_doser_batch.send_without_ping(msg_tx(tx))
535+
536+
# Ensure at least one of the peer's orphans shows up in getorphantxs. Since each peer is
537+
# reserved a portion of orphanage space, this must happen as long as the orphans are not
538+
# rejected for some other reason.
539+
peer_doser_batch.sync_with_ping()
540+
self.wait_until(lambda: any([tx.txid_hex in node.getorphantxs() for tx in this_batch_orphans]))
541+
542+
self.log.info("Check that orphan from normal peer still exists in orphanage")
543+
assert high_fee_child["txid"] in node.getorphantxs()
544+
545+
self.log.info("Provide the orphan's parent. This 1p1c package should be successfully accepted.")
546+
peer_normal.send_and_ping(msg_tx(low_fee_parent["tx"]))
547+
assert orphan_tx.txid_hex in node.getrawmempool()
548+
assert_equal(node.getmempoolentry(orphan_tx.txid_hex)["ancestorcount"], 2)
549+
376550
def run_test(self):
377551
node = self.nodes[0]
378552
# To avoid creating transactions with the same txid (can happen if we set the same feerate
@@ -407,6 +581,9 @@ def run_test(self):
407581
self.test_multiple_parents()
408582
self.test_other_parent_in_mempool()
409583

584+
self.test_orphanage_dos_large()
585+
self.test_orphanage_dos_many()
586+
410587

411588
if __name__ == '__main__':
412589
PackageRelayTest(__file__).main()

test/functional/p2p_orphan_handling.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@
55

66
import time
77

8-
from test_framework.mempool_util import tx_in_orphanage
8+
from test_framework.mempool_util import (
9+
create_large_orphan,
10+
tx_in_orphanage,
11+
)
912
from test_framework.messages import (
1013
CInv,
1114
CTxInWitness,
15+
DEFAULT_ANCESTOR_LIMIT,
1216
MSG_TX,
1317
MSG_WITNESS_TX,
1418
MSG_WTX,
@@ -627,6 +631,72 @@ def test_orphan_handling_prefer_outbound(self):
627631
peer_inbound.sync_with_ping()
628632
peer_inbound.wait_for_parent_requests([parent_tx.txid_int])
629633

634+
@cleanup
635+
def test_maximal_package_protected(self):
636+
self.log.info("Test that a node only announcing a maximally sized ancestor package is protected in orphanage")
637+
self.nodes[0].setmocktime(int(time.time()))
638+
node = self.nodes[0]
639+
640+
peer_normal = node.add_p2p_connection(P2PInterface())
641+
peer_doser = node.add_p2p_connection(P2PInterface())
642+
643+
# Each of the num_peers peers creates a distinct set of orphans
644+
large_orphans = [create_large_orphan() for _ in range(60)]
645+
646+
# Check to make sure these are orphans, within max standard size (to be accepted into the orphanage)
647+
for large_orphan in large_orphans:
648+
testres = node.testmempoolaccept([large_orphan.serialize().hex()])
649+
assert not testres[0]["allowed"]
650+
assert_equal(testres[0]["reject-reason"], "missing-inputs")
651+
652+
num_individual_dosers = 20
653+
self.log.info(f"Connect {num_individual_dosers} peers and send a very large orphan from each one")
654+
# This test assumes that unrequested transactions are processed (skipping inv and
655+
# getdata steps because they require going through request delays)
656+
# Connect 20 peers and have each of them send a large orphan.
657+
for large_orphan in large_orphans[:num_individual_dosers]:
658+
peer_doser_individual = node.add_p2p_connection(P2PInterface())
659+
peer_doser_individual.send_and_ping(msg_tx(large_orphan))
660+
node.bumpmocktime(NONPREF_PEER_TX_DELAY + TXID_RELAY_DELAY + 1)
661+
peer_doser_individual.wait_for_getdata([large_orphan.vin[0].prevout.hash])
662+
663+
# Make sure that these transactions are going through the orphan handling codepaths.
664+
# Subsequent rounds will not wait for getdata because the time mocking will cause the
665+
# normal package request to time out.
666+
self.wait_until(lambda: len(node.getorphantxs()) == num_individual_dosers)
667+
668+
# Now honest peer will send a maximally sized ancestor package of 24 orphans chaining
669+
# off of a single missing transaction, with a total vsize 404,000Wu
670+
ancestor_package = self.wallet.create_self_transfer_chain(chain_length=DEFAULT_ANCESTOR_LIMIT - 1)
671+
sum_ancestor_package_vsize = sum([tx["tx"].get_vsize() for tx in ancestor_package])
672+
final_tx = self.wallet.create_self_transfer(utxo_to_spend=ancestor_package[-1]["new_utxo"], target_vsize=101000 - sum_ancestor_package_vsize)
673+
ancestor_package.append(final_tx)
674+
675+
# Peer sends all but first tx to fill up orphange with their orphans
676+
for orphan in ancestor_package[1:]:
677+
peer_normal.send_and_ping(msg_tx(orphan["tx"]))
678+
679+
orphan_set = node.getorphantxs()
680+
for orphan in ancestor_package[1:]:
681+
assert orphan["txid"] in orphan_set
682+
683+
# Wait for ultimate parent request (the root ancestor transaction)
684+
parent_txid_int = ancestor_package[0]["tx"].txid_int
685+
node.bumpmocktime(NONPREF_PEER_TX_DELAY + TXID_RELAY_DELAY)
686+
687+
self.wait_until(lambda: "getdata" in peer_normal.last_message and parent_txid_int in [inv.hash for inv in peer_normal.last_message.get("getdata").inv])
688+
689+
self.log.info("Send another round of very large orphans from a DoSy peer")
690+
for large_orphan in large_orphans[num_individual_dosers:]:
691+
peer_doser.send_and_ping(msg_tx(large_orphan))
692+
693+
self.log.info("Provide the top ancestor. The whole package should be re-evaluated after enough time.")
694+
peer_normal.send_and_ping(msg_tx(ancestor_package[0]["tx"]))
695+
696+
# Wait until all transactions have been processed. When the last tx is accepted, it's
697+
# guaranteed to have all ancestors.
698+
self.wait_until(lambda: node.getmempoolentry(final_tx["txid"])["ancestorcount"] == DEFAULT_ANCESTOR_LIMIT)
699+
630700
@cleanup
631701
def test_announcers_before_and_after(self):
632702
self.log.info("Test that the node uses all peers who announced the tx prior to realizing it's an orphan")
@@ -779,6 +849,7 @@ def run_test(self):
779849
self.test_orphan_handling_prefer_outbound()
780850
self.test_announcers_before_and_after()
781851
self.test_parents_change()
852+
self.test_maximal_package_protected()
782853

783854

784855
if __name__ == '__main__':

test/functional/test_framework/mempool_util.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,22 @@
44
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
55
"""Helpful routines for mempool testing."""
66
from decimal import Decimal
7+
import random
78

89
from .blocktools import (
910
COINBASE_MATURITY,
1011
)
11-
from .messages import CTransaction
12+
from .messages import (
13+
COutPoint,
14+
CTransaction,
15+
CTxIn,
16+
CTxInWitness,
17+
CTxOut,
18+
)
19+
from .script import (
20+
CScript,
21+
OP_RETURN,
22+
)
1223
from .util import (
1324
assert_equal,
1425
assert_greater_than,
@@ -104,3 +115,13 @@ def tx_in_orphanage(node, tx: CTransaction) -> bool:
104115
"""Returns true if the transaction is in the orphanage."""
105116
found = [o for o in node.getorphantxs(verbosity=1) if o["txid"] == tx.txid_hex and o["wtxid"] == tx.wtxid_hex]
106117
return len(found) == 1
118+
119+
def create_large_orphan():
120+
"""Create huge orphan transaction"""
121+
tx = CTransaction()
122+
# Nonexistent UTXO
123+
tx.vin = [CTxIn(COutPoint(random.randrange(1 << 256), random.randrange(1, 100)))]
124+
tx.wit.vtxinwit = [CTxInWitness()]
125+
tx.wit.vtxinwit[0].scriptWitness.stack = [CScript(b'X' * 390000)]
126+
tx.vout = [CTxOut(100, CScript([OP_RETURN, b'a' * 20]))]
127+
return tx

0 commit comments

Comments
 (0)