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 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 15:07:09 +02:00
1 parent f936d6660c
commit a4b160e240
29 files changed
+1280 -201

No files matched your search

-14
View File
@@ -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
+4 -4
View File
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## Project status
All 10 stages of the original build order are code-complete and unit-tested — 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`).
+1 -1
View File
@@ -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
+26 -4
View File
@@ -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)
+3
View File
@@ -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,
)
+9
View File
@@ -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)
+74
View File
@@ -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", <its message>) — 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
+27
View File
@@ -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
+1 -1
View File
@@ -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).
+30
View File
@@ -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})
+244 -48
View File
@@ -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))
+2
View File
@@ -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,
+2 -6
View File
@@ -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`
+5 -4
View File
@@ -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
+10 -12
View File
@@ -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)).
+1 -4
View File
@@ -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.
+4 -7
View File
@@ -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:
+15 -17
View File
@@ -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;
+16 -25
View File
@@ -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
+23 -24
View File
@@ -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 <tipo> 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
+3 -3
View File
@@ -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`.
+11 -22
View File
@@ -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.
@@ -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})")
+46
View File
@@ -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)
+36
View File
@@ -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
+99
View File
@@ -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"
+4 -3
View File
@@ -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()
+535
View File
@@ -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
+2 -2
View File
@@ -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"