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:
1 parent
b424f670c5
commit
daec4749c2
15 files changed
+245
-49
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user