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>
257 lines
11 KiB
Python
257 lines
11 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.base import begin_immediate
|
|
from app.db.models import UtxoEvent
|
|
from app.rounds.events import broadcaster
|
|
from app.wallet.balance import recompute_balance
|
|
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
|
|
|
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, and not dust (B-75: below DUST_LIMIT_SATS nothing is credited).
|
|
|
|
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["value"] >= DUST_LIMIT_SATS # B-75 (DEP-01)
|
|
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.
|
|
|
|
B-88: opens with BEGIN IMMEDIATE, so the "already recorded?" read and the inserts
|
|
it guards are one transaction — the unique (txid, vout) constraint stays the
|
|
backstop, not the mechanism.
|
|
"""
|
|
await begin_immediate(session)
|
|
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
|
|
if entry["value"] < DUST_LIMIT_SATS:
|
|
# B-75 (DEP-01): anyone can send dust to a public address; crediting it would
|
|
# make every later refresh of this user's address do more work, for coins
|
|
# that cost more to spend than they are worth.
|
|
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 find_reinstate_candidates(session: AsyncSession, user_id: int, entries: list[dict]) -> list[dict]:
|
|
"""Outpoints flagged as spent outside the platform that this refresh's `listunspent`
|
|
lists again, confirmed and for the amount we recorded — the *candidates* for
|
|
reinstatement (B-83, DEP-02, DEP-08). Read-only: like a new credit (B-59) and a
|
|
candidate external spend (B-29), a reinstatement moves a balance, so the caller
|
|
(electrum/listener.py:refresh_user) must have the other servers corroborate each
|
|
candidate before reinstate_reappeared_utxos writes it. An entry whose amount differs
|
|
from our record is not a candidate: it is not the coin we wrote off."""
|
|
flagged = (
|
|
await session.scalars(
|
|
select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.spent_txid == _EXTERNAL_SPEND_SENTINEL)
|
|
)
|
|
).all()
|
|
amounts = {(row.txid, row.vout): row.amount_sats for row in flagged}
|
|
return [
|
|
entry
|
|
for entry in entries
|
|
if amounts.get((entry["tx_hash"], entry["tx_pos"])) == entry.get("value") and (entry.get("height") or 0) > 0
|
|
]
|
|
|
|
|
|
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.
|
|
|
|
Persistence only, for entries the caller has **already corroborated** against the
|
|
other servers (B-83, ELC-25) — it used to run on the active server's word alone, so a
|
|
hostile one could put a spent coin back into a balance. Re-checks, under the write
|
|
lock, that each row still carries the mark before clearing it (DEP-08).
|
|
"""
|
|
current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries}
|
|
|
|
# B-88: the lock is only worth taking when there is something to write, and this
|
|
# runs on every refresh — so look first (plain columns), then take it and read the
|
|
# rows again under it.
|
|
flagged = (
|
|
await session.execute(
|
|
select(UtxoEvent.txid, UtxoEvent.vout).where(
|
|
UtxoEvent.user_id == user_id, UtxoEvent.spent_txid == _EXTERNAL_SPEND_SENTINEL
|
|
)
|
|
)
|
|
).all()
|
|
if not any((txid, vout) in current_keys for txid, vout in flagged):
|
|
return 0
|
|
await begin_immediate(session)
|
|
|
|
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.
|
|
"""
|
|
await begin_immediate(session) # B-88: that re-check and the mark are one transaction
|
|
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
|