From a4b160e2405b091310053ec14fa9db86d874d32e Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Wed, 14 Oct 2026 15:07:09 +0200 Subject: [PATCH] Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32) reconcile_once abandoned a row when the *active* server's history lacked its txid: after 120s for `building` (which a restart onto a different server makes likely), after 6h for `pending`, and all at once after a forward clock jump. It re-checked the status but not the txid, so it raced the bumper, and a `failed` row was never looked at again. - The reconciler asks every server (ElectrumListener.get_histories, active + others) for the history of the row's address and looks for every txid of the row. Present on any server -> promoted (adopting the version found). Absent is only "no server of the quorum knows it" and needs the active server plus the quorum minimum of the others to have answered; even then nothing is released without proof: (a) an input spent in a block by a transaction that is none of the row's versions, agreed by a strict majority of a quorum of the other servers (confirmed_spender), or (b) a rebroadcast of the latest raw tx to every server (rebroadcast_everywhere) refused by all of them, explicitly, for missing/spent inputs. Accepted or "already known" by any server means alive; a fee refusal is not proof (`building` -> `pending` for the bumper, `pending` untouched); too few answers or anything else leaves the row alone. failure_reason names the proof. - The write re-reads status *and* current_txid under begin_immediate (TX-26). - pending_transactions.failed_at (set by _abandon and _release_failed_withdrawal) starts a 14-day watch: a failed row that a majority of the other servers (at least the quorum minimum) list in a block becomes `confirmed`, writes pending_tx_resurrected once and pauses the lottery with paused_reason `resurrected_tx`; linked rows are not corrected. The confirmation is by quorum of histories for now; merkle proofs come with B-83. - round_config.paused_reason (migration b7d2e41c9a05, with the failed_at one): `operator` from POST /admin/pause (which accepts an optional `note` and never overwrites an automatic reason), cleared by resume, which logs the reason it lifted; exposed on /admin/config and GET /rounds/current. rounds/pause.py:pause_automatically is the system's way in. - PendingTransactionReconciler takes the listener as `network`; with none it can promote but never abandon. Tests changed because the spec changed: the three reconciler tests that expect an abandonment (and the one in test_tx_versions) now supply a network that proves the transaction is gone. Co-Authored-By: Claude Sonnet 5.5 --- BUGS.md | 14 - CLAUDE.md | 8 +- README.md | 2 +- app/api/routes/admin.py | 30 +- app/api/routes/rounds.py | 3 + app/db/models.py | 9 + app/electrum/listener.py | 74 +++ app/electrum/quorum.py | 27 + app/main.py | 2 +- app/rounds/pause.py | 30 + app/tx/reconcile.py | 292 ++++++++-- app/withdrawals/service.py | 2 + docs/adr/ADR-51-dubbio-liberano-mai-fondi.md | 8 +- docs/api.md | 9 +- docs/audit-log.md | 22 +- docs/ciclo-di-vita-del-round.md | 5 +- docs/connessione-electrum.md | 11 +- docs/limiti-noti.md | 32 +- docs/modello-dati.md | 41 +- docs/operativita.md | 47 +- docs/storico-bug.md | 6 +- docs/transazioni-e-riconciliazione.md | 33 +- ...41c9a05_add_failed_at_and_paused_reason.py | 47 ++ tests/unit/fake_network.py | 46 ++ tests/unit/test_migrations.py | 36 ++ tests/unit/test_pause_reason.py | 99 ++++ tests/unit/test_reconcile.py | 7 +- tests/unit/test_reconcile_proof.py | 535 ++++++++++++++++++ tests/unit/test_tx_versions.py | 4 +- 29 files changed, 1280 insertions(+), 201 deletions(-) create mode 100644 app/electrum/quorum.py create mode 100644 app/rounds/pause.py create mode 100644 migrations/versions/b7d2e41c9a05_add_failed_at_and_paused_reason.py create mode 100644 tests/unit/test_pause_reason.py create mode 100644 tests/unit/test_reconcile_proof.py diff --git a/BUGS.md b/BUGS.md index b2c6d23..5a0b5b1 100644 --- a/BUGS.md +++ b/BUGS.md @@ -137,20 +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-78 — Abandonment rests on one server's silence - -`reconcile_once` abandons when the active server's `get_history` lacks the txid — -after 120s for `building` rows, which a restart onto a different server makes -likely, and all at once after a forward clock jump. It re-checks status but not the -txid, so it races the bumper. A `failed` row is never looked at again. - -Fix: abandon only on proof — (a) an input confirmed-spent by a different txid -(quorum), or (b) no server of the quorum knows any txid of the row *and* a -rebroadcast of the latest raw tx to all servers is explicitly refused for -missing/spent inputs. A fee-related refusal is not proof (bump instead). The re-check -includes `current_txid`. `failed` rows stay watched for 14 days; a late confirmation -writes `pending_tx_resurrected` and pauses the lottery for the operator. - ### B-79 — A payout normally has no change output, so it cannot be fee-bumped The pool normally holds exactly the round's bet outputs, the payout spends them all diff --git a/CLAUDE.md b/CLAUDE.md index b0b36dd..9839ffe 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 — 480 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 — 516 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 480 tests +python -m pytest # all 516 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 ``` @@ -115,7 +115,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW **Round cooldown** (`round_cooldown_seconds`, not in the original design): gap after a round closes before the next opens, so players can see the outcome. -**Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner. +**Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner, with `paused_reason` (B-78, DAT-69): `operator`, or an automatic reason the system wrote itself (`resurrected_tx` so far, `rounds/pause.py`) — an operator pause never overwrites an automatic one, and resume clears whichever it is. **No `fee_address`, no rounds** (`rounds/service.py:rounds_can_open`, B-66): the payout pays the 30% commission to `fee_address`, which has no column default because an operator must set their own — so until they do, `open_new_round_if_needed` refuses to open a round at all. Otherwise every round took bets, confirmed them and only then discovered it was unpayable, wedging in `paying_out` with money already in the pool and needing manual recovery. Same scope as pausing: a round already in progress still closes, draws and pays out (clearing the address mid-round is exactly the operator slip that must not strand a live round). Surfaced as `lottery_configured` on `GET /rounds/current` — `/` shows a *different* banner from the maintenance one, since "come back later" would be false — and as a warning on `/admin`'s Parametri card, the one screen that can fix it. Anything else that would make a round unpayable belongs in `rounds_can_open` next to it, not discovered at payout time. @@ -213,7 +213,7 @@ Everything that spends money is written **before** it is broadcast and resolved - `building` is written first, UTXOs already marked `spent_txid`, and committed *before* the broadcast (`bets/service.py`, `withdrawals/service.py`, and `scheduler.py:_trigger_payout` — the same shape in four phases, so no DB session is ever held across a network call). A crash in that window leaves evidence, not coins spent on-chain with no record. - **Nothing is released on doubt (B-76).** `tx/outcome.py:broadcast_with_verdict` sorts a broadcast into accepted / refused / uncertain. Only an explicit JSON-RPC error (`ElectrumRpcError`) that `ElectrumListener.transaction_known` then confirms — the active server and the quorum minimum of the others all say they do not know the tx — releases the UTXOs, restores the balance, removes the participant (or marks the withdrawal `failed`), audit-logs, and raises `broadcast_failed` → **502**. If any server returns the tx, or the message is an "already known" one, it counts as accepted. A timeout, a dropped connection or a refusal that could not be verified leaves the rows `building` for the reconciler and the API answers **202**. An accepted tx is also fanned out to every other server (`fan_out_transaction`, best effort). -- `tx/reconcile.py` asks the chain about anything still `building`/`pending`: present → promote; positively unknown → `failed` with a `failure_reason`, inputs released, domain row rolled back, `pending_tx_abandoned` logged. Grace differs by state (120s `building`, 6h `pending`, so the bumper gets its attempts first). A *transport* failure never abandons anything — only a server that positively doesn't know the tx, established by the tx's txid being absent from `get_history` of the address it spends from/pays back to (`app/tx/pending_address.py:own_address_for`) — B-41 replaced the old substring-matching on error text. A tx still in the server's mempool is never abandoned, so in practice that is what bounds the `closing` wait. +- `tx/reconcile.py` asks the **quorum** about anything still `building`/`pending` (every txid of the row, on the active server and every other one): present on any server → promote (and adopt the version found); otherwise **nothing is given up without proof** (B-78, ADR-51): a row is abandoned — `failed` with the proof in `failure_reason`, inputs released, domain row rolled back, `pending_tx_abandoned` logged — only if one of its inputs is spent *in a block* by another tx and the other servers agree (`confirmed_spender`), or no server knows any txid *and* a rebroadcast of its latest raw tx (`rebroadcast_everywhere`) is refused by every server for missing/spent inputs. A fee refusal is not proof (`building` → `pending` for the bumper); too few answers, a transport failure or the age of the row prove nothing. Grace only decides from when a row is examined (120s `building`, 6h `pending`). The write re-checks status *and* `current_txid` under `begin_immediate` (TX-26). `failed` rows stay watched for 14 days (`failed_at`): one that confirms late is marked `confirmed`, `pending_tx_resurrected` is logged and the lottery pauses (`paused_reason = 'resurrected_tx'`, `rounds/pause.py`); the linked rows are never corrected by the system. A tx still in some server's mempool is never abandoned, so in practice that is what bounds the `closing` wait. **SQLite transactions are real ones (B-88).** `_register_sqlite_pragmas` sets `synchronous=FULL` and switches pysqlite's implicit `BEGIN` off (`isolation_level = None`); an engine `begin` event emits `BEGIN` itself, so every read is inside a transaction. Every transaction that moves money or fixes its state opens with `await begin_immediate(session)` (`app/db/base.py`) — `BEGIN IMMEDIATE`, i.e. the write lock *before* the first read (it commits the caller's read-only transaction first and refuses a session with unflushed changes). A lock not granted within `busy_timeout` raises `OperationalError`: **before** a broadcast that means "refuse" (`service_busy`, 503, nothing built or sent); **after** one nothing is undone — the row stays `building` and the reconciler resolves it. New money-moving write code must call `begin_immediate` first, and tests of that must use a file DB whose engine went through `_register_sqlite_pragmas` (an in-memory engine without it never emits `BEGIN IMMEDIATE`). diff --git a/README.md b/README.md index 67b8413..1c998fa 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 ``` -480 unit tests cover HD derivation, PSBT building, the Electrum client, bets, +516 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/api/routes/admin.py b/app/api/routes/admin.py index e57fbf3..be259ed 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -13,6 +13,7 @@ from app.config import settings from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User from app.db.session import get_session from app.rounds.config import get_round_config +from app.rounds.pause import AUTOMATIC_REASONS, OPERATOR_REASON from app.wallet.address import is_valid_plm_address from app.wallet.hd import derive_user_wif from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB @@ -56,6 +57,7 @@ class RoundConfigResponse(BaseModel): rbf_timeout_seconds: int draw_animation_seconds: int paused: bool + paused_reason: str | None = None # B-78 (DAT-69): why, or None when not paused class RoundConfigUpdate(BaseModel): @@ -93,6 +95,7 @@ class RoundConfigUpdate(BaseModel): def _config_response(config) -> RoundConfigResponse: fields = {field: getattr(config, field) for field in _CONFIG_FIELDS} fields["paused"] = config.paused + fields["paused_reason"] = config.paused_reason return RoundConfigResponse(**fields) @@ -128,14 +131,29 @@ async def update_config( return _config_response(config) +class PauseRequest(BaseModel): + note: str | None = Field(default=None, max_length=256) + + @router.post("/pause", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) -async def pause_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse: +async def pause_lottery( + body: PauseRequest | None = None, session: AsyncSession = Depends(get_session) +) -> RoundConfigResponse: """Maintenance switch: the round in progress (if any) still closes, draws, and pays out its winner normally — only opening the *next* round is - suppressed until /admin/resume is called (rounds/service.py).""" + suppressed until /admin/resume is called (rounds/service.py). + + B-78 (DAT-69, API-32): the reason is "operator", except that an automatic reason + already in force stays — it is the one that needs a look. The audit row always + says `operator` for this call.""" config = await get_round_config(session) + if not (config.paused and config.paused_reason in AUTOMATIC_REASONS): + config.paused_reason = OPERATOR_REASON config.paused = True - await write_audit_log(session, "lottery_paused", {}) + payload = {"reason": OPERATOR_REASON} + if body is not None and body.note: + payload["note"] = body.note + await write_audit_log(session, "lottery_paused", payload) await session.commit() return _config_response(config) @@ -143,8 +161,12 @@ async def pause_lottery(session: AsyncSession = Depends(get_session)) -> RoundCo @router.post("/resume", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) async def resume_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse: config = await get_round_config(session) + # B-78 (AUD-12): the reason the resume took away, so the log says what the operator + # agreed to resume (None when the lottery was not paused). + lifted = config.paused_reason if config.paused else None config.paused = False - await write_audit_log(session, "lottery_resumed", {}) + config.paused_reason = None + await write_audit_log(session, "lottery_resumed", {"reason": lifted}) await session.commit() return _config_response(config) diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py index a92292e..f8d0083 100644 --- a/app/api/routes/rounds.py +++ b/app/api/routes/rounds.py @@ -120,6 +120,7 @@ class CurrentRoundResponse(BaseModel): draw_waiting_since: str | None = None chain_tip_height: int | None = None lottery_paused: bool = False + paused_reason: str | None = None # B-78 (DAT-69): operator / resurrected_tx / ..., None if not paused # B-66: false while the instance is missing configuration a round cannot run # without (today: fee_address) — no round will open until it's set, so this is # the difference between "wait, the next round is coming" and "nothing is coming @@ -147,6 +148,7 @@ async def current_round( draw_animation_seconds=config.draw_animation_seconds, chain_tip_height=chain_tip_height, lottery_paused=config.paused, + paused_reason=config.paused_reason, lottery_configured=rounds_can_open(config), ) @@ -225,6 +227,7 @@ async def current_round( draw_waiting_since=isoformat_utc(round_.drawing_started_at) if round_.status == "drawing" else None, chain_tip_height=chain_tip_height, lottery_paused=config.paused, + paused_reason=config.paused_reason, lottery_configured=rounds_can_open(config), user_played=user_played, ) diff --git a/app/db/models.py b/app/db/models.py index 96ca293..ee186bc 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -155,6 +155,12 @@ class RoundConfig(Base): # completion (closes, draws, pays out the winner) but no new round is opened # afterwards — see rounds/service.py:open_new_round_if_needed. paused: Mapped[bool] = mapped_column(default=False) + # B-78 (DAT-69): why the lottery is paused — NULL exactly when `paused` is false. + # "operator" (POST /admin/pause) or an automatic reason the system wrote itself + # ("resurrected_tx" so far; "reorg" and "payout_blocked" belong to B-85 and B-82). + # An automatic reason is never overwritten by an operator pause, so the cause that + # needs a look is not lost; POST /admin/resume clears whichever it is. + paused_reason: Mapped[str | None] = mapped_column(String(16), default=None) updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow) @@ -197,6 +203,9 @@ class PendingTransaction(Base): attempt_count: Mapped[int] = mapped_column(default=1) # Why the reconciler gave up on this tx — operator-facing, only set on "failed". failure_reason: Mapped[str | None] = mapped_column(String(128), default=None) + # B-78 (DAT-72): when the row went to "failed". A failed row stays watched for + # FAILED_WATCH_DAYS from here: a transaction given up for dead can still confirm. + failed_at: Mapped[datetime | None] = mapped_column(default=None) updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow) diff --git a/app/electrum/listener.py b/app/electrum/listener.py index df21da7..74b76fd 100644 --- a/app/electrum/listener.py +++ b/app/electrum/listener.py @@ -1,5 +1,6 @@ import asyncio import logging +from collections import Counter from collections.abc import Callable from embit.transaction import Transaction @@ -16,6 +17,7 @@ from app.deposits.service import ( ) from app.config import MIN_QUORUM_RESPONSES from app.electrum.client import ElectrumClient, ElectrumEndpoint, ElectrumRpcError +from app.electrum.quorum import ServerAnswers from app.electrum.scripthash import address_to_scripthash from app.rounds.draw import ( HeaderValidationError, @@ -497,6 +499,78 @@ class ElectrumListener: self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) + # --- What the network knows of a transaction (B-78, TX-15, TX-17) ------------------- + + async def _ask_everyone(self, ask: Callable[[ElectrumClient], "asyncio.Future"]) -> ServerAnswers: + active, others = await asyncio.gather(self._ask_active_server(ask), self._ask_other_servers(ask)) + return ServerAnswers(active, tuple(others), self._minimum_other_answers()) + + async def get_histories(self, scripthash: str) -> ServerAnswers: + """`blockchain.scripthash.get_history` of `scripthash` from the active server and + every other one: each answer is the list of {"tx_hash", "height"} entries.""" + + async def _ask(client: ElectrumClient) -> list | None: + try: + history = await client.get_history(scripthash) + except Exception: + return None + return history if isinstance(history, list) else None + + return await self._ask_everyone(_ask) + + async def rebroadcast_everywhere(self, raw_tx_hex: str) -> ServerAnswers: + """Send `raw_tx_hex` to the active server and every other one and report what each + said: ("accepted", "") or ("refused", ) — an explicit JSON-RPC error — + and None for a server that gave no answer at all (TX-17). A tx the servers already + have comes back as a refusal with an "already known" message; the caller reads it.""" + + async def _ask(client: ElectrumClient) -> tuple | None: + try: + await client.broadcast(raw_tx_hex) + except ElectrumRpcError as exc: + return ("refused", exc.message) + except Exception: + return None + return ("accepted", "") + + return await self._ask_everyone(_ask) + + async def confirmed_spender( + self, scripthash: str, own_txids: set[str], outpoints: set[tuple[str, int]] + ) -> str | None: + """The txid of a transaction, none of the row's own versions, that spends one of + `outpoints` and is **in a block** — the first proof by which a row may be given + up (TX-17): its inputs went elsewhere. Asked of the *other* servers only (the + active one is what a hostile peer would control) and returned only when at least + the quorum minimum answered and a strict majority of the answerers name the same + txid. Each server looks through the confirmed history of the address the row + spends from. + """ + + async def _ask(client: ElectrumClient) -> str | None: + history = await client.get_history(scripthash) + for entry in history: + txid = entry.get("tx_hash") + if txid in own_txids or (entry.get("height") or 0) <= 0: + continue + raw = await client.get_transaction(txid, False) + tx = Transaction.parse(bytes.fromhex(raw)) + if tx.txid().hex() != txid: + continue + if any((vin.txid.hex(), vin.vout) in outpoints for vin in tx.vin): + return txid + return "" # looked everywhere: no conflicting transaction + + answers = await self._ask_other_servers(_ask) + answered = [answer for answer in answers if answer is not None] + if len(answered) < max(self._minimum_other_answers(), 1): + return None + counts = Counter(answer for answer in answered if answer) + if not counts: + return None + txid, votes = counts.most_common(1)[0] + return txid if votes * 2 > len(answered) else None + async def corroborate_header(self, height: int, expected_hash: str) -> bool: """B-28: is `expected_hash` — the header our own active connection reported for `height` — corroborated by other configured servers before diff --git a/app/electrum/quorum.py b/app/electrum/quorum.py new file mode 100644 index 0000000..71ce834 --- /dev/null +++ b/app/electrum/quorum.py @@ -0,0 +1,27 @@ +"""What a question put to every server came back with (B-78). + +`active` is the answer of the connection the platform is running on, `others` those of +each other configured server in configuration order; `None` stands for "no usable +answer" (unreachable, timed out, an error that is not an answer).""" + +from typing import NamedTuple + + +class ServerAnswers(NamedTuple): + active: object | None + others: tuple + others_needed: int # how many of `others` must have answered for the network to count + + @property + def answered_others(self) -> list: + return [answer for answer in self.others if answer is not None] + + @property + def complete(self) -> bool: + """Enough of the network answered for its silence to mean something: the active + server and at least `others_needed` of the others.""" + return self.active is not None and len(self.answered_others) >= self.others_needed + + @property + def all(self) -> list: + return ([] if self.active is None else [self.active]) + self.answered_others diff --git a/app/main.py b/app/main.py index fd612d1..8fbca0a 100644 --- a/app/main.py +++ b/app/main.py @@ -66,7 +66,7 @@ async def lifespan(app: FastAPI): bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client, network=listener) # Resolves in-flight transactions against the chain — the piece that lets the # system recover on its own from a broadcast that never confirmed (B-04/B-08). - reconciler = PendingTransactionReconciler(AsyncSessionLocal, lambda: listener.client) + reconciler = PendingTransactionReconciler(AsyncSessionLocal, lambda: listener.client, network=listener) # Periodic safety net for deposit crediting/external-spend detection, # independent of scripthash-change notifications — catches a subscription # silently lost on an otherwise healthy connection (B-30). diff --git a/app/rounds/pause.py b/app/rounds/pause.py new file mode 100644 index 0000000..5a0a3d4 --- /dev/null +++ b/app/rounds/pause.py @@ -0,0 +1,30 @@ +"""Pausing the lottery from code (B-78, DAT-69, AUD-11). + +A pause only stops the *next* round from opening (rounds/service.py:rounds_can_open); a +round in progress still closes, draws and pays. Besides the operator's own switch there +are pauses the system raises itself when something needs a human look — a transaction +given up for dead that confirmed after all (`resurrected_tx`, B-78) and, in later +findings, a deep reorg (`reorg`, B-85) and a payout whose winning bet left the chain +(`payout_blocked`, B-82). +""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.audit.log import write_audit_log +from app.rounds.config import get_round_config + +OPERATOR_REASON = "operator" +AUTOMATIC_REASONS = ("reorg", "payout_blocked", "resurrected_tx") + + +async def pause_automatically(session: AsyncSession, reason: str) -> None: + """Pause the lottery for an automatic `reason` in the caller's transaction and write + the `lottery_paused` row. An automatic reason is written even over a pause that is + already active (DAT-69): the cause that needs checking must not be hidden behind an + earlier one, and an operator pause is never what overrides it.""" + if reason not in AUTOMATIC_REASONS: + raise ValueError(f"not an automatic pause reason: {reason!r}") + config = await get_round_config(session) + config.paused = True + config.paused_reason = reason + await write_audit_log(session, "lottery_paused", {"reason": reason}) diff --git a/app/tx/reconcile.py b/app/tx/reconcile.py index 39db0aa..8954d20 100644 --- a/app/tx/reconcile.py +++ b/app/tx/reconcile.py @@ -13,15 +13,26 @@ balance simply lost them, with no path back short of editing the DB by hand What it does, per PendingTransaction that isn't already terminal: * status "building" — we crashed (or were killed) between writing the row and - broadcasting. Ask the chain: if the tx is there after all, promote everything - to its live state; if it isn't, release the UTXOs and undo the intent. + broadcasting. Ask the network: if any server knows one of the row's txids, + promote everything to its live state. * status "pending" — broadcast, still unconfirmed. Left alone until it has been - unconfirmed for `_ABANDON_AFTER_SECONDS`, since absence from one server's - mempool is not proof of death; only then is it abandoned like the above. + unconfirmed for `_ABANDON_AFTER_SECONDS`, and even then only examined. -Deliberately conservative: it never touches a tx the chain knows about, and the -grace period is long (multiples of the RBF timeout) so a slow-but-alive tx is -bumped by RbfBumper rather than abandoned here. +and then *nothing is given up without proof* (B-78, ADR-51, TX-17). A row is +abandoned — its coins released — only when + + (a) one of its inputs is spent **in a block** by a transaction that is none of its + own versions, and the other servers agree; or + (b) no server of the quorum knows any txid of the row **and** sending its latest + raw transaction to every server is refused, explicitly and by all of them, for + missing or already-spent inputs. + +Absence from one server's history proves nothing — after a restart onto a different +server that is the normal state of a tx the new server has not seen — and neither +does the age of the row. A refusal for fee reasons is no proof either: such a row is +promoted (building) or left to the RBF bumper (pending). A transport failure never +resolves anything. Failed rows stay watched for `FAILED_WATCH_DAYS`: if one of them +confirms after all, the lottery is paused for the operator (TX-23). """ import asyncio @@ -35,11 +46,21 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.audit.log import write_audit_log from app.db.base import begin_immediate -from app.db.models import PendingTransaction, Round, RoundParticipant, UtxoEvent, Withdrawal +from app.db.models import ( + PendingTransaction, + PendingTransactionTxid, + Round, + RoundParticipant, + UtxoEvent, + Withdrawal, +) from app.electrum.client import ElectrumClient +from app.electrum.quorum import ServerAnswers from app.electrum.scripthash import address_to_scripthash from app.rounds.events import broadcaster +from app.rounds.pause import pause_automatically from app.tx.broadcast import adopt_version +from app.tx.outcome import is_already_known from app.tx.pending_address import own_address_for from app.tx.versions import txids_by_row, txids_of from app.wallet.balance import recompute_balance @@ -53,14 +74,29 @@ _POLL_INTERVAL_SECONDS = 120 _BUILDING_GRACE_SECONDS = 120 # A "pending" row was accepted by a node once. Give it a wide margin — the RBF -# bumper gets several attempts inside this window — before concluding it's gone. +# bumper gets several attempts inside this window — before it is even examined. _ABANDON_AFTER_SECONDS = 6 * 60 * 60 +# How long a failed row keeps being looked at (B-78, DAT-72): a transaction given up +# for dead can still arrive in a block. +FAILED_WATCH_DAYS = 14 + +# What a node says when the transaction's inputs do not exist or are spent already. +_MISSING_INPUTS_MARKERS = ("missing-inputs", "missingorspent", "missing inputs", "inputs-missing") +# What it says when only the fee is the problem: not proof of anything. +_FEE_MARKERS = ("min relay fee", "mempool min fee", "insufficient fee", "fee not met", "fee too low", "fee rate") + class PendingTransactionReconciler: - def __init__(self, session_factory: async_sessionmaker, get_client: Callable[[], ElectrumClient | None]): + def __init__( + self, + session_factory: async_sessionmaker, + get_client: Callable[[], ElectrumClient | None], + network=None, + ): self._session_factory = session_factory self._get_client = get_client + self._network = network # the ElectrumListener: histories of every server, rebroadcast, spenders async def run(self) -> None: # Runs once promptly at startup: a crash mid-broadcast is exactly the case @@ -69,7 +105,7 @@ class PendingTransactionReconciler: client = self._get_client() if client is not None: try: - await reconcile_once(self._session_factory, client) + await reconcile_once(self._session_factory, client, self._network) except asyncio.CancelledError: raise except Exception: @@ -77,31 +113,41 @@ class PendingTransactionReconciler: await asyncio.sleep(_POLL_INTERVAL_SECONDS) -async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int: - """Returns how many rows were resolved (promoted or abandoned). +class _Candidate: + __slots__ = ("row_id", "status", "txid", "scripthash", "known_txids", "raw_hex") - Existence is decided by checking whether a row's own address's history - (blockchain.scripthash.get_history) includes its txid at all — mempool or - mined — rather than asking blockchain.transaction.get for a verbose reply - (B-41): several Electrum server implementations and versions reject the - verbose flag outright, and the previous substring-matching on the error - text (looking for "missing", "not found", ...) was fragile as the basis for - a decision that releases funds. A transport failure fetching history still - raises and leaves the row alone until next time — get_history not - returning our txid is the only thing that means "gone". History is cached - per scripthash within one pass, since the payout rows of one round share its - pool address (B-80). + def __init__(self, row_id, status, txid, scripthash, known_txids, raw_hex): + self.row_id = row_id + self.status = status + self.txid = txid + self.scripthash = scripthash + self.known_txids = known_txids + self.raw_hex = raw_hex + + +async def reconcile_once( + session_factory: async_sessionmaker, client: ElectrumClient, network=None +) -> int: + """Returns how many rows were resolved (promoted, abandoned, realigned or + resurrected). + + Existence is decided by asking every server of the quorum for the history of the + row's own address (blockchain.scripthash.get_history) and looking for **every** txid + the row has had, mempool or mined (B-41, B-77, B-78) — never for a verbose + blockchain.transaction.get reply, which several servers reject outright, and never + on error text. History is cached per scripthash within one pass, since the payout + rows of one round share its pool address (B-80). With no `network` only the active + server is asked, which can promote a row but never abandon one. """ now = datetime.now(timezone.utc) async with session_factory() as session: rows = ( await session.scalars( - select(PendingTransaction).where(PendingTransaction.status.in_(("building", "pending"))) + select(PendingTransaction).where(PendingTransaction.status.in_(("building", "pending", "failed"))) ) ).all() - candidates = [] - # B-77 (TX-15, TX-19): a row's every txid, not only the current one. txids_by_id = await txids_by_row(session, {row.id: row.current_txid for row in rows}) + candidates: list[_Candidate] = [] for row in rows: if not _is_due(row, now): continue @@ -111,56 +157,204 @@ async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumCl except Exception: logger.exception("could not derive the address for pending_transaction %s", row.id) continue - candidates.append((row.id, row.status, row.current_txid, scripthash, txids_by_id[row.id])) + candidates.append( + _Candidate(row.id, row.status, row.current_txid, scripthash, txids_by_id[row.id], row.raw_tx_hex) + ) resolved = 0 - history_cache: dict[str, list[dict]] = {} - for row_id, status, txid, scripthash, known_txids in candidates: + history_cache: dict[str, ServerAnswers] = {} + for candidate in candidates: try: - if scripthash not in history_cache: - history_cache[scripthash] = await client.get_history(scripthash) - found = [entry["tx_hash"] for entry in history_cache[scripthash] if entry.get("tx_hash") in known_txids] - exists = bool(found) + if candidate.scripthash not in history_cache: + history_cache[candidate.scripthash] = await _histories(client, network, candidate.scripthash) + answers = history_cache[candidate.scripthash] + decision = await _decide(network, candidate, answers) except Exception: # Transport/server problem — say nothing about this tx and try again on # the next pass rather than abandoning a tx that may be perfectly alive. - logger.warning("could not check pending_transaction %s (txid %s) against the chain", row_id, txid) + logger.warning( + "could not check pending_transaction %s (txid %s) against the chain", + candidate.row_id, + candidate.txid, + exc_info=True, + ) + continue + if decision is None: continue async with session_factory() as session: await begin_immediate(session) # B-88: the re-check and the resolution under one lock - row = await session.get(PendingTransaction, row_id) - if row is None or row.status != status: - continue # something else moved it while we were asking - if exists: - if txid not in found: + row = await session.get(PendingTransaction, candidate.row_id) + # TX-26: the status *and* the current txid must still be what they were when + # the network was asked — a confirmation or an RBF in the meantime changes + # what the answer means. + if row is None or row.status != candidate.status or row.current_txid != candidate.txid: + continue + kind, payload = decision + changed = False + if kind == "resurrected": + await _resurrect(session, row, *payload) + changed = True + elif kind == "present": + if payload and payload != row.current_txid: # Another version of the row is the one on the network (B-77, TX-19): # the row follows it, so the inputs and the domain row point at it. - await adopt_version(session, row, found[0]) - resolved += 1 + await adopt_version(session, row, payload) + changed = True if row.status == "building": await _promote(session, row) - resolved += 1 - else: - await _abandon(session, row, "not found on chain") - resolved += 1 + changed = True + elif kind == "promote": + await _promote(session, row) + changed = True + elif kind == "abandon": + await _abandon(session, row, payload) + changed = True await session.commit() + resolved += changed broadcaster.publish() return resolved +async def _histories(client: ElectrumClient, network, scripthash: str) -> ServerAnswers: + if network is not None: + return await network.get_histories(scripthash) + # No quorum to ask: the active server alone can show a transaction exists, but its + # silence can never be taken for absence (`complete` stays false). + return ServerAnswers(await client.get_history(scripthash), (), 1) + + +def _entries_for(answers: ServerAnswers, known_txids: set[str]) -> list[tuple[str, int, bool]]: + """(txid, height, from_active) for every history entry of every answering server that + is one of the row's txids.""" + found: list[tuple[str, int, bool]] = [] + if answers.active is not None: + found += [(e["tx_hash"], e.get("height") or 0, True) for e in answers.active if e.get("tx_hash") in known_txids] + for history in answers.answered_others: + found += [(e["tx_hash"], e.get("height") or 0, False) for e in history if e.get("tx_hash") in known_txids] + return found + + +def _confirmed_by_the_quorum(answers: ServerAnswers, known_txids: set[str]) -> tuple[str, int] | None: + """A txid of the row that a strict majority of the answering *other* servers (at + least the quorum minimum of them) list in a block, with the height they report. + The active server's word is not part of it.""" + others = answers.answered_others + if len(others) < max(answers.others_needed, 1): + return None + votes: dict[tuple[str, int], int] = {} + for history in others: + for entry in history: + if entry.get("tx_hash") in known_txids and (entry.get("height") or 0) > 0: + key = (entry["tx_hash"], entry["height"]) + votes[key] = votes.get(key, 0) + 1 + for key, count in sorted(votes.items(), key=lambda item: -item[1]): + if count * 2 > len(others): + return key + return None + + +async def _decide(network, candidate: _Candidate, answers: ServerAnswers): + """What the network says about one row, as ("present", txid | None), ("promote", None), + ("abandon", reason), ("resurrected", (txid, height)) or None for "nothing to do or + not enough to tell". Pure reads — nothing is written here.""" + if candidate.status == "failed": + confirmed = _confirmed_by_the_quorum(answers, candidate.known_txids) + return ("resurrected", confirmed) if confirmed is not None else None + + found = _entries_for(answers, candidate.known_txids) + if found: + # Prefer a version that is mined, then the current one, then any. + mined = [txid for txid, height, _ in found if height > 0] + present = {txid for txid, _, _ in found} + chosen = mined[0] if mined else (candidate.txid if candidate.txid in present else found[0][0]) + return ("present", chosen) + + if not answers.complete: + logger.warning( + "pending_transaction %s (txid %s): too few servers answered to say the tx is unknown — left as is", + candidate.row_id, + candidate.txid, + ) + return None + if network is None: + return None + + # No server of the quorum knows any txid of the row. That alone proves nothing (TX-15). + tx = Transaction.parse(bytes.fromhex(candidate.raw_hex)) + outpoints = {(vin.txid.hex(), vin.vout) for vin in tx.vin} + spender = await network.confirmed_spender(candidate.scripthash, candidate.known_txids, outpoints) + if spender is not None: + return ("abandon", f"input spent in a block by {spender}") + + rebroadcast: ServerAnswers = await network.rebroadcast_everywhere(candidate.raw_hex) + verdicts = rebroadcast.all + if any(v[0] == "accepted" or (v[0] == "refused" and is_already_known(v[1])) for v in verdicts): + # It was alive after all (or is again): the same as having found it. + return ("present", None) + refusals = [v[1].lower() for v in verdicts if v[0] == "refused"] + if rebroadcast.complete and len(refusals) == len(verdicts) and all( + any(marker in message for marker in _MISSING_INPUTS_MARKERS) for message in refusals + ): + return ("abandon", f"rebroadcast refused for missing inputs: {refusals[0]}") + if candidate.status == "building" and any(any(marker in m for marker in _FEE_MARKERS) for m in refusals): + return ("promote", None) # not proof: let the RBF bumper accelerate it (TX-17) + return None + + def _is_due(row: PendingTransaction, now: datetime) -> bool: + if row.status == "failed": + # B-78 (TX-23, DAT-72): watched every pass for FAILED_WATCH_DAYS from failed_at. + failed_at = row.failed_at or row.updated_at + return failed_at is not None and now < failed_at.replace(tzinfo=timezone.utc) + timedelta(days=FAILED_WATCH_DAYS) # Deliberately broadcast_at (the *first* broadcast), not last_broadcast_at: an # RBF bump used to overwrite this same field, which reset this grace period on # every bump and meant a repeatedly-bumped-but-never-mined tx was never # abandoned (B-27). tx/broadcast.py:bump_fee now only ever touches # last_broadcast_at, so this keeps measuring from when the tx first appeared, - # no matter how many times it's since been bumped. + # no matter how many times it's since been bumped. The period only decides from + # when a row is *examined*; giving it up still needs proof (TX-14, TX-17). grace = _BUILDING_GRACE_SECONDS if row.status == "building" else _ABANDON_AFTER_SECONDS return now >= row.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=grace) +async def _resurrect(session: AsyncSession, row: PendingTransaction, txid: str, height: int) -> None: + """A row given up for dead was mined (B-78, TX-23, DAT-72). The row becomes + `confirmed` and the lottery is paused for the operator: the domain rows the + abandonment already rolled back (a deleted participant, a failed withdrawal, released + coins, a rebuilt payout) are *not* put back by the system, which cannot know what has + happened to them since.""" + if txid != row.current_txid: + version_raw = None + version = await session.scalar( + select(PendingTransactionTxid).where( + PendingTransactionTxid.txid == txid, PendingTransactionTxid.pending_transaction_id == row.id + ) + ) + if version is not None and version.raw_hex: + version_raw = version.raw_hex + row.current_txid = txid + if version_raw: + row.raw_tx_hex = version_raw + row.status = "confirmed" + await write_audit_log( + session, + "pending_tx_resurrected", + {"pending_transaction_id": row.id, "kind": row.kind, "txid": txid, "block_height": height}, + user_id=row.user_id, + round_id=row.round_id, + ) + await pause_automatically(session, "resurrected_tx") + logger.error( + "%s pending_transaction %s was given up as failed but tx %s is in block %s — lottery paused for the operator", + row.kind, + row.id, + txid, + height, + ) + + async def _promote(session: AsyncSession, row: PendingTransaction) -> None: """The tx did make it onto the chain before we died — finish what phase 2 of place_bet/request_withdrawal would have done.""" @@ -190,8 +384,10 @@ async def _promote(session: AsyncSession, row: PendingTransaction) -> None: async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str) -> None: """The tx is gone for good. Release whatever it reserved so the funds come back, and roll the domain row back to something truthful.""" + reason = reason[:128] row.status = "failed" - row.failure_reason = reason[:128] + row.failure_reason = reason + row.failed_at = datetime.now(timezone.utc) # B-78: starts the 14-day watch released = await _release_inputs(session, row, await txids_of(session, row)) diff --git a/app/withdrawals/service.py b/app/withdrawals/service.py index fe8e836..4fbd407 100644 --- a/app/withdrawals/service.py +++ b/app/withdrawals/service.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime, timezone from embit import script from sqlalchemy import select @@ -246,6 +247,7 @@ async def _release_failed_withdrawal( withdrawal.txid = None pending.status = "failed" pending.failure_reason = reason[:128] + pending.failed_at = datetime.now(timezone.utc) # B-78 (DAT-36): starts the 14-day watch await recompute_balance(session, user_id) await write_audit_log( session, diff --git a/docs/adr/ADR-51-dubbio-liberano-mai-fondi.md b/docs/adr/ADR-51-dubbio-liberano-mai-fondi.md index d89288f..a0764a0 100644 --- a/docs/adr/ADR-51-dubbio-liberano-mai-fondi.md +++ b/docs/adr/ADR-51-dubbio-liberano-mai-fondi.md @@ -1,6 +1,6 @@ # ADR-51 — Nel dubbio non si liberano mai i fondi -- **Stato:** Accettata — implementazione da completare (B-78) +- **Stato:** Accettata - **Area:** Denaro in movimento - **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*. @@ -36,8 +36,4 @@ pausa la lotteria ([ADR-28](ADR-28-pausa-manutenzione-ferma-round-successivo.md) congelato e visibile, mai duplicato. *Codice:* `app/tx/reconcile.py:reconcile_once` · `app/tx/reconcile.py:_is_due` · `app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` · `app/tx/confirmation.py:poll_once` · `app/tx/broadcast.py:bump_fee` · -*Test:* `tests/unit/test_broadcast_outcome.py` -*Stato:* da implementare (B-78) · *Oggi:* l'abbandono avviene -quando la cronologia del solo server attivo non contiene alcun txid della -riga, dopo 120s per `building` e 6 ore per `pending`; le righe `failed` non -sono più controllate. +*Test:* `tests/unit/test_broadcast_outcome.py`, `tests/unit/test_tx_versions.py`, `tests/unit/test_reconcile_proof.py` diff --git a/docs/api.md b/docs/api.md index 095a65b..2a19259 100644 --- a/docs/api.md +++ b/docs/api.md @@ -555,7 +555,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. | `pool_address` | stringa o `null` | indirizzo del montepremi del round, verificabile su un esploratore | | `chain_tip_height` | intero o `null` | ultima altezza nota della catena; `null` finché non è nota | | `lottery_paused` | booleano | lotteria in pausa, per qualunque motivo | - | `paused_reason` | stringa o `null` | `operator`, `reorg`, `payout_blocked`, `resurrected_tx`, oppure `null` se non in pausa ([modello-dati.md](modello-dati.md), DAT-69) — *da implementare (B-78, B-82, B-85)* | + | `paused_reason` | stringa o `null` | `operator`, `reorg`, `payout_blocked`, `resurrected_tx`, oppure `null` se non in pausa ([modello-dati.md](modello-dati.md), DAT-69) | | `lottery_configured` | booleano | falso finché manca configurazione indispensabile (oggi: `fee_address`), e quindi nessun round può aprirsi | | `user_played` | booleano | vero se il chiamante autenticato ha una riga di partecipazione in questo round, in qualunque stato; sempre falso per un anonimo | @@ -831,9 +831,10 @@ dei parametri operativi in vede `lottery_paused` al polling successivo. *Codice:* `app/api/routes/admin.py:pause_lottery`, `app/api/routes/admin.py:resume_lottery` · - *Test:* `tests/unit/test_admin.py` - *Stato:* da implementare (B-78, B-82, B-85) · *Oggi:* nessun corpo, nessun - `paused_reason`, e la pausa è solo manuale. + *Test:* `tests/unit/test_admin.py`, `tests/unit/test_pause_reason.py` + *Stato:* da implementare (B-82), per l'azzeramento di `payout_blocked_at` · + *Oggi:* `rounds.payout_blocked_at` non esiste ancora; le pause automatiche + esistenti sono solo `resurrected_tx`. #### GET /admin/users diff --git a/docs/audit-log.md b/docs/audit-log.md index 6bd1cc3..370b740 100644 --- a/docs/audit-log.md +++ b/docs/audit-log.md @@ -213,9 +213,11 @@ flowchart LR `app/electrum/listener.py:ElectrumListener._apply_header`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/tx/reconcile.py:reconcile_once` · - *Test:* nessun test verifica la riga di audit. - *Stato:* da implementare (B-78, B-82, B-85, B-90) · *Oggi:* scritto solo - da `POST /admin/pause`, con payload vuoto `{}`. + *Test:* `tests/unit/test_pause_reason.py` + *Stato:* da implementare (B-82, B-85, B-90) · *Oggi:* scritto da + `POST /admin/pause` (`operator`) e dal riconciliatore (`resurrected_tx`); + `reorg`, `payout_blocked` e l'avvio con `START_PAUSED` non lo scrivono + ancora. - **[AUD-12]** `lottery_resumed` — `POST /admin/resume`. Payload `{"reason"}`: il `paused_reason` che la ripresa ha tolto (`null` se la @@ -225,8 +227,9 @@ flowchart LR `paused = false`, `paused_reason = NULL` e dell'azzeramento di `payout_blocked_at`. *Codice:* `app/api/routes/admin.py:resume_lottery` · - *Test:* nessun test verifica la riga di audit. - *Stato:* da implementare (B-78, B-82, B-85) · *Oggi:* payload vuoto `{}`. + *Test:* `tests/unit/test_pause_reason.py` + *Stato:* da implementare (B-82), per l'azzeramento di `payout_blocked_at` · + *Oggi:* `rounds.payout_blocked_at` non esiste ancora. - **[AUD-13]** `admin_privkey_accessed` — `GET /admin/users/{id}/privkey` ha esportato la chiave privata (WIF) di un utente. Payload `{"user_id"}`. @@ -582,10 +585,7 @@ flowchart LR montepremi non è tracciato in `utxo_events`). `user_id` e `round_id` come in [AUD-30]. Stessa transazione dell'abbandono. *Codice:* `app/tx/reconcile.py:_abandon` · - *Test:* `tests/unit/test_reconcile.py` - *Stato:* da implementare (B-78) · *Oggi:* basta che la cronologia del - server attivo non contenga il txid corrente, e `reason` è sempre - `"not found on chain"`. + *Test:* `tests/unit/test_reconcile.py`, `tests/unit/test_reconcile_proof.py` - **[AUD-39]** `pending_tx_resurrected` — una riga `failed` da meno di 14 giorni è risultata confermata: una transazione data per persa è arrivata in @@ -599,9 +599,7 @@ flowchart LR passaggio a `confirmed`, della pausa e della riga `lottery_paused` con `reason = "resurrected_tx"` ([AUD-11]). Le righe collegate non vengono corrette: la procedura è in [operativita.md](operativita.md) (OPS-30). - *Codice:* `app/tx/reconcile.py:reconcile_once` · *Test:* — - *Stato:* da implementare (B-78) · *Oggi:* una riga `failed` non viene più - guardata e l'evento non esiste. + *Codice:* `app/tx/reconcile.py:_resurrect` · *Test:* `tests/unit/test_reconcile_proof.py` - **[AUD-40]** `rbf_bump_failed` — un aumento di commissione non è riuscito ([TX-24](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)). diff --git a/docs/ciclo-di-vita-del-round.md b/docs/ciclo-di-vita-del-round.md index 8e57c01..35b4069 100644 --- a/docs/ciclo-di-vita-del-round.md +++ b/docs/ciclo-di-vita-del-round.md @@ -219,10 +219,7 @@ giocata, ma il montepremi pubblicizzato non supera mai quello che verrà pagato. ([TX-11](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)), pagato dall'utente. *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · - *Test:* `tests/unit/test_scheduler.py` (solo il secondo controllo, in `_close_and_draw`; nessun test copre l'attesa in `_tick` con una scommessa in volo) - *Stato:* da implementare (B-78) · *Oggi:* l'attesa è già senza limite, ma - il riconciliatore abbandona una scommessa anche solo perché la cronologia - del server attivo non contiene il suo txid, senza altra prova. + *Test:* `tests/unit/test_scheduler.py` (solo il secondo controllo, in `_close_and_draw`; nessun test copre l'attesa in `_tick` con una scommessa in volo), `tests/unit/test_reconcile_proof.py` - **[RND-13]** Quando non resta nulla in volo e **nessun partecipante è confermato**, il round passa a `closed` senza vincitore, si scrive `round_closed` (con `participants: 0`) e si invia una notifica SSE. diff --git a/docs/connessione-electrum.md b/docs/connessione-electrum.md index ec2254f..609a076 100644 --- a/docs/connessione-electrum.md +++ b/docs/connessione-electrum.md @@ -274,14 +274,13 @@ Ogni decisione che sposta denaro in base a una risposta della rete viene confermata dagli **altri** server configurati, mai dal solo server attivo (che è proprio quello che un attaccante controllerebbe). -Oggi non è ancora così: solo le tre verifiche [ELC-15]–[ELC-17] sono -corroborate. Le conferme delle transazioni (`app/tx/confirmation.py:poll_once`), -l'abbandono di una transazione (`app/tx/reconcile.py:reconcile_once`), il -reintegro di una moneta ricomparsa +Oggi non è ancora così per tre decisioni: le conferme delle transazioni +(`app/tx/confirmation.py:poll_once`), il reintegro di una moneta ricomparsa (`app/deposits/service.py:reinstate_reappeared_utxos`) e la lettura delle monete del montepremi per il pagamento (`app/rounds/scheduler.py:RoundScheduler._trigger_payout`) si basano sul solo -server attivo; sono le righe [ELC-26], [ELC-28], [ELC-25] e [ELC-27]. +server attivo; sono le righe [ELC-26], [ELC-25] e [ELC-27]. L'abbandono di una +transazione ([ELC-28]) e l'invio ([TX-04], [TX-21]) usano già tutti i server. - **[ELC-14]** La corroborazione interroga in parallelo tutti i server configurati tranne quello attivo, ciascuno con una connessione nuova e un @@ -327,8 +326,6 @@ Stato delle righe della tabella: con altezza maggiore di 0. - [ELC-27] — *Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie fra le monete che il server attivo elenca per il montepremi. -- [ELC-28] — *Stato:* da implementare (B-78) · *Oggi:* si abbandona quando la - cronologia dell'indirizzo sul server attivo non contiene il txid. I saldi si muovono in entrambe le direzioni, quindi entrambe richiedono lo stesso quorum. Le conseguenze di una verifica non riuscita: diff --git a/docs/limiti-noti.md b/docs/limiti-noti.md index aff8895..7a587ab 100644 --- a/docs/limiti-noti.md +++ b/docs/limiti-noti.md @@ -108,16 +108,16 @@ implementato il [piano delle correzioni](piano-correzioni.md). *Codice:* `app/tx/broadcast.py:bump_fee` · `app/wallet/psbt_builder.py:build_payout_transaction` · `app/tx/reconcile.py:reconcile_once` - **[LIM-03]** **L'attesa in `closing` non ha un limite proprio.** Il round - aspetta ogni scommessa `building`/`broadcast`. Oggi a porre fine all'attesa - di una scommessa che non conferma è solo il riconciliatore, dopo 6 ore dal - primo invio se la transazione è sparita dalla rete del server attivo; una - scommessa ferma ma presente in mempool trattiene il round senza limite - ([LIM-02]). + aspetta ogni scommessa `building`/`broadcast`. A porre fine all'attesa di + una scommessa che non conferma è la sua conferma (una qualsiasi delle sue + versioni) oppure il riconciliatore, ma solo con la prova che non è partita + ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)); una scommessa ferma ma presente in mempool trattiene il round senza + limite ([LIM-02]). - *Impatto:* un round può restare chiuso ma non estratto per ore; nessun nuovo round si apre nel frattempo. - *Mitigazione attuale:* lo stato è visibile (`closing`) e il pannello «Transazioni pendenti» mostra la scommessa che trattiene il round. - - *Stato:* da chiudere con B-78 (in parte, per scelta). + - *Stato:* B-78 e B-77 chiusi; resta da chiudere B-87 (resto minimo), e per scelta l'assenza di un limite di tempo. - *Per chiuderlo:* con B-78 l'attesa finisce quando la scommessa conferma (una qualsiasi delle sue versioni, B-77) o quando c'è la prova che non confermerà mai ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)), non dopo un tempo fisso; con B-87 la scommessa @@ -277,19 +277,17 @@ implementato il [piano delle correzioni](piano-correzioni.md). - **[LIM-12]** **Non tutte le letture di rete sono corroborate.** Passano dal solo server attivo: la conferma di scommesse, pagamenti e prelievi - (`poll_once`), la decisione del riconciliatore che una transazione «non - esiste più», l'elenco delle monete del montepremi usato per il pagamento, e il + (`poll_once`), l'elenco delle monete del montepremi usato per il pagamento, e il ripristino di una moneta prima segnata come spesa all'esterno. - *Impatto:* un server ostile potrebbe far considerare confermata una - scommessa che non lo è, o far abbandonare a torto una transazione viva - (liberandone le monete e, per un pagamento, azzerando `payout_txid`), - lasciando il database in disaccordo con la catena; il ripristino non - corroborato può reintegrare nel saldo una moneta davvero spesa. - - *Mitigazione attuale:* l'abbandono avviene solo dopo 6 ore per `pending`; un - invio basato su monete inesistenti è rifiutato dalla rete. - - *Stato:* da chiudere con B-83 (conferme e ripristini), B-78 (abbandono), - B-82 (monete del round prima del pagamento) e B-76 (invio a tutti i - server). + scommessa che non lo è, lasciando il database in disaccordo con la + catena; il ripristino non corroborato può reintegrare nel saldo una + moneta davvero spesa. + - *Mitigazione attuale:* l'abbandono richiede una prova ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)) e l'invio + arriva a tutti i server; un invio basato su monete inesistenti è rifiutato + dalla rete. + - *Stato:* da chiudere con B-83 (conferme e ripristini) e B-82 (monete del + round prima del pagamento). - *Per chiuderlo:* una conferma vale quando il quorum restituisce una prova merkle valida (`blockchain.transaction.get_merkle`) contro un header che possediamo; il ripristino richiede lo stesso quorum di un accredito; diff --git a/docs/modello-dati.md b/docs/modello-dati.md index b32ef87..2fe0dfc 100644 --- a/docs/modello-dati.md +++ b/docs/modello-dati.md @@ -162,7 +162,7 @@ erDiagram string replaced_by_txid int attempt_count string failure_reason - datetime failed_at "da implementare" + datetime failed_at datetime updated_at } pending_transaction_txids { @@ -391,9 +391,8 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso *Codice:* le funzioni in tabella · *Test:* `tests/unit/test_deposits.py`, `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py`, `tests/unit/test_reconcile.py` - *Stato:* da implementare (B-78, B-83) · *Oggi:* l'abbandono avviene sul - silenzio del solo server attivo, il reintegro sulla parola del solo server - attivo. + *Stato:* da implementare (B-83) · *Oggi:* il reintegro avviene sulla parola + del solo server attivo. - **[DAT-13]** Il riconciliatore libera un input solo se il suo `spent_txid` è uno dei txid della riga che sta abbandonando (`current_txid` o una sua @@ -798,7 +797,7 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)). | `draw_confirmations` | Integer | `3` | 1 – 20 | Conferme richieste sul blocco di estrazione ([DAT-62]) — *da implementare (B-73)* | Dal round **successivo**, copiato all'apertura ([DAT-61]) | | `max_round_pool_sats` | BigInteger | `0` (tetto disattivato) | `0`, oppure `≥ bet_amount_sats` e `≤ 4 000 000 000 000 000` (400 × 100 000 PLM) | Tetto alla somma delle scommesse di un round ([DAT-27], [DAT-62]) — *da implementare (B-95)* | Subito, anche per il round in corso: vale per le scommesse successive | | `paused` | Boolean | `False` | non modificabile da `PUT /admin/config` | Pausa della lotteria | Subito per l'apertura del round successivo; il round in corso non è toccato | -| `paused_reason` | String(16) | `NULL` | non modificabile da `PUT /admin/config` | Perché la lotteria è in pausa ([DAT-69]) — *da implementare (B-78, B-82, B-85)* | Insieme a `paused` | +| `paused_reason` | String(16) | `NULL` | non modificabile da `PUT /admin/config` | Perché la lotteria è in pausa ([DAT-69]) | Insieme a `paused` | | `updated_at` | DateTime | `utcnow`, aggiornato a ogni modifica (`onupdate`) | — | Ultima modifica della riga | ORM | - **[DAT-28]** La tabella contiene al più una riga, creata alla prima lettura @@ -828,8 +827,8 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)). [DAT-69], che scrivono la propria voce di audit. *Codice:* `app/api/routes/admin.py:pause_lottery` · `app/api/routes/admin.py:resume_lottery` · *Test:* `tests/unit/test_admin.py` - *Stato:* da implementare (B-78, B-82, B-85) · *Oggi:* `paused_reason` non - esiste e nessun codice mette in pausa la lotteria da solo. + *Stato:* da implementare (B-82, B-85) · *Oggi:* l'unica pausa automatica è + `resurrected_tx` (B-78); quelle di B-82 e B-85 non esistono ancora. - **[DAT-62]** `deposit_confirmations` (predefinito 6) è il numero di conferme dopo cui un output che paga un utente si accredita ([DAT-10]); @@ -868,9 +867,10 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)). `app/api/routes/admin.py:resume_lottery` · `app/electrum/listener.py:ElectrumListener._apply_header` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · - `app/tx/reconcile.py:reconcile_once` · *Test:* — - *Stato:* da implementare (B-78, B-82, B-85, B-90) · *Oggi:* esiste solo - `paused`, scritto solo dai due endpoint admin. + `app/tx/reconcile.py:reconcile_once` · *Test:* `tests/unit/test_pause_reason.py` + *Stato:* da implementare (B-82, B-85, B-90) · *Oggi:* si scrivono solo + `operator` (`POST /admin/pause`) e `resurrected_tx` (il riconciliatore); + `reorg`, `payout_blocked` e `START_PAUSED` non hanno ancora chi li scrive. - **[DAT-31]** Con `fee_address` vuoto (o di soli spazi) non si apre nessun round (B-66). Una volta impostato, l'API non permette di svuotarlo di nuovo: la @@ -902,7 +902,7 @@ riconciliatore (vedi | `replaced_by_txid` | String(64) | sì | `NULL` | Il txid **sostituito** dall'ultimo bump (nonostante il nome, punta all'indietro). La cronologia completa è in `pending_transaction_txids` ([DAT-70]) e questa colonna resta solo informativa | `bump_fee` | | `attempt_count` | Integer | no | `1` | 1 + numero di bump riusciti | `bump_fee` | | `failure_reason` | String(128) | sì | `NULL` | Perché la transazione è fallita; solo con `status = failed` | `_abandon`; `_release_failed_withdrawal` | -| `failed_at` | DateTime | sì | `NULL` | Istante del passaggio a `failed`; da lì si contano i 14 giorni di sorveglianza ([DAT-72]) — *da implementare (B-78)* | `_abandon`; `_release_failed_withdrawal` | +| `failed_at` | DateTime | sì | `NULL` | Istante del passaggio a `failed`; da lì si contano i 14 giorni di sorveglianza ([DAT-72]) | `_abandon`; `_release_failed_withdrawal` | | `updated_at` | DateTime | no | `utcnow`, `onupdate` | Ultima modifica | ORM | Le funzioni citate sono `app/bets/service.py:place_bet`, @@ -967,11 +967,8 @@ Le funzioni citate sono `app/bets/service.py:place_bet`, guarda le righe `pending`, più le `failed` entro 14 giorni. *Codice:* le funzioni in tabella · *Test:* `tests/unit/test_confirmation.py`, `tests/unit/test_reconcile.py`, - `tests/unit/test_scheduler.py`, `tests/unit/test_bets.py`, - `tests/unit/test_withdrawals.py` - *Stato:* da implementare (B-78) · *Oggi:* l'abbandono avviene quando la - cronologia del solo server attivo non contiene `current_txid`; il poller - guarda solo le righe `pending` e una riga `failed` non viene più guardata. + `tests/unit/test_reconcile_proof.py`, `tests/unit/test_scheduler.py`, + `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py` - **[DAT-34]** Dopo la creazione, `current_txid`, `raw_tx_hex`, `fee_rate_sat_vb`, `replaced_by_txid`, `attempt_count` e `last_broadcast_at` @@ -1012,9 +1009,7 @@ Le funzioni citate sono `app/bets/service.py:place_bet`, conserva `failure_reason` e `failed_at`. *Codice:* `app/tx/reconcile.py:_abandon` · `app/withdrawals/service.py:_release_failed_withdrawal` · - *Test:* `tests/unit/test_reconcile.py` - *Stato:* da implementare (B-78) · *Oggi:* il riconciliatore scrive sempre - `"not found on chain"` e `failed_at` non esiste. + *Test:* `tests/unit/test_reconcile.py`, `tests/unit/test_reconcile_proof.py` - **[DAT-72]** Una riga `failed` con `failed_at` negli ultimi 14 giorni resta tra quelle che il riconciliatore controlla a ogni giro (120 s, @@ -1027,9 +1022,7 @@ Le funzioni citate sono `app/bets/service.py:place_bet`, ricostruite automaticamente: le riallinea l'operatore ([operativita.md](operativita.md) B-78). *Codice:* `app/tx/reconcile.py:reconcile_once` · `app/tx/reconcile.py:_abandon` · - *Test:* — - *Stato:* da implementare (B-78) · *Oggi:* una riga `failed` non viene mai - più letta. + *Test:* `tests/unit/test_reconcile_proof.py` - **[DAT-37]** Per le righe `bet` e `withdrawal` in `building`/`pending`, `raw_tx_hex` è la fonte del saldo "in attesa" (gli output che tornano @@ -1138,9 +1131,7 @@ dall'utente. Se la riga `pending_transactions` di un prelievo `failed` risorge ([DAT-72]), il prelievo resta `failed` finché l'operatore non lo riallinea. *Codice:* le funzioni in tabella · - *Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_reconcile.py` - *Stato:* da implementare (B-78) · *Oggi:* l'abbandono non richiede una - prova. + *Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_reconcile.py`, `tests/unit/test_reconcile_proof.py` - **[DAT-40]** `amount_requested_sats` è ciò che l'utente ha chiesto; `amount_sent_sats` è l'output effettivo verso il destinatario: l'importo diff --git a/docs/operativita.md b/docs/operativita.md index b3f1dd6..e7ebb77 100644 --- a/docs/operativita.md +++ b/docs/operativita.md @@ -313,7 +313,7 @@ modifica di `.env` e da solo dopo un crash (`restart: unless-stopped`). Le transazioni rimaste `building` (crash a metà invio) vengono risolte dal riconciliatore secondo le regole di [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) - (B-78): promosse se in catena, abbandonate solo con una prova. Si perdono + promosse se in catena, abbandonate solo con una prova. Si perdono invece, perché solo in memoria: i blocchi dei login per troppi tentativi e le quote per IP, le connessioni SSE (i browser si riconnettono da soli), i lock per utente. @@ -321,11 +321,9 @@ modifica di `.env` e da solo dopo un crash (`restart: unless-stopped`). `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`, `app/tx/reconcile.py:reconcile_once` · *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_reconcile.py` - *Stato:* da implementare (B-81, B-78) · *Oggi:* un round in `drawing` + *Stato:* da implementare (B-81) · *Oggi:* un round in `drawing` **non riprende**: resta in `drawing` per sempre e nessun nuovo round si apre - (serve [OPS-13]); una riga `building` non trovata nella cronologia del - server attivo viene abbandonata dopo 120 secondi dalla creazione, al primo - passaggio del riconciliatore (ogni 120s) con la connessione attiva. + (serve [OPS-13]). - **[OPS-10]** **Riavvio sicuro e deploy** (nuova versione, modifica di `.env`, rotazione di un segreto): @@ -547,12 +545,12 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09). impossibile: vedi [OPS-17]. 3. Se la riga è `building` da più di 2 minuti, il riconciliatore la risolve al suo prossimo passaggio (ogni 120s). - 4. Una transazione che il server attivo non conosce più viene abbandonata - dopo 6 ore dal primo invio (`pending_tx_abandoned`); il partecipante viene - rimosso e il round può chiudere. Dopo B-78 l'abbandono richiede una prova - (ingressi spesi altrove, o un nuovo invio rifiutato da tutti i server): - una transazione che nessuno conosce ma i cui ingressi non sono spesi non - viene più abbandonata per il solo passare del tempo. + 4. Una transazione viene abbandonata (`pending_tx_abandoned`) solo con una + prova: un suo ingresso speso in un blocco da un'altra transazione, oppure + nessun server che la conosca *e* un nuovo invio rifiutato da tutti per + ingressi mancanti o già spesi. Il partecipante viene allora rimosso e il + round può chiudere. Una transazione che nessuno conosce ma i cui ingressi + non sono spesi non viene abbandonata per il solo passare del tempo. *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, `app/tx/broadcast.py:RbfBumper`, `app/tx/reconcile.py:_is_due` · *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`, @@ -579,7 +577,7 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09). | `winner_share_below_network_fee` | Il montepremi è troppo piccolo per la commissione di rete. Abbassare `fee_rate_sat_vb`; se è già 1, nessuna procedura supportata | | `commission_below_dust_limit` | Il 30% è sotto 294 sat (montepremi sotto 980 sat, solo con un costo della scommessa minuscolo). Nessuna procedura supportata | | `payout build failed` | Leggere la traccia nel log (`round N payout build failed`) | - | `broadcast rejected` | Si risolve da sola: la riga `building` viene abbandonata dal riconciliatore fra 120 e 240 secondi dopo (al primo passaggio oltre i 120s), `payout_txid` si azzera e il pagamento viene ricostruito. Se si ripete, la rete rifiuta la transazione: controllare il messaggio nel log e `fee_rate_sat_vb`. Oggi lo stesso motivo compare anche per un timeout o una connessione caduta durante l'invio, quando la transazione potrebbe essere partita; dopo B-76 solo un rifiuto esplicito libera la riga | + | `broadcast rejected` | L'invio non è stato accettato: rifiutato, oppure senza risposta (timeout, connessione caduta) quando la transazione può essere partita. La riga resta `building`: dopo 120 secondi il riconciliatore la cerca su tutti i server e, se la trova, la promuove a `pending`; solo con la prova che non è partita (ingressi spesi altrove, o un nuovo invio rifiutato da tutti per ingressi mancanti) la abbandona, `payout_txid` si azzera e il pagamento viene ricostruito. Finché non succede nessuna delle due il round resta in `paying_out`: controllare il messaggio nel log, i server Electrum raggiungibili e `fee_rate_sat_vb` | **`insufficient pool UTXOs`.** Il pagamento usa solo gli UTXO **confermati** dell'indirizzo del montepremi, escludendo quelli già riservati da un altro @@ -611,12 +609,13 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09). **Sintomi.** Riga `pending_tx_abandoned` nel registro di audit; nel log `abandoned pending_transaction …`; la riga in "Transazioni pendenti" passa a `failed`. - **Causa.** Oggi: la transazione non compare nella cronologia del proprio - indirizzo secondo il **solo server attivo**: mai partita (riga `building` da - oltre 120s) o scomparsa dal mempool (riga `pending` da oltre 6 ore dal primo - invio). Non è una prova: un server diverso da quello che ha ricevuto l'invio - può non conoscerla (si cercano tutte le versioni di una transazione - accelerata con RBF, ma solo sul server attivo). + **Causa.** Una prova che la transazione non è partita o non confermerà + mai: un suo ingresso è stato speso in un blocco da un'altra transazione + (il `reason` del payload nomina il txid), oppure nessun server la + conosceva e un nuovo invio è stato rifiutato da tutti per ingressi mancanti + o già spesi. Il solo passare del tempo o il silenzio di un server non + bastano mai: si cercano tutte le versioni di una transazione accelerata con + RBF su tutti i server. **Effetto e cosa fare.** Il sistema ha già annullato tutto: UTXO liberati e saldo ricalcolato; per una scommessa il partecipante è rimosso (l'utente può riscommettere se il round è ancora aperto); per un prelievo la richiesta è @@ -630,10 +629,10 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09). per un pagamento già arrivato al vincitore, mettere subito in pausa la lotteria e trattare il round come in [OPS-07], passo 6. Se gli abbandoni sono frequenti, la commissione di rete configurata è probabilmente troppo - bassa. Dopo B-78 si abbandona solo con una prova, e una riga `failed` che - conferma più tardi mette in pausa la lotteria da sola ([OPS-30]). + bassa. Una riga `failed` che conferma più tardi (entro 14 giorni) mette in + pausa la lotteria da sola ([OPS-30]). *Codice:* `app/tx/reconcile.py:_abandon` · - *Test:* `tests/unit/test_reconcile.py` + *Test:* `tests/unit/test_reconcile.py`, `tests/unit/test_reconcile_proof.py` ### RBF impossibile @@ -820,9 +819,9 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09). `app/tx/confirmation.py:poll_once`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* — - *Stato:* da implementare (B-85, B-78, B-82, B-90) · *Oggi:* esiste solo la - pausa dell'operatore, senza motivo; nessun evento del sistema mette in pausa - la lotteria. + *Stato:* da implementare (B-85, B-82, B-90) · *Oggi:* il sistema mette in + pausa la lotteria solo per `resurrected_tx` (B-78); `reorg` e + `payout_blocked` non esistono ancora. ### Intervento manuale sui fondi diff --git a/docs/storico-bug.md b/docs/storico-bug.md index b5a9a4c..aa98125 100644 --- a/docs/storico-bug.md +++ b/docs/storico-bug.md @@ -226,7 +226,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). | 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 | *da registrare* | `test_broadcast_outcome.py`, `test_electrum_client.py` | 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 | *da registrare* | `test_tx_versions.py`, `test_migrations.py`, `test_broadcast.py` | TX | -| B-78 | Una transazione viene abbandonata sulla base del silenzio di un solo server | Critica | **aperto** | — | TX, ELC | +| B-78 | Una transazione viene abbandonata sulla base del silenzio di un solo server | Critica | *da registrare* | `test_reconcile_proof.py`, `test_reconcile.py`, `test_pause_reason.py`, `test_migrations.py` | TX, ELC | | B-79 | Il pagamento di norma non ha resto, quindi non si può accelerare e può bloccare la lotteria | Critica | **aperto** | — | RND, TX | | B-80 | Un solo indirizzo del montepremi per tutti i round | Critica | *da registrare* | `test_round_pool_address.py`, `test_pending_address.py`, `test_scheduler.py` | RND, BET, WDR | | B-81 | `drawing` resta bloccato dopo qualsiasi eccezione, non solo dopo un riavvio | Critica | **aperto** | — | RND | @@ -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, B-75, B-97, B-80, B-84, B-76, B-77. + come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78. - **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 (6 critici, + B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (5 critici, 7 alti, 6 medi), da implementare nell'ordine di [piano-correzioni.md](piano-correzioni.md). - **Prossimo numero libero:** `B-99`. diff --git a/docs/transazioni-e-riconciliazione.md b/docs/transazioni-e-riconciliazione.md index 833a20f..9856c40 100644 --- a/docs/transazioni-e-riconciliazione.md +++ b/docs/transazioni-e-riconciliazione.md @@ -327,23 +327,14 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre. alcuno, e anche in quel caso l'assenza da sola non basta ad abbandonarla ([TX-17]). Un **errore di rete** non porta a nulla: la riga resta com'è fino al giro successivo. - *Codice:* `app/tx/reconcile.py:reconcile_once`, `app/electrum/listener.py:ElectrumListener._corroborate_majority` · - *Test:* `tests/unit/test_reconcile.py`, `tests/unit/test_tx_versions.py` - *Stato:* da implementare (B-78) · *Oggi:* si interroga solo il server - attivo, e un txid assente dalla sua cronologia è - trattato come "sconosciuto alla rete": dopo un riavvio su un server diverso - una riga `building` viene abbandonata dopo 120s anche se la transazione è - nella mempool degli altri nodi (che la tengono fino a 336 ore con le - impostazioni predefinite di Bitcoin Core, contro le 6 ore di tolleranza). + *Codice:* `app/tx/reconcile.py:reconcile_once`, `app/electrum/listener.py:ElectrumListener.get_histories` · + *Test:* `tests/unit/test_reconcile_proof.py`, `tests/unit/test_reconcile.py`, `tests/unit/test_tx_versions.py` - **[TX-26]** Prima di scrivere un esito, il riconciliatore rilegge la riga nella propria transazione del database ([TX-28]) e non fa nulla se il suo stato **o il suo `current_txid`** non sono più quelli che aveva quando ha interrogato la rete (una conferma o un RBF avvenuti nel frattempo). *Codice:* `app/tx/reconcile.py:reconcile_once` · - *Test:* — - *Stato:* da implementare (B-78) · *Oggi:* ricontrolla solo lo stato, quindi - può abbandonare una riga appena sostituita dall'RBF sulla base del txid - precedente. + *Test:* `tests/unit/test_reconcile_proof.py` - **[TX-16]** **Presente in rete**: una riga `building` viene promossa a `pending` (e partecipante o prelievo da `building` a `broadcast`), con un evento `pending_tx_recovered` e l'evento che l'invio riuscito avrebbe @@ -377,11 +368,8 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre. il partecipante viene rimosso, o il prelievo segnato `failed` senza txid; il saldo dell'utente viene ricalcolato; si scrive `pending_tx_abandoned` (con il numero di monete liberate) e si invia una notifica SSE. - *Codice:* `app/tx/reconcile.py:_abandon`, `app/tx/reconcile.py:_release_inputs`, `app/tx/reconcile.py:reconcile_once` · - *Test:* `tests/unit/test_reconcile.py` - *Stato:* da implementare (B-78) · *Oggi:* basta che la cronologia del - server attivo non contenga `current_txid` (motivo `not found on chain`), - senza reinvio e senza guardare gli ingressi. + *Codice:* `app/tx/reconcile.py:_abandon`, `app/tx/reconcile.py:_release_inputs`, `app/tx/reconcile.py:reconcile_once`, `app/electrum/listener.py:ElectrumListener.confirmed_spender`, `app/electrum/listener.py:ElectrumListener.rebroadcast_everywhere` · + *Test:* `tests/unit/test_reconcile_proof.py`, `tests/unit/test_reconcile.py` - **[TX-23]** **Le righe `failed` restano sorvegliate** per 14 giorni dal passaggio a `failed` (`failed_at`, [modello-dati.md](modello-dati.md) (B-78)): a ogni giro il riconciliatore cerca i loro txid come in [TX-15]. @@ -391,11 +379,12 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre. lotteria viene messa in pausa (`paused_reason = 'resurrected_tx'`) perché l'operatore rimetta in ordine partecipante, prelievo o pagamento; il riconciliatore non corregge da solo le righe collegate. - *Codice:* `app/tx/reconcile.py:reconcile_once` · - *Test:* — - *Stato:* da implementare (B-78) · *Oggi:* una riga `failed` non viene più - guardata: se la transazione conferma dopo l'abbandono, nessuno se ne - accorge. + *Codice:* `app/tx/reconcile.py:reconcile_once`, `app/tx/reconcile.py:_resurrect`, `app/rounds/pause.py:pause_automatically` · + *Test:* `tests/unit/test_reconcile_proof.py`, `tests/unit/test_pause_reason.py` + *Stato:* da implementare (B-83), per la conferma con prova merkle · *Oggi:* + la conferma di una riga `failed` è quella dei server **altri dall'attivo**: + una maggioranza stretta di essi (almeno il minimo del quorum) elenca uno + dei suoi txid in un blocco, senza prova merkle ([TX-22]). - **[TX-27]** Il riconciliatore invia una notifica SSE solo per le righe di cui ha cambiato qualcosa (promozione, abbandono, riallineamento del txid), non per ogni riga esaminata. diff --git a/migrations/versions/b7d2e41c9a05_add_failed_at_and_paused_reason.py b/migrations/versions/b7d2e41c9a05_add_failed_at_and_paused_reason.py new file mode 100644 index 0000000..2c9a86f --- /dev/null +++ b/migrations/versions/b7d2e41c9a05_add_failed_at_and_paused_reason.py @@ -0,0 +1,47 @@ +"""add failed_at to pending_transactions and paused_reason to round_config + +Revision ID: b7d2e41c9a05 +Revises: a3f5c2d9b7e1 +Create Date: 2026-10-14 15:48:12.003915 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b7d2e41c9a05' +down_revision: Union[str, Sequence[str], None] = 'a3f5c2d9b7e1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_ACTIVE = "'open', 'closing', 'drawing', 'paying_out'" + + +def upgrade() -> None: + """Upgrade schema. + + B-78: `failed_at` (DAT-36, DAT-72) starts the 14-day watch over a failed row; rows + that are already failed get their last update as the best available estimate. + `paused_reason` (DAT-69) says why the lottery is paused; an instance that is already + paused was paused by its operator. + + Both are plain add_column calls — never batch_alter_table, which on SQLite rebuilds + the table and silently drops the expression indexes (B-97, DAT-56). + """ + op.add_column("pending_transactions", sa.Column("failed_at", sa.DateTime(), nullable=True)) + op.execute("UPDATE pending_transactions SET failed_at = updated_at WHERE status = 'failed'") + + op.add_column("round_config", sa.Column("paused_reason", sa.String(length=16), nullable=True)) + op.execute("UPDATE round_config SET paused_reason = 'operator' WHERE paused = 1") + + +def downgrade() -> None: + """Downgrade schema.""" + # drop_column on SQLite is a table rebuild: recreate the expression indexes (B-97). + op.drop_column("round_config", "paused_reason") + op.drop_column("pending_transactions", "failed_at") + op.execute("DROP INDEX IF EXISTS ix_rounds_single_active") + op.execute(f"CREATE UNIQUE INDEX ix_rounds_single_active ON rounds ((1)) WHERE status IN ({_ACTIVE})") diff --git a/tests/unit/fake_network.py b/tests/unit/fake_network.py index 2b1baae..74b6846 100644 --- a/tests/unit/fake_network.py +++ b/tests/unit/fake_network.py @@ -24,3 +24,49 @@ class FakeNetwork: def fan_out_transaction(self, raw_tx_hex: str) -> None: self.fanned_out.append(raw_tx_hex) + + +MISSING_INPUTS = "bad-txns-inputs-missingorspent" + + +class ScriptedNetwork(FakeNetwork): + """What the reconciler asks of the listener (B-78), scripted per test. + + `active` and `others` are the history answers of the active server and of each other + server — a list of {"tx_hash", "height"} entries, or None for a server that did not + answer. `spender` is what `confirmed_spender` returns (the txid of a mined + transaction spending the row's inputs, or None). `rebroadcast` is the + (active, others) pair of verdicts `rebroadcast_everywhere` returns: ("accepted", ""), + ("refused", message) or None.""" + + def __init__(self, *, active=(), others=((), ()), spender=None, rebroadcast=None, others_needed=2): + super().__init__(known=False) + self.active = None if active is None else list(active) + self.others = tuple(None if o is None else list(o) for o in others) + self.spender = spender + self.rebroadcast = rebroadcast + self.others_needed = others_needed + self.history_calls = 0 + self.rebroadcast_calls: list[str] = [] + + async def get_histories(self, scripthash): + from app.electrum.quorum import ServerAnswers + + self.history_calls += 1 + return ServerAnswers(self.active, self.others, self.others_needed) + + async def confirmed_spender(self, scripthash, own_txids, outpoints): + return self.spender + + async def rebroadcast_everywhere(self, raw_tx_hex): + from app.electrum.quorum import ServerAnswers + + self.rebroadcast_calls.append(raw_tx_hex) + active, others = self.rebroadcast if self.rebroadcast is not None else (None, (None, None)) + return ServerAnswers(active, tuple(others), self.others_needed) + + @classmethod + def proof_of_absence(cls, message: str = MISSING_INPUTS, **kwargs): + """Nobody of the quorum knows the tx and every server refuses it for missing inputs.""" + refusal = ("refused", message) + return cls(rebroadcast=(refusal, (refusal, refusal)), **kwargs) diff --git a/tests/unit/test_migrations.py b/tests/unit/test_migrations.py index 9387017..3dc339f 100644 --- a/tests/unit/test_migrations.py +++ b/tests/unit/test_migrations.py @@ -124,3 +124,39 @@ def test_existing_pending_transactions_are_backfilled_with_the_txids_they_rememb assert rows == [(1, "bb", ""), (1, "cc", "rawcc"), (2, "dd", "rawdd")] assert _index_sql(db_path, "ix_rounds_single_active") is not None assert _index_sql(db_path, "ix_users_username_lower") is not None + + +def test_failed_at_and_paused_reason_are_backfilled_for_existing_rows(tmp_path): + """B-78 (DAT-36, DAT-69, DAT-72): rows that are already failed start their 14-day + watch from their last update, and an instance that is already paused was paused by its + operator; the expression indexes survive the add_column steps.""" + db_path = tmp_path / "old.db" + _alembic(db_path, "upgrade", "a3f5c2d9b7e1") + with sqlite3.connect(db_path) as conn: + columns = ( + "(kind, current_txid, fee_rate_sat_vb, raw_tx_hex, broadcast_at, last_broadcast_at, status, " + "attempt_count, updated_at)" + ) + conn.execute( + f"INSERT INTO pending_transactions {columns} VALUES " + "('bet', 'aa', 1, 'raw', '2026-01-01 00:00:00', '2026-01-01 00:00:00', 'failed', 1, '2026-02-03 04:05:06')" + ) + conn.execute( + f"INSERT INTO pending_transactions {columns} VALUES " + "('bet', 'bb', 1, 'raw', '2026-01-01 00:00:00', '2026-01-01 00:00:00', 'pending', 1, '2026-02-03 04:05:06')" + ) + round_config = [r[1] for r in conn.execute("PRAGMA table_info(round_config)")] + assert "paused_reason" not in round_config + conn.execute( + "INSERT INTO round_config (fee_address, bet_amount_sats, round_duration_seconds, round_cooldown_seconds, " + "fee_rate_sat_vb, rbf_timeout_seconds, draw_animation_seconds, paused, updated_at) " + "VALUES ('', 1000000000, 600, 30, 1, 900, 20, 1, '2026-01-01 00:00:00')" + ) + _alembic(db_path, "upgrade", "head") + with sqlite3.connect(db_path) as conn: + failed_at = dict(conn.execute("SELECT current_txid, failed_at FROM pending_transactions")) + reason = conn.execute("SELECT paused_reason FROM round_config").fetchone()[0] + assert failed_at == {"aa": "2026-02-03 04:05:06", "bb": None} + assert reason == "operator" + assert _index_sql(db_path, "ix_rounds_single_active") is not None + assert _index_sql(db_path, "ix_users_username_lower") is not None diff --git a/tests/unit/test_pause_reason.py b/tests/unit/test_pause_reason.py new file mode 100644 index 0000000..3799e86 --- /dev/null +++ b/tests/unit/test_pause_reason.py @@ -0,0 +1,99 @@ +"""B-78 (DAT-69, AUD-11, AUD-12, API-32): why the lottery is paused. + +`paused_reason` is `operator` for POST /admin/pause and an automatic reason when the +system pauses itself (`resurrected_tx` so far). An operator pause never overwrites an +automatic reason, and POST /admin/resume — the only way out — clears whichever it is and +logs what it lifted. +""" + +import json + +from sqlalchemy import select + +from app.db.models import AuditLog +from app.rounds.pause import pause_automatically +from tests.unit.test_admin import client # noqa: F401 (fixture) + +_HEADERS = {"X-Admin-Token": "test-admin-token"} + + +async def _audit(event_type: str) -> list[dict]: + from app.db import base as db_base + + async with db_base.AsyncSessionLocal() as session: + rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == event_type).order_by(AuditLog.id))).all() + return [json.loads(r.payload_json) for r in rows] + + +async def test_an_operator_pause_has_the_reason_operator_and_may_carry_a_note(client): + assert (await client.get("/admin/config", headers=_HEADERS)).json()["paused_reason"] is None + + response = await client.post("/admin/pause", headers=_HEADERS, json={"note": "migrating the server"}) + + assert response.status_code == 200 + assert response.json()["paused"] is True and response.json()["paused_reason"] == "operator" + assert await _audit("lottery_paused") == [{"reason": "operator", "note": "migrating the server"}] + + +async def test_pause_still_works_without_a_body_and_rejects_a_note_over_256_characters(client): + assert (await client.post("/admin/pause", headers=_HEADERS)).status_code == 200 + assert (await client.post("/admin/pause", headers=_HEADERS, json={"note": "x" * 257})).status_code == 422 + assert await _audit("lottery_paused") == [{"reason": "operator"}] + + +async def test_an_automatic_reason_survives_an_operator_pause_and_resume_clears_it(client): + from app.db import base as db_base + + async with db_base.AsyncSessionLocal() as session: + await pause_automatically(session, "resurrected_tx") + await session.commit() + assert (await client.get("/admin/config", headers=_HEADERS)).json()["paused_reason"] == "resurrected_tx" + + again = await client.post("/admin/pause", headers=_HEADERS) + assert again.json()["paused_reason"] == "resurrected_tx" # the cause that needs a look is not hidden + assert (await _audit("lottery_paused"))[-1] == {"reason": "operator"} # but the row says who asked + + resumed = await client.post("/admin/resume", headers=_HEADERS) + assert resumed.json()["paused"] is False and resumed.json()["paused_reason"] is None + assert (await _audit("lottery_resumed"))[-1] == {"reason": "resurrected_tx"} # what was lifted + + +async def test_resuming_a_lottery_that_was_not_paused_logs_a_null_reason(client): + await client.post("/admin/resume", headers=_HEADERS) + assert await _audit("lottery_resumed") == [{"reason": None}] + + +async def test_an_automatic_pause_overwrites_an_operator_reason(client): + """DAT-69: the automatic reason is always written, even over an active pause.""" + from app.db import base as db_base + + await client.post("/admin/pause", headers=_HEADERS) + async with db_base.AsyncSessionLocal() as session: + await pause_automatically(session, "resurrected_tx") + await session.commit() + assert (await client.get("/admin/config", headers=_HEADERS)).json()["paused_reason"] == "resurrected_tx" + + +async def test_only_automatic_reasons_can_be_written_by_the_system(client): + import pytest + from app.db import base as db_base + + async with db_base.AsyncSessionLocal() as session: + with pytest.raises(ValueError): + await pause_automatically(session, "operator") + + +async def test_the_public_round_endpoint_reports_the_reason(client): + from fastapi import FastAPI + from httpx import ASGITransport, AsyncClient + + from app.api.routes.rounds import router as rounds_router + from app.rounds.events import broadcaster # noqa: F401 + + app = FastAPI() + app.include_router(rounds_router) + app.state.electrum_listener = type("L", (), {"tip_height": 0, "client": None})() + await client.post("/admin/pause", headers=_HEADERS) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as public: + body = (await public.get("/rounds/current")).json() + assert body["lottery_paused"] is True and body["paused_reason"] == "operator" diff --git a/tests/unit/test_reconcile.py b/tests/unit/test_reconcile.py index 5b04f6b..ab047fe 100644 --- a/tests/unit/test_reconcile.py +++ b/tests/unit/test_reconcile.py @@ -16,6 +16,7 @@ from app.config import settings from app.db.base import Base from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal from app.tx.reconcile import reconcile_once +from tests.unit.fake_network import ScriptedNetwork class UnknownTxClient: @@ -158,7 +159,7 @@ async def test_abandons_a_building_bet_and_gives_the_coins_back(session_factory) session_factory, pending_status="building", participant_status="building", age_seconds=300 ) - resolved = await reconcile_once(session_factory, UnknownTxClient()) + resolved = await reconcile_once(session_factory, UnknownTxClient(), ScriptedNetwork.proof_of_absence()) assert resolved == 1 async with session_factory() as session: @@ -230,7 +231,7 @@ async def test_abandons_a_repeatedly_bumped_tx_despite_a_recent_last_broadcast(s last_broadcast_age_seconds=60, # bumped a minute ago ) - assert await reconcile_once(session_factory, UnknownTxClient()) == 1 + assert await reconcile_once(session_factory, UnknownTxClient(), ScriptedNetwork.proof_of_absence()) == 1 async with session_factory() as session: assert (await session.scalars(select(PendingTransaction))).one().status == "failed" @@ -295,7 +296,7 @@ async def test_abandoned_withdrawal_is_marked_failed_and_kept(session_factory): ) await session.commit() - assert await reconcile_once(session_factory, UnknownTxClient()) == 1 + assert await reconcile_once(session_factory, UnknownTxClient(), ScriptedNetwork.proof_of_absence()) == 1 async with session_factory() as session: withdrawal = (await session.scalars(select(Withdrawal))).one() diff --git a/tests/unit/test_reconcile_proof.py b/tests/unit/test_reconcile_proof.py new file mode 100644 index 0000000..6542eb6 --- /dev/null +++ b/tests/unit/test_reconcile_proof.py @@ -0,0 +1,535 @@ +"""B-78 (TX-14, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, +AUD-12, AUD-31, AUD-39, API-32): a row is abandoned only on proof, and failed rows stay +watched. + +The old rule gave a row up when the *active* server's history lacked its txid — after +120s for `building`, 6h for `pending`. After a restart onto a different server, or a +forward clock jump, that released the coins of transactions that were alive: a bet's sats +sat in the pool with no participant, a withdrawal left while marked failed, a payout was +rebuilt and paid twice. Each test here fails against that rule. +""" + +from datetime import datetime, timedelta, timezone + +import pytest +from embit import script +from embit.transaction import Transaction, TransactionInput, TransactionOutput +from sqlalchemy import select + +from app.db.models import AuditLog, PendingTransaction, PendingTransactionTxid, RoundConfig, RoundParticipant, User, UtxoEvent, Withdrawal +from app.tx import reconcile as reconcile_module +from app.tx.reconcile import FAILED_WATCH_DAYS, reconcile_once +from tests.unit.fake_network import MISSING_INPUTS, ScriptedNetwork +from tests.unit.test_reconcile import ( # noqa: F401 (fixture and helpers shared with the reconciler tests) + _RAW_TX, + _TX_INPUT_TXID, + UnknownTxClient, + _seed_bet, + session_factory, +) + +_BET_TXID = "betxid" +_KNOWN = [{"tx_hash": _BET_TXID, "height": 0}] + + +async def _row(session_factory): + async with session_factory() as session: + return (await session.scalars(select(PendingTransaction))).one() + + +async def _events(session_factory): + async with session_factory() as session: + return [e.event_type for e in (await session.scalars(select(AuditLog))).all()] + + +async def _seed_building(session_factory, **kwargs): + return await _seed_bet(session_factory, pending_status="building", participant_status="building", age_seconds=300, **kwargs) + + +# --- presence: any server, any version (TX-15, TX-16) -------------------------------------- + + +async def test_a_row_the_active_server_has_not_seen_is_promoted_when_another_server_knows_it(session_factory): + """The restart-onto-another-server case: the new active server has no record of the + tx, a different node holds it in its mempool. Abandoning here released a live tx.""" + await _seed_building(session_factory) + network = ScriptedNetwork(active=[], others=(_KNOWN, [])) + + assert await reconcile_once(session_factory, UnknownTxClient(), network) == 1 + + row = await _row(session_factory) + assert row.status == "pending" + assert network.rebroadcast_calls == [] # nothing needed proving + async with session_factory() as session: + assert (await session.scalars(select(RoundParticipant))).one().status == "broadcast" + assert (await session.scalars(select(UtxoEvent))).one().spent_txid == _BET_TXID + assert "pending_tx_abandoned" not in await _events(session_factory) + + +# --- silence is not proof (TX-15, TX-17) -------------------------------------------------- + + +@pytest.mark.parametrize("age_seconds, status", [(300, "building"), (7 * 3600, "pending")]) +async def test_too_few_answers_leave_the_row_alone(session_factory, age_seconds, status): + """Only one of the other servers answered, below the quorum minimum: the empty + histories prove nothing and nothing is even rebroadcast.""" + await _seed_bet( + session_factory, + pending_status=status, + participant_status="building" if status == "building" else "broadcast", + age_seconds=age_seconds, + ) + network = ScriptedNetwork.proof_of_absence(active=[], others=([], None)) + + assert await reconcile_once(session_factory, UnknownTxClient(), network) == 0 + + assert (await _row(session_factory)).status == status + assert network.rebroadcast_calls == [] + assert "pending_tx_abandoned" not in await _events(session_factory) + + +async def test_an_active_server_that_does_not_answer_leaves_the_row_alone(session_factory): + await _seed_building(session_factory) + network = ScriptedNetwork.proof_of_absence(active=None, others=([], [])) + assert await reconcile_once(session_factory, UnknownTxClient(), network) == 0 + assert (await _row(session_factory)).status == "building" + + +async def test_no_network_never_abandons(session_factory): + """With nobody to ask beyond the active server, silence cannot be told from absence.""" + await _seed_building(session_factory) + assert await reconcile_once(session_factory, UnknownTxClient()) == 0 + assert (await _row(session_factory)).status == "building" + + +async def test_a_rebroadcast_that_one_server_accepts_means_the_tx_is_alive(session_factory): + """TX-17: nobody listed it, but one server takes it when it is sent again — it is + alive (or alive again), never abandoned; a `building` row is promoted.""" + await _seed_building(session_factory) + network = ScriptedNetwork(rebroadcast=(("refused", MISSING_INPUTS), (("accepted", ""), ("refused", MISSING_INPUTS)))) + + assert await reconcile_once(session_factory, UnknownTxClient(), network) == 1 + + assert (await _row(session_factory)).status == "pending" + assert "pending_tx_abandoned" not in await _events(session_factory) + + +async def test_an_already_known_refusal_of_the_rebroadcast_means_the_tx_is_alive(session_factory): + await _seed_building(session_factory) + network = ScriptedNetwork(rebroadcast=(("refused", "txn-already-in-mempool"), (None, None))) + assert await reconcile_once(session_factory, UnknownTxClient(), network) == 1 + assert (await _row(session_factory)).status == "pending" + + +async def test_a_fee_refusal_is_not_proof_a_building_row_goes_to_pending_for_the_bumper(session_factory): + """TX-17: the rebroadcast was refused for its fee — the tx may well be valid at a + higher one, so nothing is released; `building` becomes `pending` so RBF can bump it.""" + await _seed_building(session_factory) + fee = ("refused", "min relay fee not met") + network = ScriptedNetwork(rebroadcast=(fee, (fee, fee))) + + assert await reconcile_once(session_factory, UnknownTxClient(), network) == 1 + + assert (await _row(session_factory)).status == "pending" + async with session_factory() as session: + assert (await session.scalars(select(UtxoEvent))).one().spent_txid == _BET_TXID + assert "pending_tx_abandoned" not in await _events(session_factory) + + +async def test_a_fee_refusal_leaves_a_pending_row_as_it_is(session_factory): + await _seed_bet(session_factory, pending_status="pending", participant_status="broadcast", age_seconds=7 * 3600) + fee = ("refused", "insufficient fee") + network = ScriptedNetwork(rebroadcast=(fee, (fee, fee))) + assert await reconcile_once(session_factory, UnknownTxClient(), network) == 0 + assert (await _row(session_factory)).status == "pending" + + +@pytest.mark.parametrize( + "rebroadcast", + [ + pytest.param((("refused", "txn-mempool-conflict"), (("refused", "txn-mempool-conflict"),) * 2), id="mempool-conflict"), + pytest.param((("refused", MISSING_INPUTS), (("refused", MISSING_INPUTS), None)), id="too-few-refusals"), + pytest.param((("refused", MISSING_INPUTS), (("refused", MISSING_INPUTS), ("refused", "something else"))), id="mixed"), + pytest.param((None, (None, None)), id="nobody-answers"), + ], +) +async def test_a_rebroadcast_that_is_not_a_unanimous_missing_inputs_refusal_is_not_proof(session_factory, rebroadcast): + await _seed_building(session_factory) + network = ScriptedNetwork(rebroadcast=rebroadcast) + assert await reconcile_once(session_factory, UnknownTxClient(), network) == 0 + assert (await _row(session_factory)).status == "building" + + +# --- the two proofs (TX-17) ------------------------------------------------------------- + + +async def test_an_input_spent_in_a_block_by_another_transaction_is_proof(session_factory): + await _seed_building(session_factory) + spender = "ab" * 32 + network = ScriptedNetwork(spender=spender) + + assert await reconcile_once(session_factory, UnknownTxClient(), network) == 1 + + row = await _row(session_factory) + assert row.status == "failed" and spender in row.failure_reason + assert row.failed_at is not None + async with session_factory() as session: + assert (await session.scalars(select(UtxoEvent))).one().spent_txid is None + assert (await session.scalars(select(RoundParticipant))).all() == [] + abandoned = ( + await session.scalars(select(AuditLog).where(AuditLog.event_type == "pending_tx_abandoned")) + ).one() + assert spender in abandoned.payload_json + + +async def test_a_unanimous_missing_inputs_refusal_of_the_rebroadcast_is_proof(session_factory): + await _seed_bet(session_factory, pending_status="pending", participant_status="broadcast", age_seconds=7 * 3600) + network = ScriptedNetwork.proof_of_absence() + + assert await reconcile_once(session_factory, UnknownTxClient(), network) == 1 + + row = await _row(session_factory) + assert row.status == "failed" and "missing" in row.failure_reason and row.failed_at is not None + assert network.rebroadcast_calls == [_RAW_TX] # the latest raw tx, to every server + async with session_factory() as session: + assert (await session.scalars(select(UtxoEvent))).one().spent_txid is None + + +# --- age alone proves nothing (TX-14) ----------------------------------------------------- + + +async def test_a_forward_clock_jump_abandons_nothing(session_factory, monkeypatch): + """Seven hours jump by on the host's clock: every `pending` row is suddenly past the + 6h grace period. They are examined — and, the network knowing them, left alone.""" + await _seed_bet(session_factory, pending_status="pending", participant_status="broadcast", age_seconds=60) + + real_datetime = reconcile_module.datetime + + class _Jumped(real_datetime): + @classmethod + def now(cls, tz=None): + return real_datetime.now(tz) + timedelta(hours=7) + + monkeypatch.setattr(reconcile_module, "datetime", _Jumped) + + # The row is examined (a real network that does not know the tx would have to prove + # its absence) — here one server lists it. + network = ScriptedNetwork(active=[], others=(_KNOWN, [])) + await reconcile_once(session_factory, UnknownTxClient(), network) + assert network.history_calls == 1 # it was examined + assert (await _row(session_factory)).status == "pending" + + # And where nobody lists it but the network is only silent, still nothing is given up. + quiet = ScriptedNetwork(active=[], others=([], None)) + await reconcile_once(session_factory, UnknownTxClient(), quiet) + assert (await _row(session_factory)).status == "pending" + assert "pending_tx_abandoned" not in await _events(session_factory) + + +# --- the re-check (TX-26) --------------------------------------------------------------------- + + +async def test_a_row_bumped_while_the_network_was_asked_is_not_abandoned(session_factory): + """The answer was about the txid the row had when it was asked; an RBF that lands + in the meantime changes what it means, so the outcome is not written.""" + await _seed_bet(session_factory, pending_status="pending", participant_status="broadcast", age_seconds=7 * 3600) + + class BumpingNetwork(ScriptedNetwork): + async def confirmed_spender(self, *args): + async with session_factory() as session: + row = (await session.scalars(select(PendingTransaction))).one() + row.current_txid = "bumped-txid" + await session.commit() + return "ab" * 32 # would be proof — about the old txid + + assert await reconcile_once(session_factory, UnknownTxClient(), BumpingNetwork()) == 0 + row = await _row(session_factory) + assert row.status == "pending" and row.current_txid == "bumped-txid" + + +# --- failed rows stay watched (TX-23, DAT-72) --------------------------------------------------- + + +async def _seed_failed_withdrawal(session_factory, *, failed_days_ago: float = 1.0, with_earlier_version: str | None = None): + from app.wallet.hd import derive_user_address + + failed_at = datetime.now(timezone.utc) - timedelta(days=failed_days_ago) + async with session_factory() as session: + user = User(username="w", password_hash="x", derivation_index=1, address=derive_user_address(1)) + session.add(user) + await session.flush() + session.add(UtxoEvent(user_id=user.id, txid=_TX_INPUT_TXID, vout=0, amount_sats=500_000_000, confirmed_height=10)) + withdrawal = Withdrawal( + user_id=user.id, + external_address="plm1qexternal", + amount_requested_sats=400_000_000, + amount_sent_sats=399_000_000, + txid=None, + status="failed", + ) + session.add(withdrawal) + await session.flush() + pending = PendingTransaction( + kind="withdrawal", + withdrawal_id=withdrawal.id, + user_id=user.id, + current_txid="wdtxid", + fee_rate_sat_vb=1, + raw_tx_hex=_RAW_TX, + status="failed", + failure_reason="rebroadcast refused for missing inputs", + failed_at=failed_at, + broadcast_at=failed_at - timedelta(days=1), + ) + session.add(pending) + await session.flush() + session.add(PendingTransactionTxid(pending_transaction_id=pending.id, txid="wdtxid", raw_hex=_RAW_TX)) + if with_earlier_version: + session.add(PendingTransactionTxid(pending_transaction_id=pending.id, txid=with_earlier_version, raw_hex=_RAW_TX)) + await session.commit() + + +async def test_a_failed_row_that_confirms_later_is_resurrected_and_pauses_the_lottery(session_factory): + await _seed_failed_withdrawal(session_factory) + mined = [{"tx_hash": "wdtxid", "height": 321}] + network = ScriptedNetwork(active=mined, others=(mined, mined)) + + assert await reconcile_once(session_factory, UnknownTxClient(), network) == 1 + + async with session_factory() as session: + row = (await session.scalars(select(PendingTransaction))).one() + assert row.status == "confirmed" + config = (await session.scalars(select(RoundConfig))).one() + assert config.paused is True and config.paused_reason == "resurrected_tx" + resurrected = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "pending_tx_resurrected"))).one() + assert '"block_height": 321' in resurrected.payload_json and '"txid": "wdtxid"' in resurrected.payload_json + assert resurrected.user_id == row.user_id + paused = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "lottery_paused"))).one() + assert '"reason": "resurrected_tx"' in paused.payload_json + # The linked rows are not corrected by the system (DAT-72): the withdrawal stays failed. + assert (await session.scalars(select(Withdrawal))).one().status == "failed" + assert (await session.scalars(select(UtxoEvent))).one().spent_txid is None + + # Written once: the row is confirmed now and no longer watched. + assert await reconcile_once(session_factory, UnknownTxClient(), network) == 0 + events = await _events(session_factory) + assert events.count("pending_tx_resurrected") == 1 and events.count("lottery_paused") == 1 + + +async def test_an_earlier_version_of_a_failed_row_that_confirms_is_resurrected_too(session_factory): + await _seed_failed_withdrawal(session_factory, with_earlier_version="older-txid") + mined = [{"tx_hash": "older-txid", "height": 400}] + network = ScriptedNetwork(active=[], others=(mined, mined)) + + assert await reconcile_once(session_factory, UnknownTxClient(), network) == 1 + row = await _row(session_factory) + assert row.status == "confirmed" and row.current_txid == "older-txid" + + +async def test_a_failed_row_the_quorum_does_not_confirm_stays_failed(session_factory): + """Only the active server (or only a minority of the others) says it is mined.""" + await _seed_failed_withdrawal(session_factory) + mined = [{"tx_hash": "wdtxid", "height": 321}] + + hostile_active = ScriptedNetwork(active=mined, others=([], [])) + assert await reconcile_once(session_factory, UnknownTxClient(), hostile_active) == 0 + minority = ScriptedNetwork(active=mined, others=(mined, [], []), others_needed=2) + assert await reconcile_once(session_factory, UnknownTxClient(), minority) == 0 + too_few = ScriptedNetwork(active=mined, others=(mined, None), others_needed=2) + assert await reconcile_once(session_factory, UnknownTxClient(), too_few) == 0 + + assert (await _row(session_factory)).status == "failed" + async with session_factory() as session: + config = (await session.scalars(select(RoundConfig))).first() + assert config is None or not config.paused # no pause without the quorum + + +async def test_a_failed_row_is_watched_for_fourteen_days_and_no_longer(session_factory): + await _seed_failed_withdrawal(session_factory, failed_days_ago=FAILED_WATCH_DAYS - 1) + network = ScriptedNetwork() + await reconcile_once(session_factory, UnknownTxClient(), network) + assert network.history_calls == 1 # still watched + + async with session_factory() as session: + row = (await session.scalars(select(PendingTransaction))).one() + row.failed_at = datetime.now(timezone.utc) - timedelta(days=FAILED_WATCH_DAYS + 1) + await session.commit() + network = ScriptedNetwork() + await reconcile_once(session_factory, UnknownTxClient(), network) + assert network.history_calls == 0 # past the watch + + +async def test_a_refused_withdrawal_starts_its_watch_too(session_factory): + """DAT-36: `_release_failed_withdrawal` stamps failed_at like `_abandon`.""" + from app.electrum.client import ElectrumRpcError + from app.withdrawals.service import WithdrawalError, request_withdrawal + from tests.unit.fake_network import FakeNetwork + from app.wallet.hd import derive_user_address + + async with session_factory() as session: + user = User(username="r", password_hash="x", derivation_index=2, address=derive_user_address(2)) + session.add(user) + await session.flush() + session.add(UtxoEvent(user_id=user.id, txid="22" * 32, vout=0, amount_sats=3_000_000_000, confirmed_height=10)) + session.add(RoundConfig(fee_address="plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n")) + await session.commit() + user_id = user.id + + class Refusing: + async def broadcast(self, raw): + raise ElectrumRpcError({"code": -26, "message": "min relay fee not met"}) + + async with session_factory() as session: + user = await session.get(User, user_id) + with pytest.raises(WithdrawalError): + await request_withdrawal( + session, Refusing(), user, "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx", 1_000_000_000, + network=FakeNetwork(known=False), + ) + row = await _row(session_factory) + assert row.status == "failed" and row.failed_at is not None + + +# --- the listener's side: asking every server (TX-15, TX-17, ELC-28) --------------------------- + + +class _Server: + """One fake connection. `history`: the address's entries (None = unreachable); + `txs`: raw transactions by txid it can return; `broadcast`: "ok", an error message + (an explicit JSON-RPC refusal) or None (no answer).""" + + def __init__(self, history=(), txs=None, broadcast="ok"): + self._history = history + self._txs = txs or {} + self._broadcast = broadcast + self.broadcasted: list[str] = [] + + async def connect(self): + if self._history is None: + raise ConnectionRefusedError("down") + + async def get_history(self, scripthash): + return list(self._history) + + async def get_transaction(self, txid, verbose=False): + return self._txs[txid] + + async def broadcast(self, raw): + from app.electrum.client import ElectrumError, ElectrumRpcError + + self.broadcasted.append(raw) + if self._broadcast is None: + raise ElectrumError("timed out") + if self._broadcast != "ok": + raise ElectrumRpcError({"code": -25, "message": self._broadcast}) + return "txid" + + async def close(self): + pass + + +def _make_listener(session_factory, active: _Server | None, others: dict[str, _Server]): + from app.electrum.client import ElectrumEndpoint + from app.electrum.listener import ElectrumListener + + endpoints = [ElectrumEndpoint("active.example", 50002, True)] + [ElectrumEndpoint(h, 50002, True) for h in others] + listener = ElectrumListener(lambda endpoint: others[endpoint.host], session_factory, endpoints) + listener.client = active + return listener + + +def _spending_tx(spent_txid: str, vout: int = 0): + """A real transaction spending (spent_txid, vout), and its txid.""" + tx = Transaction( + vin=[TransactionInput(bytes.fromhex(spent_txid), vout)], + vout=[TransactionOutput(1_000, script.Script.from_address("plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"))], + ) + return tx.serialize().hex(), tx.txid().hex() + + +async def test_get_histories_returns_every_servers_own_answer(session_factory): + listener = _make_listener( + session_factory, + _Server([{"tx_hash": "aa", "height": 1}]), + {"first.example": _Server([]), "second.example": _Server(None), "third.example": _Server([{"tx_hash": "bb", "height": 0}])}, + ) + answers = await listener.get_histories("scripthash") + assert answers.active == [{"tx_hash": "aa", "height": 1}] + assert answers.others == ([], None, [{"tx_hash": "bb", "height": 0}]) + assert answers.complete # the active server and two of the three others answered + + +async def test_get_histories_is_incomplete_without_the_active_server_or_enough_others(session_factory): + no_active = _make_listener(session_factory, None, {"first.example": _Server([]), "second.example": _Server([])}) + assert not (await no_active.get_histories("s")).complete + too_few = _make_listener(session_factory, _Server([]), {"first.example": _Server([]), "second.example": _Server(None)}) + assert not (await too_few.get_histories("s")).complete + + +async def test_rebroadcast_everywhere_reports_each_servers_verdict(session_factory): + active = _Server(broadcast="bad-txns-inputs-missingorspent") + listener = _make_listener( + session_factory, + active, + {"first.example": _Server(broadcast="ok"), "second.example": _Server(broadcast=None), "third.example": _Server(broadcast="min relay fee not met")}, + ) + answers = await listener.rebroadcast_everywhere("deadbeef") + assert answers.active == ("refused", "bad-txns-inputs-missingorspent") + assert answers.others == (("accepted", ""), None, ("refused", "min relay fee not met")) + assert active.broadcasted == ["deadbeef"] + + +async def test_confirmed_spender_needs_a_majority_of_a_quorum_of_the_other_servers(session_factory): + raw, spender = _spending_tx(_TX_INPUT_TXID) + mined = [{"tx_hash": spender, "height": 50}] + outpoints = {(_TX_INPUT_TXID, 0)} + txs = {spender: raw} + + agree = _make_listener( + session_factory, + _Server([]), # the active server's view is irrelevant + {"first.example": _Server(mined, txs), "second.example": _Server(mined, txs), "third.example": _Server([])}, + ) + assert await agree.confirmed_spender("s", {"own"}, outpoints) == spender + + minority = _make_listener( + session_factory, + _Server(mined, txs), + {"first.example": _Server(mined, txs), "second.example": _Server([]), "third.example": _Server([])}, + ) + assert await minority.confirmed_spender("s", {"own"}, outpoints) is None + + too_few = _make_listener( + session_factory, + _Server([]), + {"first.example": _Server(mined, txs), "second.example": _Server(None), "third.example": _Server(None)}, + ) + assert await too_few.confirmed_spender("s", {"own"}, outpoints) is None + + +async def test_confirmed_spender_ignores_unconfirmed_transactions_and_the_rows_own_versions(session_factory): + raw, other = _spending_tx(_TX_INPUT_TXID) + outpoints = {(_TX_INPUT_TXID, 0)} + in_mempool = [{"tx_hash": other, "height": 0}] + unconfirmed = _make_listener( + session_factory, _Server([]), {"first.example": _Server(in_mempool, {other: raw}), "second.example": _Server(in_mempool, {other: raw})} + ) + assert await unconfirmed.confirmed_spender("s", {"own"}, outpoints) is None + + mined = [{"tx_hash": other, "height": 9}] + own = _make_listener( + session_factory, _Server([]), {"first.example": _Server(mined, {other: raw}), "second.example": _Server(mined, {other: raw})} + ) + assert await own.confirmed_spender("s", {other}, outpoints) is None # it is one of the row's own versions + + +async def test_confirmed_spender_ignores_a_server_that_returns_a_different_transaction(session_factory): + raw, spender = _spending_tx(_TX_INPUT_TXID) + other_raw, _ = _spending_tx("33" * 32) + mined = [{"tx_hash": spender, "height": 9}] + lying = _make_listener( + session_factory, + _Server([]), + {"first.example": _Server(mined, {spender: other_raw}), "second.example": _Server(mined, {spender: other_raw})}, + ) + assert await lying.confirmed_spender("s", set(), {(_TX_INPUT_TXID, 0)}) is None diff --git a/tests/unit/test_tx_versions.py b/tests/unit/test_tx_versions.py index 1730f12..bc15fd2 100644 --- a/tests/unit/test_tx_versions.py +++ b/tests/unit/test_tx_versions.py @@ -37,7 +37,7 @@ from app.wallet.hd import derive_pool_address, derive_user_address, derive_user_ from app.wallet.plm_network import PLM_MAINNET from app.wallet.psbt_builder import Utxo, build_signed_transaction from app.withdrawals.service import request_withdrawal -from tests.unit.fake_network import FakeNetwork, rpc_error +from tests.unit.fake_network import FakeNetwork, ScriptedNetwork, rpc_error from tests.unit.test_bets import _make_funded_user from tests.unit.test_bets import session_factory as bets_session_factory # noqa: F401 (fixture) from tests.unit.test_broadcast import FakeClient as ChainClient, session_factory # noqa: F401 (fixture) @@ -441,7 +441,7 @@ async def test_abandoning_a_row_releases_inputs_claimed_by_any_of_its_versions(s await session.commit() client.history = [] # nobody knows any version - await reconcile_once(session_factory, client) + await reconcile_once(session_factory, client, ScriptedNetwork.proof_of_absence()) async with session_factory() as session: assert (await session.get(PendingTransaction, seeded["pending_id"])).status == "failed"