Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32)

reconcile_once abandoned a row when the *active* server's history lacked its txid:
after 120s for `building` (which a restart onto a different server makes likely),
after 6h for `pending`, and all at once after a forward clock jump. It re-checked the
status but not the txid, so it raced the bumper, and a `failed` row was never looked at
again.

- The reconciler asks every server (ElectrumListener.get_histories, active + others) for
  the history of the row's address and looks for every txid of the row. Present on any
  server -> promoted (adopting the version found). Absent is only "no server of the
  quorum knows it" and needs the active server plus the quorum minimum of the others to
  have answered; even then nothing is released without proof:
  (a) an input spent in a block by a transaction that is none of the row's versions,
      agreed by a strict majority of a quorum of the other servers (confirmed_spender), or
  (b) a rebroadcast of the latest raw tx to every server (rebroadcast_everywhere)
      refused by all of them, explicitly, for missing/spent inputs.
  Accepted or "already known" by any server means alive; a fee refusal is not proof
  (`building` -> `pending` for the bumper, `pending` untouched); too few answers or
  anything else leaves the row alone. failure_reason names the proof.
- The write re-reads status *and* current_txid under begin_immediate (TX-26).
- pending_transactions.failed_at (set by _abandon and _release_failed_withdrawal) starts
  a 14-day watch: a failed row that a majority of the other servers (at least the quorum
  minimum) list in a block becomes `confirmed`, writes pending_tx_resurrected once and
  pauses the lottery with paused_reason `resurrected_tx`; linked rows are not corrected.
  The confirmation is by quorum of histories for now; merkle proofs come with B-83.
- round_config.paused_reason (migration b7d2e41c9a05, with the failed_at one): `operator`
  from POST /admin/pause (which accepts an optional `note` and never overwrites an
  automatic reason), cleared by resume, which logs the reason it lifted; exposed on
  /admin/config and GET /rounds/current. rounds/pause.py:pause_automatically is the
  system's way in.
- PendingTransactionReconciler takes the listener as `network`; with none it can promote
  but never abandon.

Tests changed because the spec changed: the three reconciler tests that expect an
abandonment (and the one in test_tx_versions) now supply a network that proves the
transaction is gone.

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

No files matched your search

+74
View File
@@ -1,5 +1,6 @@
import asyncio
import logging
from collections import Counter
from collections.abc import Callable
from embit.transaction import Transaction
@@ -16,6 +17,7 @@ from app.deposits.service import (
)
from app.config import MIN_QUORUM_RESPONSES
from app.electrum.client import ElectrumClient, ElectrumEndpoint, ElectrumRpcError
from app.electrum.quorum import ServerAnswers
from app.electrum.scripthash import address_to_scripthash
from app.rounds.draw import (
HeaderValidationError,
@@ -497,6 +499,78 @@ class ElectrumListener:
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
# --- What the network knows of a transaction (B-78, TX-15, TX-17) -------------------
async def _ask_everyone(self, ask: Callable[[ElectrumClient], "asyncio.Future"]) -> ServerAnswers:
active, others = await asyncio.gather(self._ask_active_server(ask), self._ask_other_servers(ask))
return ServerAnswers(active, tuple(others), self._minimum_other_answers())
async def get_histories(self, scripthash: str) -> ServerAnswers:
"""`blockchain.scripthash.get_history` of `scripthash` from the active server and
every other one: each answer is the list of {"tx_hash", "height"} entries."""
async def _ask(client: ElectrumClient) -> list | None:
try:
history = await client.get_history(scripthash)
except Exception:
return None
return history if isinstance(history, list) else None
return await self._ask_everyone(_ask)
async def rebroadcast_everywhere(self, raw_tx_hex: str) -> ServerAnswers:
"""Send `raw_tx_hex` to the active server and every other one and report what each
said: ("accepted", "") or ("refused", <its message>) — an explicit JSON-RPC error —
and None for a server that gave no answer at all (TX-17). A tx the servers already
have comes back as a refusal with an "already known" message; the caller reads it."""
async def _ask(client: ElectrumClient) -> tuple | None:
try:
await client.broadcast(raw_tx_hex)
except ElectrumRpcError as exc:
return ("refused", exc.message)
except Exception:
return None
return ("accepted", "")
return await self._ask_everyone(_ask)
async def confirmed_spender(
self, scripthash: str, own_txids: set[str], outpoints: set[tuple[str, int]]
) -> str | None:
"""The txid of a transaction, none of the row's own versions, that spends one of
`outpoints` and is **in a block** — the first proof by which a row may be given
up (TX-17): its inputs went elsewhere. Asked of the *other* servers only (the
active one is what a hostile peer would control) and returned only when at least
the quorum minimum answered and a strict majority of the answerers name the same
txid. Each server looks through the confirmed history of the address the row
spends from.
"""
async def _ask(client: ElectrumClient) -> str | None:
history = await client.get_history(scripthash)
for entry in history:
txid = entry.get("tx_hash")
if txid in own_txids or (entry.get("height") or 0) <= 0:
continue
raw = await client.get_transaction(txid, False)
tx = Transaction.parse(bytes.fromhex(raw))
if tx.txid().hex() != txid:
continue
if any((vin.txid.hex(), vin.vout) in outpoints for vin in tx.vin):
return txid
return "" # looked everywhere: no conflicting transaction
answers = await self._ask_other_servers(_ask)
answered = [answer for answer in answers if answer is not None]
if len(answered) < max(self._minimum_other_answers(), 1):
return None
counts = Counter(answer for answer in answered if answer)
if not counts:
return None
txid, votes = counts.most_common(1)[0]
return txid if votes * 2 > len(answered) else None
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
"""B-28: is `expected_hash` — the header our own active connection
reported for `height` — corroborated by other configured servers before
+27
View File
@@ -0,0 +1,27 @@
"""What a question put to every server came back with (B-78).
`active` is the answer of the connection the platform is running on, `others` those of
each other configured server in configuration order; `None` stands for "no usable
answer" (unreachable, timed out, an error that is not an answer)."""
from typing import NamedTuple
class ServerAnswers(NamedTuple):
active: object | None
others: tuple
others_needed: int # how many of `others` must have answered for the network to count
@property
def answered_others(self) -> list:
return [answer for answer in self.others if answer is not None]
@property
def complete(self) -> bool:
"""Enough of the network answered for its silence to mean something: the active
server and at least `others_needed` of the others."""
return self.active is not None and len(self.answered_others) >= self.others_needed
@property
def all(self) -> list:
return ([] if self.active is None else [self.active]) + self.answered_others