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

-13
View File
@@ -137,19 +137,6 @@ the intermediate headers (`blockchain.block.headers`) and validate the chain; on
linkage mismatch walk back up to 10 headers to find the fork point and follow the
reorg instead of ending the session (reorg handling: B-85).
### B-75 — Replies over 64 KiB kill the connection; the pool's history grows past that
`asyncio.open_connection` uses the default 64 KiB `StreamReader` limit; a longer
reply line raises `ValueError` in `_read_loop` and closes the connection. The
confirmation poller and the reconciler fetch the pool address's full history every
cycle, which passes 64 KiB after ~800 transactions: from then on every 10s poll
drops the only connection and payouts never confirm. A few hundred dust outputs sent
to any public address trigger the same thing on purpose.
Fix: 16 MiB reader limit; an oversized or malformed reply is a request error, not a
dead connection; UTXOs below `DUST_LIMIT_SATS` are ignored for crediting and
selection; per-round pool addresses (B-80) keep every address's history bounded.
### B-76 — An ambiguous broadcast is rolled back as if refused
`place_bet`/`request_withdrawal` release everything on *any* broadcast exception,
+2 -2
View File
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## Project status
All 10 stages of the original build order are code-complete and unit-tested — 388 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
All 10 stages of the original build order are code-complete and unit-tested — 394 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
@@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
python -m pytest # all 388 tests
python -m pytest # all 394 tests
python -m pytest tests/unit/test_hd.py # one file
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
```
+1 -1
View File
@@ -62,7 +62,7 @@ python -m pytest # all tests
python -m pytest tests/unit/test_hd.py # one file
```
388 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
394 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
the pending-inclusive balance calculation, and the SSE push channel. No
automated integration tests against a live Electrum connection — mainnet
+10 -2
View File
@@ -8,6 +8,7 @@ from app.db.base import begin_immediate
from app.db.models import UtxoEvent
from app.rounds.events import broadcaster
from app.wallet.balance import recompute_balance
from app.wallet.psbt_builder import DUST_LIMIT_SATS
logger = logging.getLogger(__name__)
@@ -15,7 +16,7 @@ logger = logging.getLogger(__name__)
async def find_new_credit_candidates(session: AsyncSession, user_id: int, entries: list[dict]) -> list[dict]:
"""The subset of `entries` that would actually credit something: confirmed
(height > 0, per the Electrum convention where <= 0 means mempool) and not
already recorded.
already recorded, and not dust (B-75: below DUST_LIMIT_SATS nothing is credited).
Split out from credit_confirmed_utxos so the caller
(electrum/listener.py:refresh_user) can corroborate each *new* outpoint
@@ -34,7 +35,9 @@ async def find_new_credit_candidates(session: AsyncSession, user_id: int, entrie
return [
entry
for entry in entries
if entry["height"] > 0 and (entry["tx_hash"], entry["tx_pos"]) not in existing_keys
if entry["height"] > 0
and entry["value"] >= DUST_LIMIT_SATS # B-75 (DEP-01)
and (entry["tx_hash"], entry["tx_pos"]) not in existing_keys
]
@@ -63,6 +66,11 @@ async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: l
for entry in entries:
if entry["height"] <= 0:
continue
if entry["value"] < DUST_LIMIT_SATS:
# B-75 (DEP-01): anyone can send dust to a public address; crediting it would
# make every later refresh of this user's address do more work, for coins
# that cost more to spend than they are worth.
continue
key = (entry["tx_hash"], entry["tx_pos"])
if key in existing_keys:
continue
+80 -6
View File
@@ -1,10 +1,15 @@
import asyncio
import itertools
import json
import logging
import re
import ssl
from typing import NamedTuple
logger = logging.getLogger(__name__)
class ElectrumEndpoint(NamedTuple):
"""One server to connect to. The listener rotates over a list of these so a
single unreachable or misbehaving server doesn't take the platform down —
@@ -64,6 +69,18 @@ def parse_endpoints(
# and the confirmation poller could stop polling permanently.
_REQUEST_TIMEOUT_SECONDS = 15
# Longest reply line the client accepts (B-75, ELC-20). asyncio's default StreamReader
# limit is 64 KiB, and a longer line used to raise out of the read loop and close the
# only connection: the pool address's get_history passed that after ~800 transactions
# (and a few hundred dust outputs sent to any public address do it on purpose), after
# which every confirmation poll dropped the connection and payouts never confirmed.
_MAX_LINE_BYTES = 16 * 1024 * 1024
_READ_CHUNK_BYTES = 64 * 1024
# How much of each end of a discarded line is kept to find the reply's `id` in, so the
# request it answered can fail at once instead of waiting out the 15s timeout.
_ID_SNIFF_BYTES = 256
_ID_PATTERN = re.compile(rb'"id"\s*:\s*(\d+)')
class ElectrumError(Exception):
pass
@@ -104,7 +121,9 @@ class ElectrumClient:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
self._reader, self._writer = await asyncio.open_connection(self.host, self.port, ssl=ssl_context)
self._reader, self._writer = await asyncio.open_connection(
self.host, self.port, ssl=ssl_context, limit=_MAX_LINE_BYTES
)
self._read_task = asyncio.create_task(self._read_loop())
await self.request("server.version", ["plm-lottery", "1.4"])
@@ -187,16 +206,44 @@ class ElectrumClient:
return await self.request("blockchain.transaction.get", [txid, verbose])
async def _read_loop(self) -> None:
"""Frame the stream into lines by hand rather than with StreamReader.readline:
readline raises on a line over the limit, and that exception used to end this
loop and with it the connection. An oversized line is now dropped up to its
newline (B-75, ELC-20) and the connection stays up."""
assert self._reader is not None
buffer = bytearray()
discarding = False
head = b""
tail = b""
try:
while True:
line = await self._reader.readline()
if not line:
chunk = await self._reader.read(_READ_CHUNK_BYTES)
if not chunk:
break
message = json.loads(line)
self._dispatch(message)
while chunk:
newline = chunk.find(b"\n")
piece = chunk if newline < 0 else chunk[:newline]
chunk = b"" if newline < 0 else chunk[newline + 1 :]
if discarding:
tail = (tail + piece)[-_ID_SNIFF_BYTES:]
else:
buffer += piece
if len(buffer) > _MAX_LINE_BYTES:
discarding = True
head = bytes(buffer[:_ID_SNIFF_BYTES])
tail = bytes(buffer[-_ID_SNIFF_BYTES:])
buffer.clear()
if newline >= 0:
if discarding:
self._fail_unreadable_reply(head + b" " + tail, "reply exceeds the line limit")
discarding = False
else:
self._handle_line(bytes(buffer))
buffer.clear()
if buffer and not discarding:
self._handle_line(bytes(buffer)) # a final line the server never terminated
finally:
# Whatever ended this loop — clean EOF, protocol error, TLS failure — the
# Whatever ended this loop — clean EOF, TLS failure — the
# connection is unusable from here on. Setting this is what makes the
# death observable to wait_closed(), and so to the listener's reconnect
# logic; without it the listener waited on notification queues nobody
@@ -208,6 +255,33 @@ class ElectrumClient:
future.set_exception(error)
self._pending.clear()
def _handle_line(self, line: bytes) -> None:
if not line.strip():
return
try:
message = json.loads(line)
except ValueError:
# A reply that is not JSON is a problem of that one request, not of the
# connection (B-75).
self._fail_unreadable_reply(line[:_ID_SNIFF_BYTES] + b" " + line[-_ID_SNIFF_BYTES:], "reply is not valid JSON")
return
if not isinstance(message, dict):
logger.warning("ignoring an Electrum message that is not a JSON object")
return
self._dispatch(message)
def _fail_unreadable_reply(self, sample: bytes, reason: str) -> None:
"""Fail the request an unusable reply belongs to, when its `id` can be read
from `sample` (the first and last bytes of the line); otherwise that request
fails by its own timeout (ELC-04)."""
logger.warning("dropping an unusable Electrum reply: %s", reason)
for match in _ID_PATTERN.finditer(sample):
future = self._pending.pop(int(match.group(1)), None)
if future is not None:
if not future.done():
future.set_exception(ElectrumError(reason))
return
def _dispatch(self, message: dict) -> None:
message_id = message.get("id")
if message_id is not None and message_id in self._pending:
+5 -1
View File
@@ -120,7 +120,11 @@ def select_utxos(
eat into their own bet/withdrawal, while the payout drains a pool holding one
UTXO per bet and needs MAX_PAYOUT_TX_INPUTS to be able to pay a full round at
all."""
ordered = sorted(utxos, key=lambda u: u.amount_sats, reverse=True)
# B-75: a UTXO below the dust limit is never offered, even if one is already on
# record: it costs more to spend than it is worth.
ordered = sorted(
(u for u in utxos if u.amount_sats >= DUST_LIMIT_SATS), key=lambda u: u.amount_sats, reverse=True
)
selected: list[Utxo] = []
total = 0
for utxo in ordered:
+6 -8
View File
@@ -194,13 +194,13 @@ sono vuoti e non sono elencati.
| `app/wallet/hd.py` | Generazione della master key, derivazione di indirizzi/chiavi utente (`0/i`) e del pool (`1/0`), export WIF; la chiave d'account decifrata resta in cache nel processo. *Da implementare:* indirizzo del montepremi per round (`2/<round_id>`), con `1/0` come indirizzo di riserva (B-80) |
| `app/wallet/address.py` | `is_valid_plm_address`: checksum bech32 + HRP `plm` |
| `app/wallet/balance.py` | `recompute_balance` (saldo confermato), `compute_pending_balance` (saldo mostrato) — vedi [registrazione-e-depositi.md](registrazione-e-depositi.md) |
| `app/wallet/psbt_builder.py` | Selezione UTXO, costruzione e firma di scommessa, prelievo e pagamento; limiti `MAX_TX_INPUTS`, `MAX_PAYOUT_TX_INPUTS`, `MAX_PARTICIPANTS_PER_ROUND`, `DUST_LIMIT_SATS`. *Da implementare:* uscita di riserva per l'RBF sul pagamento (B-79), UTXO sotto `DUST_LIMIT_SATS` esclusi dalla selezione (B-75) |
| `app/wallet/psbt_builder.py` | Selezione UTXO, costruzione e firma di scommessa, prelievo e pagamento; limiti `MAX_TX_INPUTS`, `MAX_PAYOUT_TX_INPUTS`, `MAX_PARTICIPANTS_PER_ROUND`, `DUST_LIMIT_SATS`. *Da implementare:* uscita di riserva per l'RBF sul pagamento (B-79) |
### Rete Electrum
| File | Responsabilità |
|---|---|
| `app/electrum/client.py` | `ElectrumClient` (JSON-RPC, timeout per richiesta), `ElectrumEndpoint`, `parse_endpoints`. *Da implementare:* limite di lettura di 16 MiB per riga, risposta troppo grande o malformata come errore della richiesta e non della connessione (B-75); pin del certificato per endpoint (B-84) |
| `app/electrum/client.py` | `ElectrumClient` (JSON-RPC, timeout per richiesta), `ElectrumEndpoint`, `parse_endpoints`; limite di lettura di 16 MiB per riga, risposta troppo grande o malformata come errore della richiesta e non della connessione. *Da implementare:* pin del certificato per endpoint (B-84) |
| `app/electrum/listener.py` | `ElectrumListener`: l'unica connessione, rotazione, keepalive, validazione header, corroborazione, sottoscrizioni e accredito — vedi [connessione-electrum.md](connessione-electrum.md). Il docstring della classe cita un metodo `add_address` che non esiste: l'indirizzo di un nuovo utente si aggiunge con `address_for_new_user` |
| `app/electrum/scripthash.py` | `address_to_scripthash`: scripthash Electrum di un indirizzo |
@@ -540,12 +540,10 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
*Codice:* `app/electrum/listener.py:ElectrumListener.run` ·
`app/electrum/listener.py:ElectrumListener._apply_header` ·
`app/electrum/client.py:ElectrumClient.connect` ·
*Test:* `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-74, B-75, B-85, B-86) · *Oggi:* il listener
tiene solo la punta, chiude la sessione su ogni header che non si concatena,
non confronta la propria punta con gli altri server, e il client usa il
limite predefinito di 64 KiB di `asyncio.open_connection`, oltre il quale
cade l'intera connessione.
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_electrum_client.py`
*Stato:* da implementare (B-74, B-85, B-86) · *Oggi:* il listener
tiene solo la punta, chiude la sessione su ogni header che non si concatena
e non confronta la propria punta con gli altri server.
- **[ARC-27]** **`RoundScheduler`.** A ogni giro (5s) chiama
`open_new_round_if_needed` e poi avanza il round attivo, in qualunque stato
sia, `drawing` compreso: per un round in `drawing` il giro rientra
+2 -6
View File
@@ -85,12 +85,8 @@ Le difese sono cinque.
cui rispondeva fallisce subito con un errore se il suo `id` si legge nei
primi o negli ultimi 256 byte della riga; altrimenti fallisce allo scadere
di [ELC-04].
*Codice:* `app/electrum/client.py:ElectrumClient.connect`, `app/electrum/client.py:ElectrumClient._read_loop` · *Test:* —
*Stato:* da implementare (B-75) · *Oggi:* il lettore ha il limite
predefinito di asyncio (64 KiB); una riga più lunga, o non JSON, interrompe
`_read_loop` e chiude l'unica connessione. La cronologia dell'indirizzo del
montepremi supera 64 KiB dopo 800 transazioni (stima di B-75), e da lì ogni controllo
delle conferme la fa cadere.
*Codice:* `app/electrum/client.py:ElectrumClient.connect`, `app/electrum/client.py:ElectrumClient._read_loop` ·
*Test:* `tests/unit/test_electrum_client.py`
## 3. Caduta rilevata
+3 -3
View File
@@ -337,7 +337,7 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
corroborazione è in [connessione-electrum.md](connessione-electrum.md)).
Le conferme richieste e la soglia sotto cui un output non si accredita
(`DUST_LIMIT_SATS`) sono regole del deposito, definite in
[registrazione-e-depositi.md](registrazione-e-depositi.md) (B-82, B-75): lo
[registrazione-e-depositi.md](registrazione-e-depositi.md) (B-82): lo
schema non le ripete. Il numero di conferme configurabile è la colonna
`round_config.deposit_confirmations` ([DAT-62]). L'inserimento è
idempotente su (`txid`, `vout`), che è unico su tutta la tabella, non solo
@@ -347,8 +347,8 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
`app/deposits/service.py:credit_confirmed_utxos` ·
`app/electrum/listener.py:ElectrumListener.refresh_user` ·
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-82, B-75) · *Oggi:* si accredita ogni output
con `height > 0` (1 conferma), coinbase comprese, di qualunque importo.
*Stato:* da implementare (B-82) · *Oggi:* si accredita ogni output
con `height > 0` (1 conferma), coinbase comprese.
- **[DAT-11]** Gli UTXO dei montepremi e della riserva **non** sono in
`utxo_events`: non appartengono a un utente. Il pagamento di un round spende
+1 -1
View File
@@ -685,7 +685,7 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
`could not subscribe a newly-registered user's address` o
`resubscribing all users failed partway through`).
4. Electrum irraggiungibile o fermo ([OPS-11]).
5. Dopo B-75: l'importo è sotto `DUST_LIMIT_SATS` (294 sat) e non viene
5. L'importo è sotto `DUST_LIMIT_SATS` (294 sat) e non viene
accreditato per scelta.
**Cosa fare.** Verificare con un explorer che la transazione abbia le
conferme richieste e paghi esattamente l'indirizzo dell'utente (sezione
+2 -3
View File
@@ -226,9 +226,8 @@ monete sotto la soglia non compaiono nel saldo mostrato.
questo documento. Non è spendibile in modo economico e, se accreditata,
farebbe crescere senza limite il lavoro di ogni aggiornamento; chiunque
può inviarne a qualunque indirizzo pubblico.
*Codice:* `app/deposits/service.py:find_new_credit_candidates`, `app/deposits/service.py:credit_confirmed_utxos`, `app/wallet/psbt_builder.py:DUST_LIMIT_SATS` · *Test:* —
*Stato:* da implementare (B-75) · *Oggi:* si accredita una moneta di
qualunque importo.
*Codice:* `app/deposits/service.py:find_new_credit_candidates`, `app/deposits/service.py:credit_confirmed_utxos`, `app/wallet/psbt_builder.py:DUST_LIMIT_SATS`, `app/wallet/psbt_builder.py:select_utxos` ·
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_psbt_builder.py`
- **[DEP-02]** Un aggiornamento dell'indirizzo di un utente legge l'elenco
delle monete non spese (`listunspent`) dal server Electrum attivo e procede
in tre fasi. **Lettura**, senza scrivere nulla: individuazione degli
+3 -3
View File
@@ -223,7 +223,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
|---|---|---|---|---|---|
| B-73 | L'estrazione dipende solo dall'hash del blocco: un minatore con biglietti o il server Electrum attivo possono pilotarla; ordine dei partecipanti non verificabile | Critica | **aperto** | — | RND, ELC |
| B-74 | Header accettati senza controllo della difficoltà minima; un salto di più blocchi non viene collegato; una riorganizzazione di un blocco chiude la sessione | Critica | **aperto** | — | ELC |
| B-75 | Una risposta Electrum oltre 64 KiB chiude la connessione, e la cronologia dell'indirizzo del montepremi la supera dopo ~800 transazioni | Critica | **aperto** | — | ELC, TX |
| B-75 | Una risposta Electrum oltre 64 KiB chiude la connessione, e la cronologia dell'indirizzo del montepremi la supera dopo ~800 transazioni | Critica | *da registrare* | `test_electrum_client.py`, `test_deposits.py`, `test_psbt_builder.py` | ELC, TX |
| B-76 | Un invio ambiguo (timeout, connessione caduta) viene annullato come se fosse rifiutato | Critica | **aperto** | — | TX, BET, WDR |
| B-77 | Si ricorda solo l'ultimo txid di una transazione accelerata, e la sostituzione viene inviata prima di essere salvata | Critica | **aperto** | — | TX |
| B-78 | Una transazione viene abbandonata sulla base del silenzio di un solo server | Critica | **aperto** | — | TX, ELC |
@@ -252,9 +252,9 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
come limite accettato; dalla revisione del 2026-09-28: B-88.
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75.
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (10 critici,
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (9 critici,
8 alti, 7 medi), da implementare nell'ordine di
[piano-correzioni.md](piano-correzioni.md).
- **Prossimo numero libero:** `B-99`.
+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):