diff --git a/BUGS.md b/BUGS.md index 18dda1d..5c7741c 100644 --- a/BUGS.md +++ b/BUGS.md @@ -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, diff --git a/CLAUDE.md b/CLAUDE.md index da9f5ee..de54580 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ``` diff --git a/README.md b/README.md index 984937b..2014e94 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/deposits/service.py b/app/deposits/service.py index d67444d..fd90e57 100644 --- a/app/deposits/service.py +++ b/app/deposits/service.py @@ -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 diff --git a/app/electrum/client.py b/app/electrum/client.py index db4242b..bf98665 100644 --- a/app/electrum/client.py +++ b/app/electrum/client.py @@ -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: diff --git a/app/wallet/psbt_builder.py b/app/wallet/psbt_builder.py index 219ed96..386709c 100644 --- a/app/wallet/psbt_builder.py +++ b/app/wallet/psbt_builder.py @@ -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: diff --git a/docs/architettura.md b/docs/architettura.md index bd92afc..d09d16c 100644 --- a/docs/architettura.md +++ b/docs/architettura.md @@ -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/`), 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 diff --git a/docs/connessione-electrum.md b/docs/connessione-electrum.md index b1ba57c..5b34498 100644 --- a/docs/connessione-electrum.md +++ b/docs/connessione-electrum.md @@ -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 diff --git a/docs/modello-dati.md b/docs/modello-dati.md index 64c1de9..a7a72cd 100644 --- a/docs/modello-dati.md +++ b/docs/modello-dati.md @@ -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 diff --git a/docs/operativita.md b/docs/operativita.md index 553f8ae..950662d 100644 --- a/docs/operativita.md +++ b/docs/operativita.md @@ -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 diff --git a/docs/registrazione-e-depositi.md b/docs/registrazione-e-depositi.md index 4f1c3aa..7f2a515 100644 --- a/docs/registrazione-e-depositi.md +++ b/docs/registrazione-e-depositi.md @@ -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 diff --git a/docs/storico-bug.md b/docs/storico-bug.md index 569d613..e2ec911 100644 --- a/docs/storico-bug.md +++ b/docs/storico-bug.md @@ -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`. diff --git a/tests/unit/test_deposits.py b/tests/unit/test_deposits.py index 9c43ebe..589321f 100644 --- a/tests/unit/test_deposits.py +++ b/tests/unit/test_deposits.py @@ -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] diff --git a/tests/unit/test_electrum_client.py b/tests/unit/test_electrum_client.py index 7f45a6a..f355158 100644 --- a/tests/unit/test_electrum_client.py +++ b/tests/unit/test_electrum_client.py @@ -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() diff --git a/tests/unit/test_psbt_builder.py b/tests/unit/test_psbt_builder.py index b69c365..1525f08 100644 --- a/tests/unit/test_psbt_builder.py +++ b/tests/unit/test_psbt_builder.py @@ -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):