A candidate external spend has needed a quorum since B-29, but `value` and `height` for a *credit* came from the single active connection and went straight into utxo_events. One hostile or broken server could therefore inflate a user's displayed balance with outpoints that don't exist. It never spends anyone else's coins — a bet or withdrawal built on a phantom UTXO is refused at broadcast and rolled back — but it wedges the balance display and burns build attempts, and on a custodial platform a balance that isn't real is a support incident either way. Balances move in both directions; both directions now need the same quorum. corroborate_utxo_credit asks the other configured servers whether they report the same outpoint, for the same amount, confirmed. The height itself isn't compared: a server still catching up reports height 0 and simply doesn't agree, which is the same answer, while two honest servers can't disagree on the height of a genuinely confirmed outpoint. refresh_user gains the phase that shape already implied: find_new_credit_ candidates (new, confirmed, not already held) inside the first session, corroboration outside any session, then credit_confirmed_utxos over what survived. Only new outpoints are corroborated — re-checking what we already hold would open a connection to every other server on every refresh for an answer that can no longer change anything. A failed corroboration delays a credit, it never loses one: the next scripthash notification or DepositReconciler sweep (300s) re-offers the same outpoint, and the withholding is logged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
210 lines
9.3 KiB
Python
210 lines
9.3 KiB
Python
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 AuditLog, User, UtxoEvent
|
|
from app.deposits.service import (
|
|
credit_confirmed_utxos,
|
|
find_utxos_missing_from,
|
|
mark_utxos_spent_externally,
|
|
reinstate_reappeared_utxos,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def session_factory():
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
yield async_sessionmaker(engine, expire_on_commit=False)
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
async def user_id(session_factory):
|
|
async with session_factory() as session:
|
|
user = User(username="alice", password_hash="x", derivation_index=0, address="plm1qxxx")
|
|
session.add(user)
|
|
await session.commit()
|
|
return user.id
|
|
|
|
|
|
async def test_credits_confirmed_utxo_and_updates_balance(session_factory, user_id):
|
|
entries = [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 10_000_000}]
|
|
async with session_factory() as session:
|
|
credited = await credit_confirmed_utxos(session, user_id, entries)
|
|
assert credited == 1
|
|
user = await session.get(User, user_id)
|
|
assert user.cached_balance_sats == 10_000_000
|
|
|
|
|
|
async def test_unconfirmed_entry_is_ignored(session_factory, user_id):
|
|
entries = [{"tx_hash": "bb" * 32, "tx_pos": 0, "height": 0, "value": 5_000_000}]
|
|
async with session_factory() as session:
|
|
credited = await credit_confirmed_utxos(session, user_id, entries)
|
|
assert credited == 0
|
|
user = await session.get(User, user_id)
|
|
assert user.cached_balance_sats == 0
|
|
|
|
|
|
async def test_idempotent_on_repeated_notification(session_factory, user_id):
|
|
entries = [{"tx_hash": "cc" * 32, "tx_pos": 0, "height": 100, "value": 7_000_000}]
|
|
async with session_factory() as session:
|
|
first = await credit_confirmed_utxos(session, user_id, entries)
|
|
async with session_factory() as session:
|
|
second = await credit_confirmed_utxos(session, user_id, entries)
|
|
user = await session.get(User, user_id)
|
|
assert first == 1
|
|
assert second == 0
|
|
assert user.cached_balance_sats == 7_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:
|
|
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
|
|
|
|
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
|
assert utxo.spent_txid == "external-spend"
|
|
|
|
audit_events = (await session.scalars(select(AuditLog))).all()
|
|
assert any(e.event_type == "utxo_spent_externally" for e in audit_events)
|
|
|
|
|
|
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:
|
|
reinstated = await reinstate_reappeared_utxos(session, user_id, entries)
|
|
assert reinstated == 0
|
|
|
|
|
|
async def test_find_new_credit_candidates_skips_unconfirmed_and_already_known(session_factory, user_id): # B-59
|
|
"""What the caller has to corroborate before crediting: only entries that would
|
|
actually write something. Re-corroborating what we already hold would open a
|
|
connection to every other server on every refresh, for an answer that can no
|
|
longer change anything."""
|
|
from app.deposits.service import find_new_credit_candidates
|
|
|
|
async with session_factory() as session:
|
|
await credit_confirmed_utxos(
|
|
session, user_id, [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 1_000}]
|
|
)
|
|
|
|
entries = [
|
|
{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 1_000}, # already credited
|
|
{"tx_hash": "bb" * 32, "tx_pos": 0, "height": 0, "value": 2_000}, # still in the mempool
|
|
{"tx_hash": "cc" * 32, "tx_pos": 1, "height": 101, "value": 3_000}, # genuinely new
|
|
]
|
|
async with session_factory() as session:
|
|
candidates = await find_new_credit_candidates(session, user_id, entries)
|
|
|
|
assert [(c["tx_hash"], c["tx_pos"]) for c in candidates] == [("cc" * 32, 1)]
|