Files
plm-lottery/tests/unit/test_confirmation_proof.py
T
davideandClaude Sonnet 5.5 a3c51719bd 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>
2026-10-14 15:29:30 +02:00

450 lines
20 KiB
Python

"""B-83 (TX-22, TX-06, ELC-25, ELC-26, DEP-02, DEP-08, AUD-18, ADR-07): confirmations and
reinstatements need the quorum, not the active server's word.
`poll_once` used to mark a transaction confirmed on one server's `height > 0`, and
`reinstate_reappeared_utxos` restored a UTXO on its word — so a hostile active server could
declare a never-relayed payout confirmed (the winner unpaid, the round closed) or put a spent
coin back into a balance. Each test here fails against that.
"""
import hashlib
import struct
import pytest
from sqlalchemy import select
import app.bets.confirmation # noqa: F401 (registers the "bet" handler)
from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant, User, UtxoEvent
from app.electrum.client import ElectrumEndpoint
from app.electrum.listener import ElectrumListener
from app.electrum.merkle import header_merkle_root, merkle_root_from_branch, proof_matches_header
from app.rounds.draw import header_meets_its_own_target
from app.tx.confirmation import poll_once
from app.wallet.hd import derive_user_address
from tests.unit.fake_network import FakeNetwork
from tests.unit.test_confirmation import FakeClient, session_factory # noqa: F401 (fixture and fake)
from tests.unit.test_electrum_listener import ( # noqa: F401 (fixture and helpers)
_ENDPOINTS,
_EASY_BITS,
_ActiveClient,
_seed_funded_user,
session_factory as listener_session_factory,
)
def _sha256d(data: bytes) -> bytes:
return hashlib.sha256(hashlib.sha256(data).digest()).digest()
class Block:
"""A tiny block: `txids` (display order) under a real merkle tree, and a mined header
that commits to its root."""
def __init__(self, txids: list[str], prev_hash: str = "00" * 32):
self.txids = txids
level = [bytes.fromhex(t)[::-1] for t in txids]
self._levels = [level]
while len(level) > 1:
if len(level) % 2:
level = level + [level[-1]]
level = [_sha256d(level[i] + level[i + 1]) for i in range(0, len(level), 2)]
self._levels.append(level)
self.root = level[0]
self.header = self._mine(prev_hash)
def _mine(self, prev_hash: str) -> str:
for nonce in range(100_000):
header = (
struct.pack("<I", 1)
+ bytes.fromhex(prev_hash)[::-1]
+ self.root
+ struct.pack("<I", 0)
+ struct.pack("<I", _EASY_BITS)
+ struct.pack("<I", nonce)
).hex()
if header_meets_its_own_target(header):
return header
raise RuntimeError("could not mine the test header")
def proof(self, txid: str, height: int) -> dict:
"""What `blockchain.transaction.get_merkle` answers."""
index = self.txids.index(txid)
position, branch = index, []
for level in self._levels[:-1]:
padded = level + [level[-1]] if len(level) % 2 else level
branch.append(padded[position ^ 1][::-1].hex())
position //= 2
return {"block_height": height, "merkle": branch, "pos": index}
_TXIDS = [f"{i:02x}" * 32 for i in range(1, 8)] # 7 transactions: an odd level, so a duplicate sibling too
_BLOCK = Block(_TXIDS)
# --- the proof itself -----------------------------------------------------------------------
@pytest.mark.parametrize("txid", _TXIDS)
def test_every_transaction_of_a_block_proves_against_its_header(txid):
assert proof_matches_header(txid, 100, _BLOCK.proof(txid, 100), _BLOCK.header)
def test_a_single_transaction_block_proves_with_an_empty_branch():
block = Block(["ab" * 32])
assert block.proof("ab" * 32, 5)["merkle"] == []
assert proof_matches_header("ab" * 32, 5, block.proof("ab" * 32, 5), block.header)
def test_header_merkle_root_reads_the_committed_root():
assert header_merkle_root(_BLOCK.header) == _BLOCK.root
def test_a_proof_does_not_prove_anything_it_was_not_made_for():
txid = _TXIDS[2]
proof = _BLOCK.proof(txid, 100)
other_block = Block(["f0" * 32, "f1" * 32])
assert not proof_matches_header(_TXIDS[3], 100, proof, _BLOCK.header) # another txid
assert not proof_matches_header(txid, 101, proof, _BLOCK.header) # another height
assert not proof_matches_header(txid, 100, proof, other_block.header) # another block
assert not proof_matches_header(txid, 100, {**proof, "pos": proof["pos"] ^ 1}, _BLOCK.header) # another position
assert not proof_matches_header(txid, 100, {**proof, "merkle": proof["merkle"][:-1]}, _BLOCK.header)
tampered = list(proof["merkle"])
tampered[0] = "00" * 32
assert not proof_matches_header(txid, 100, {**proof, "merkle": tampered}, _BLOCK.header)
@pytest.mark.parametrize(
"proof",
[None, "garbage", {}, {"block_height": 100}, {"block_height": 100, "merkle": "x", "pos": 0},
{"block_height": 100, "merkle": ["zz"], "pos": 0}, {"block_height": 100, "merkle": ["00" * 32], "pos": 9}],
)
def test_a_malformed_proof_is_simply_a_no(proof):
assert proof_matches_header(_TXIDS[0], 100, proof, _BLOCK.header) is False
def test_the_root_computation_matches_a_hand_built_two_leaf_tree():
a, b = "01" * 32, "02" * 32
expected = _sha256d(bytes.fromhex(a)[::-1] + bytes.fromhex(b)[::-1])
assert merkle_root_from_branch(a, [b], 0) == expected
assert merkle_root_from_branch(b, [a], 1) == expected
# --- the listener's verdict: confirmation_proven ---------------------------------------------
class _ProofServer:
"""One other server: `proofs` maps txid -> the get_merkle reply; None = unreachable;
`headers` maps height -> the header it reports."""
def __init__(self, proofs=None, headers=None):
self._proofs = proofs
self._headers = headers or {}
async def connect(self):
if self._proofs is None:
raise ConnectionRefusedError("down")
async def get_merkle(self, txid, height):
return self._proofs[txid]
async def get_block_header(self, height):
return self._headers[height]
async def close(self):
pass
def _proof_listener(session_factory, others: dict[str, _ProofServer], *, tip_height=100, tip_header=None, **kwargs):
listener = ElectrumListener(lambda endpoint: others[endpoint.host], session_factory, _ENDPOINTS, **kwargs)
listener.tip_height = tip_height
listener.tip_header_hex = tip_header if tip_header is not None else _BLOCK.header
return listener
def _honest():
txid = _TXIDS[3]
return _ProofServer({txid: _BLOCK.proof(txid, 100)})
def _lying():
"""Claims the tx is in the block, with a proof that does not add up."""
txid = _TXIDS[3]
proof = _BLOCK.proof(txid, 100)
return _ProofServer({txid: {**proof, "merkle": ["11" * 32] + proof["merkle"][1:]}})
async def test_a_valid_proof_from_the_quorum_confirms(listener_session_factory):
listener = _proof_listener(listener_session_factory, {"second.example": _honest(), "third.example": _honest()})
assert await listener.confirmation_proven(_TXIDS[3], 100) is True
async def test_a_lying_active_server_cannot_confirm_a_transaction(listener_session_factory):
"""The active server claims height 100; the other servers' proofs do not check out.
The active server's own word is never part of the verdict."""
listener = _proof_listener(listener_session_factory, {"second.example": _lying(), "third.example": _lying()})
assert await listener.confirmation_proven(_TXIDS[3], 100) is False
async def test_a_minority_of_liars_does_not_outvote_the_honest_servers(listener_session_factory):
four = [*_ENDPOINTS, ElectrumEndpoint("fourth.example", 50002, True)]
listener = ElectrumListener(
lambda endpoint: {"second.example": _honest(), "third.example": _honest(), "fourth.example": _lying()}[endpoint.host],
listener_session_factory,
four,
)
listener.tip_height, listener.tip_header_hex = 100, _BLOCK.header
assert await listener.confirmation_proven(_TXIDS[3], 100) is True
flipped = ElectrumListener(
lambda endpoint: {"second.example": _lying(), "third.example": _lying(), "fourth.example": _honest()}[endpoint.host],
listener_session_factory,
four,
)
flipped.tip_height, flipped.tip_header_hex = 100, _BLOCK.header
assert await flipped.confirmation_proven(_TXIDS[3], 100) is False
async def test_too_few_answers_do_not_confirm(listener_session_factory):
listener = _proof_listener(listener_session_factory, {"second.example": _honest(), "third.example": _ProofServer(None)})
assert await listener.confirmation_proven(_TXIDS[3], 100) is False # one answer, below the quorum minimum
async def test_a_proof_for_another_height_does_not_confirm(listener_session_factory):
txid = _TXIDS[3]
wrong_height = _ProofServer({txid: _BLOCK.proof(txid, 99)})
listener = _proof_listener(listener_session_factory, {"second.example": wrong_height, "third.example": wrong_height})
assert await listener.confirmation_proven(txid, 100) is False
async def test_a_height_we_have_not_seen_cannot_be_proven(listener_session_factory):
listener = _proof_listener(
listener_session_factory, {"second.example": _honest(), "third.example": _honest()}, tip_height=99
)
assert await listener.confirmation_proven(_TXIDS[3], 100) is False # above our tip: no header to check against
async def test_an_older_block_is_checked_against_the_header_the_quorum_agrees_on(listener_session_factory):
txid = _TXIDS[3]
proof = _BLOCK.proof(txid, 90)
servers = {
"second.example": _ProofServer({txid: proof}, {90: _BLOCK.header}),
"third.example": _ProofServer({txid: proof}, {90: _BLOCK.header}),
}
listener = _proof_listener(listener_session_factory, servers, tip_height=100, tip_header="00" * 80)
assert await listener.confirmation_proven(txid, 90) is True
# The servers disagree about that block's header: nothing trusted to check against.
other = Block(["f0" * 32, "f1" * 32])
split = {
"second.example": _ProofServer({txid: proof}, {90: _BLOCK.header}),
"third.example": _ProofServer({txid: proof}, {90: other.header}),
}
listener = _proof_listener(listener_session_factory, split, tip_height=100, tip_header="00" * 80)
assert await listener.confirmation_proven(txid, 90) is False
# A header that was never mined (does not meet its own target) is not accepted either.
unmined = "00" * 80
fake = {
"second.example": _ProofServer({txid: proof}, {90: unmined}),
"third.example": _ProofServer({txid: proof}, {90: unmined}),
}
listener = _proof_listener(listener_session_factory, fake, tip_height=100, tip_header="00" * 80)
assert await listener.confirmation_proven(txid, 90) is False
async def test_the_reduced_rule_under_allow_no_quorum_accepts_a_single_proof(listener_session_factory):
listener = _proof_listener(
listener_session_factory, {"second.example": _honest(), "third.example": _ProofServer(None)}, allow_no_quorum=True
)
assert await listener.confirmation_proven(_TXIDS[3], 100) is True
# --- poll_once: the active server only nominates --------------------------------------------------
async def _seed_pending_bet(session_factory, txid: str = "tx1"):
async with session_factory() as session:
user = User(username="u0", password_hash="x", derivation_index=0, address=derive_user_address(0))
session.add(user)
await session.flush()
session.add(Round(id=1, status="open"))
session.add(RoundParticipant(round_id=1, user_id=user.id, bet_amount_sats=1_000, bet_txid=txid, status="broadcast"))
session.add(
PendingTransaction(
kind="bet", round_id=1, user_id=user.id, current_txid=txid, fee_rate_sat_vb=1, raw_tx_hex="00", status="pending"
)
)
await session.commit()
async def test_a_height_the_quorum_cannot_prove_leaves_the_row_pending(session_factory):
"""A hostile active server reports a bet (or a payout) as mined at some height. The
quorum does not prove it, so nothing is confirmed and no handler runs."""
await _seed_pending_bet(session_factory)
network = FakeNetwork(proven=False)
assert await poll_once(session_factory, FakeClient({"tx1": 100}), network) == 0
assert network.proofs_asked == [("tx1", 100)] # the claim of the active server was put to the quorum
async with session_factory() as session:
assert (await session.scalars(select(PendingTransaction))).one().status == "pending"
assert (await session.scalars(select(RoundParticipant))).one().status == "broadcast"
async def test_a_proven_height_confirms(session_factory):
await _seed_pending_bet(session_factory)
network = FakeNetwork(proven=True)
assert await poll_once(session_factory, FakeClient({"tx1": 100}), network) == 1
assert network.proofs_asked == [("tx1", 100)]
async with session_factory() as session:
assert (await session.scalars(select(RoundParticipant))).one().status == "confirmed"
async def test_nothing_is_confirmed_without_a_network_to_prove_it(session_factory):
await _seed_pending_bet(session_factory)
assert await poll_once(session_factory, FakeClient({"tx1": 100}), None) == 0
async with session_factory() as session:
assert (await session.scalars(select(PendingTransaction))).one().status == "pending"
async def test_an_unconfirmed_transaction_is_not_put_to_the_quorum(session_factory):
await _seed_pending_bet(session_factory)
network = FakeNetwork()
assert await poll_once(session_factory, FakeClient({"tx1": 0}), network) == 0
assert network.proofs_asked == []
async def test_poll_once_confirms_through_a_real_listener_and_a_lying_active_server(session_factory, listener_session_factory):
"""End to end with the real quorum code: the transaction is really in the block, the
other servers prove it, and the same call refuses when they do not."""
txid = _TXIDS[3]
await _seed_pending_bet(session_factory, txid=txid)
liars = _proof_listener(listener_session_factory, {"second.example": _lying(), "third.example": _lying()})
assert await poll_once(session_factory, FakeClient({txid: 100}), liars) == 0
honest = _proof_listener(listener_session_factory, {"second.example": _honest(), "third.example": _honest()})
assert await poll_once(session_factory, FakeClient({txid: 100}), honest) == 1
# --- reinstatement (ELC-25, DEP-02, DEP-08, AUD-18) ------------------------------------------------------
_FLAGGED_TXID = "dd" * 32
_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
class _UnspentServer:
def __init__(self, entries):
self._entries = entries
async def connect(self):
if self._entries is None:
raise ConnectionRefusedError("down")
async def listunspent(self, scripthash):
return self._entries
async def close(self):
pass
_BACK = {"tx_hash": _FLAGGED_TXID, "tx_pos": 0, "height": 100, "value": 20_000_000}
_OTHER = {"tx_hash": "99" * 32, "tx_pos": 0, "height": 100, "value": 1_000_000} # keeps the reply non-empty
async def _flagged_user(session_factory) -> int:
user_id = await _seed_funded_user(session_factory, username="carol", address=_ADDRESS)
async with session_factory() as session:
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
utxo.spent_txid = "external-spend"
user = await session.get(User, user_id)
user.cached_balance_sats = 0
await session.commit()
return user_id
def _reinstate_listener(session_factory, others: dict[str, _UnspentServer], active_entries):
listener = ElectrumListener(lambda endpoint: others[endpoint.host], session_factory, _ENDPOINTS)
listener.client = _ActiveClient(active_entries)
return listener
async def _state(session_factory, user_id):
async with session_factory() as session:
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.txid == _FLAGGED_TXID))).one()
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
return utxo.spent_txid, (await session.get(User, user_id)).cached_balance_sats, events
async def test_a_reappeared_coin_is_reinstated_once_the_quorum_confirms_it(listener_session_factory):
user_id = await _flagged_user(listener_session_factory)
others = {"second.example": _UnspentServer([_BACK, _OTHER]), "third.example": _UnspentServer([_BACK, _OTHER])}
listener = _reinstate_listener(listener_session_factory, others, [_BACK, _OTHER])
await listener.refresh_user(user_id, "scripthash")
spent, balance, events = await _state(listener_session_factory, user_id)
assert spent is None and balance == 20_000_000 + 1_000_000 # the coin is back (+ the unrelated credit)
assert "utxo_external_spend_reinstated" in events
@pytest.mark.parametrize(
"others",
[
pytest.param({"second.example": [_OTHER], "third.example": [_OTHER]}, id="others-do-not-list-it"),
pytest.param({"second.example": [_BACK, _OTHER], "third.example": [_OTHER]}, id="a-minority-lists-it"),
pytest.param({"second.example": [_BACK, _OTHER], "third.example": None}, id="too-few-answers"),
pytest.param(
{"second.example": [{**_BACK, "value": 1}, _OTHER], "third.example": [{**_BACK, "value": 1}, _OTHER]},
id="other-amount",
),
pytest.param(
{"second.example": [{**_BACK, "height": 0}, _OTHER], "third.example": [{**_BACK, "height": 0}, _OTHER]},
id="unconfirmed",
),
],
)
async def test_a_hostile_active_server_cannot_reinstate_a_coin_alone(listener_session_factory, others):
"""The active server lists the coin again; the quorum does not bear it out. Before the
fix the reinstatement was written on the active server's word alone."""
user_id = await _flagged_user(listener_session_factory)
servers = {host: _UnspentServer(entries) for host, entries in others.items()}
listener = _reinstate_listener(listener_session_factory, servers, [_BACK, _OTHER])
await listener.refresh_user(user_id, "scripthash")
spent, balance, events = await _state(listener_session_factory, user_id)
assert spent == "external-spend" # still written off
assert "utxo_external_spend_reinstated" not in events
async def test_a_coin_listed_with_another_amount_than_recorded_is_not_even_a_candidate(listener_session_factory):
from app.deposits.service import find_reinstate_candidates
user_id = await _flagged_user(listener_session_factory)
async with listener_session_factory() as session:
assert await find_reinstate_candidates(session, user_id, [_BACK]) == [_BACK]
assert await find_reinstate_candidates(session, user_id, [{**_BACK, "value": 5}]) == []
assert await find_reinstate_candidates(session, user_id, [{**_BACK, "height": 0}]) == []
assert await find_reinstate_candidates(session, user_id, [_OTHER]) == []
async def test_finding_candidates_writes_nothing(listener_session_factory):
from app.deposits.service import find_reinstate_candidates
user_id = await _flagged_user(listener_session_factory)
async with listener_session_factory() as session:
await find_reinstate_candidates(session, user_id, [_BACK])
spent, balance, events = await _state(listener_session_factory, user_id)
assert spent == "external-spend" and balance == 0 and "utxo_external_spend_reinstated" not in events
async def test_the_reinstatement_write_rechecks_that_the_mark_is_still_there(listener_session_factory):
"""DEP-08: a coin whose mark was removed meanwhile (or never carried one) is not touched."""
from app.deposits.service import reinstate_reappeared_utxos
user_id = await _seed_funded_user(listener_session_factory, username="dave", address=_ADDRESS) # not flagged
async with listener_session_factory() as session:
assert await reinstate_reappeared_utxos(session, user_id, [_BACK]) == 0