Survive oversized Electrum replies and ignore dust deposits (B-75, ELC-20, DEP-01, DAT-10)

A reply line over asyncio's default 64 KiB StreamReader limit raised out of
_read_loop and closed the only connection; the pool address's get_history passes
that after ~800 transactions, and dust sent to any public address does it on
purpose.

- ElectrumClient opens the stream with a 16 MiB limit and frames lines itself:
  an oversized line is discarded up to its newline, a non-JSON line is dropped,
  and the request whose id is readable in the first/last 256 bytes fails at once;
  the connection stays up either way.
- Coins below DUST_LIMIT_SATS are never offered as credit candidates, never
  credited, and select_utxos never selects them.
- Spec (ELC-20, DEP-01, DAT-10, ARC), storico, BUGS.md and test counts updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 12:14:12 +02:00
1 parent b424f670c5
commit daec4749c2
15 files changed
+245 -49

No files matched your search

+22
View File
@@ -207,3 +207,25 @@ async def test_find_new_credit_candidates_skips_unconfirmed_and_already_known(se
candidates = await find_new_credit_candidates(session, user_id, entries)
assert [(c["tx_hash"], c["tx_pos"]) for c in candidates] == [("cc" * 32, 1)]
# --- B-75 (DEP-01): coins below DUST_LIMIT_SATS are never credited -------------
async def test_dust_is_never_credited_nor_offered_as_a_candidate(session_factory, user_id):
"""DEP-01 / B-75: anyone can send dust to a public address."""
from app.deposits.service import find_new_credit_candidates
from app.wallet.psbt_builder import DUST_LIMIT_SATS
entries = [
{"tx_hash": "e1" * 32, "tx_pos": 0, "height": 100, "value": DUST_LIMIT_SATS - 1},
{"tx_hash": "e2" * 32, "tx_pos": 1, "height": 100, "value": DUST_LIMIT_SATS},
]
async with session_factory() as session:
candidates = await find_new_credit_candidates(session, user_id, entries)
assert [c["tx_hash"] for c in candidates] == ["e2" * 32]
assert await credit_confirmed_utxos(session, user_id, entries) == 1
user = await session.get(User, user_id)
assert user.cached_balance_sats == DUST_LIMIT_SATS
rows = (await session.scalars(select(UtxoEvent))).all()
assert [r.txid for r in rows] == ["e2" * 32]
+98
View File
@@ -155,3 +155,101 @@ def test_parse_endpoints_rejects_a_malformed_entry():
for bad in ["nohost:", "host:notaport", "host", "host:50002:weird"]:
with pytest.raises(ValueError):
parse_endpoints("primary.example", 50002, True, bad)
# --- B-75 (ELC-20): an oversized or malformed reply is a request error, never a
# dead connection. A tiny line limit stands in for 16 MiB. ---------------------
async def _send_request(client, writer, reader):
task = asyncio.create_task(client.request("blockchain.scripthash.get_history", ["ab"]))
await asyncio.sleep(0)
return task, json.loads(writer.written.decode().splitlines()[-1])["id"]
async def test_the_line_limit_is_16_mib_and_connect_applies_it(monkeypatch):
"""ELC-20 / B-75: the StreamReader is opened with a 16 MiB limit, not 64 KiB."""
from app.electrum import client as client_module
assert client_module._MAX_LINE_BYTES == 16 * 1024 * 1024
seen = {}
async def fake_open_connection(host, port, **kwargs):
seen.update(kwargs)
raise OSError("stop here")
monkeypatch.setattr(asyncio, "open_connection", fake_open_connection)
try:
await ElectrumClient("localhost", 1).connect()
except OSError:
pass
assert seen["limit"] == 16 * 1024 * 1024
async def test_an_oversized_reply_fails_its_request_and_keeps_the_connection(monkeypatch):
"""ELC-20 / B-75: the reply is discarded to its newline (even when it arrives in
pieces), the request whose id is in its head fails at once, and the next reply
on the same connection is still delivered."""
from app.electrum import client as client_module
monkeypatch.setattr(client_module, "_MAX_LINE_BYTES", 1024)
client, reader, writer = await _client_with_fake_transport()
task, request_id = await _send_request(client, writer, reader)
head = b'{"id": %d, "result": [' % request_id
reader.feed_data(head + b"1," * 600) # already over the limit, no newline yet
await asyncio.sleep(0)
reader.feed_data(b"1," * 600 + b"1]}\n")
try:
await asyncio.wait_for(task, timeout=1)
assert False, "expected ElectrumError"
except ElectrumError:
pass
assert not client._closed.is_set()
task2, request_id2 = await _send_request(client, writer, reader)
reader.feed_data((json.dumps({"id": request_id2, "result": "ok"}) + "\n").encode())
assert await asyncio.wait_for(task2, timeout=1) == "ok"
client._read_task.cancel()
async def test_an_oversized_reply_with_the_id_at_the_end_also_fails_its_request(monkeypatch):
"""ELC-20: the id is looked for in the last bytes of the line too."""
from app.electrum import client as client_module
monkeypatch.setattr(client_module, "_MAX_LINE_BYTES", 1024)
client, reader, writer = await _client_with_fake_transport()
task, request_id = await _send_request(client, writer, reader)
reader.feed_data(b'{"result": [' + b"1," * 1000 + b'1], "id": %d}\n' % request_id)
try:
await asyncio.wait_for(task, timeout=1)
assert False, "expected ElectrumError"
except ElectrumError:
pass
assert not client._closed.is_set()
client._read_task.cancel()
async def test_a_reply_that_is_not_json_fails_its_request_not_the_connection():
"""ELC-20 / B-75: json.loads used to raise out of the read loop and close it."""
client, reader, writer = await _client_with_fake_transport()
task, request_id = await _send_request(client, writer, reader)
reader.feed_data(b'{"id": %d, "result": [1, 2,\n' % request_id)
try:
await asyncio.wait_for(task, timeout=1)
assert False, "expected ElectrumError"
except ElectrumError:
pass
assert not client._closed.is_set()
# Garbage with no readable id, and a non-object message, are dropped silently.
reader.feed_data(b"not json at all\n[1, 2]\n")
task2, request_id2 = await _send_request(client, writer, reader)
reader.feed_data((json.dumps({"id": request_id2, "result": 7}) + "\n").encode())
assert await asyncio.wait_for(task2, timeout=1) == 7
client._read_task.cancel()
+10
View File
@@ -32,6 +32,16 @@ def test_select_utxos_picks_largest_first():
assert total == 20_000_000
def test_select_utxos_ignores_dust(): # B-75 (DEP-01)
from app.wallet.psbt_builder import DUST_LIMIT_SATS
utxos = [Utxo("a" * 64, 0, DUST_LIMIT_SATS - 1), Utxo("b" * 64, 0, DUST_LIMIT_SATS)]
selected, total = select_utxos(utxos, target_sats=DUST_LIMIT_SATS)
assert [u.txid for u in selected] == ["b" * 64]
with pytest.raises(InsufficientFundsError):
select_utxos(utxos, target_sats=DUST_LIMIT_SATS + 1)
def test_select_utxos_raises_when_insufficient():
utxos = [Utxo("a" * 64, 0, 1_000_000)]
with pytest.raises(InsufficientFundsError):