Confirm transactions by merkle proof and reinstate coins only on a quorum (B-83, TX-06, TX-22, TX-23, ELC-25, ELC-26, DEP-02, DEP-08, AUD-18, ADR-07)

poll_once marked a transaction confirmed on one server's `height > 0`, and
reinstate_reappeared_utxos restored a coin written off as spent outside the platform on
the active server's word alone, so a hostile active server could report a
never-relayed payout as confirmed (winner unpaid, round closed) or put a spent coin
back into a balance.

- electrum/merkle.py: pure merkle-branch verification against a block header.
- ElectrumListener.confirmation_proven(txid, height): the *other* servers are asked for
  blockchain.transaction.get_merkle; a strict majority of at least the quorum minimum
  must return a proof that recomputes to the merkle root of that block's header (block
  height and position checked too). The header is the tip the listener holds, or for an
  older block the one a majority of the quorum agrees on and that meets its own target;
  a height above our tip cannot be proven. Quorum rules are _corroborate_majority's.
- poll_once takes the listener as `network`: the active server's history only nominates
  a candidate (txid, height); the row confirms only when the proof holds, and stays
  pending otherwise. With no network nothing confirms. ConfirmationPoller wires it.
- The reconciler's late-confirmation check of failed rows (B-78) now puts each claimed
  (txid, height) to the same proof instead of counting histories.
- refresh_user splits reinstatement like a credit: find_reinstate_candidates (read-only;
  same outpoint, recorded amount, confirmed) -> corroborate_utxo_reinstatement (same
  quorum as a credit) -> reinstate_reappeared_utxos writes only what was corroborated,
  rechecking the mark under the lock. Nothing is committed in the read phase any more.
- Broadcast fan-out, the third part of the finding, shipped with B-76.

Tests changed because the spec changed: every poll_once call now passes a network that
proves (FakeNetwork) — the confirmation tests are otherwise unchanged; the failed-row
"quorum does not confirm" test now asserts the claim goes to the proof.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 15:29:30 +02:00
1 parent a4b160e240
commit a3c51719bd
23 files changed
+745 -119

No files matched your search

+34 -3
View File
@@ -1,6 +1,7 @@
import asyncio
import logging
from collections.abc import Awaitable, Callable
from typing import Protocol
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
@@ -29,7 +30,19 @@ def register_handler(kind: str, handler: ConfirmationHandler) -> None:
_handlers[kind] = handler
async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int:
class ConfirmationProver(Protocol):
"""The part of ElectrumListener a confirmation needs (B-83): the quorum's verdict on
a merkle proof of `txid` at `height`."""
async def confirmation_proven(self, txid: str, height: int) -> bool: ...
async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient, network: ConfirmationProver | None) -> int:
"""One pass over the `pending` rows. The active server's history only nominates a
candidate: a row is confirmed when the quorum proves it with a merkle proof
(B-83, TX-22). With no `network` to ask nothing can be confirmed — the active
server's word alone is exactly what a hostile one would use to declare a payout that
was never broadcast confirmed."""
async with session_factory() as session:
# Plain columns, not entities: nothing then outlives the session, so this
# can't break if expire_on_commit is ever turned on (B-21).
@@ -90,6 +103,18 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient)
if entry is None:
continue
# B-83 (TX-22, ELC-26): the height is a claim of the active server. The quorum has
# to return a valid merkle proof of the txid against that block's header; without
# one the row stays pending and is looked at again on the next pass.
if network is None or not await network.confirmation_proven(entry["tx_hash"], entry["height"]):
logger.warning(
"pending_transaction %s: %s reported at height %s but the quorum did not prove it — still pending",
pending_id,
entry["tx_hash"],
entry["height"],
)
continue
async with session_factory() as session:
await begin_immediate(session) # B-88: read the row and write its outcome under one lock
row = await session.get(PendingTransaction, pending_id)
@@ -111,16 +136,22 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient)
class ConfirmationPoller:
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: ConfirmationProver | None = None,
):
self._session_factory = session_factory
self._get_client = get_client
self._network = network # the ElectrumListener, which holds the headers and asks the quorum
async def run(self) -> None:
while True:
client = self._get_client()
if client is not None:
try:
await poll_once(self._session_factory, client)
await poll_once(self._session_factory, client, self._network)
except asyncio.CancelledError:
raise
except Exception:
+15 -14
View File
@@ -81,6 +81,9 @@ _ABANDON_AFTER_SECONDS = 6 * 60 * 60
# for dead can still arrive in a block.
FAILED_WATCH_DAYS = 14
# How many distinct (txid, height) claims of one failed row are put to the quorum per pass.
_MAX_RESURRECTION_CLAIMS = 3
# 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.
@@ -236,23 +239,17 @@ def _entries_for(answers: ServerAnswers, known_txids: set[str]) -> list[tuple[st
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
def _confirmed_candidates(answers: ServerAnswers, known_txids: set[str]) -> list[tuple[str, int]]:
"""(txid, height) of every txid of the row that any answering server lists in a block,
the one most servers agree on first. They are only *claims*: each is proven by the
quorum's merkle proofs before it is believed (B-83, TX-22)."""
votes: dict[tuple[str, int], int] = {}
for history in others:
for history in answers.all:
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
return [key for key, _ in sorted(votes.items(), key=lambda item: -item[1])]
async def _decide(network, candidate: _Candidate, answers: ServerAnswers):
@@ -260,8 +257,12 @@ async def _decide(network, candidate: _Candidate, answers: ServerAnswers):
("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
if network is None:
return None # nothing can be proven without the quorum
for txid, height in _confirmed_candidates(answers, candidate.known_txids)[:_MAX_RESURRECTION_CLAIMS]:
if await network.confirmation_proven(txid, height):
return ("resurrected", (txid, height))
return None
found = _entries_for(answers, candidate.known_txids)
if found: