Never roll back an ambiguous broadcast as if it had been refused (B-76, TX-04, TX-05, TX-20, TX-21, BET-16, BET-17, BET-23, WDR-11, WDR-12, WDR-16, API-13)
place_bet and request_withdrawal released everything on any exception from the broadcast, including the 15s timeout and a connection dropped after the write. The tx may have propagated: a bet's sats landed in the pool with no participant, a withdrawal left while its row said failed and its coins were spendable again. - client: ElectrumRpcError (subclass of ElectrumError) is raised only for a reply carrying a JSON-RPC `error`; timeouts, drops and write errors stay plain ElectrumError. - tx/outcome.py:broadcast_with_verdict sorts a broadcast into accepted / refused / uncertain. A refusal is verified first: ElectrumListener.transaction_known asks blockchain.transaction.get of the active server and every other one (10s each) — any server returning the tx means accepted; the active server plus MIN_QUORUM_RESPONSES others all denying it means refused (ALLOW_NO_QUORUM keeps the reduced rule); too few answers is uncertain. "already known" wording is only a shortcut that skips the check. With no network to ask, a refusal is uncertain. - place_bet / request_withdrawal / _trigger_payout use it: only a verified refusal releases; uncertain leaves the rows building for the reconciler, publishes SSE and POST /bets and POST /withdrawals answer 202 with status building; "already known" promotes a payout (payout_sent) like any accepted broadcast. - An accepted transaction is fanned out to every other server (ElectrumListener.fan_out_transaction, best effort, off the caller's path). The RBF replacement in bump_fee is left for B-77, which rewrites that function. Tests changed because the spec changed: the existing "refused broadcast" tests raised a bare RuntimeError (now an uncertain outcome) and are made explicit refusals (rpc_error) with a network that denies the tx; direct route calls pass a Response; FakePayoutListener gains the network surface. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
13cb66cc06
commit
2b6ddb019b
27 files changed
+965
-150
No files matched your search
@@ -0,0 +1,543 @@
|
||||
"""B-76 (TX-04, TX-20, TX-21, TX-05, BET-16, BET-17, BET-23, WDR-11, WDR-12, WDR-16, API-09):
|
||||
an ambiguous broadcast is never rolled back as if it had been refused.
|
||||
|
||||
Only an explicit JSON-RPC refusal that the whole network then confirms it does not know
|
||||
may release coins. A timeout, a dropped connection, or a refusal nobody could verify
|
||||
leaves the rows `building` for the reconciler. Each of these tests fails against the
|
||||
old code, where *any* exception from the broadcast undid everything.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import Response
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.bets.service import BetError, place_bet
|
||||
from app.db.models import AuditLog, PendingTransaction, RoundParticipant, User, UtxoEvent, Withdrawal
|
||||
from app.electrum.client import ElectrumEndpoint, ElectrumError
|
||||
from app.electrum.listener import ElectrumListener
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.scheduler import RoundScheduler
|
||||
from app.tx.outcome import BroadcastOutcome, broadcast_with_verdict
|
||||
from app.withdrawals.service import WithdrawalError, request_withdrawal
|
||||
from tests.unit.fake_network import FakeNetwork, rpc_error
|
||||
from tests.unit.test_bets import _make_funded_user, session_factory # noqa: F401 (fixture)
|
||||
from tests.unit.test_scheduler import ( # noqa: F401 (fixtures and helpers shared with the payout tests)
|
||||
FakePayoutClient,
|
||||
FakePayoutListener,
|
||||
_POOL_AMOUNT_SATS,
|
||||
_seed_paying_out_round,
|
||||
payout_session_factory,
|
||||
)
|
||||
|
||||
_EXTERNAL = "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx"
|
||||
_FUNDS = 3_000_000_000
|
||||
|
||||
|
||||
class _FailingClient:
|
||||
"""A client whose broadcast fails the way `exc` says."""
|
||||
|
||||
def __init__(self, exc: BaseException):
|
||||
self._exc = exc
|
||||
self.broadcasted: list[str] = []
|
||||
|
||||
async def broadcast(self, raw_tx_hex: str) -> str:
|
||||
self.broadcasted.append(raw_tx_hex)
|
||||
raise self._exc
|
||||
|
||||
|
||||
_UNCERTAIN_FAILURES = [
|
||||
pytest.param(ElectrumError("blockchain.transaction.broadcast timed out after 15s"), id="timeout-after-write"),
|
||||
pytest.param(asyncio.TimeoutError(), id="bare-timeout"),
|
||||
pytest.param(ElectrumError("connection closed"), id="disconnect"),
|
||||
pytest.param(ElectrumError("write failed: broken pipe"), id="write-error"),
|
||||
pytest.param(ConnectionResetError("reset by peer"), id="connection-reset"),
|
||||
]
|
||||
|
||||
|
||||
async def _bet_rows(session_factory, user_id):
|
||||
async with session_factory() as session:
|
||||
participants = (await session.scalars(select(RoundParticipant))).all()
|
||||
pendings = (await session.scalars(select(PendingTransaction))).all()
|
||||
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
return participants, pendings, utxo, events
|
||||
|
||||
|
||||
# --- bets ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", _UNCERTAIN_FAILURES)
|
||||
async def test_a_bet_whose_broadcast_ends_without_an_answer_is_left_building(session_factory, failure):
|
||||
"""TX-20 / BET-23: nothing is released, the player gets the `building` participant,
|
||||
and the network is never asked to verify (there was no refusal to verify)."""
|
||||
user_id = await _make_funded_user(session_factory, 4, _FUNDS)
|
||||
network = FakeNetwork(known=False) # would say "release" if asked — it must not be
|
||||
queue = broadcaster.subscribe()
|
||||
try:
|
||||
while not queue.empty():
|
||||
queue.get_nowait()
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
participant = await place_bet(session, _FailingClient(failure), user, network=network)
|
||||
assert participant.status == "building"
|
||||
assert not queue.empty() # SSE
|
||||
finally:
|
||||
broadcaster.unsubscribe(queue)
|
||||
|
||||
participants, pendings, utxo, events = await _bet_rows(session_factory, user_id)
|
||||
assert [p.status for p in participants] == ["building"]
|
||||
assert [p.status for p in pendings] == ["building"]
|
||||
assert utxo.spent_txid == participants[0].bet_txid # the coins stay committed to the tx
|
||||
assert "bet_broadcast_failed" not in events and "bet_placed" not in events
|
||||
assert network.asked == [] and network.fanned_out == []
|
||||
async with session_factory() as session:
|
||||
assert (await session.get(User, user_id)).cached_balance_sats == 0 # not given back
|
||||
|
||||
|
||||
async def test_a_refused_bet_that_another_server_knows_counts_as_accepted(session_factory):
|
||||
"""TX-04: the active server refused ("txn-mempool-conflict", whatever it said) but a
|
||||
server of the network returns the transaction — it is on the network, so the bet
|
||||
stands and is promoted. Releasing here sent the sats to the pool without a participant."""
|
||||
user_id = await _make_funded_user(session_factory, 4, _FUNDS)
|
||||
network = FakeNetwork(known=True)
|
||||
client = _FailingClient(rpc_error("some wording we have never seen"))
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
participant = await place_bet(session, client, user, network=network)
|
||||
|
||||
assert participant.status == "broadcast"
|
||||
participants, pendings, utxo, events = await _bet_rows(session_factory, user_id)
|
||||
assert [p.status for p in pendings] == ["pending"]
|
||||
assert "bet_placed" in events and "bet_broadcast_failed" not in events
|
||||
assert network.asked == [participants[0].bet_txid]
|
||||
assert len(network.fanned_out) == 1 # TX-21: sent to the other servers too
|
||||
|
||||
|
||||
async def test_a_refused_bet_nobody_knows_is_released(session_factory):
|
||||
"""TX-04 / BET-16: a verified refusal still undoes everything and answers 502."""
|
||||
user_id = await _make_funded_user(session_factory, 4, _FUNDS)
|
||||
network = FakeNetwork(known=False)
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
with pytest.raises(BetError) as excinfo:
|
||||
await place_bet(session, _FailingClient(rpc_error("min relay fee not met")), user, network=network)
|
||||
assert excinfo.value.code == "broadcast_failed"
|
||||
participants, pendings, utxo, events = await _bet_rows(session_factory, user_id)
|
||||
assert participants == [] and pendings == [] and utxo.spent_txid is None
|
||||
assert "bet_broadcast_failed" in events
|
||||
assert network.fanned_out == []
|
||||
|
||||
|
||||
async def test_a_refusal_the_network_cannot_verify_releases_nothing(session_factory):
|
||||
"""TX-04: too few servers answered the verification — the outcome is uncertain, so
|
||||
the bet stays `building` instead of being rolled back on the refusal's word alone."""
|
||||
user_id = await _make_funded_user(session_factory, 4, _FUNDS)
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
participant = await place_bet(
|
||||
session, _FailingClient(rpc_error("min relay fee not met")), user, network=FakeNetwork(known=None)
|
||||
)
|
||||
assert participant.status == "building"
|
||||
participants, pendings, utxo, events = await _bet_rows(session_factory, user_id)
|
||||
assert [p.status for p in pendings] == ["building"] and utxo.spent_txid == participants[0].bet_txid
|
||||
assert "bet_broadcast_failed" not in events
|
||||
|
||||
|
||||
async def test_a_refusal_with_no_network_to_verify_it_releases_nothing(session_factory):
|
||||
user_id = await _make_funded_user(session_factory, 4, _FUNDS)
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
participant = await place_bet(session, _FailingClient(rpc_error("min relay fee not met")), user)
|
||||
assert participant.status == "building"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
[
|
||||
"txn-already-known",
|
||||
"txn-already-in-mempool",
|
||||
"Transaction already in block chain",
|
||||
"the transaction was rejected by network rules.\n\ntxn-already-in-mempool",
|
||||
],
|
||||
)
|
||||
async def test_already_known_is_a_shortcut_that_skips_the_verification(session_factory, message):
|
||||
"""TX-04: a recognised "already known" message counts as accepted without asking
|
||||
anyone; it is a shortcut, never a condition of safety."""
|
||||
user_id = await _make_funded_user(session_factory, 4, _FUNDS)
|
||||
network = FakeNetwork(known=False)
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
participant = await place_bet(session, _FailingClient(rpc_error(message)), user, network=network)
|
||||
assert participant.status == "broadcast"
|
||||
assert network.asked == []
|
||||
assert len(network.fanned_out) == 1
|
||||
|
||||
|
||||
async def test_the_bet_route_answers_202_for_an_uncertain_outcome_and_201_for_success(session_factory):
|
||||
"""API-09 / BET-23: `building` is HTTP 202; a promoted bet keeps 201."""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from app.api.routes.bets import create_bet
|
||||
|
||||
class _Locks:
|
||||
@asynccontextmanager
|
||||
async def acquire(self, user_id):
|
||||
yield
|
||||
|
||||
def _request(client, network):
|
||||
listener = SimpleNamespace(client=client, **{"transaction_known": network.transaction_known,
|
||||
"fan_out_transaction": network.fan_out_transaction})
|
||||
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(electrum_listener=listener, user_locks=_Locks())))
|
||||
|
||||
first = await _make_funded_user(session_factory, 4, _FUNDS)
|
||||
second = await _make_funded_user(session_factory, 5, _FUNDS)
|
||||
|
||||
response = Response()
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, first)
|
||||
body = await create_bet(
|
||||
_request(_FailingClient(ElectrumError("connection closed")), FakeNetwork()), response, user=user, session=session
|
||||
)
|
||||
assert response.status_code == 202 and body.status == "building"
|
||||
|
||||
class _OkClient:
|
||||
async def broadcast(self, raw_tx_hex):
|
||||
return "txid"
|
||||
|
||||
response = Response()
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, second)
|
||||
body = await create_bet(_request(_OkClient(), FakeNetwork()), response, user=user, session=session)
|
||||
assert response.status_code != 202 and body.status == "broadcast" # the route's declared 201 stands
|
||||
|
||||
|
||||
# --- withdrawals -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def _withdrawal_rows(session_factory, user_id):
|
||||
async with session_factory() as session:
|
||||
withdrawals = (await session.scalars(select(Withdrawal))).all()
|
||||
pendings = (await session.scalars(select(PendingTransaction))).all()
|
||||
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
return withdrawals, pendings, utxo, events
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", _UNCERTAIN_FAILURES)
|
||||
async def test_a_withdrawal_whose_broadcast_ends_without_an_answer_is_left_building(session_factory, failure):
|
||||
"""TX-20 / WDR-16: the transaction may have left; the withdrawal must not be
|
||||
marked failed with its coins spendable again (the money would go out twice)."""
|
||||
user_id = await _make_funded_user(session_factory, 7, _FUNDS)
|
||||
network = FakeNetwork(known=False)
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
withdrawal = await request_withdrawal(
|
||||
session, _FailingClient(failure), user, _EXTERNAL, 1_000_000_000, network=network
|
||||
)
|
||||
assert withdrawal.status == "building"
|
||||
withdrawals, pendings, utxo, events = await _withdrawal_rows(session_factory, user_id)
|
||||
assert [w.status for w in withdrawals] == ["building"]
|
||||
assert [p.status for p in pendings] == ["building"]
|
||||
assert utxo.spent_txid == withdrawals[0].txid
|
||||
assert "withdrawal_broadcast_failed" not in events and "withdrawal_sent" not in events
|
||||
assert network.asked == []
|
||||
|
||||
|
||||
async def test_a_refused_withdrawal_that_another_server_knows_counts_as_accepted(session_factory):
|
||||
user_id = await _make_funded_user(session_factory, 7, _FUNDS)
|
||||
network = FakeNetwork(known=True)
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
withdrawal = await request_withdrawal(
|
||||
session, _FailingClient(rpc_error("unrecognised")), user, _EXTERNAL, 1_000_000_000, network=network
|
||||
)
|
||||
assert withdrawal.status == "broadcast"
|
||||
withdrawals, pendings, utxo, events = await _withdrawal_rows(session_factory, user_id)
|
||||
assert [p.status for p in pendings] == ["pending"] and "withdrawal_sent" in events
|
||||
assert len(network.fanned_out) == 1
|
||||
|
||||
|
||||
async def test_a_refused_withdrawal_nobody_knows_is_released_and_marked_failed(session_factory):
|
||||
user_id = await _make_funded_user(session_factory, 7, _FUNDS)
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
with pytest.raises(WithdrawalError) as excinfo:
|
||||
await request_withdrawal(
|
||||
session,
|
||||
_FailingClient(rpc_error("min relay fee not met")),
|
||||
user,
|
||||
_EXTERNAL,
|
||||
1_000_000_000,
|
||||
network=FakeNetwork(known=False),
|
||||
)
|
||||
assert excinfo.value.code == "broadcast_failed"
|
||||
withdrawals, pendings, utxo, events = await _withdrawal_rows(session_factory, user_id)
|
||||
assert [w.status for w in withdrawals] == ["failed"] and utxo.spent_txid is None
|
||||
assert "withdrawal_broadcast_failed" in events
|
||||
|
||||
|
||||
async def test_an_unverifiable_withdrawal_refusal_releases_nothing(session_factory):
|
||||
user_id = await _make_funded_user(session_factory, 7, _FUNDS)
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
withdrawal = await request_withdrawal(
|
||||
session, _FailingClient(rpc_error("x")), user, _EXTERNAL, 1_000_000_000, network=FakeNetwork(known=None)
|
||||
)
|
||||
assert withdrawal.status == "building"
|
||||
|
||||
|
||||
async def test_the_withdrawal_route_answers_202_for_an_uncertain_outcome(session_factory):
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from app.api.routes.withdrawals import WithdrawalRequest, create_withdrawal
|
||||
|
||||
class _Locks:
|
||||
@asynccontextmanager
|
||||
async def acquire(self, user_id):
|
||||
yield
|
||||
|
||||
user_id = await _make_funded_user(session_factory, 7, _FUNDS)
|
||||
network = FakeNetwork()
|
||||
listener = SimpleNamespace(
|
||||
client=_FailingClient(ElectrumError("connection closed")),
|
||||
transaction_known=network.transaction_known,
|
||||
fan_out_transaction=network.fan_out_transaction,
|
||||
)
|
||||
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(electrum_listener=listener, user_locks=_Locks())))
|
||||
response = Response()
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
body = await create_withdrawal(
|
||||
WithdrawalRequest(external_address=_EXTERNAL, amount_sats=1_000_000_000), request, response, user=user, session=session
|
||||
)
|
||||
assert response.status_code == 202 and body.status == "building"
|
||||
|
||||
|
||||
# --- payouts ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def _payout_entries():
|
||||
return [{"tx_hash": "44" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
|
||||
|
||||
|
||||
class _RaisingPayoutClient(FakePayoutClient):
|
||||
def __init__(self, entries, exc):
|
||||
super().__init__(entries)
|
||||
self._exc = exc
|
||||
|
||||
async def broadcast(self, raw_tx_hex):
|
||||
raise self._exc
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc, known",
|
||||
[
|
||||
pytest.param(ElectrumError("connection closed"), False, id="uncertain"),
|
||||
pytest.param(rpc_error("min relay fee not met"), False, id="verified-refusal"),
|
||||
pytest.param(rpc_error("min relay fee not met"), None, id="unverifiable-refusal"),
|
||||
],
|
||||
)
|
||||
async def test_a_payout_that_is_not_accepted_stays_building_with_a_payout_failed_entry(payout_session_factory, exc, known):
|
||||
"""TX-05: refusal or uncertainty alike leave the row `building` — the reconciler,
|
||||
asking the chain, decides whether it left (TX-16, TX-17)."""
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(_RaisingPayoutClient(_payout_entries(), exc), known=known))
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
async with payout_session_factory() as session:
|
||||
pending = (await session.scalars(select(PendingTransaction))).one()
|
||||
assert pending.status == "building"
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert "payout_failed" in events and "payout_sent" not in events
|
||||
|
||||
|
||||
async def test_a_payout_refused_as_already_known_is_promoted(payout_session_factory):
|
||||
"""TX-05: "already known" is an accepted broadcast — `pending` + `payout_sent`."""
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
listener = FakePayoutListener(_RaisingPayoutClient(_payout_entries(), rpc_error("txn-already-in-mempool")))
|
||||
await RoundScheduler(payout_session_factory, listener)._trigger_payout(1)
|
||||
|
||||
async with payout_session_factory() as session:
|
||||
pending = (await session.scalars(select(PendingTransaction))).one()
|
||||
assert pending.status == "pending"
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert "payout_sent" in events
|
||||
assert len(listener.fanned_out) == 1
|
||||
|
||||
|
||||
async def test_an_accepted_payout_is_fanned_out_to_the_other_servers(payout_session_factory):
|
||||
"""TX-21."""
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
client = FakePayoutClient(_payout_entries())
|
||||
listener = FakePayoutListener(client)
|
||||
await RoundScheduler(payout_session_factory, listener)._trigger_payout(1)
|
||||
assert listener.fanned_out == client.broadcasted
|
||||
|
||||
|
||||
# --- the verdict itself -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_verdict_distinguishes_the_three_outcomes():
|
||||
class _Ok:
|
||||
async def broadcast(self, raw):
|
||||
return "txid"
|
||||
|
||||
ok = await broadcast_with_verdict(_Ok(), FakeNetwork(), "00", "aa")
|
||||
refused = await broadcast_with_verdict(_FailingClient(rpc_error("bad")), FakeNetwork(known=False), "00", "aa")
|
||||
uncertain = await broadcast_with_verdict(_FailingClient(ElectrumError("timed out")), FakeNetwork(known=False), "00", "aa")
|
||||
assert (ok.outcome, refused.outcome, uncertain.outcome) == (
|
||||
BroadcastOutcome.ACCEPTED,
|
||||
BroadcastOutcome.REFUSED,
|
||||
BroadcastOutcome.UNCERTAIN,
|
||||
)
|
||||
|
||||
|
||||
# --- the listener: transaction_known / fan_out_transaction ----------------------------
|
||||
|
||||
|
||||
_ENDPOINTS = [
|
||||
ElectrumEndpoint("active.example", 50002, True),
|
||||
ElectrumEndpoint("first.example", 50002, True),
|
||||
ElectrumEndpoint("second.example", 50002, True),
|
||||
ElectrumEndpoint("third.example", 50002, True),
|
||||
]
|
||||
|
||||
|
||||
def _real_tx():
|
||||
"""A real serialised transaction and its txid — transaction_known checks the
|
||||
returned bytes hash to the txid asked about."""
|
||||
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
||||
from embit import script
|
||||
|
||||
tx = Transaction(
|
||||
vin=[TransactionInput(bytes.fromhex("11" * 32), 0)],
|
||||
vout=[TransactionOutput(1000, script.p2wpkh(__import__("embit").ec.PrivateKey(b"\x01" * 32).get_public_key()))],
|
||||
)
|
||||
return tx.serialize().hex(), tx.txid().hex()
|
||||
|
||||
|
||||
class _ServerClient:
|
||||
"""A fake connection to one server: `answer` is a raw tx hex (it knows the tx), the
|
||||
string "unknown" (an RPC error: no such transaction), or None (unreachable)."""
|
||||
|
||||
def __init__(self, answer, log):
|
||||
self._answer = answer
|
||||
self._log = log
|
||||
|
||||
async def connect(self):
|
||||
if self._answer is None:
|
||||
raise ConnectionRefusedError("down")
|
||||
|
||||
async def get_transaction(self, txid, verbose=False):
|
||||
if self._answer == "unknown":
|
||||
raise rpc_error("No such mempool or blockchain transaction", code=-5)
|
||||
return self._answer
|
||||
|
||||
async def broadcast(self, raw):
|
||||
self._log.append(raw)
|
||||
return "txid"
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _listener(session_factory, answers: dict, active_answer, log=None, allow_no_quorum=False, endpoints=None):
|
||||
log = log if log is not None else []
|
||||
listener = ElectrumListener(
|
||||
lambda endpoint: _ServerClient(answers[endpoint.host], log),
|
||||
session_factory,
|
||||
endpoints or _ENDPOINTS,
|
||||
allow_no_quorum=allow_no_quorum,
|
||||
)
|
||||
listener._endpoint_index = 0
|
||||
listener.client = None if active_answer is Ellipsis else _ServerClient(active_answer, log)
|
||||
return listener
|
||||
|
||||
|
||||
async def test_transaction_known_is_true_when_any_server_returns_it(session_factory):
|
||||
raw, txid = _real_tx()
|
||||
listener = _listener(
|
||||
session_factory,
|
||||
{"first.example": "unknown", "second.example": raw, "third.example": None},
|
||||
active_answer="unknown",
|
||||
)
|
||||
assert await listener.transaction_known(txid) is True
|
||||
|
||||
|
||||
async def test_transaction_known_is_true_when_only_the_active_server_has_it(session_factory):
|
||||
raw, txid = _real_tx()
|
||||
listener = _listener(
|
||||
session_factory,
|
||||
{"first.example": "unknown", "second.example": "unknown", "third.example": "unknown"},
|
||||
active_answer=raw,
|
||||
)
|
||||
assert await listener.transaction_known(txid) is True
|
||||
|
||||
|
||||
async def test_transaction_known_is_false_only_when_the_active_server_and_a_quorum_deny_it(session_factory):
|
||||
_, txid = _real_tx()
|
||||
listener = _listener(
|
||||
session_factory,
|
||||
{"first.example": "unknown", "second.example": "unknown", "third.example": None},
|
||||
active_answer="unknown",
|
||||
)
|
||||
assert await listener.transaction_known(txid) is False
|
||||
|
||||
|
||||
async def test_transaction_known_is_uncertain_with_too_few_answers(session_factory):
|
||||
"""Only one of the other servers answered: below MIN_QUORUM_RESPONSES, so the
|
||||
denial proves nothing."""
|
||||
_, txid = _real_tx()
|
||||
listener = _listener(
|
||||
session_factory,
|
||||
{"first.example": "unknown", "second.example": None, "third.example": None},
|
||||
active_answer="unknown",
|
||||
)
|
||||
assert await listener.transaction_known(txid) is None
|
||||
|
||||
|
||||
async def test_transaction_known_is_uncertain_when_the_active_server_does_not_answer(session_factory):
|
||||
_, txid = _real_tx()
|
||||
listener = _listener(
|
||||
session_factory,
|
||||
{"first.example": "unknown", "second.example": "unknown", "third.example": "unknown"},
|
||||
active_answer=Ellipsis, # no active connection
|
||||
)
|
||||
assert await listener.transaction_known(txid) is None
|
||||
|
||||
|
||||
async def test_transaction_known_ignores_a_reply_that_is_not_the_transaction(session_factory):
|
||||
"""A server answering with some other transaction does not make this one known —
|
||||
and does not count as a denial either."""
|
||||
listener = _listener(
|
||||
session_factory,
|
||||
{"first.example": "unknown", "second.example": "00", "third.example": "unknown"},
|
||||
active_answer="unknown",
|
||||
)
|
||||
assert await listener.transaction_known("ab" * 32) is False # 2 real denials, 1 ignored reply
|
||||
|
||||
|
||||
async def test_transaction_known_follows_the_reduced_rule_under_allow_no_quorum(session_factory):
|
||||
_, txid = _real_tx()
|
||||
single = [ElectrumEndpoint("active.example", 50002, True)]
|
||||
listener = _listener(session_factory, {}, active_answer="unknown", allow_no_quorum=True, endpoints=single)
|
||||
assert await listener.transaction_known(txid) is False
|
||||
strict = _listener(session_factory, {}, active_answer="unknown", endpoints=single)
|
||||
assert await strict.transaction_known(txid) is None
|
||||
|
||||
|
||||
async def test_fan_out_sends_to_every_other_server_and_never_to_the_active_one(session_factory):
|
||||
log: list[str] = []
|
||||
listener = _listener(
|
||||
session_factory,
|
||||
{"first.example": "unknown", "second.example": "unknown", "third.example": None},
|
||||
active_answer="unknown",
|
||||
log=log,
|
||||
)
|
||||
listener.fan_out_transaction("deadbeef")
|
||||
await asyncio.gather(*listener._background_tasks)
|
||||
assert log == ["deadbeef", "deadbeef"] # first + second; third unreachable; active untouched
|
||||
Reference in new issue
Block a user