Skip to content

Commit 715a904

Browse files
Merge pull request #60 from SIDDHANTCOOKIE/complex-architecture-cleanup
refactor: simplify mempool to sorted queue and fix tx removal semantics
2 parents e935c94 + 38bbde7 commit 715a904

5 files changed

Lines changed: 117 additions & 97 deletions

File tree

main.py

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@
1919
import argparse
2020
import asyncio
2121
import logging
22-
import re
2322
import sys
2423

2524
from nacl.signing import SigningKey
2625
from nacl.encoding import HexEncoder
2726

2827
from minichain import Transaction, Blockchain, Block, State, Mempool, P2PNetwork, mine_block
28+
from minichain.validators import is_valid_receiver
2929

3030

3131
logger = logging.getLogger(__name__)
@@ -49,21 +49,41 @@ def create_wallet():
4949

5050
def mine_and_process_block(chain, mempool, miner_pk):
5151
"""Mine pending transactions into a new block."""
52-
pending_txs = mempool.get_transactions_for_block(chain.state)
52+
pending_txs = mempool.get_transactions_for_block()
5353
if not pending_txs:
5454
logger.info("Mempool is empty — nothing to mine.")
5555
return None
5656

57+
# Filter queue candidates against a temporary state snapshot.
58+
temp_state = chain.state.copy()
59+
mineable_txs = []
60+
stale_txs = []
61+
for tx in pending_txs:
62+
expected_nonce = temp_state.get_account(tx.sender).get("nonce", 0)
63+
if tx.nonce < expected_nonce:
64+
stale_txs.append(tx)
65+
continue
66+
if temp_state.validate_and_apply(tx):
67+
mineable_txs.append(tx)
68+
69+
if stale_txs:
70+
mempool.remove_transactions(stale_txs)
71+
72+
if not mineable_txs:
73+
logger.info("No mineable transactions in current queue window.")
74+
return None
75+
5776
block = Block(
5877
index=chain.last_block.index + 1,
5978
previous_hash=chain.last_block.hash,
60-
transactions=pending_txs,
79+
transactions=mineable_txs,
6180
)
6281

6382
mined_block = mine_block(block)
6483

6584
if chain.add_block(mined_block):
66-
logger.info("✅ Block #%d mined and added (%d txs)", mined_block.index, len(pending_txs))
85+
logger.info("✅ Block #%d mined and added (%d txs)", mined_block.index, len(mineable_txs))
86+
mempool.remove_transactions(mineable_txs)
6787
chain.state.credit_mining_reward(miner_pk)
6888
return mined_block
6989
else:
@@ -97,8 +117,8 @@ async def handler(data):
97117
logger.info("📥 Received tx from %s... (amount=%s)", tx.sender[:8], tx.amount)
98118

99119
elif msg_type == "block":
100-
txs_raw = payload.pop("transactions", [])
101-
block_hash = payload.pop("hash", None)
120+
txs_raw = payload.get("transactions", [])
121+
block_hash = payload.get("hash")
102122
transactions = [Transaction(**t) for t in txs_raw]
103123

