Corroborate an external spend before marking a UTXO gone (B-29)
detect_external_spends marked a UTXO spent_txid='external-spend' irreversibly the moment it was missing from one listunspent reply, on one server, with no way to undo it. A rotated-to server that's broken or behind, or an empty reply, could zero a user's balance permanently. Split into three functions in deposits/service.py: find_utxos_missing_ from (read-only candidate detection, and refuses to flag anything at all when listunspent comes back entirely empty for a funded address - that reads as a broken response, not a full sweep), mark_utxos_spent_ externally (persistence only, once a candidate is already confirmed), and reinstate_reappeared_utxos (undoes the mark if the outpoint reappears as unspent later). ElectrumListener gains corroborate_utxo_spent, sharing the same majority-quorum logic corroborate_header already uses for B-28: before a candidate is marked, the other configured servers are asked whether they also see it as spent. _refresh_user now reads candidates, then corroborates each one with no DB session held open across those network calls (same shape as B-18/B-25), then persists. Suite grows from 165 to 176 tests. BUGS.md moves B-29 to Previously fixed.
This commit is contained in:
+111
-10
@@ -4,7 +4,12 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.models import AuditLog, User, UtxoEvent
|
||||
from app.deposits.service import credit_confirmed_utxos, detect_external_spends
|
||||
from app.deposits.service import (
|
||||
credit_confirmed_utxos,
|
||||
find_utxos_missing_from,
|
||||
mark_utxos_spent_externally,
|
||||
reinstate_reappeared_utxos,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -55,14 +60,66 @@ async def test_idempotent_on_repeated_notification(session_factory, user_id):
|
||||
assert user.cached_balance_sats == 7_000_000
|
||||
|
||||
|
||||
async def test_external_spend_marks_utxo_spent_and_corrects_balance(session_factory, user_id):
|
||||
entries = [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
|
||||
# --- B-29: detecting a UTXO spent outside the platform is now a three-step,
|
||||
# corroborate-before-you-mark process, split across find_utxos_missing_from
|
||||
# (read-only candidate detection), the caller's own corroboration against other
|
||||
# servers (electrum/listener.py, not exercised here), and mark_utxos_spent_
|
||||
# externally (persistence only, once a candidate is already confirmed). ---------
|
||||
|
||||
|
||||
async def test_find_utxos_missing_from_returns_the_missing_candidate(session_factory, user_id):
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(
|
||||
session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
|
||||
)
|
||||
|
||||
# A different outpoint present in this refresh — our own tracked one is
|
||||
# genuinely absent from it, not just from an entirely empty reply.
|
||||
other_entries = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 100, "value": 1_000_000}]
|
||||
async with session_factory() as session:
|
||||
candidates = await find_utxos_missing_from(session, user_id, other_entries)
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].txid == "dd" * 32
|
||||
assert candidates[0].spent_txid is None # read-only: nothing is marked yet
|
||||
|
||||
|
||||
async def test_find_utxos_missing_from_returns_nothing_when_present(session_factory, user_id):
|
||||
entries = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 100, "value": 3_000_000}]
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(session, user_id, entries)
|
||||
|
||||
async with session_factory() as session:
|
||||
spent = await detect_external_spends(session, user_id, [])
|
||||
assert spent == 1
|
||||
candidates = await find_utxos_missing_from(session, user_id, entries)
|
||||
assert candidates == []
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 3_000_000
|
||||
|
||||
|
||||
async def test_find_utxos_missing_from_skips_a_totally_empty_response(session_factory, user_id):
|
||||
"""B-29: an entirely empty listunspent for a funded address reads as an
|
||||
incomplete/broken response, not proof of a full external sweep — it would
|
||||
otherwise flag every UTXO of this user as missing from one bad reply."""
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(
|
||||
session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
candidates = await find_utxos_missing_from(session, user_id, [])
|
||||
assert candidates == []
|
||||
|
||||
|
||||
async def test_mark_utxos_spent_externally_marks_and_corrects_balance(session_factory, user_id):
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(
|
||||
session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
||||
marked = await mark_utxos_spent_externally(session, user_id, [utxo.id])
|
||||
assert marked == 1
|
||||
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 0
|
||||
|
||||
@@ -73,13 +130,57 @@ async def test_external_spend_marks_utxo_spent_and_corrects_balance(session_fact
|
||||
assert any(e.event_type == "utxo_spent_externally" for e in audit_events)
|
||||
|
||||
|
||||
async def test_no_spend_detected_when_utxo_still_unspent(session_factory, user_id):
|
||||
async def test_mark_utxos_spent_externally_skips_an_already_resolved_row(session_factory, user_id):
|
||||
"""Something else (a legitimate platform spend, or a prior refresh) may have
|
||||
resolved the row between the caller reading the candidate list and finishing
|
||||
corroboration — mark_utxos_spent_externally must not clobber that."""
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(
|
||||
session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
|
||||
)
|
||||
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
||||
utxo_id = utxo.id
|
||||
utxo.spent_txid = "some-real-platform-txid"
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
marked = await mark_utxos_spent_externally(session, user_id, [utxo_id])
|
||||
assert marked == 0
|
||||
utxo = await session.get(UtxoEvent, utxo_id)
|
||||
assert utxo.spent_txid == "some-real-platform-txid" # untouched
|
||||
|
||||
|
||||
async def test_reinstate_reappeared_utxos_clears_the_mark_and_restores_balance(session_factory, user_id):
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(
|
||||
session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
|
||||
)
|
||||
utxo_id = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one().id
|
||||
|
||||
async with session_factory() as session:
|
||||
await mark_utxos_spent_externally(session, user_id, [utxo_id])
|
||||
|
||||
# The outpoint reappears as unspent in a later refresh.
|
||||
entries = [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
|
||||
async with session_factory() as session:
|
||||
reinstated = await reinstate_reappeared_utxos(session, user_id, entries)
|
||||
assert reinstated == 1
|
||||
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 20_000_000
|
||||
|
||||
utxo = await session.get(UtxoEvent, utxo_id)
|
||||
assert utxo.spent_txid is None
|
||||
|
||||
audit_events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert "utxo_external_spend_reinstated" in audit_events
|
||||
|
||||
|
||||
async def test_reinstate_reappeared_utxos_ignores_unmarked_rows(session_factory, user_id):
|
||||
entries = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 100, "value": 3_000_000}]
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(session, user_id, entries)
|
||||
|
||||
async with session_factory() as session:
|
||||
spent = await detect_external_spends(session, user_id, entries)
|
||||
assert spent == 0
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 3_000_000
|
||||
reinstated = await reinstate_reappeared_utxos(session, user_id, entries)
|
||||
assert reinstated == 0
|
||||
|
||||
@@ -11,9 +11,11 @@ import asyncio
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.models import User, UtxoEvent
|
||||
from app.electrum.client import ElectrumEndpoint
|
||||
from app.electrum.listener import ElectrumListener
|
||||
from app.rounds.draw import HeaderValidationError, header_hex_to_block_hash, header_meets_its_own_target
|
||||
@@ -304,3 +306,148 @@ async def test_corroborate_header_false_when_nobody_responds(session_factory):
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_header(100, "deadbeef") is False
|
||||
|
||||
|
||||
# --- B-29: a UTXO absent from our own connection's listunspent must be
|
||||
# corroborated by other configured servers before it's treated as genuinely spent
|
||||
# outside the platform. ------------------------------------------------------------
|
||||
|
||||
|
||||
async def _listunspent_client_factory(responses: dict[str, object]):
|
||||
"""Builds a client_factory whose fake clients answer listunspent per-endpoint:
|
||||
a list of entries to report as unspent, `None` to simulate an unreachable
|
||||
server (fails at listunspent), or an Exception instance to simulate a connect
|
||||
failure."""
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, answer):
|
||||
self._answer = answer
|
||||
|
||||
async def connect(self):
|
||||
if isinstance(self._answer, Exception):
|
||||
raise self._answer
|
||||
|
||||
async def listunspent(self, scripthash):
|
||||
if self._answer is None:
|
||||
raise ConnectionRefusedError("unreachable")
|
||||
return self._answer
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
def factory(endpoint):
|
||||
return _FakeClient(responses[endpoint.host])
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
async def test_corroborate_utxo_spent_true_with_no_other_servers_configured(session_factory):
|
||||
single = [ElectrumEndpoint("only.example", 50002, True)]
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, single)
|
||||
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is True
|
||||
|
||||
|
||||
async def test_corroborate_utxo_spent_true_when_others_agree_its_gone(session_factory):
|
||||
factory = await _listunspent_client_factory({"first.example": [], "second.example": [], "third.example": []})
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is True
|
||||
|
||||
|
||||
async def test_corroborate_utxo_spent_false_when_majority_still_see_it_unspent(session_factory):
|
||||
still_there = [{"tx_hash": "dd" * 32, "tx_pos": 0}]
|
||||
factory = await _listunspent_client_factory(
|
||||
{"first.example": [], "second.example": still_there, "third.example": still_there}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is False
|
||||
|
||||
|
||||
async def test_corroborate_utxo_spent_false_when_nobody_responds(session_factory):
|
||||
factory = await _listunspent_client_factory(
|
||||
{"first.example": [], "second.example": None, "third.example": ConnectionRefusedError("down")}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is False
|
||||
|
||||
|
||||
class _ActiveClient:
|
||||
"""Stands in for `self.client`, the listener's one active connection —
|
||||
_refresh_user only ever calls listunspent on it."""
|
||||
|
||||
def __init__(self, entries: list[dict]):
|
||||
self._entries = entries
|
||||
|
||||
async def listunspent(self, scripthash):
|
||||
return self._entries
|
||||
|
||||
|
||||
async def _seed_funded_user(session_factory, *, username: str, address: str) -> int:
|
||||
from app.wallet.balance import recompute_balance
|
||||
|
||||
async with session_factory() as session:
|
||||
user = User(username=username, password_hash="x", derivation_index=0, address=address)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
session.add(
|
||||
UtxoEvent(user_id=user.id, txid="dd" * 32, vout=0, amount_sats=20_000_000, confirmed_height=100)
|
||||
)
|
||||
await recompute_balance(session, user.id)
|
||||
await session.commit()
|
||||
return user.id
|
||||
|
||||
|
||||
# An unrelated outpoint present alongside our own connection's listunspent reply —
|
||||
# keeps `entries` non-empty so find_utxos_missing_from's "entirely empty response"
|
||||
# guard doesn't swallow these tests; our own tracked UTXO is still genuinely
|
||||
# absent from it.
|
||||
_UNRELATED_ENTRY = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 100, "value": 1_000_000}]
|
||||
|
||||
|
||||
async def test_refresh_user_marks_a_utxo_spent_once_others_corroborate_it(session_factory):
|
||||
user_id = await _seed_funded_user(session_factory, username="bob", address="plm1qtest")
|
||||
|
||||
others_factory = await _listunspent_client_factory(
|
||||
{"first.example": [], "second.example": [], "third.example": []}
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
listener.client = _ActiveClient(_UNRELATED_ENTRY) # our own connection no longer sees the UTXO either
|
||||
|
||||
await listener._refresh_user(user_id, "scripthash")
|
||||
|
||||
async with session_factory() as session:
|
||||
utxo = (
|
||||
await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.txid == "dd" * 32))
|
||||
).one()
|
||||
assert utxo.spent_txid == "external-spend"
|
||||
user = await session.get(User, user_id)
|
||||
# The original 20_000_000 is spent; the unrelated entry the "active"
|
||||
# connection also reported gets freshly credited alongside it.
|
||||
assert user.cached_balance_sats == 1_000_000
|
||||
|
||||
|
||||
async def test_refresh_user_does_not_mark_when_corroboration_fails(session_factory):
|
||||
"""The single most important case: our own connection alone reporting the
|
||||
UTXO missing must not be enough — before B-29 this zeroed the balance on one
|
||||
bad reply."""
|
||||
user_id = await _seed_funded_user(session_factory, username="carol", address="plm1qtest2")
|
||||
|
||||
still_there = [{"tx_hash": "dd" * 32, "tx_pos": 0}]
|
||||
others_factory = await _listunspent_client_factory(
|
||||
{"first.example": [], "second.example": still_there, "third.example": still_there}
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
listener.client = _ActiveClient(_UNRELATED_ENTRY)
|
||||
|
||||
await listener._refresh_user(user_id, "scripthash")
|
||||
|
||||
async with session_factory() as session:
|
||||
utxo = (
|
||||
await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.txid == "dd" * 32))
|
||||
).one()
|
||||
assert utxo.spent_txid is None
|
||||
user = await session.get(User, user_id)
|
||||
# Untouched, plus the unrelated entry credited alongside it.
|
||||
assert user.cached_balance_sats == 21_000_000
|
||||
|
||||
Reference in New Issue
Block a user