Skip to content

Commit 07bdf55

Browse files
committed
v2.3.4: dust limit, LRU eviction, ban expiry, block cache bound, sync stall timeout, ops limit, miner resilience, version check
- Add min_output_value to ChainParams (default 1 scar) to prevent UTXO set spam - Replace _invalidate dict mass-clear with LRU eviction to prevent cache-flush DoS - Add 24-hour expiry to misbehaviour-triggered bans (was permanent) - Add byte limit (128 MB) to block cache alongside entry-count limit - Re-assign stalled blocks from slow peers after 30s timeout during header sync - Add MAX_SCRIPT_OPS (200) limit for defence-in-depth script execution bound - Restart multiprocessing pool on worker crash instead of letting exception propagate - Clarify mempool minimum_fee with math.ceil instead of opaque (x+999)//1000 - Add version check to all CLI tools and GUI apps (checks PyPI once per day) - Bump version to 2.3.4
1 parent af9340e commit 07bdf55

19 files changed

Lines changed: 282 additions & 30 deletions

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,18 @@ retargeting, halvings, a hard supply cap — with faster blocks and a modern
1111
feature set (BIP-39/32/44 wallets, P2SH multisig, replace-by-fee, encrypted
1212
P2P links).
1313

14-
**Version 2.3.3 · Mainnet is live and mined · MIT licensed**
14+
**Version 2.3.4 · Mainnet is live and mined · MIT licensed**
1515

1616
<div align="center">
1717

