A user could bet and then double-spend the 1-conference deposit that funded the bet with a private chain: the bet left the chain but its participant stayed `confirmed`, and the payout failed "insufficient pool UTXOs" for ever with everyone else's money stuck. Coinbase outputs were also credited at 1 confirmation (maturity is 120). Deposits - `round_config.deposit_confirmations` (default 6, 1-100, editable from /admin) is the threshold for a deposit; an output of a platform transaction (any txid of a pending_transactions row, RBF versions included) keeps 1; a coinbase output needs 120, recognised from the raw transaction, which must hash to the txid (`is_coinbase`, cached). `read_deposit_policy` / `classify_new_deposits` run between the read and the write, so no session is open across the lookup. - The quorum must also place the coin deep enough on our own tip (ELC-17). - Confirmed outputs short of their threshold are pending deposits (memory only, replaced at every refresh): shown in `pending_balance_sats` / `has_pending` (also for DEP-13's withdrawal message), never spendable. No scripthash notification announces a maturing deposit, so each new block refreshes only the users that have one, one task per user (DEP-09 now says so). Payout - `_trigger_payout` verifies the round's confirmed participants' bet outputs with the other servers (`verify_pool_outpoints`: strict majority of >= 2 answers per outpoint, a mempool-only coin or a tie gives no verdict) and builds from exactly those outpoints (RND-22), never from whatever else sits at the address. No quorum: `payout_failed`, `pool verification unavailable`, nothing changes. Bets that are gone become `reorged_out`, one `participant_reorged_out` row each, `pool_amount_sats` rewritten (only downward) in the same transaction, and the 70/30 split is computed on what is left. - Winner's own bet gone: nothing is built; `rounds.payout_blocked_at` (Alembic f2c64a8d1e93, with `deposit_confirmations`, plain add_column) is set, `payout_blocked` and the pause with `paused_reason = 'payout_blocked'` are written in one transaction; `POST /admin/resume` clears the mark and the next attempt verifies again. /admin/rounds returns it. - Rounds opened before B-80 (pool = the shared reserve address) keep the old active-server `listunspent` choice, since that address holds no single round's bets. Ambiguities resolved: the winner's row stays `confirmed` while held (AUD-45 says every participant whose bet is gone becomes `reorged_out`, but OPS-30/DAT-68 need the verification to repeat after the resume, which only a `confirmed` row allows); `/`'s deposit hint no longer says "1 confirmation" (7 languages, same keys); guides updated. Tests changed because the spec changed: listener tests that credited height-100 coins at tip 0 now set a tip past every threshold; the payout fixtures seed confirmed participants (the new source of the inputs) instead of an active-server `listunspent`, FakeNetwork answers the verification, and test_round_pool_address asserts the quorum is asked about the round's address. New: thresholds at exactly 6/5, configured value, coinbase at 119/120 and a mismatching or missing raw tx, platform outputs at 1, dust, quorum height, waiting-deposit display and disappearance, block-triggered refresh, resume, verification verdicts, gone bets excluded with the split recomputed, winner gone held and paused, held payouts untouched until the resume, re-verification after it, no quorum, a payout with 1 and with 400 participants against reserve+fee+dust, admin config bounds and migration. Mutation-checked: threshold 1, ignored coinbase, no height bound, no verification and no winner hold each fail tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
90 lines
4.4 KiB
Python
90 lines
4.4 KiB
Python
from embit.transaction import Transaction
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.models import PendingTransaction, User, UtxoEvent
|
|
from app.wallet.plm_network import PLM_MAINNET
|
|
|
|
|
|
async def recompute_balance(session: AsyncSession, user_id: int) -> int:
|
|
"""Source of truth: sum of this user's confirmed, unspent UTXOs. Updates and
|
|
returns the read-cache column (User.cached_balance_sats). Must be called
|
|
within the same transaction as whatever inserted/updated utxo_events rows."""
|
|
balance = await session.scalar(
|
|
select(func.sum(UtxoEvent.amount_sats)).where(UtxoEvent.user_id == user_id, UtxoEvent.spent_txid.is_(None))
|
|
)
|
|
user = await session.get(User, user_id)
|
|
user.cached_balance_sats = balance or 0
|
|
return user.cached_balance_sats
|
|
|
|
|
|
async def compute_pending_balance(
|
|
session: AsyncSession, user: User, pending_deposit_sats: int = 0
|
|
) -> tuple[int, bool]:
|
|
"""Balance including the user's own change still in flight.
|
|
|
|
Placing a bet or a withdrawal spends whatever UTXOs cover the amount — often
|
|
much larger than the amount actually moving, since select_utxos() picks
|
|
whole UTXOs — and recompute_balance() drops that entire input total from
|
|
cached_balance_sats the moment the tx is broadcast (spent_txid is set right
|
|
away, well before the tx has any confirmations). The change output that
|
|
returns the difference only becomes a UtxoEvent (and so counts toward the
|
|
confirmed balance again) once it gets its own 1st confirmation. In between,
|
|
User.cached_balance_sats alone understates the user's real balance by the
|
|
full unconfirmed change amount, which can look like a much bigger loss than
|
|
the tx actually represents.
|
|
|
|
This walks every in-flight (status="pending") bet/withdrawal PendingTransaction
|
|
of this user, decodes its current raw tx (kept up to date across RBF bumps —
|
|
see tx/broadcast.py:bump_fee), and sums whichever outputs pay back to the
|
|
user's own address. Adding that to cached_balance_sats gives the balance the
|
|
user will end up with once everything currently in flight confirms.
|
|
|
|
The change output's own confirmation is credited by two independent, unordered
|
|
paths: the Electrum listener (event-driven, near-instant — app/deposits/service.py
|
|
turns it into a UtxoEvent and folds it into cached_balance_sats via
|
|
recompute_balance) and this module's PendingTransaction.status flip
|
|
(app/tx/confirmation.py, polled every 10s). The listener usually wins that race,
|
|
so for the gap until the poller catches up the row is still "pending" here while
|
|
the same sats are already inside cached_balance_sats — double-counting the
|
|
change unless excluded below.
|
|
|
|
B-82 (DEP-12 (b), DEP-14): `pending_deposit_sats` is the total of the user's deposits
|
|
that are confirmed but still short of their threshold (the listener keeps them, in
|
|
memory); they are added to what is shown, and make `has_pending` true, exactly like
|
|
change in flight — without ever being spendable.
|
|
|
|
Returns (pending_inclusive_balance_sats, has_pending) — has_pending tells the
|
|
caller whether this differs from the confirmed-only balance at all.
|
|
"""
|
|
pending = (
|
|
await session.scalars(
|
|
select(PendingTransaction).where(
|
|
PendingTransaction.user_id == user.id,
|
|
PendingTransaction.kind.in_(("bet", "withdrawal")),
|
|
# "building" as well as "pending": a building row's UTXOs are already
|
|
# marked spent (see place_bet's two phases), so leaving it out would
|
|
# make the displayed balance dip for the duration of the broadcast.
|
|
PendingTransaction.status.in_(("building", "pending")),
|
|
)
|
|
)
|
|
).all()
|
|
|
|
already_credited = {
|
|
(txid, vout)
|
|
for txid, vout in (
|
|
await session.execute(select(UtxoEvent.txid, UtxoEvent.vout).where(UtxoEvent.user_id == user.id))
|
|
).all()
|
|
}
|
|
|
|
pending_change_sats = 0
|
|
for row in pending:
|
|
tx = Transaction.parse(bytes.fromhex(row.raw_tx_hex))
|
|
for vout, out in enumerate(tx.vout):
|
|
if (row.current_txid, vout) in already_credited:
|
|
continue
|
|
if out.script_pubkey.address(network=PLM_MAINNET) == user.address:
|
|
pending_change_sats += out.value
|
|
|
|
return user.cached_balance_sats + pending_change_sats + pending_deposit_sats, bool(pending) or pending_deposit_sats > 0
|