Skip to content

Commit 852590b

Browse files
committed
Make sure the fee and cost specified in a NewTransaction match the ones from validating its spend bundle.
1 parent 742a81a commit 852590b

File tree

5 files changed

+74
-26
lines changed

5 files changed

+74
-26
lines changed

chia/_tests/core/full_node/test_full_node.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3362,18 +3362,21 @@ async def test_pending_tx_cache_retry_on_new_peak(
33623362
@pytest.mark.anyio
33633363
@pytest.mark.parametrize("mismatch_cost", [True, False])
33643364
@pytest.mark.parametrize("mismatch_fee", [True, False])
3365+
@pytest.mark.parametrize("tx_already_seen", [True, False])
33653366
async def test_ban_for_mismatched_tx_cost_fee(
33663367
setup_two_nodes_fixture: tuple[list[FullNodeSimulator], list[tuple[WalletNode, ChiaServer]], BlockTools],
33673368
self_hostname: str,
33683369
mismatch_cost: bool,
33693370
mismatch_fee: bool,
3371+
tx_already_seen: bool,
33703372
) -> None:
33713373
"""
33723374
Tests that a peer gets banned if it sends a `NewTransaction` message with a
33733375
cost and/or fee that doesn't match the transaction's validation cost/fee.
3374-
We setup two full nodes with the test transaction as already seen, and we
3375-
check its validation cost and fee against the ones specified in the
3376-
`NewTransaction` message.
3376+
We setup two full nodes, and with `tx_already_seen` we control whether the
3377+
first full node has this transaction already or it needs to request it.
3378+
In both cases we check the transaction's validation cost and fee against
3379+
the ones specified in the `NewTransaction` message.
33773380
"""
33783381
nodes, _, bt = setup_two_nodes_fixture
33793382
full_node_1, full_node_2 = nodes
@@ -3384,17 +3387,26 @@ async def test_ban_for_mismatched_tx_cost_fee(
33843387
ws_con_2 = next(iter(server_2.all_connections.values()))
33853388
wallet = WalletTool(test_constants)
33863389
wallet_ph = wallet.get_new_puzzlehash()
3390+
# If we're covering that the first full node has this transaction already
3391+
# we must add it accordingly, otherwise we'll add it to the second node so
3392+
# that the first node requests it, reacting to the NewTransaction message.
3393+
if tx_already_seen:
3394+
node = full_node_1.full_node
3395+
ws_con = ws_con_1
3396+
else:
3397+
node = full_node_2.full_node
3398+
ws_con = ws_con_2
33873399
blocks = bt.get_consecutive_blocks(
33883400
3, guarantee_transaction_block=True, farmer_reward_puzzle_hash=wallet_ph, pool_reward_puzzle_hash=wallet_ph
33893401
)
33903402
for block in blocks:
3391-
await full_node_1.full_node.add_block(block)
3403+
await node.add_block(block)
33923404
# Create a transaction and add it to the relevant full node's mempool
33933405
coin = blocks[-1].get_included_reward_coins()[0]
33943406
sb = wallet.generate_signed_transaction(uint64(42), wallet_ph, coin)
33953407
sb_name = sb.name()
3396-
await full_node_1.full_node.add_transaction(sb, sb_name, ws_con_1)
3397-
mempool_item = full_node_1.full_node.mempool_manager.get_mempool_item(sb_name)
3408+
await node.add_transaction(sb, sb_name, ws_con)
3409+
mempool_item = node.mempool_manager.get_mempool_item(sb_name)
33983410
assert mempool_item is not None
33993411
# Now send a NewTransaction with a cost and/or fee mismatch from the second
34003412
# full node.
@@ -3407,6 +3419,14 @@ async def test_ban_for_mismatched_tx_cost_fee(
34073419
ws_con_1.peer_info = PeerInfo(full_node_2_ip, ws_con_1.peer_info.port)
34083420
# Send the NewTransaction message from the second node to the first
34093421
await ws_con_2.send_message(msg)
3422+
if not tx_already_seen:
3423+
# When the first full node receives the NewTransaction message and it
3424+
# hasen't seen the transaction before, it will issue a transaction
3425+
# request. We need to wait until it receives the transaction and add it
3426+
# to its mempool.
3427+
await time_out_assert(
3428+
10, lambda: full_node_1.full_node.mempool_manager.get_mempool_item(mempool_item.name) is not None
3429+
)
34103430
# Make sure the first full node has banned the second as the item it has
34113431
# already seen has a different validation cost and/or fee than the one from
34123432
# the NewTransaction message.

chia/full_node/full_node.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
from chia.protocols.full_node_protocol import RequestBlocks, RespondBlock, RespondBlocks, RespondSignagePoint
6969
from chia.protocols.outbound_message import Message, NodeType, make_msg
7070
from chia.protocols.protocol_message_types import ProtocolMessageTypes
71-
from chia.protocols.protocol_timing import CONSENSUS_ERROR_BAN_SECONDS
71+
from chia.protocols.protocol_timing import CONSENSUS_ERROR_BAN_SECONDS, RATE_LIMITER_BAN_SECONDS
7272
from chia.protocols.shared_protocol import Capability
7373
from chia.protocols.wallet_protocol import CoinStateUpdate, RemovedMempoolItem
7474
from chia.rpc.rpc_server import StateChangedProtocol
@@ -498,7 +498,9 @@ def _set_state_changed_callback(self, callback: StateChangedProtocol) -> None:
498498
async def _handle_one_transaction(self, entry: TransactionQueueEntry) -> None:
499499
peer = entry.peer
500500
try:
501-
inc_status, err = await self.add_transaction(entry.transaction, entry.spend_name, peer, entry.test)
501+
inc_status, err = await self.add_transaction(
502+
entry.transaction, entry.spend_name, peer, entry.test, entry.peers_with_tx
503+
)
502504
entry.done.set((inc_status, err))
503505
except asyncio.CancelledError:
504506
error_stack = traceback.format_exc()
@@ -2761,7 +2763,12 @@ async def add_end_of_sub_slot(
27612763
return None, False
27622764

27632765
async def add_transaction(
2764-
self, transaction: SpendBundle, spend_name: bytes32, peer: Optional[WSChiaConnection] = None, test: bool = False
2766+
self,
2767+
transaction: SpendBundle,
2768+
spend_name: bytes32,
2769+
peer: Optional[WSChiaConnection] = None,
2770+
test: bool = False,
2771+
peers_with_tx: dict[bytes32, tuple[uint64, uint64]] = {},
27652772
) -> tuple[MempoolInclusionStatus, Optional[Err]]:
27662773
if self.sync_store.get_sync_mode():
27672774
return MempoolInclusionStatus.FAILED, Err.NO_TRANSACTIONS_WHILE_SYNCING
@@ -2810,10 +2817,23 @@ async def add_transaction(
28102817
f"{self.mempool_manager.mempool.total_mempool_cost() / 5000000}"
28112818
)
28122819

2813-
# Only broadcast successful transactions, not pending ones. Otherwise it's a DOS
2814-
# vector.
28152820
mempool_item = self.mempool_manager.get_mempool_item(spend_name)
28162821
assert mempool_item is not None
2822+
# Now that we validated this transaction, check what fees and
2823+
# costs the peers have advertised for it.
2824+
for peer_id, (advertised_fee, advertised_cost) in peers_with_tx.items():
2825+
if advertised_fee == mempool_item.fee and advertised_cost == mempool_item.cost:
2826+
continue
2827+
if peer_id not in self.server.all_connections:
2828+
continue
2829+
self.log.warning(
2830+
f"Banning peer {peer_id}. Sent us a new tx {spend_name} with mismatch "
2831+
f"on cost {advertised_cost} vs validation cost {mempool_item.cost} and/or "
2832+
f"fee {advertised_fee} vs {mempool_item.fee}."
2833+
)
2834+
await self.server.all_connections[peer_id].close(RATE_LIMITER_BAN_SECONDS)
2835+
# Only broadcast successful transactions, not pending ones. Otherwise it's a DOS
2836+
# vector.
28172837
await self.broadcast_removed_tx(info.removals)
28182838
await self.broadcast_added_tx(mempool_item, current_peer=peer)
28192839

chia/full_node/full_node_api.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import copy
45
import logging
56
import time
67
import traceback
@@ -89,6 +90,7 @@ async def tx_request_and_timeout(full_node: FullNode, transaction_id: bytes32, t
8990
receive it or timeout.
9091
"""
9192
counter = 0
93+
peers_with_tx = copy.copy(full_node.full_node_store.peers_with_tx.get(transaction_id, {}))
9294
try:
9395
while True:
9496
# Limit to asking a few peers, it's possible that this tx got included on chain already
@@ -98,10 +100,9 @@ async def tx_request_and_timeout(full_node: FullNode, transaction_id: bytes32, t
98100
break
99101
if transaction_id not in full_node.full_node_store.peers_with_tx:
100102
break
101-
peers_with_tx: set[bytes32] = full_node.full_node_store.peers_with_tx[transaction_id]
102103
if len(peers_with_tx) == 0:
103104
break
104-
peer_id = peers_with_tx.pop()
105+
peer_id, _ = peers_with_tx.popitem()
105106
assert full_node.server is not None
106107
if peer_id not in full_node.server.all_connections:
107108
continue
@@ -111,7 +112,7 @@ async def tx_request_and_timeout(full_node: FullNode, transaction_id: bytes32, t
111112
await random_peer.send_message(msg)
112113
await asyncio.sleep(5)
113114
counter += 1
114-
if full_node.mempool_manager.seen(transaction_id):
115+
if full_node.mempool_manager.get_mempool_item(transaction_id) is not None:
115116
break
116117
except asyncio.CancelledError:
117118
pass
@@ -228,21 +229,22 @@ async def new_transaction(
228229
# If there's current pending request just add this peer to the set of peers that have this tx
229230
if transaction.transaction_id in self.full_node.full_node_store.pending_tx_request:
230231
if transaction.transaction_id in self.full_node.full_node_store.peers_with_tx:
231-
current_set = self.full_node.full_node_store.peers_with_tx[transaction.transaction_id]
232-
if peer.peer_node_id in current_set:
232+
current_map = self.full_node.full_node_store.peers_with_tx[transaction.transaction_id]
233+
if peer.peer_node_id in current_map:
233234
return None
234-
current_set.add(peer.peer_node_id)
235+
current_map[peer.peer_node_id] = (transaction.fees, transaction.cost)
235236
return None
236237
else:
237-
new_set = set()
238-
new_set.add(peer.peer_node_id)
239-
self.full_node.full_node_store.peers_with_tx[transaction.transaction_id] = new_set
238+
self.full_node.full_node_store.peers_with_tx[transaction.transaction_id] = {
239+
peer.peer_node_id: (transaction.fees, transaction.cost)
240+
}
240241
return None
241242

242243
self.full_node.full_node_store.pending_tx_request[transaction.transaction_id] = peer.peer_node_id
243-
new_set = set()
244-
new_set.add(peer.peer_node_id)
245-
self.full_node.full_node_store.peers_with_tx[transaction.transaction_id] = new_set
244+
self.full_node.full_node_store.peers_with_tx[transaction.transaction_id] = {
245+
peer.peer_node_id: (transaction.fees, transaction.cost)
246+
}
247+
246248
task_id: bytes32 = bytes32.secret()
247249
fetch_task = create_referenced_task(
248250
tx_request_and_timeout(self.full_node, transaction.transaction_id, task_id)
@@ -282,13 +284,15 @@ async def respond_transaction(
282284
spend_name = std_hash(tx_bytes)
283285
if spend_name in self.full_node.full_node_store.pending_tx_request:
284286
self.full_node.full_node_store.pending_tx_request.pop(spend_name)
287+
peers_with_tx = {}
285288
if spend_name in self.full_node.full_node_store.peers_with_tx:
286-
self.full_node.full_node_store.peers_with_tx.pop(spend_name)
289+
peers_with_tx = self.full_node.full_node_store.peers_with_tx.pop(spend_name)
287290

288291
# TODO: Use fee in priority calculation, to prioritize high fee TXs
289292
try:
290293
await self.full_node.transaction_queue.put(
291-
TransactionQueueEntry(tx.transaction, tx_bytes, spend_name, peer, test), peer.peer_node_id
294+
TransactionQueueEntry(tx.transaction, tx_bytes, spend_name, peer, test, peers_with_tx),
295+
peer.peer_node_id,
292296
)
293297
except TransactionQueueFull:
294298
pass # we can't do anything here, the tx will be dropped. We might do something in the future.

chia/full_node/full_node_store.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ class FullNodeStore:
132132
recent_eos: LRUCache[bytes32, tuple[EndOfSubSlotBundle, float]]
133133

134134
pending_tx_request: dict[bytes32, bytes32] # tx_id: peer_id
135-
peers_with_tx: dict[bytes32, set[bytes32]] # tx_id: set[peer_ids}
135+
peers_with_tx: dict[bytes32, dict[bytes32, tuple[uint64, uint64]]] # tx_id: dict[peer_id, (fee, cost)]
136136
tx_fetch_tasks: dict[bytes32, asyncio.Task[None]] # Task id: task
137137
serialized_wp_message: Optional[Message]
138138
serialized_wp_message_tip: Optional[bytes32]

chia/full_node/tx_processing_queue.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from chia_rs import SpendBundle
1111
from chia_rs.sized_bytes import bytes32
12+
from chia_rs.sized_ints import uint64
1213

1314
from chia.server.ws_connection import WSChiaConnection
1415
from chia.types.mempool_inclusion_status import MempoolInclusionStatus
@@ -56,6 +57,9 @@ class TransactionQueueEntry:
5657
spend_name: bytes32
5758
peer: Optional[WSChiaConnection] = field(compare=False)
5859
test: bool = field(compare=False)
60+
# IDs of peers that advertised this transaction via new_transaction, along
61+
# with their fee and cost.
62+
peers_with_tx: dict[bytes32, tuple[uint64, uint64]] = field(default_factory=dict, compare=False)
5963
done: ValuedEvent[tuple[MempoolInclusionStatus, Optional[Err]]] = field(
6064
default_factory=ValuedEvent,
6165
compare=False,

0 commit comments

Comments
 (0)