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