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
+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:
|
||||
|
||||
Reference in new issue
Block a user