Files
plm-lottery/app/deposits/service.py
T
davideandClaude Opus 5 6246b13247 Corroborate deposit credits, not just external spends (B-59)
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>
2026-08-03 22:40:15 +02:00

202 lines
7.7 KiB
Python

import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.audit.log import write_audit_log
from app.db.models import UtxoEvent
from app.rounds.events import broadcaster
from app.wallet.balance import recompute_balance
logger = logging.getLogger(__name__)
async def find_new_credit_candidates(session: AsyncSession, user_id: int, entries: list[dict]) -> list[dict]:
"""The subset of `entries` that would actually credit something: confirmed
(height > 0, per the Electrum convention where <= 0 means mempool) and not
already recorded.
Split out from credit_confirmed_utxos so the caller
(electrum/listener.py:refresh_user) can corroborate each *new* outpoint
against the other configured servers before any of it is written (B-59) —
the mirror image of what B-29 already required before a balance may go
*down*. Only new ones: corroborating outpoints already credited would open a
connection to every other server on every refresh, for an answer that can no
longer change what we hold.
"""
existing_keys = {
(txid, vout)
for txid, vout in (
await session.execute(select(UtxoEvent.txid, UtxoEvent.vout).where(UtxoEvent.user_id == user_id))
).all()
}
return [
entry
for entry in entries
if entry["height"] > 0 and (entry["tx_hash"], entry["tx_pos"]) not in existing_keys
]
async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
"""Insert utxo_events for newly-confirmed entries from an Electrum
`listunspent` response (idempotent on txid+vout), refresh the user's cached
balance. Returns the number of newly-credited UTXOs.
entries: [{"tx_hash": ..., "tx_pos": ..., "height": ..., "value": ...}, ...]
height <= 0 means unconfirmed (mempool) per the Electrum protocol convention —
skipped, since the spec requires 1 confirmation before crediting.
"""
existing_keys = {
(txid, vout)
for txid, vout in (
await session.execute(select(UtxoEvent.txid, UtxoEvent.vout).where(UtxoEvent.user_id == user_id))
).all()
}
newly_credited = 0
for entry in entries:
if entry["height"] <= 0:
continue
key = (entry["tx_hash"], entry["tx_pos"])
if key in existing_keys:
continue
session.add(
UtxoEvent(
user_id=user_id,
txid=entry["tx_hash"],
vout=entry["tx_pos"],
amount_sats=entry["value"],
confirmed_height=entry["height"],
)
)
await write_audit_log(
session,
"deposit_credited",
{"txid": entry["tx_hash"], "vout": entry["tx_pos"], "amount_sats": entry["value"]},
user_id=user_id,
)
newly_credited += 1
if newly_credited:
await session.flush()
await recompute_balance(session, user_id)
await session.commit()
broadcaster.publish() # nudges this user's dashboard to refetch its balance instantly
return newly_credited
_EXTERNAL_SPEND_SENTINEL = "external-spend"
async def reinstate_reappeared_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
"""The reverse of a mark applied by find_utxos_missing_from/
mark_utxos_spent_externally (B-29): if an outpoint we'd previously flagged as
spent outside the platform reappears as unspent in a later listunspent, undo
the mark instead of leaving it permanent no matter what the chain says
afterwards. Cheap and purely DB-side — always safe to run on every refresh.
"""
current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries}
marked_rows = (
await session.scalars(
select(UtxoEvent).where(
UtxoEvent.user_id == user_id, UtxoEvent.spent_txid == _EXTERNAL_SPEND_SENTINEL
)
)
).all()
reinstated = 0
for row in marked_rows:
if (row.txid, row.vout) not in current_keys:
continue
row.spent_txid = None
await write_audit_log(
session,
"utxo_external_spend_reinstated",
{"txid": row.txid, "vout": row.vout, "amount_sats": row.amount_sats},
user_id=user_id,
)
reinstated += 1
if reinstated:
await session.flush()
await recompute_balance(session, user_id)
await session.commit()
broadcaster.publish()
return reinstated
async def find_utxos_missing_from(session: AsyncSession, user_id: int, entries: list[dict]) -> list[UtxoEvent]:
"""Candidates for an external spend (B-29): unspent UTXOs the DB believes this
user still holds that are absent from `entries`, this address's current
listunspent. Everything the platform itself spends (bets, withdrawals,
payouts) sets spent_txid at broadcast time, before the tx ever reaches the
chain — so an outpoint still marked unspent in our own DB that Electrum no
longer reports as unspent was never on our own radar.
Returning a row here is *not* proof it was actually spent — only that this one
server's reply no longer lists it. A single broken, behind, or malicious
server could otherwise zero a user's balance on one bad reply, which is why
the caller (electrum/listener.py:refresh_user) must independently
corroborate each candidate against other configured servers before treating
it as genuine, rather than this function marking anything itself.
An entirely empty `entries` for an address the DB believes is funded returns
no candidates at all: it would otherwise flag every one of this user's UTXOs
as missing from a single reply, which is a strong sign of an incomplete or
broken response rather than N independent spends landing in the same refresh.
"""
current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries}
unspent_rows = (
await session.scalars(
select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.spent_txid.is_(None))
)
).all()
if not entries and unspent_rows:
logger.warning(
"listunspent for user_id=%s returned no entries at all while %s UTXO(s) are still recorded "
"unspent — treating this as an incomplete response rather than a full external sweep",
user_id,
len(unspent_rows),
)
return []
return [row for row in unspent_rows if (row.txid, row.vout) not in current_keys]
async def mark_utxos_spent_externally(session: AsyncSession, user_id: int, utxo_ids: list[int]) -> int:
"""Applies the external-spend sentinel to UTXOs the caller has already
corroborated against other servers (B-29) — this function does no
verification of its own, only persistence, so it never runs with a session
held open across the network calls that verification needs.
Re-checks each row is still unspent before applying the mark: something else
may have resolved it (a legitimate platform spend, or a prior refresh) between
when the caller read the candidate list and finished corroborating it.
"""
marked = 0
for utxo_id in utxo_ids:
row = await session.get(UtxoEvent, utxo_id)
if row is None or row.spent_txid is not None:
continue
row.spent_txid = _EXTERNAL_SPEND_SENTINEL
await write_audit_log(
session,
"utxo_spent_externally",
{"txid": row.txid, "vout": row.vout, "amount_sats": row.amount_sats},
user_id=user_id,
)
marked += 1
if marked:
await session.flush()
await recompute_balance(session, user_id)
await session.commit()
broadcaster.publish()
return marked