Files
plm-lottery/app/withdrawals/service.py
T
davideandClaude Sonnet 5.5 54dbffb552 Credit deposits at deposit_confirmations and verify the round's coins with the quorum before paying (B-82, DEP-03, DEP-04, DEP-09, DEP-12, DEP-14, ELC-17, ELC-27, RND-21, RND-22, RND-25, RND-39, DAT-10, DAT-11, DAT-24, DAT-62, DAT-67, DAT-68, AUD-12, AUD-44, AUD-45, API-30, API-31, API-36, ADR-06, ADR-52)
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>
2026-10-16 14:25:41 +02:00

263 lines
11 KiB
Python

import logging
from datetime import datetime, timezone
from embit import script
from sqlalchemy import select
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import ApiError
from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import PendingTransaction, Round, User, UtxoEvent, Withdrawal
from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
from app.rounds.events import broadcaster
from app.tx.versions import record_version
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
from app.wallet.address import is_valid_plm_address
from app.wallet.balance import compute_pending_balance, recompute_balance
from app.wallet.hd import derive_pool_address, derive_user_key
from app.wallet.psbt_builder import (
BuiltTransaction,
InsufficientFundsError,
Utxo,
build_signed_transaction,
)
logger = logging.getLogger(__name__)
class WithdrawalError(ApiError):
pass
async def request_withdrawal(
session: AsyncSession,
client: ElectrumClient,
user: User,
external_address: str,
amount_sats: int,
network: TransactionNetwork | None = None,
) -> Withdrawal:
# Checked before anything else: an address from another chain parses fine as a
# witness program (see wallet/address.py), so without this the tx would build,
# broadcast and be irrecoverable rather than fail.
if not is_valid_plm_address(external_address):
raise WithdrawalError("invalid_address", "not a valid PLM bech32 address")
# Withdrawing to your own deposit address is a no-op that costs a network fee,
# and it breaks two things that assume the recipient and the change are
# distinguishable by address: the RBF bump would shrink the recipient output
# instead of the change (tx/broadcast.py:_find_change_output), and
# compute_pending_balance would count the amount twice (B-17).
if external_address == user.address:
raise WithdrawalError(
"withdrawal_to_own_address",
"that is your own deposit address — withdraw to an external wallet instead",
)
# B-88 (DAT-74, TX-28): the write lock before the first read that decides
# anything, so the UTXOs selected below are the ones phase 1 then marks spent.
# Taken only after the checks that need no database at all, so a malformed
# request never queues for it. No lock within busy_timeout means nothing has been
# built or broadcast: the caller is asked to retry.
try:
await begin_immediate(session)
except OperationalError as exc:
raise WithdrawalError(
"service_busy", "the service is busy right now, nothing was sent — please try again"
) from exc
config = await get_round_config(session)
# B-80 (WDR-14): never to an address of the platform itself. Coins sent to a round's
# pool address land among that round's UTXOs, and to the reserve or fee address they
# leave the user's balance for good; the amount would be spent from the balance and
# then belong to nobody. Compared lower-cased, since bech32 is case-insensitive.
platform_addresses = {derive_pool_address().lower(), config.fee_address.strip().lower()}
platform_addresses.update(
address.lower() for address in await session.scalars(select(Round.pool_address).distinct()) if address
)
platform_addresses.discard("")
if external_address.strip().lower() in platform_addresses:
raise WithdrawalError(
"withdrawal_to_platform_address",
"that is an address of the platform itself — withdraw to an external wallet instead",
)
if amount_sats < config.bet_amount_sats:
raise WithdrawalError(
"amount_below_minimum",
f"amount below the minimum of {config.bet_amount_sats} sats",
minimum_sats=config.bet_amount_sats,
)
unspent = (
await session.scalars(
select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None))
)
).all()
confirmed_sats = sum(u.amount_sats for u in unspent)
if confirmed_sats < amount_sats:
# B-37: cached_balance_sats (== confirmed_sats here) can understate the real
# balance by a whole unconfirmed change output right after a bet/withdrawal —
# the UI shows pending_balance_sats instead (compute_pending_balance), which
# can cover an amount this check would otherwise reject as flatly
# "insufficient". Distinguish "you don't have the money" from "your money
# hasn't confirmed yet" so the error doesn't contradict what the user is
# looking at on screen.
# B-82 (DEP-13): the displayed balance includes deposits still short of their
# threshold, so a withdrawal they would cover gets "wait for the confirmation".
waiting_sats = network.pending_deposit_sats(user.id) if hasattr(network, "pending_deposit_sats") else 0
pending_inclusive_sats, has_pending = await compute_pending_balance(session, user, waiting_sats)
if has_pending and pending_inclusive_sats >= amount_sats:
raise WithdrawalError(
"balance_pending_confirmation",
"the requested amount is covered by your pending balance, which has not confirmed yet",
pending_sats=pending_inclusive_sats - confirmed_sats,
)
raise WithdrawalError("insufficient_balance", "insufficient balance", required_sats=amount_sats)
user_key = derive_user_key(user.derivation_index)
from_script = script.p2wpkh(user_key.to_public())
utxos = [Utxo(u.txid, u.vout, u.amount_sats) for u in unspent]
try:
built = build_signed_transaction(
signing_key=user_key,
from_script=from_script,
utxos=utxos,
to_address=external_address,
amount_sats=amount_sats,
change_address=user.address,
fee_rate_sat_vb=config.fee_rate_sat_vb,
# B-62: "withdraw everything" asks for the whole confirmed balance, which
# would leave no change output and therefore nothing bump_fee could
# shrink — the one tx shape RBF cannot rescue, and the UI's default
# withdrawal path at that. Move a dust limit less instead of producing an
# unbumpable transaction; amount_sent_sats below records what actually
# went out, which is already how a fee-deducted withdrawal is reported.
reduce_amount_to_keep_change=True,
)
except InsufficientFundsError as exc:
raise WithdrawalError(exc.code, str(exc), **exc.params) from exc
# Persist the intent before broadcasting, and only promote the rows once the
# network has accepted the tx — same two-phase shape as place_bet (B-08).
spent_by_key = {(u.txid, u.vout): u for u in unspent}
for spent in built.spent_utxos:
spent_by_key[(spent.txid, spent.vout)].spent_txid = built.txid
await recompute_balance(session, user.id)
withdrawal = Withdrawal(
user_id=user.id,
external_address=external_address,
amount_requested_sats=amount_sats,
amount_sent_sats=built.recipient_sats,
txid=built.txid,
status="building",
)
session.add(withdrawal)
await session.flush()
pending = PendingTransaction(
kind="withdrawal",
withdrawal_id=withdrawal.id,
user_id=user.id,
current_txid=built.txid,
fee_rate_sat_vb=config.fee_rate_sat_vb,
raw_tx_hex=built.raw_hex,
status="building",
)
session.add(pending)
await record_version(session, pending, built.txid, built.raw_hex) # B-77: the first version, with the row
await session.commit()
# B-76 (TX-04, TX-20): only a verified refusal releases the coins. A timeout, a
# drop after the write, or a refusal nobody could verify may mean the transaction
# left: the withdrawal would then be paid out while its row said "failed" and its
# coins were spendable again. Those outcomes leave the rows "building".
verdict = await broadcast_with_verdict(client, network, built.raw_hex, built.txid)
if verdict.outcome is BroadcastOutcome.REFUSED:
try:
await _release_failed_withdrawal(session, withdrawal, pending, built, user.id, verdict.reason)
except OperationalError:
# B-88: the release could not be written (database busy). The rows stay
# "building", the state committed before the broadcast, and the
# reconciler resolves them against the chain.
await session.rollback()
logger.error(
"could not release the failed withdrawal %s: database busy, left to the reconciler", built.txid
)
raise WithdrawalError("broadcast_failed", f"the network refused the transaction: {verdict.reason}")
if verdict.outcome is BroadcastOutcome.UNCERTAIN:
logger.warning(
"withdrawal %s: broadcast outcome uncertain (%s), left 'building' for the reconciler",
built.txid,
verdict.reason,
)
await session.refresh(withdrawal)
broadcaster.publish()
return withdrawal
# B-88: after the broadcast nothing is undone. Without the lock the rows stay
# "building" and the reconciler promotes them; the caller sees the real status.
try:
await begin_immediate(session)
except OperationalError:
await session.rollback()
logger.error(
"withdrawal %s broadcast but the database is busy: left 'building' for the reconciler", built.txid
)
await session.refresh(withdrawal)
return withdrawal
withdrawal.status = "broadcast"
pending.status = "pending"
await write_audit_log(
session,
"withdrawal_sent",
{"txid": built.txid, "amount_sent_sats": built.recipient_sats, "external_address": external_address},
user_id=user.id,
)
await session.commit()
await session.refresh(withdrawal)
broadcaster.publish() # balance just went "pending" — nudge the dashboard to refetch
return withdrawal
async def _release_failed_withdrawal(
session: AsyncSession,
withdrawal: Withdrawal,
pending: PendingTransaction,
built: BuiltTransaction,
user_id: int,
reason: str,
) -> None:
"""Nothing reached the chain, so free the reserved UTXOs and restore the balance.
The Withdrawal row is kept (marked "failed") rather than deleted: unlike a bet, a
withdrawal is an instruction the user gave, and they should be able to see that it
didn't go through."""
await begin_immediate(session) # B-88
for spent in built.spent_utxos:
row = await session.scalar(
select(UtxoEvent).where(UtxoEvent.txid == spent.txid, UtxoEvent.vout == spent.vout)
)
if row is not None:
row.spent_txid = None
withdrawal.status = "failed"
withdrawal.txid = None
pending.status = "failed"
pending.failure_reason = reason[:128]
pending.failed_at = datetime.now(timezone.utc) # B-78 (DAT-36): starts the 14-day watch
await recompute_balance(session, user_id)
await write_audit_log(
session,
"withdrawal_broadcast_failed",
{"txid": built.txid, "reason": reason[:200]},
user_id=user_id,
)
await session.commit()
broadcaster.publish() # the reserved UTXOs are spendable again — refetch the balance (B-49)