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>
79 lines
3.3 KiB
Python
79 lines
3.3 KiB
Python
"""Test doubles for what the services ask of the network (B-76 and after)."""
|
|
|
|
from app.electrum.client import ElectrumRpcError
|
|
|
|
|
|
def rpc_error(message: str, code: int = -26) -> ElectrumRpcError:
|
|
"""A server that answered with a JSON-RPC `error` object — an explicit refusal."""
|
|
return ElectrumRpcError({"code": code, "message": message})
|
|
|
|
|
|
class FakeNetwork:
|
|
"""The ElectrumListener surface a broadcast needs: `known` is what
|
|
`transaction_known` answers (True: some server has it, False: every server denies
|
|
it, None: too few answers to tell); fanned-out transactions are recorded."""
|
|
|
|
def __init__(self, known: bool | None = False, proven: bool = True):
|
|
self.known = known
|
|
self.proven = proven # what `confirmation_proven` answers (B-83)
|
|
self.asked: list[str] = []
|
|
self.fanned_out: list[str] = []
|
|
self.proofs_asked: list[tuple[str, int]] = []
|
|
|
|
async def transaction_known(self, txid: str) -> bool | None:
|
|
self.asked.append(txid)
|
|
return self.known
|
|
|
|
def fan_out_transaction(self, raw_tx_hex: str) -> None:
|
|
self.fanned_out.append(raw_tx_hex)
|
|
|
|
async def confirmation_proven(self, txid: str, height: int) -> bool:
|
|
self.proofs_asked.append((txid, height))
|
|
return self.proven
|
|
|
|
|
|
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, proven=True):
|
|
super().__init__(known=False, proven=proven)
|
|
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)
|