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:
1 parent
f936d6660c
commit
a4b160e240
29 files changed
+1280
-201
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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)).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`.
|
||||
@@ -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})")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user