18-
[![Download for Windows (64-bit)](https://img.shields.io/badge/Download-Windows_64--bit-0078D6?style=for-the-badge&logo=windows&logoColor=white)](https://github.com/alessio-ds/ScarletCoin/releases/download/v2.3.3/ScarletCoin-2.3.3-win64.zip)
19-
[![Windows installer](https://img.shields.io/badge/Windows_Installer-.exe-0078D6?style=for-the-badge&logo=windows&logoColor=white)](https://github.com/alessio-ds/ScarletCoin/releases/download/v2.3.3/ScarletCoin-Setup-2.3.3.exe)
18+
[![Download for Windows (64-bit)](https://img.shields.io/badge/Download-Windows_64--bit-0078D6?style=for-the-badge&logo=windows&logoColor=white)](https://github.com/alessio-ds/ScarletCoin/releases/download/v2.3.4/ScarletCoin-2.3.4-win64.zip)
19+
[![Windows installer](https://img.shields.io/badge/Windows_Installer-.exe-0078D6?style=for-the-badge&logo=windows&logoColor=white)](https://github.com/alessio-ds/ScarletCoin/releases/download/v2.3.4/ScarletCoin-Setup-2.3.4.exe)
2020

21-
[![Download for Ubuntu](https://img.shields.io/badge/Download-Ubuntu-E95420?style=for-the-badge&logo=ubuntu&logoColor=white)](https://github.com/alessio-ds/ScarletCoin/releases/download/v2.3.3/ScarletCoin-2.3.3-linux-x86_64.tar.gz)
22-
[![Download for Fedora](https://img.shields.io/badge/Download-Fedora-51A2DA?style=for-the-badge&logo=fedora&logoColor=white)](https://github.com/alessio-ds/ScarletCoin/releases/download/v2.3.3/ScarletCoin-2.3.3-linux-fc44-x86_64.tar.gz)
21+
[![Download for Ubuntu](https://img.shields.io/badge/Download-Ubuntu-E95420?style=for-the-badge&logo=ubuntu&logoColor=white)](https://github.com/alessio-ds/ScarletCoin/releases/download/v2.3.4/ScarletCoin-2.3.4-linux-x86_64.tar.gz)
22+
[![Download for Fedora](https://img.shields.io/badge/Download-Fedora-51A2DA?style=for-the-badge&logo=fedora&logoColor=white)](https://github.com/alessio-ds/ScarletCoin/releases/download/v2.3.4/ScarletCoin-2.3.4-linux-fc44-x86_64.tar.gz)
2323

24-
[![Download for macOS (Apple Silicon)](https://img.shields.io/badge/Download-macOS_Apple_Silicon-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/alessio-ds/ScarletCoin/releases/download/v2.3.3/ScarletCoin-2.3.3-macos-arm64.tar.gz)
25-
[![Download for macOS (Intel)](https://img.shields.io/badge/Download-macOS_Intel-555555?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/alessio-ds/ScarletCoin/releases/download/v2.3.3/ScarletCoin-2.3.3-macos-x86_64.tar.gz)
24+
[![Download for macOS (Apple Silicon)](https://img.shields.io/badge/Download-macOS_Apple_Silicon-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/alessio-ds/ScarletCoin/releases/download/v2.3.4/ScarletCoin-2.3.4-macos-arm64.tar.gz)
25+
[![Download for macOS (Intel)](https://img.shields.io/badge/Download-macOS_Intel-555555?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/alessio-ds/ScarletCoin/releases/download/v2.3.4/ScarletCoin-2.3.4-macos-x86_64.tar.gz)
2626

2727
**No Python. No dependencies. Download, extract, run.**
2828

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "scarletcoin"
3-
version = "2.3.3"
3+
version = "2.3.4"
44
description = "ScarletCoin: a small but complete proof-of-work cryptocurrency with a real blockchain, peer-to-peer nodes, wallet and miner."
55
readme = "README.md"
66
license = { file = "LICENSE" }

src/scarletcoin/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@
2020

2121
__all__ = ["COIN", "NETWORKS", "__version__", "get_params"]
2222

23-
__version__ = "2.3.3"
23+
__version__ = "2.3.4"

src/scarletcoin/cli_common.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from scarletcoin.core.params import get_params, network_names
1616
from scarletcoin.net.client import RpcClient
17+
from scarletcoin.version_check import check_version
1718

1819
__all__ = [
1920
"DEFAULT_DATADIR",
@@ -26,6 +27,7 @@
2627
"load_connection",
2728
"local_url",
2829
"make_client",
30+
"maybe_check_version",
2931
"read_rpc_token",
3032
"save_connection",
3133
"setup_logging",
@@ -230,3 +232,27 @@ def make_client(args: argparse.Namespace) -> RpcClient:
230232
url = args.rpc_url or local_url(args.network)
231233
token = args.rpc_token or read_rpc_token(args.datadir, args.network)
232234
return RpcClient(url, token=token, timeout=args.timeout)
235+
236+
237+
def maybe_check_version(datadir: str | Path) -> None:
238+
"""Log a warning when a newer ScarletCoin release is on PyPI.
239+
240+
Called once at start-up by every CLI tool. The check is cached for a day
241+
and never blocks — a slow or unreachable PyPI is silently ignored.
242+
"""
243+
import logging
244+
245+
logger = logging.getLogger(__name__)
246+
try:
247+
latest = check_version(datadir)
248+
except Exception:
249+
return
250+
if latest is not None:
251+
from scarletcoin import __version__ as current
252+
253+
logger.warning(
254+
"ScarletCoin %s is available (you are running %s)."
255+
" Upgrade with: pip install --upgrade scarletcoin",
256+
latest,
257+
current,
258+
)

src/scarletcoin/core/block.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,9 @@ def size(self) -> int:
216216
"""Serialised size in bytes."""
217217
return len(self.serialize())
218218

219-
def check_sanity(self, *, pow_limit: int, max_block_size: int) -> None:
219+
def check_sanity(
220+
self, *, pow_limit: int, max_block_size: int, min_output_value: int = 0
221+
) -> None:
220222
"""Validate the block without consulting the chain.
221223
222224
Checks proof of work, size, the Merkle root, that exactly one coinbase is
@@ -238,7 +240,7 @@ def check_sanity(self, *, pow_limit: int, max_block_size: int) -> None:
238240
if tx.is_coinbase:
239241
raise BlockError("block contains more than one coinbase transaction")
240242
for tx in self.transactions:
241-
tx.check_sanity()
243+
tx.check_sanity(min_output_value=min_output_value)
242244
txids = self.txids()
243245
if len(set(txids)) != len(txids):
244246
raise BlockError("block contains duplicate transactions")

src/scarletcoin/core/chain.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import statistics
2424
import threading
2525
import time
26+
from collections import OrderedDict
2627
from collections.abc import Iterable
2728
from dataclasses import dataclass, replace
2829
from enum import Enum
@@ -157,7 +158,7 @@ def __init__(self, storage: Storage, params: ChainParams) -> None:
157158
self.params = params
158159
self._lock = threading.RLock()
159160
self._listeners: list[ChainListener] = []
160-
self._invalid: dict[bytes, str] = {}
161+
self._invalid: OrderedDict[bytes, str] = OrderedDict()
161162
self._tip = self._load_or_create_genesis()
162163
self._stats_cache: dict | None = None
163164
self._stats_at = 0.0
@@ -179,7 +180,9 @@ def _load_or_create_genesis(self) -> BlockIndexEntry:
179180

180181
genesis = self.params.genesis_block
181182
genesis.check_sanity(
182-
pow_limit=self.params.pow_limit, max_block_size=self.params.max_block_size
183+
pow_limit=self.params.pow_limit,
184+
max_block_size=self.params.max_block_size,
185+
min_output_value=0,
183186
)
184187
with self.storage.write():
185188
entry = self.storage.put_block(
@@ -678,7 +681,9 @@ def add_block(self, block: Block) -> AddBlockResult:
678681

679682
try:
680683
block.check_sanity(
681-
pow_limit=self.params.pow_limit, max_block_size=self.params.max_block_size
684+
pow_limit=self.params.pow_limit,
685+
max_block_size=self.params.max_block_size,
686+
min_output_value=self.params.min_output_value,
682687
)
683688
except (BlockError, TransactionError) as exc:
684689
self._invalid[block_hash] = str(exc)
@@ -791,9 +796,14 @@ def _best_candidate(self) -> BlockIndexEntry | None:
791796
return None
792797

793798
def _invalidate(self, block_hash: bytes, reason: str) -> None:
794-
"""Mark a block and everything built on top of it as unusable."""
795-
if len(self._invalid) > _MAX_REMEMBERED_INVALID:
796-
self._invalid.clear()
799+
"""Mark a block and everything built on top of it as unusable.
800+
801+
When the cache fills up the oldest entries are evicted one at a time
802+
rather than all at once, so an attacker who floods 5001 distinct
803+
invalid blocks cannot flush the entire cache and force re-validation.
804+
"""
805+
while len(self._invalid) >= _MAX_REMEMBERED_INVALID:
806+
self._invalid.popitem(last=False)
797807
pending = [(block_hash, reason)]
798808
while pending:
799809
current, why = pending.pop()

src/scarletcoin/core/mempool.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ def add(self, transaction: Transaction) -> MempoolEntry:
172172
if txid in self._by_txid:
173173
raise MempoolError("transaction is already in the mempool")
174174
try:
175-
transaction.check_sanity()
175+
transaction.check_sanity(min_output_value=self.params.min_output_value)
176176
except TransactionError as exc:
177177
raise MempoolError(str(exc)) from exc
178178
if transaction.is_coinbase:
@@ -240,8 +240,14 @@ def _replace_conflicts(self, transaction: Transaction, fee: int, size: int, conf
240240
self.remove(txid)
241241

242242
def minimum_fee(self, size: int) -> int:
243-
"""Return the smallest fee the node will relay for a transaction of ``size``."""
244-
return max(1, (size * self.params.min_relay_fee_per_kb + 999) // 1000)
243+
"""Return the smallest fee the node will relay for a transaction of ``size``.
244+
245+
Calculated as ``ceil(size * min_relay_fee_per_kb / 1000)``, clamped to at
246+
least 1 scar so every transaction pays something.
247+
"""
248+
from math import ceil
249+
250+
return max(1, ceil(size * self.params.min_relay_fee_per_kb / 1000))
245251

246252
def estimate_fee_rate(self, blocks: int = 1) -> int:
247253
"""Roughly estimate the fee rate (scar/kB) needed to confirm in ``blocks``.

src/scarletcoin/core/params.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ class ChainParams:
9494
"""Window used for the "greater than the median of the last N" timestamp rule."""
9595
min_relay_fee_per_kb: int = 1_000
9696
"""Cheapest fee rate a node will relay or mine, in scar per kilobyte."""
97+
min_output_value: int = 1
98+
"""Smallest output value the network will relay and mine, in scar.
99+
100+
Outputs below this value are rejected by the mempool and by block validation,
101+
which prevents an attacker from filling the UTXO set with millions of
102+
sub-dust outputs. The default of ``1`` (the smallest indivisible unit) keeps
103+
every valid transaction spendable while closing the obvious spam vector.
104+
"""
97105

98106
# BIP-0044
99107
bip44_coin_type: int = 0

src/scarletcoin/core/script.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
__all__ = [
2626
"MAX_PUBKEYS",
27+
"MAX_SCRIPT_OPS",
2728
"MAX_SCRIPT_SIZE",
2829
"OP_CHECKMULTISIG",
2930
"OP_CHECKSIG",
@@ -49,6 +50,14 @@
4950
MAX_STACK_ITEMS = 20
5051
"""Largest stack a script may build."""
5152

53+
MAX_SCRIPT_OPS = 200
54+
"""Most operations a single script execution may perform.
55+
56+
Together with :data:`MAX_SCRIPT_SIZE` this bounds the worst-case cost of
57+
evaluating a redeem script. Even a script built entirely of single-byte
58+
push operations cannot exceed this limit.
59+
"""
60+
5261
OP_0 = 0x00
5362
OP_PUSHBYTES_MAX = 0x4B
5463
OP_PUSHDATA1 = 0x4C
@@ -211,7 +220,9 @@ def evaluate_script(script: bytes, arguments: list[bytes], digest: bytes) -> boo
211220
"""
212221
try:
213222
stack = list(arguments)
214-
for opcode, data in decode_ops(script):
223+
for index, (opcode, data) in enumerate(decode_ops(script), 1):
224+
if index > MAX_SCRIPT_OPS:
225+
return False
215226
if opcode <= OP_PUSHBYTES_MAX or opcode in (OP_PUSHDATA1, OP_PUSHDATA2):
216227
stack.append(data)
217228
elif OP_1 <= opcode <= OP_16:

src/scarletcoin/core/storage.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@
5858
#: Blocks are immutable, so this is only a read cache; it is dropped on pruning.
5959
BLOCK_CACHE_SIZE = 1024
6060

61+
#: Maximum total bytes the block cache may hold before the oldest entries are
62+
#: evicted. A full-size (1 MB) block at the default entry count would take
63+
#: 1 GB of memory, so the byte limit is the stricter bound in practice.
64+
BLOCK_CACHE_MAX_BYTES = 128 * 1024 * 1024 # 128 MB
65+
6166
_SCHEMA = """
6267
CREATE TABLE IF NOT EXISTS meta (
6368
key TEXT PRIMARY KEY,
@@ -293,6 +298,7 @@ def __init__(self, path: str | Path) -> None:
293298
self._size_cache: dict | None = None
294299
self._size_measured_at = 0.0
295300
self._block_cache: OrderedDict[bytes, Block] = OrderedDict()
301+
self._block_cache_bytes = 0
296302
self._connection = sqlite3.connect(
297303
str(self.path), check_same_thread=False, isolation_level=None, timeout=30.0
298304
)
@@ -555,8 +561,13 @@ def get_block(self, block_hash: bytes) -> Block | None:
555561
with self._lock:
556562
self._block_cache[block_hash] = block
557563
self._block_cache.move_to_end(block_hash)
558-
while len(self._block_cache) > BLOCK_CACHE_SIZE:
559-
self._block_cache.popitem(last=False)
564+
self._block_cache_bytes += len(raw)
565+
while (
566+
len(self._block_cache) > BLOCK_CACHE_SIZE
567+
or self._block_cache_bytes > BLOCK_CACHE_MAX_BYTES
568+
):
569+
_, old = self._block_cache.popitem(last=False)
570+
self._block_cache_bytes -= old.size()
560571
return block
561572

562573
def set_in_chain(self, block_hash: bytes, in_chain: bool) -> None:
@@ -829,6 +840,7 @@ def prune_to(self, height: int) -> PruneResult:
829840
self.set_meta("prune_height", str(marker).encode())
830841
self._forget_sizes()
831842
self._block_cache.clear()
843+
self._block_cache_bytes = 0
832844
return PruneResult(
833845
blocks=len(candidates),
834846
transactions=transactions,

0 commit comments

Comments
 (0)