104124
block = Block(
@@ -147,7 +167,7 @@ async def handler(data):
147167
"""
148168

149169

150-
async def cli_loop(sk, pk, chain, mempool, network, nonce_counter):
170+
async def cli_loop(sk, pk, chain, mempool, network):
151171
"""Read commands from stdin asynchronously."""
152172
loop = asyncio.get_event_loop()
153173
print(HELP_TEXT)
@@ -179,18 +199,23 @@ async def cli_loop(sk, pk, chain, mempool, network, nonce_counter):
179199
print(" Usage: send <receiver_address> <amount>")
180200
continue
181201
receiver = parts[1]
202+
if not is_valid_receiver(receiver):
203+
print(" Invalid receiver format. Expected 40 or 64 hex characters.")
204+
continue
182205
try:
183206
amount = int(parts[2])
184207
except ValueError:
185208
print(" Amount must be an integer.")
186209
continue
210+
if amount <= 0:
211+
print(" Amount must be greater than 0.")
212+
continue
187213

188-
nonce = nonce_counter[0]
214+
nonce = chain.state.get_account(pk).get("nonce", 0)
189215
tx = Transaction(sender=pk, receiver=receiver, amount=amount, nonce=nonce)
190216
tx.sign(sk)
191217

192218
if mempool.add_transaction(tx):
193-
nonce_counter[0] += 1
194219
await network.broadcast_transaction(tx)
195220
print(f" ✅ Tx sent: {amount} coins → {receiver[:12]}...")
196221
else:
@@ -201,9 +226,6 @@ async def cli_loop(sk, pk, chain, mempool, network, nonce_counter):
201226
mined = mine_and_process_block(chain, mempool, pk)
202227
if mined:
203228
await network.broadcast_block(mined, miner=pk)
204-
# Sync local nonce from chain state
205-
acc = chain.state.get_account(pk)
206-
nonce_counter[0] = acc.get("nonce", 0)
207229

208230
# ── peers ──
209231
elif cmd == "peers":
@@ -288,11 +310,8 @@ async def on_peer_connected(writer):
288310
except ValueError:
289311
logger.error("Invalid --connect format. Use host:port")
290312

291-
# Nonce counter kept as a mutable list so the CLI closure can mutate it
292-
nonce_counter = [0]
293-
294313
try:
295-
await cli_loop(sk, pk, chain, mempool, network, nonce_counter)
314+
await cli_loop(sk, pk, chain, mempool, network)
296315
finally:
297316
await network.stop()
298317

minichain/mempool.py

Lines changed: 36 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,28 @@
1-
from collections import defaultdict
21
import logging
32
import threading
43

54
logger = logging.getLogger(__name__)
65

76

87
class Mempool:
9-
def __init__(self, max_size=1000):
10-
self._pending_by_sender = defaultdict(dict)
8+
TRANSACTIONS_PER_BLOCK = 100
9+
10+
def __init__(self, max_size=1000, transactions_per_block=TRANSACTIONS_PER_BLOCK):
11+
self._pending_txs = []
1112
self._seen_tx_ids = set()
1213
self._lock = threading.Lock()
1314
self.max_size = max_size
15+
self.transactions_per_block = transactions_per_block
1416

1517
def _get_tx_id(self, tx):
1618
return tx.tx_id
1719

18-
def _count_transactions_unlocked(self):
19-
return sum(len(sender_queue) for sender_queue in self._pending_by_sender.values())
20-
21-
def _expected_nonce_for_sender(self, sender, state):
22-
if state is not None:
23-
return state.get_account(sender)["nonce"]
24-
25-
sender_queue = self._pending_by_sender.get(sender, {})
26-
if not sender_queue:
27-
return 0
28-
return min(sender_queue)
29-
3020
def add_transaction(self, tx):
3121
"""
3222
Adds a transaction to the pool if:
3323
- Signature is valid
3424
- Transaction is not a duplicate
35-
- Sender nonce is not already present in the pool
25+
- Mempool is not full
3626
"""
3727
tx_id = self._get_tx_id(tx)
3828

@@ -45,58 +35,50 @@ def add_transaction(self, tx):
4535
logger.warning("Mempool: Duplicate transaction rejected %s", tx_id)
4636
return False
4737

48-
sender_queue = self._pending_by_sender[tx.sender]
49-
if tx.nonce in sender_queue:
50-
logger.warning(
51-
"Mempool: Duplicate sender nonce rejected sender=%s nonce=%s",
52-
tx.sender[:8],
53-
tx.nonce,
54-
)
55-
return False
38+
replacement_index = None
39+
for index, pending_tx in enumerate(self._pending_txs):
40+
if pending_tx.sender == tx.sender and pending_tx.nonce == tx.nonce:
41+
replacement_index = index
42+
break
5643

57-
if self._count_transactions_unlocked() >= self.max_size:
44+
if replacement_index is None and len(self._pending_txs) >= self.max_size:
5845
logger.warning("Mempool: Full, rejecting transaction")
5946
return False
6047

61-
sender_queue[tx.nonce] = tx
48+
if replacement_index is not None:
49+
old_tx = self._pending_txs[replacement_index]
50+
self._seen_tx_ids.discard(self._get_tx_id(old_tx))
51+
self._pending_txs[replacement_index] = tx
52+
else:
53+
self._pending_txs.append(tx)
54+
6255
self._seen_tx_ids.add(tx_id)
6356
return True
6457

65-
def get_transactions_for_block(self, state=None):
58+
def get_transactions_for_block(self):
6659
"""
67-
Returns ready transactions only.
68-
69-
Transactions for the same sender are included in nonce order starting
70-
from the sender's current account nonce. Later nonces stay queued until
71-
earlier ones are confirmed.
60+
Returns transactions in deterministic sorted queue order.
61+
This is read-only; transactions are removed only after block acceptance.
7262
"""
7363
with self._lock:
74-
selected = []
75-
76-
for sender, sender_queue in self._pending_by_sender.items():
77-
expected_nonce = self._expected_nonce_for_sender(sender, state)
78-
while expected_nonce in sender_queue:
79-
selected.append(sender_queue[expected_nonce])
80-
expected_nonce += 1
81-
64+
selected = list(self._pending_txs)
8265
selected.sort(key=lambda tx: (tx.timestamp, tx.sender, tx.nonce))
83-
self._remove_transactions_unlocked(selected)
84-
return selected
66+
return selected[: self.transactions_per_block]
8567

8668
def remove_transactions(self, transactions):
8769
with self._lock:
88-
self._remove_transactions_unlocked(transactions)
89-
90-
def _remove_transactions_unlocked(self, transactions):
91-
for tx in transactions:
92-
tx_id = self._get_tx_id(tx)
93-
sender_queue = self._pending_by_sender.get(tx.sender)
94-
if sender_queue and tx.nonce in sender_queue:
95-
del sender_queue[tx.nonce]
96-
if not sender_queue:
97-
del self._pending_by_sender[tx.sender]
98-
self._seen_tx_ids.discard(tx_id)
70+
remove_ids = {self._get_tx_id(tx) for tx in transactions}
71+
remove_sender_nonces = {(tx.sender, tx.nonce) for tx in transactions}
72+
if not remove_ids:
73+
return
74+
self._pending_txs = [
75+
tx
76+
for tx in self._pending_txs
77+
if self._get_tx_id(tx) not in remove_ids
78+
and (tx.sender, tx.nonce) not in remove_sender_nonces
79+
]
80+
self._seen_tx_ids = {self._get_tx_id(tx) for tx in self._pending_txs}
9981

10082
def __len__(self):
10183
with self._lock:
102-
return self._count_transactions_unlocked()
84+
return len(self._pending_txs)

minichain/p2p.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import logging
1111

1212
from .serialization import canonical_json_hash
13+
from .validators import is_valid_receiver
1314

1415
logger = logging.getLogger(__name__)
1516

@@ -133,6 +134,13 @@ def _validate_transaction_payload(self, payload):
133134
if not isinstance(payload.get(field), expected_type):
134135
return False
135136

137+
if payload["amount"] <= 0:
138+
return False
139+
140+
receiver = payload.get("receiver")
141+
if receiver is not None and not is_valid_receiver(receiver):
142+
return False
143+
136144
return True
137145

138146
def _validate_sync_payload(self, payload):

minichain/validators.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import re
2+
3+
4+
def is_valid_receiver(receiver):
5+
return bool(re.fullmatch(r"[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", receiver))

tests/test_protocol_hardening.py

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,15 @@ def test_block_hash_matches_compute_hash(self):
2323
self.assertEqual(block.compute_hash(), calculate_hash(block.to_header_dict()))
2424

2525

26-
class TestMempoolNonceQueues(unittest.TestCase):
26+
class TestMempoolQueue(unittest.TestCase):
2727
def setUp(self):
2828
self.state = State()
2929
self.sender_sk = SigningKey.generate()
3030
self.sender_pk = self.sender_sk.verify_key.encode(encoder=HexEncoder).decode()
3131
self.receiver_pk = SigningKey.generate().verify_key.encode(encoder=HexEncoder).decode()
3232
self.state.credit_mining_reward(self.sender_pk, 100)
3333

34-
def _signed_tx(self, nonce, amount=1, timestamp=None):
34+
def _signed_tx(self, nonce, amount=1, timestamp=None) -> Transaction:
3535
tx = Transaction(
3636
sender=self.sender_pk,
3737
receiver=self.receiver_pk,
@@ -42,38 +42,31 @@ def _signed_tx(self, nonce, amount=1, timestamp=None):
4242
tx.sign(self.sender_sk)
4343
return tx
4444

45-
def test_ready_transactions_preserve_sender_nonce_order(self):
45+
def test_transactions_for_block_are_sorted_and_capped(self):
4646
mempool = Mempool()
47-
late_tx = self._signed_tx(1, timestamp=2000)
48-
early_tx = self._signed_tx(0, timestamp=1000)
47+
for nonce in range(mempool.transactions_per_block + 5):
48+
self.assertTrue(mempool.add_transaction(self._signed_tx(nonce, timestamp=5000 - nonce)))
4949

50-
self.assertTrue(mempool.add_transaction(late_tx))
51-
self.assertTrue(mempool.add_transaction(early_tx))
50+
selected = mempool.get_transactions_for_block()
5251

53-
selected = mempool.get_transactions_for_block(self.state)
54-
55-
self.assertEqual([tx.nonce for tx in selected], [0, 1])
56-
self.assertEqual(len(mempool), 0)
52+
self.assertEqual(len(selected), mempool.transactions_per_block)
53+
self.assertEqual(len(mempool), mempool.transactions_per_block + 5)
54+
self.assertEqual(
55+
[tx.timestamp for tx in selected],
56+
sorted(tx.timestamp for tx in selected),
57+
)
5758

58-
def test_gap_transactions_stay_waiting(self):
59+
def test_same_nonce_replaces_pending_transaction(self):
5960
mempool = Mempool()
60-
ready_tx = self._signed_tx(0, timestamp=1000)
61-
waiting_tx = self._signed_tx(2, timestamp=3000)
62-
63-
self.assertTrue(mempool.add_transaction(ready_tx))
64-
self.assertTrue(mempool.add_transaction(waiting_tx))
65-
66-
selected = mempool.get_transactions_for_block(self.state)
67-
68-
self.assertEqual([tx.nonce for tx in selected], [0])
69-
self.assertEqual(len(mempool), 1)
61+
original_tx = self._signed_tx(0, amount=1, timestamp=1000)
62+
replacement_tx = self._signed_tx(0, amount=2, timestamp=2000)
7063

71-
self.state.apply_transaction(ready_tx)
72-
middle_tx = self._signed_tx(1, timestamp=2000)
73-
self.assertTrue(mempool.add_transaction(middle_tx))
64+
self.assertTrue(mempool.add_transaction(original_tx))
65+
self.assertTrue(mempool.add_transaction(replacement_tx))
7466

75-
selected = mempool.get_transactions_for_block(self.state)
76-
self.assertEqual([tx.nonce for tx in selected], [1, 2])
67+
selected = mempool.get_transactions_for_block()
68+
self.assertEqual(len(selected), 1)
69+
self.assertEqual(selected[0].amount, 2)
7770

7871
def test_remove_transactions_keeps_other_pending(self):
7972
mempool = Mempool()
@@ -83,8 +76,21 @@ def test_remove_transactions_keeps_other_pending(self):
8376
self.assertTrue(mempool.add_transaction(tx0))
8477
self.assertTrue(mempool.add_transaction(tx1))
8578
mempool.remove_transactions([tx0])
79+
selected = mempool.get_transactions_for_block()
8680

8781
self.assertEqual(len(mempool), 1)
82+
self.assertEqual(len(selected), 1)
83+
self.assertEqual(selected[0].tx_id, tx1.tx_id)
84+
85+
def test_remove_transactions_by_sender_nonce_when_tx_id_differs(self):
86+
mempool = Mempool()
87+
local_tx = self._signed_tx(0, amount=1, timestamp=1000)
88+
remote_confirmed_tx = self._signed_tx(0, amount=2, timestamp=2000)
89+
90+
self.assertTrue(mempool.add_transaction(local_tx))
91+
mempool.remove_transactions([remote_confirmed_tx])
92+
93+
self.assertEqual(len(mempool), 0)
8894

8995

9096
class TestP2PValidationAndDedup(unittest.IsolatedAsyncioTestCase):

0 commit comments

Comments
 (0)