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
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user