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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
@@ -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